Initial version
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user