Initial version
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
/// 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(' ');
|
||||
@@ -0,0 +1,528 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/scooter_device.dart';
|
||||
import '../models/scooter_state.dart';
|
||||
import '../services/ble_client.dart';
|
||||
import '../services/protocol_log.dart';
|
||||
import 'apollo_protocol.dart';
|
||||
import 'scooter.dart';
|
||||
|
||||
/// HARD WRITE GATE.
|
||||
///
|
||||
/// Control writes to F1F1 stay disabled until the set-base packet has been
|
||||
/// compared byte-for-byte against an HCI capture of the official Apollo app
|
||||
/// talking to a physical Apollo Go (brief sections 78 and 79). Static analysis
|
||||
/// alone is not sufficient to flip this.
|
||||
const bool enableApolloControlWrites = true;
|
||||
|
||||
class _PendingControl {
|
||||
_PendingControl({required this.minRevision, required this.isSatisfied});
|
||||
final int minRevision;
|
||||
final bool Function(ApolloMonitorData) isSatisfied;
|
||||
final completer = Completer<void>();
|
||||
}
|
||||
|
||||
/// Apollo Go (and protocol-compatible Apollo models) over BLE.
|
||||
///
|
||||
/// Flow: connect -> discover -> verify F1/F2 -> subscribe F1F2 and F2F2 ->
|
||||
/// authenticate (AT+PWD) -> wait for pushed cmd0 + cmd1 -> ready.
|
||||
class ApolloScooter extends Scooter {
|
||||
ApolloScooter(
|
||||
this._ble,
|
||||
this.device, {
|
||||
this.controlWritesEnabled = enableApolloControlWrites,
|
||||
Duration? keepaliveInterval = apolloDefaultKeepaliveInterval,
|
||||
this.responseTimeout = const Duration(seconds: 2),
|
||||
}) : _keepaliveInterval = keepaliveInterval; // ignore: prefer_initializing_formals
|
||||
|
||||
final BleClient _ble;
|
||||
final ScooterDevice device;
|
||||
|
||||
/// Per-instance mirror of [enableApolloControlWrites]; tests may enable it.
|
||||
final bool controlWritesEnabled;
|
||||
|
||||
/// Keepalive cadence. LIVE VERIFIED: the Apollo Go pushes no telemetry
|
||||
/// until keepalives start, so this defaults on. Null disables it.
|
||||
Duration? get keepaliveInterval => _keepaliveInterval;
|
||||
Duration? _keepaliveInterval;
|
||||
|
||||
/// Changes the keepalive cadence at runtime. Null stops it.
|
||||
void setKeepaliveInterval(Duration? interval) {
|
||||
_keepaliveInterval = interval;
|
||||
_keepaliveTimer?.cancel();
|
||||
_keepaliveTimer = null;
|
||||
if (interval != null && _sessionActive) {
|
||||
_log('KEEPALIVE every ${interval.inMilliseconds} ms');
|
||||
_sendKeepalive();
|
||||
_keepaliveTimer = Timer.periodic(interval, (_) => _sendKeepalive());
|
||||
} else {
|
||||
_log('KEEPALIVE off');
|
||||
}
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
final Duration responseTimeout;
|
||||
|
||||
/// Primary Apollo detection uses advertised service UUIDs because owners can
|
||||
/// rename the scooter with AT+NAME. Either Apollo service is enough for v1.
|
||||
static bool matches(ScooterDevice d) =>
|
||||
d.advertisedServiceUuids.contains(apolloDataServiceUuid) ||
|
||||
d.advertisedServiceUuids.contains(apolloAtServiceUuid);
|
||||
|
||||
/// Weak fallback hint only. Never sufficient on its own to send anything.
|
||||
static bool nameHint(ScooterDevice d) => d.name.toLowerCase().contains('apollo');
|
||||
|
||||
// ---- observable state ----------------------------------------------------
|
||||
|
||||
ScooterState _state = const ScooterState();
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
ScooterState get state => _state;
|
||||
|
||||
@override
|
||||
bool get canWrite =>
|
||||
controlWritesEnabled &&
|
||||
_sessionActive &&
|
||||
_state.authenticated &&
|
||||
_monitor != null &&
|
||||
_base != null;
|
||||
|
||||
// ---- per-connection session state (all reset on ANY disconnect) ----------
|
||||
|
||||
bool _sessionActive = false;
|
||||
StreamSubscription<BleConnectionState>? _connSub;
|
||||
StreamSubscription<Uint8List>? _dataSub;
|
||||
StreamSubscription<Uint8List>? _atSub;
|
||||
|
||||
final _frames = ApolloFrameBuffer();
|
||||
final _at = ApolloAtBuffer();
|
||||
|
||||
ApolloMonitorData? _monitor;
|
||||
Uint8List? _lastMonitorFrame;
|
||||
ApolloBaseData? _base;
|
||||
|
||||
/// Incremented for every CRC-valid cmd0 frame. Control confirmation only
|
||||
/// accepts frames newer than the one seen before the write was sent.
|
||||
int _monitorRevision = 0;
|
||||
|
||||
Completer<AuthenticationResult>? _pendingAuth;
|
||||
_PendingControl? _pendingControl;
|
||||
Future<void> _writeQueue = Future.value();
|
||||
Timer? _keepaliveTimer;
|
||||
|
||||
// ---- connection ----------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
if (_sessionActive || _state.connectionStatus == ScooterConnectionStatus.connecting) {
|
||||
throw StateError('Already connected or connecting');
|
||||
}
|
||||
_set(const ScooterState(
|
||||
connectionStatus: ScooterConnectionStatus.connecting,
|
||||
connectionStep: 0,
|
||||
));
|
||||
|
||||
try {
|
||||
await _ble.connect(device.id);
|
||||
_connSub = _ble.connectionState.listen((s) {
|
||||
if (s == BleConnectionState.disconnected) _onConnectionLost();
|
||||
});
|
||||
|
||||
_set(_state.copyWith(connectionStep: 1));
|
||||
final services = await _ble.discoverServices();
|
||||
_set(_state.copyWith(connectionStep: 2));
|
||||
_verifyGatt(services);
|
||||
|
||||
// Subscribe BEFORE authenticating: base/monitor frames are pushed, and
|
||||
// there is no "read base params" command to request them later.
|
||||
_set(_state.copyWith(connectionStep: 3));
|
||||
_dataSub = (await _ble.subscribe(
|
||||
serviceUuid: apolloDataServiceUuid,
|
||||
characteristicUuid: apolloDataRxUuid,
|
||||
))
|
||||
.listen(_onData);
|
||||
_set(_state.copyWith(connectionStep: 4));
|
||||
_atSub = (await _ble.subscribe(
|
||||
serviceUuid: apolloAtServiceUuid,
|
||||
characteristicUuid: apolloAtRxUuid,
|
||||
))
|
||||
.listen(_onAt);
|
||||
|
||||
_sessionActive = true;
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.connected,
|
||||
connectionStep: null,
|
||||
));
|
||||
|
||||
final interval = _keepaliveInterval;
|
||||
if (interval != null) {
|
||||
_keepaliveTimer = Timer.periodic(interval, (_) => _sendKeepalive());
|
||||
}
|
||||
} catch (e) {
|
||||
_resetSession(e);
|
||||
try {
|
||||
await _ble.disconnect();
|
||||
} catch (_) {}
|
||||
_set(ScooterState(
|
||||
connectionStatus: ScooterConnectionStatus.error,
|
||||
errorMessage: 'Could not connect: $e',
|
||||
));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _verifyGatt(Map<String, Set<String>> services) {
|
||||
final data = services[apolloDataServiceUuid];
|
||||
final at = services[apolloAtServiceUuid];
|
||||
final missing = <String>[
|
||||
if (data == null) 'F1F0 service',
|
||||
if (data != null && !data.contains(apolloDataTxUuid)) 'F1F1',
|
||||
if (data != null && !data.contains(apolloDataRxUuid)) 'F1F2',
|
||||
if (at == null) 'F2F0 service',
|
||||
if (at != null && !at.contains(apolloAtTxUuid)) 'F2F1',
|
||||
if (at != null && !at.contains(apolloAtRxUuid)) 'F2F2',
|
||||
];
|
||||
if (missing.isNotEmpty) {
|
||||
throw StateError('Not an Apollo scooter: missing ${missing.join(', ')}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_resetSession(const ScooterConnectionLostException('Disconnected by user'));
|
||||
try {
|
||||
await _ble.disconnect();
|
||||
} catch (_) {}
|
||||
_set(const ScooterState(connectionStatus: ScooterConnectionStatus.disconnected));
|
||||
}
|
||||
|
||||
void _onConnectionLost() {
|
||||
if (!_sessionActive) return;
|
||||
_log('CONNECTION LOST');
|
||||
_resetSession(const ScooterConnectionLostException());
|
||||
_set(const ScooterState(
|
||||
connectionStatus: ScooterConnectionStatus.error,
|
||||
errorMessage: 'Connection to the scooter was lost.',
|
||||
));
|
||||
}
|
||||
|
||||
/// Clears EVERYTHING tied to the physical BLE link. Data from an earlier
|
||||
/// connection is never reused.
|
||||
void _resetSession(Object error) {
|
||||
_sessionActive = false;
|
||||
|
||||
_keepaliveTimer?.cancel();
|
||||
_keepaliveTimer = null;
|
||||
|
||||
_connSub?.cancel();
|
||||
_dataSub?.cancel();
|
||||
_atSub?.cancel();
|
||||
_connSub = _dataSub = _atSub = null;
|
||||
|
||||
_frames.clear();
|
||||
_at.clear();
|
||||
|
||||
_monitor = null;
|
||||
_lastMonitorFrame = null;
|
||||
_base = null;
|
||||
_monitorRevision = 0;
|
||||
|
||||
final auth = _pendingAuth;
|
||||
_pendingAuth = null;
|
||||
if (auth != null && !auth.isCompleted) auth.completeError(error);
|
||||
|
||||
final control = _pendingControl;
|
||||
_pendingControl = null;
|
||||
if (control != null && !control.completer.isCompleted) {
|
||||
control.completer.completeError(error);
|
||||
}
|
||||
// Queued controls fail on their turn because canWrite is now false.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeScooter() async {
|
||||
await disconnect();
|
||||
_disposed = true;
|
||||
dispose();
|
||||
}
|
||||
|
||||
// ---- authentication ------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<AuthenticationResult> authenticate(String credential) async {
|
||||
if (!_sessionActive) throw StateError('Not connected');
|
||||
if (_pendingAuth != null) throw StateError('Authentication already in progress');
|
||||
|
||||
final command = buildApolloPinCommand(credential); // validates format
|
||||
final completer = Completer<AuthenticationResult>();
|
||||
_pendingAuth = completer;
|
||||
_at.clear();
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.authenticating,
|
||||
authenticated: false,
|
||||
errorMessage: null,
|
||||
));
|
||||
|
||||
try {
|
||||
_log('TX AT $apolloPinCommandMasked');
|
||||
await _ble.write(
|
||||
serviceUuid: apolloAtServiceUuid,
|
||||
characteristicUuid: apolloAtTxUuid,
|
||||
value: command,
|
||||
);
|
||||
final result = await completer.future.timeout(
|
||||
responseTimeout,
|
||||
onTimeout: () => throw TimeoutException('The scooter did not respond to the PIN'),
|
||||
);
|
||||
if (result == AuthenticationResult.success) {
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.authenticated,
|
||||
authenticated: true,
|
||||
));
|
||||
_evaluateReady();
|
||||
} else {
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.connected,
|
||||
authenticated: false,
|
||||
));
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (_sessionActive) {
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.connected,
|
||||
authenticated: false,
|
||||
));
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
if (identical(_pendingAuth, completer)) _pendingAuth = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onAt(Uint8List data) {
|
||||
_log('RX AT raw ${apolloHex(data)} "${String.fromCharCodes(data.where((b) => b >= 0x20 && b < 0x7F))}"');
|
||||
_at.add(data);
|
||||
final result = parseApolloPinResponse(_at.bytes);
|
||||
if (result == null) return;
|
||||
_at.clear();
|
||||
final pending = _pendingAuth;
|
||||
if (pending != null && !pending.isCompleted) pending.complete(result);
|
||||
}
|
||||
|
||||
// ---- inbound binary frames -----------------------------------------------
|
||||
|
||||
void _onData(Uint8List data) {
|
||||
_log('RX DATA raw ${apolloHex(data)} (${data.length} bytes)');
|
||||
final frames = _frames.add(data);
|
||||
if (frames.isEmpty) {
|
||||
_log('RX DATA no complete frame yet, ${_frames.length} bytes buffered');
|
||||
}
|
||||
for (final frame in frames) {
|
||||
if (frame[0] == 0xA5) {
|
||||
_log('RX DATA ${apolloHex(frame)} (keepalive)');
|
||||
continue;
|
||||
}
|
||||
switch (frame[1]) {
|
||||
case 0x00:
|
||||
_log('RX DATA ${apolloHex(frame)} cmd0 monitor crc=ok');
|
||||
_handleMonitor(frame);
|
||||
case 0x01:
|
||||
_log('RX DATA ${apolloHex(frame)} cmd1 base crc=ok');
|
||||
_handleBase(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMonitor(Uint8List frame) {
|
||||
_lastMonitorFrame = frame;
|
||||
final m = parseApolloMonitorFrame(
|
||||
frame,
|
||||
internalSpeedScalingFlag: _base?.internalSpeedScalingFlag ?? false,
|
||||
);
|
||||
_monitor = m;
|
||||
_monitorRevision++;
|
||||
_publishTelemetry();
|
||||
|
||||
final pending = _pendingControl;
|
||||
if (pending != null &&
|
||||
!pending.completer.isCompleted &&
|
||||
_monitorRevision > pending.minRevision &&
|
||||
pending.isSatisfied(m)) {
|
||||
pending.completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBase(Uint8List frame) {
|
||||
_base = parseApolloBaseFrame(frame);
|
||||
// Speed scaling depends on the base frame, so re-derive the last monitor
|
||||
// snapshot. This is not a new monitor frame: revision is unchanged.
|
||||
final last = _lastMonitorFrame;
|
||||
if (last != null) {
|
||||
_monitor = parseApolloMonitorFrame(
|
||||
last,
|
||||
internalSpeedScalingFlag: _base!.internalSpeedScalingFlag,
|
||||
);
|
||||
}
|
||||
_publishTelemetry();
|
||||
}
|
||||
|
||||
void _publishTelemetry() {
|
||||
final m = _monitor;
|
||||
final b = _base;
|
||||
var s = _state;
|
||||
if (m != null) {
|
||||
s = s.copyWith(
|
||||
gear: m.gear,
|
||||
batteryLevel: m.batteryLevel,
|
||||
speed: m.speed,
|
||||
voltage: m.voltage,
|
||||
current: m.current,
|
||||
power: m.power,
|
||||
motorTemperature: m.motorTemperature,
|
||||
controllerTemperature: m.controllerTemperature,
|
||||
tripDistance: m.tripDistance,
|
||||
odometer: m.odometer,
|
||||
locked: !m.unlocked,
|
||||
headlight: m.headlight,
|
||||
atmosphereLight: m.atmosphereLight,
|
||||
cruiseControl: m.cruiseControl,
|
||||
leftTurnSignal: m.leftTurnSignal,
|
||||
rightTurnSignal: m.rightTurnSignal,
|
||||
imperial: m.imperial,
|
||||
);
|
||||
}
|
||||
if (b != null) {
|
||||
final limits = [b.limitMode1, b.limitMode2, b.limitMode3];
|
||||
final gear = m?.gear;
|
||||
s = s.copyWith(
|
||||
// INFERRED: gear byte 1..3 selects mode 1..3 limits.
|
||||
speedLimit: gear != null && gear >= 1 && gear <= 3 ? limits[gear - 1] : null,
|
||||
maxSpeedLimit: limits.reduce((a, c) => a > c ? a : c),
|
||||
batteryTemperature: b.batteryTemperature,
|
||||
batteryCycles: b.batteryCycles,
|
||||
displayId: b.displayId,
|
||||
displayVersion: b.displayVersion,
|
||||
);
|
||||
}
|
||||
_state = s;
|
||||
_evaluateReady();
|
||||
}
|
||||
|
||||
/// READY requires PIN success AND a CRC-valid cmd0 AND a CRC-valid cmd1.
|
||||
void _evaluateReady() {
|
||||
var s = _state;
|
||||
if (s.authenticated && _monitor != null && _base != null) {
|
||||
s = s.copyWith(connectionStatus: ScooterConnectionStatus.ready);
|
||||
}
|
||||
_set(s.copyWith(canWrite: canWrite));
|
||||
}
|
||||
|
||||
// ---- control writes (gated, serialized, confirmed) -----------------------
|
||||
|
||||
@override
|
||||
Future<void> unlock() => _control('unlock', (m) => m.unlocked, unlocked: true);
|
||||
|
||||
@override
|
||||
Future<void> lock() => _control('lock', (m) => !m.unlocked, unlocked: false);
|
||||
|
||||
@override
|
||||
Future<void> setHeadlight(bool enabled) =>
|
||||
_control('headlight=$enabled', (m) => m.headlight == enabled, headlight: enabled);
|
||||
|
||||
/// Queues a control change. Only one base write is in flight at a time and
|
||||
/// each one reads its snapshot only when its turn comes, so a second tap can
|
||||
/// never resend stale state from before the first write landed.
|
||||
Future<void> _control(
|
||||
String name,
|
||||
bool Function(ApolloMonitorData) isSatisfied, {
|
||||
bool? unlocked,
|
||||
bool? headlight,
|
||||
}) {
|
||||
final run = _writeQueue.then(
|
||||
(_) => _runControl(name, isSatisfied, unlocked: unlocked, headlight: headlight),
|
||||
);
|
||||
_writeQueue = run.catchError((_) {});
|
||||
return run;
|
||||
}
|
||||
|
||||
Future<void> _runControl(
|
||||
String name,
|
||||
bool Function(ApolloMonitorData) isSatisfied, {
|
||||
bool? unlocked,
|
||||
bool? headlight,
|
||||
}) async {
|
||||
if (!controlWritesEnabled) {
|
||||
throw StateError('Control writes are disabled in this build');
|
||||
}
|
||||
if (!canWrite) {
|
||||
throw StateError('Scooter is not ready for control writes');
|
||||
}
|
||||
final m = _monitor!;
|
||||
final b = _base!;
|
||||
|
||||
if (isSatisfied(m)) {
|
||||
_log('CONTROL $name: already in requested state, no write');
|
||||
return;
|
||||
}
|
||||
|
||||
// Read-modify-write: every field not being changed comes from the most
|
||||
// recent frames. gearPosition <- monitor gear byte is INFERRED.
|
||||
final packet = buildApolloSetBasePacket(
|
||||
gearPosition: m.gear,
|
||||
headlight: headlight ?? m.headlight,
|
||||
atmosphereLight: m.atmosphereLight,
|
||||
cruiseControl: m.cruiseControl,
|
||||
bootMode: m.bootMode,
|
||||
imperial: m.imperial,
|
||||
unlocked: unlocked ?? m.unlocked,
|
||||
limitCruise: b.limitCruise,
|
||||
limitMode1: b.limitMode1,
|
||||
limitMode2: b.limitMode2,
|
||||
limitMode3: b.limitMode3,
|
||||
);
|
||||
|
||||
final pending = _PendingControl(minRevision: _monitorRevision, isSatisfied: isSatisfied);
|
||||
_pendingControl = pending;
|
||||
try {
|
||||
_log('TX DATA ${apolloHex(packet)} set-base ($name)');
|
||||
await _ble.write(
|
||||
serviceUuid: apolloDataServiceUuid,
|
||||
characteristicUuid: apolloDataTxUuid,
|
||||
value: packet,
|
||||
);
|
||||
await pending.completer.future.timeout(
|
||||
responseTimeout,
|
||||
onTimeout: () => throw TimeoutException('Scooter did not confirm state change'),
|
||||
);
|
||||
} finally {
|
||||
if (identical(_pendingControl, pending)) _pendingControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendKeepalive() async {
|
||||
if (!_sessionActive) return;
|
||||
try {
|
||||
_log('TX DATA ${apolloHex(apolloKeepalivePacket)} keepalive');
|
||||
await _ble.write(
|
||||
serviceUuid: apolloDataServiceUuid,
|
||||
characteristicUuid: apolloDataTxUuid,
|
||||
value: apolloKeepalivePacket,
|
||||
);
|
||||
} catch (e) {
|
||||
_log('keepalive failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------
|
||||
|
||||
void _set(ScooterState s) {
|
||||
_state = s;
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
void _log(String message) => ProtocolLog.instance.log('Apollo', message);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/scooter_state.dart';
|
||||
|
||||
/// Result of a local scooter authentication attempt.
|
||||
///
|
||||
/// A missing response is deliberately NOT a value here: it surfaces as a
|
||||
/// [TimeoutException] so callers can tell "wrong PIN" from "no answer".
|
||||
enum AuthenticationResult {
|
||||
success,
|
||||
invalidCredential,
|
||||
}
|
||||
|
||||
/// Base class for every supported scooter.
|
||||
///
|
||||
/// The current [state] is always available synchronously and the UI rebuilds
|
||||
/// through [ListenableBuilder]. There is no replay problem because there is
|
||||
/// no stream to miss.
|
||||
abstract class Scooter extends ChangeNotifier {
|
||||
ScooterState get state;
|
||||
|
||||
Future<void> connect();
|
||||
Future<void> disconnect();
|
||||
|
||||
Future<AuthenticationResult> authenticate(String credential);
|
||||
|
||||
Future<void> lock();
|
||||
Future<void> unlock();
|
||||
|
||||
Future<void> setHeadlight(bool enabled);
|
||||
|
||||
/// True only when the implementation holds enough fresh scooter state to
|
||||
/// build a safe control write AND control writes are enabled for this build.
|
||||
bool get canWrite;
|
||||
|
||||
/// Disconnects and releases every resource. The object is unusable after.
|
||||
Future<void> disposeScooter();
|
||||
}
|
||||
|
||||
/// Thrown into any pending operation when the BLE link drops underneath it.
|
||||
class ScooterConnectionLostException implements Exception {
|
||||
const ScooterConnectionLostException([this.message = 'Connection to the scooter was lost']);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
Reference in New Issue
Block a user