462 lines
15 KiB
Dart
462 lines
15 KiB
Dart
/// Apollo BLE protocol: pure functions and small data structures.
|
|
///
|
|
/// Everything here is free of Flutter and BLE plugin dependencies so it can be
|
|
/// unit-tested with `flutter test` and no hardware.
|
|
///
|
|
/// Confidence terms used in comments:
|
|
/// STATICALLY CONFIRMED recovered from Apollo Scooters 4.8.18340 (libapollo-ble.so)
|
|
/// LIVE VERIFIED confirmed against a physical Apollo Go
|
|
/// INFERRED strongly suggested, not directly proven
|
|
/// UNKNOWN not yet mapped
|
|
///
|
|
/// Nothing in this file is LIVE VERIFIED yet.
|
|
library;
|
|
|
|
import 'dart:typed_data';
|
|
|
|
import 'scooter.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GATT identifiers. STATICALLY CONFIRMED.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Binary data service.
|
|
const apolloDataServiceUuid = '0000f1f0-0000-1000-8000-00805f9b34fb';
|
|
|
|
/// Phone -> scooter binary writes (set-base packets, keepalive).
|
|
const apolloDataTxUuid = '0000f1f1-0000-1000-8000-00805f9b34fb';
|
|
|
|
/// Scooter -> phone binary notifications (monitor and base frames).
|
|
const apolloDataRxUuid = '0000f1f2-0000-1000-8000-00805f9b34fb';
|
|
|
|
/// AT / config / authentication service.
|
|
const apolloAtServiceUuid = '0000f2f0-0000-1000-8000-00805f9b34fb';
|
|
|
|
/// Phone -> scooter ASCII AT commands.
|
|
const apolloAtTxUuid = '0000f2f1-0000-1000-8000-00805f9b34fb';
|
|
|
|
/// Scooter -> phone ASCII AT responses.
|
|
const apolloAtRxUuid = '0000f2f2-0000-1000-8000-00805f9b34fb';
|
|
|
|
/// Fixed keepalive packet. STATICALLY CONFIRMED as a constant.
|
|
///
|
|
/// It does NOT carry an [apolloCrc16] checksum (CRC of A5 02 would be 21 FB),
|
|
/// so never regenerate it with the frame CRC routine.
|
|
///
|
|
/// LIVE VERIFIED 2026-09-21 on an Apollo Go: the scooter sends NOTHING on F1F2
|
|
/// after PIN success until this packet is written to F1F1. Once it has been
|
|
/// sent every second the scooter streams alternating cmd0/cmd1 frames at
|
|
/// roughly 5 Hz, each split into a 20 byte and a 5 byte notification.
|
|
/// Whether the stream stops when keepalives stop is not yet verified.
|
|
final Uint8List apolloKeepalivePacket = Uint8List.fromList([0xA5, 0x02, 0xFD, 0x5A]);
|
|
|
|
/// Keepalive cadence that produced continuous telemetry on the Apollo Go.
|
|
const apolloDefaultKeepaliveInterval = Duration(seconds: 1);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CRC. STATICALLY CONFIRMED.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// CRC-16 with poly 0x8005 (reflected 0xA001), init 0xFFFF, RefIn/RefOut,
|
|
/// XorOut 0. This is CRC-16/MODBUS.
|
|
int apolloCrc16(Iterable<int> bytes) {
|
|
var crc = 0xFFFF;
|
|
for (final byte in bytes) {
|
|
crc ^= byte & 0xFF;
|
|
for (var i = 0; i < 8; i++) {
|
|
if ((crc & 1) != 0) {
|
|
crc = (crc >> 1) ^ 0xA001;
|
|
} else {
|
|
crc >>= 1;
|
|
}
|
|
}
|
|
}
|
|
return crc & 0xFFFF;
|
|
}
|
|
|
|
/// True when the trailing little-endian CRC covers frame[0..length-3].
|
|
/// STATICALLY CONFIRMED coverage: the header byte IS included.
|
|
bool apolloValidateFrame(Uint8List frame) {
|
|
if (frame.length < 3) return false;
|
|
final expected = apolloCrc16(frame.sublist(0, frame.length - 2));
|
|
final stored = frame[frame.length - 2] | (frame[frame.length - 1] << 8);
|
|
return expected == stored;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Frame lengths and buffering. STATICALLY CONFIRMED lengths.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const apolloMonitorFrameLength = 25;
|
|
const apolloBaseFrameLength = 25;
|
|
|
|
/// Expected total length for a frame starting with [head], [second], or null
|
|
/// when the combination is not supported by this decoder.
|
|
///
|
|
/// A5 02 -> 4 bytes, other A5 -> 8 bytes, AA/AB with cmd 00/01 -> 25 bytes.
|
|
int? apolloFrameLength(int head, int second) {
|
|
switch (head) {
|
|
case 0xA5:
|
|
return second == 0x02 ? 4 : 8;
|
|
case 0xAA:
|
|
case 0xAB:
|
|
return (second == 0x00 || second == 0x01) ? 25 : null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
bool _isApolloHead(int b) => b == 0xA5 || b == 0xAA || b == 0xAB;
|
|
|
|
/// Stateful decoder that turns arbitrary BLE notification chunks into
|
|
/// complete CRC-valid frames.
|
|
///
|
|
/// Never assumes one notification equals one frame. Resynchronises by dropping
|
|
/// a single byte at a time so one bad byte cannot wedge parsing.
|
|
///
|
|
/// A5 frames: their integrity check is UNKNOWN (the keepalive constant does not
|
|
/// match [apolloCrc16]). Only the exact keepalive packet is recognised and
|
|
/// emitted; any other A5 sequence is skipped one byte at a time.
|
|
class ApolloFrameBuffer {
|
|
ApolloFrameBuffer({this.maxLength = 4096});
|
|
|
|
final int maxLength;
|
|
final List<int> _buf = <int>[];
|
|
|
|
int get length => _buf.length;
|
|
|
|
void clear() => _buf.clear();
|
|
|
|
/// Appends [data] and returns every complete valid frame now available.
|
|
List<Uint8List> add(Uint8List data) {
|
|
_buf.addAll(data);
|
|
if (_buf.length > maxLength) {
|
|
_buf.removeRange(0, _buf.length - maxLength);
|
|
}
|
|
|
|
final frames = <Uint8List>[];
|
|
while (_buf.isNotEmpty) {
|
|
if (!_isApolloHead(_buf[0])) {
|
|
_buf.removeAt(0);
|
|
continue;
|
|
}
|
|
if (_buf.length < 2) break; // need the second byte to size the frame
|
|
|
|
final expectedLength = apolloFrameLength(_buf[0], _buf[1]);
|
|
if (expectedLength == null) {
|
|
_buf.removeAt(0);
|
|
continue;
|
|
}
|
|
if (_buf.length < expectedLength) break;
|
|
|
|
final candidate = Uint8List.fromList(_buf.sublist(0, expectedLength));
|
|
final valid = candidate[0] == 0xA5
|
|
? _isKeepalive(candidate)
|
|
: apolloValidateFrame(candidate);
|
|
if (!valid) {
|
|
_buf.removeAt(0);
|
|
continue;
|
|
}
|
|
frames.add(candidate);
|
|
_buf.removeRange(0, expectedLength);
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
static bool _isKeepalive(Uint8List f) {
|
|
if (f.length != apolloKeepalivePacket.length) return false;
|
|
for (var i = 0; i < f.length; i++) {
|
|
if (f[i] != apolloKeepalivePacket[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AT channel: PIN authentication. STATICALLY CONFIRMED.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Builds `AT+PWD[pin]` with NO trailing CR/LF.
|
|
Uint8List buildApolloPinCommand(String pin) {
|
|
if (!RegExp(r'^\d{6}$').hasMatch(pin)) {
|
|
throw ArgumentError.value(pin, 'pin', 'must be exactly six digits');
|
|
}
|
|
return Uint8List.fromList('AT+PWD[$pin]'.codeUnits);
|
|
}
|
|
|
|
/// Masked form for logs. Never log the real PIN.
|
|
const apolloPinCommandMasked = 'AT+PWD[******]';
|
|
|
|
/// Searches [data] for `OK+PWD:Y` / `OK+PWD:N` after stripping NUL bytes, the
|
|
/// same way Apollo's native parser does. Returns null when neither is present.
|
|
AuthenticationResult? parseApolloPinResponse(Uint8List data) {
|
|
final text = String.fromCharCodes(data.where((b) => b != 0));
|
|
if (text.contains('OK+PWD:Y')) return AuthenticationResult.success;
|
|
if (text.contains('OK+PWD:N')) return AuthenticationResult.invalidCredential;
|
|
return null;
|
|
}
|
|
|
|
/// Small accumulating buffer for fragmented ASCII AT responses.
|
|
class ApolloAtBuffer {
|
|
ApolloAtBuffer({this.maxLength = 512});
|
|
|
|
final int maxLength;
|
|
final List<int> _buf = <int>[];
|
|
|
|
/// Appends [data] (dropping NUL bytes) and returns the full buffered text.
|
|
String add(Uint8List data) {
|
|
_buf.addAll(data.where((b) => b != 0));
|
|
if (_buf.length > maxLength) {
|
|
_buf.removeRange(0, _buf.length - maxLength);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
String get text => String.fromCharCodes(_buf);
|
|
|
|
Uint8List get bytes => Uint8List.fromList(_buf);
|
|
|
|
void clear() => _buf.clear();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Monitor frame (cmd = 0). STATICALLY CONFIRMED layout.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
typedef ApolloMonitorData = ({
|
|
int gear,
|
|
int batteryLevel,
|
|
int rawSpeed,
|
|
double speed,
|
|
double voltage,
|
|
double current,
|
|
double power,
|
|
int motorTemperature,
|
|
int controllerTemperature,
|
|
double tripDistance,
|
|
double odometer,
|
|
bool headlight,
|
|
bool atmosphereLight,
|
|
bool cruiseControl,
|
|
bool bootMode,
|
|
bool imperial,
|
|
bool unlocked,
|
|
bool leftTurnSignal,
|
|
bool rightTurnSignal,
|
|
});
|
|
|
|
int signedByte(int value) => value >= 128 ? value - 256 : value;
|
|
|
|
int _u16be(Uint8List f, int i) => (f[i] << 8) | f[i + 1];
|
|
|
|
int _s16be(Uint8List f, int i) {
|
|
final u = _u16be(f, i);
|
|
return u >= 0x8000 ? u - 0x10000 : u;
|
|
}
|
|
|
|
bool _bit(int value, int bit) => (value & (1 << bit)) != 0;
|
|
|
|
void _requireFrame(Uint8List frame, int cmd, String name) {
|
|
if (frame.length != 25) {
|
|
throw FormatException('$name frame must be 25 bytes, got ${frame.length}');
|
|
}
|
|
if (frame[0] != 0xAA && frame[0] != 0xAB) {
|
|
throw FormatException('$name frame has bad head 0x${frame[0].toRadixString(16)}');
|
|
}
|
|
if (frame[1] != cmd) {
|
|
throw FormatException('$name frame has cmd ${frame[1]}, expected $cmd');
|
|
}
|
|
if (!apolloValidateFrame(frame)) {
|
|
throw const FormatException('CRC mismatch');
|
|
}
|
|
}
|
|
|
|
/// Computes normalised speed from the raw monitor value.
|
|
///
|
|
/// Apollo divides by 1000 and then, when the base frame's
|
|
/// [ApolloBaseData.internalSpeedScalingFlag] is set, multiplies by 100.
|
|
/// Effective raw/10 vs raw/1000 depends on the physical Go: LIVE VERIFICATION PENDING.
|
|
double apolloScaleSpeed(int rawSpeed, {required bool internalSpeedScalingFlag}) {
|
|
var speed = rawSpeed / 1000.0;
|
|
if (internalSpeedScalingFlag) speed *= 100.0;
|
|
return speed;
|
|
}
|
|
|
|
ApolloMonitorData parseApolloMonitorFrame(
|
|
Uint8List frame, {
|
|
required bool internalSpeedScalingFlag,
|
|
}) {
|
|
_requireFrame(frame, 0x00, 'Monitor');
|
|
// frame[2], frame[3]: unknown metadata, preserved and CRC-covered only.
|
|
|
|
final speedA = _u16be(frame, 6);
|
|
final speedB = _u16be(frame, 8);
|
|
final rawSpeed = speedA > speedB ? speedA : speedB;
|
|
|
|
final voltage = _u16be(frame, 10) / 10.0;
|
|
final current = _s16be(frame, 12) / 64.0;
|
|
final power = ((voltage * current) * 10).roundToDouble() / 10.0;
|
|
|
|
final flagsA = frame[21];
|
|
final flagsB = frame[22];
|
|
|
|
return (
|
|
gear: frame[4],
|
|
// Live Apollo Go at full charge reported 0x64 (100). INFERRED percentage.
|
|
batteryLevel: frame[5],
|
|
rawSpeed: rawSpeed,
|
|
speed: apolloScaleSpeed(rawSpeed, internalSpeedScalingFlag: internalSpeedScalingFlag),
|
|
voltage: voltage,
|
|
current: current,
|
|
power: power,
|
|
motorTemperature: signedByte(frame[14]),
|
|
controllerTemperature: signedByte(frame[15]),
|
|
tripDistance: _u16be(frame, 16) / 10.0,
|
|
odometer: ((frame[18] << 16) | (frame[19] << 8) | frame[20]) / 10.0,
|
|
atmosphereLight: _bit(flagsA, 1),
|
|
unlocked: _bit(flagsA, 3),
|
|
rightTurnSignal: _bit(flagsA, 5),
|
|
leftTurnSignal: _bit(flagsA, 6),
|
|
headlight: _bit(flagsA, 7),
|
|
cruiseControl: _bit(flagsB, 2),
|
|
imperial: _bit(flagsB, 5),
|
|
bootMode: _bit(flagsB, 6),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Base / config frame (cmd = 1). STATICALLY CONFIRMED layout.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
typedef ApolloBaseData = ({
|
|
int limitCruise,
|
|
int limitMode1,
|
|
int limitMode2,
|
|
int limitMode3,
|
|
int batteryTemperature,
|
|
int totalBatteryCapacity,
|
|
int remainingBatteryCapacity,
|
|
int batteryCycles,
|
|
String? displayId,
|
|
String displayVersion,
|
|
bool faultEnable,
|
|
bool e9,
|
|
bool f1,
|
|
bool f2,
|
|
bool ctrlFaultEarlyWarning,
|
|
bool e1,
|
|
bool e2,
|
|
bool e3,
|
|
bool e4,
|
|
bool e7,
|
|
bool ctrlSn,
|
|
bool ctrlMp3,
|
|
bool ctrlRgb,
|
|
bool ctrlBms,
|
|
bool internalSpeedScalingFlag,
|
|
});
|
|
|
|
/// Live Apollo Go base frame observations (2026-09-21): capability byte 0x1F,
|
|
/// so [ApolloBaseData.internalSpeedScalingFlag] is TRUE and monitor speed is
|
|
/// effectively raw/10. Battery temperature read 0xD8 (-40), capacities, cycle
|
|
/// count and display id/version were all zero: INFERRED "not fitted" sentinels.
|
|
ApolloBaseData parseApolloBaseFrame(Uint8List frame) {
|
|
_requireFrame(frame, 0x01, 'Base');
|
|
// frame[2]: unknown/reserved, preserved and CRC-covered only.
|
|
// frame[11]: unused by the current Apollo parser.
|
|
|
|
final flagsA = frame[8];
|
|
final flagsB = frame[9];
|
|
final caps = frame[10];
|
|
|
|
final idHi = frame[18];
|
|
final idLo = frame[19];
|
|
final displayId = (idHi == 0 && idLo == 0)
|
|
? null
|
|
: '${idHi.toRadixString(16).padLeft(2, '0')}${idLo.toRadixString(16).padLeft(2, '0')}';
|
|
|
|
return (
|
|
limitCruise: frame[3],
|
|
limitMode1: frame[4],
|
|
limitMode2: frame[5],
|
|
limitMode3: frame[6],
|
|
batteryTemperature: signedByte(frame[7]),
|
|
totalBatteryCapacity: _u16be(frame, 12),
|
|
remainingBatteryCapacity: _u16be(frame, 14),
|
|
batteryCycles: _u16be(frame, 16),
|
|
displayId: displayId,
|
|
displayVersion: 'V${frame[20]}.${frame[21]}.${frame[22]}',
|
|
faultEnable: _bit(flagsA, 7),
|
|
e9: _bit(flagsA, 1),
|
|
f1: _bit(flagsA, 2),
|
|
f2: _bit(flagsA, 3),
|
|
// Apollo 4.8.18340 maps both f2 and ctrlFaultEarlyWarning
|
|
// to frame[8] bit 3. Preserve until live/protocol evidence says otherwise.
|
|
ctrlFaultEarlyWarning: _bit(flagsA, 3),
|
|
e1: _bit(flagsB, 1),
|
|
e2: _bit(flagsB, 2),
|
|
e3: _bit(flagsB, 3),
|
|
e4: _bit(flagsB, 4),
|
|
e7: _bit(flagsB, 7),
|
|
ctrlSn: _bit(caps, 0),
|
|
ctrlMp3: _bit(caps, 1),
|
|
ctrlRgb: _bit(caps, 2),
|
|
ctrlBms: _bit(caps, 3),
|
|
internalSpeedScalingFlag: _bit(caps, 4),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Outbound set-base packet. STATICALLY CONFIRMED layout, NOT LIVE VERIFIED.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Builds the 10-byte `AB 00 0A FLAGS LC M1 M2 M3 CRC_LO CRC_HI` packet.
|
|
///
|
|
/// FLAGS: bits 0-1 gear, bit 2 headlight, bit 3 atmosphere light,
|
|
/// bit 4 cruise, bit 5 boot mode, bit 6 imperial, bit 7 unlocked.
|
|
///
|
|
/// Every field must come from CURRENT scooter state (read-modify-write).
|
|
/// Callers must never invent speed limits or unit preferences.
|
|
Uint8List buildApolloSetBasePacket({
|
|
required int gearPosition,
|
|
required bool headlight,
|
|
required bool atmosphereLight,
|
|
required bool cruiseControl,
|
|
required bool bootMode,
|
|
required bool imperial,
|
|
required bool unlocked,
|
|
required int limitCruise,
|
|
required int limitMode1,
|
|
required int limitMode2,
|
|
required int limitMode3,
|
|
}) {
|
|
for (final (name, v) in [
|
|
('limitCruise', limitCruise),
|
|
('limitMode1', limitMode1),
|
|
('limitMode2', limitMode2),
|
|
('limitMode3', limitMode3),
|
|
]) {
|
|
if (v < 0 || v > 0xFF) throw ArgumentError.value(v, name, 'must fit one byte');
|
|
}
|
|
|
|
var flags = gearPosition & 0x03;
|
|
if (headlight) flags |= 1 << 2;
|
|
if (atmosphereLight) flags |= 1 << 3;
|
|
if (cruiseControl) flags |= 1 << 4;
|
|
if (bootMode) flags |= 1 << 5;
|
|
if (imperial) flags |= 1 << 6;
|
|
if (unlocked) flags |= 1 << 7;
|
|
|
|
final data = <int>[
|
|
0xAB, 0x00, 0x0A, flags,
|
|
limitCruise, limitMode1, limitMode2, limitMode3,
|
|
];
|
|
final crc = apolloCrc16(data);
|
|
return Uint8List.fromList([...data, crc & 0xFF, (crc >> 8) & 0xFF]);
|
|
}
|
|
|
|
/// Hex dump helper for debug logs: `AA 00 0A ...`.
|
|
String apolloHex(Iterable<int> bytes) => bytes
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
.join(' ');
|