Initial version

This commit is contained in:
2026-09-21 18:03:21 -04:00
commit e8e7dc4a81
83 changed files with 7177 additions and 0 deletions
+528
View File
@@ -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);
}