529 lines
16 KiB
Dart
529 lines
16 KiB
Dart
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);
|
|
}
|