/// 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 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 _buf = []; int get length => _buf.length; void clear() => _buf.clear(); /// Appends [data] and returns every complete valid frame now available. List add(Uint8List data) { _buf.addAll(data); if (_buf.length > maxLength) { _buf.removeRange(0, _buf.length - maxLength); } final frames = []; 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 _buf = []; /// 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 = [ 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 bytes) => bytes .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(' ');