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
+461
View File
@@ -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(' ');
+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);
}
+46
View File
@@ -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;
}