Files
OpenMotion/test/scooters/apollo_scooter_test.dart
T
2026-09-21 18:03:21 -04:00

500 lines
16 KiB
Dart

import 'dart:async';
import 'dart:typed_data';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:openscooter/models/scooter_device.dart';
import 'package:openscooter/models/scooter_state.dart';
import 'package:openscooter/scooters/apollo_protocol.dart';
import 'package:openscooter/scooters/apollo_scooter.dart';
import 'package:openscooter/scooters/scooter.dart';
import 'package:openscooter/services/ble_client.dart';
Uint8List hex(String s) => Uint8List.fromList(
s.trim().split(RegExp(r'\s+')).map((h) => int.parse(h, radix: 16)).toList(),
);
final monitorFrame =
hex('AA 00 00 00 02 4B 00 FA 00 C8 01 E0 01 40 23 28 00 7B 00 30 39 8A 24 E9 1F');
final baseFrame =
hex('AB 01 00 19 06 0C 14 1E 80 00 1F 00 27 10 13 88 00 64 12 AB 02 05 11 F0 C0');
Uint8List withFlags(Uint8List frame, {int? flagsA}) {
final out = Uint8List.fromList(frame);
if (flagsA != null) out[21] = flagsA;
final crc = apolloCrc16(out.sublist(0, out.length - 2));
out[23] = crc & 0xFF;
out[24] = crc >> 8;
return out;
}
class FakeBleClient extends BleClient {
final connState = StreamController<BleConnectionState>.broadcast();
final dataRx = StreamController<Uint8List>.broadcast();
final atRx = StreamController<Uint8List>.broadcast();
Map<String, Set<String>> services = {
apolloDataServiceUuid: {apolloDataTxUuid, apolloDataRxUuid},
apolloAtServiceUuid: {apolloAtTxUuid, apolloAtRxUuid},
};
final subscribed = <String>[];
final writes = <(String, Uint8List)>[];
String? connectedId;
int disconnectCalls = 0;
/// Auto-respond to AT+PWD with this text (null: stay silent).
String? pinReply = 'OK+PWD:Y';
@override
Stream<List<ScooterDevice>> scan() => const Stream.empty();
@override
Future<void> stopScan() async {}
@override
Future<void> connect(String deviceId) async => connectedId = deviceId;
@override
Future<void> disconnect() async {
disconnectCalls++;
connectedId = null;
}
@override
Stream<BleConnectionState> get connectionState => connState.stream;
@override
Future<Map<String, Set<String>>> discoverServices() async => services;
@override
Future<Stream<Uint8List>> subscribe({
required String serviceUuid,
required String characteristicUuid,
}) async {
subscribed.add(characteristicUuid);
if (characteristicUuid == apolloDataRxUuid) return dataRx.stream;
if (characteristicUuid == apolloAtRxUuid) return atRx.stream;
throw StateError('unknown characteristic');
}
@override
Future<void> write({
required String serviceUuid,
required String characteristicUuid,
required Uint8List value,
}) async {
writes.add((characteristicUuid, value));
if (characteristicUuid == apolloAtTxUuid && pinReply != null) {
scheduleMicrotask(() => atRx.add(Uint8List.fromList(pinReply!.codeUnits)));
}
}
void dropLink() => connState.add(BleConnectionState.disconnected);
}
final device = ScooterDevice(
id: 'AA:BB',
name: 'Apollo Go',
rssi: -50,
advertisedServiceUuids: {apolloDataServiceUuid},
);
/// Drains microtasks and timers so async chains settle inside fakeAsync.
void settle(FakeAsync fa) => fa.flushMicrotasks();
void main() {
test('matches uses advertised service UUIDs, not name', () {
expect(ApolloScooter.matches(device), isTrue);
expect(
ApolloScooter.matches(const ScooterDevice(
id: 'x', name: 'Apollo', rssi: 0, advertisedServiceUuids: {apolloAtServiceUuid})),
isTrue,
);
expect(
ApolloScooter.matches(const ScooterDevice(id: 'x', name: 'Apollo Go', rssi: 0)),
isFalse,
);
expect(ApolloScooter.nameHint(const ScooterDevice(id: 'x', name: 'Apollo Go', rssi: 0)), isTrue);
});
test('connect subscribes to F1F2 before F2F2 and reports connected', () {
fakeAsync((fa) {
final ble = FakeBleClient();
final s = ApolloScooter(ble, device, keepaliveInterval: null);
s.connect();
settle(fa);
expect(ble.connectedId, 'AA:BB');
expect(ble.subscribed, [apolloDataRxUuid, apolloAtRxUuid]);
expect(s.state.connectionStatus, ScooterConnectionStatus.connected);
expect(s.state.authenticated, isFalse);
expect(s.canWrite, isFalse);
});
});
test('connect fails when Apollo characteristics are missing', () {
fakeAsync((fa) {
final ble = FakeBleClient()..services = {apolloDataServiceUuid: {apolloDataTxUuid}};
final s = ApolloScooter(ble, device, keepaliveInterval: null);
Object? error;
s.connect().catchError((e) => error = e);
settle(fa);
expect(error, isStateError);
expect(s.state.connectionStatus, ScooterConnectionStatus.error);
expect(ble.disconnectCalls, 1);
expect(ble.subscribed, isEmpty);
});
});
test('PIN success: masked command sent, state authenticated', () {
fakeAsync((fa) {
final ble = FakeBleClient();
final s = ApolloScooter(ble, device, keepaliveInterval: null);
s.connect();
settle(fa);
AuthenticationResult? result;
s.authenticate('123456').then((r) => result = r);
settle(fa);
expect(ble.writes.single.$1, apolloAtTxUuid);
expect(String.fromCharCodes(ble.writes.single.$2), 'AT+PWD[123456]');
expect(result, AuthenticationResult.success);
expect(s.state.authenticated, isTrue);
expect(s.state.connectionStatus, ScooterConnectionStatus.authenticated);
expect(s.canWrite, isFalse, reason: 'no frames yet');
});
});
test('PIN failure: remains unauthenticated', () {
fakeAsync((fa) {
final ble = FakeBleClient()..pinReply = 'OK+PWD:N';
final s = ApolloScooter(ble, device, keepaliveInterval: null);
s.connect();
settle(fa);
AuthenticationResult? result;
s.authenticate('000000').then((r) => result = r);
settle(fa);
expect(result, AuthenticationResult.invalidCredential);
expect(s.state.authenticated, isFalse);
expect(s.state.connectionStatus, ScooterConnectionStatus.connected);
});
});
test('PIN fragmented across notifications still succeeds', () {
fakeAsync((fa) {
final ble = FakeBleClient()..pinReply = null;
final s = ApolloScooter(ble, device, keepaliveInterval: null);
s.connect();
settle(fa);
AuthenticationResult? result;
s.authenticate('123456').then((r) => result = r);
settle(fa);
ble.atRx.add(Uint8List.fromList('OK+PW'.codeUnits));
settle(fa);
expect(result, isNull);
ble.atRx.add(Uint8List.fromList('D:Y'.codeUnits));
settle(fa);
expect(result, AuthenticationResult.success);
});
});
test('PIN with no response times out after 2 seconds, distinct from wrong PIN', () {
fakeAsync((fa) {
final ble = FakeBleClient()..pinReply = null;
final s = ApolloScooter(ble, device, keepaliveInterval: null);
s.connect();
settle(fa);
Object? error;
s.authenticate('123456').catchError((e) {
error = e;
return AuthenticationResult.invalidCredential;
});
fa.elapse(const Duration(milliseconds: 1999));
expect(error, isNull);
expect(s.state.connectionStatus, ScooterConnectionStatus.authenticating);
fa.elapse(const Duration(milliseconds: 2));
expect(error, isA<TimeoutException>());
expect(s.state.connectionStatus, ScooterConnectionStatus.connected);
expect(s.state.authenticated, isFalse);
expect(ble.writes.length, 1, reason: 'no retries');
});
});
group('READY gating', () {
late FakeBleClient ble;
late ApolloScooter s;
void connectAndAuth(FakeAsync fa, {bool writes = false}) {
ble = FakeBleClient();
s = ApolloScooter(ble, device, controlWritesEnabled: writes, keepaliveInterval: null);
s.connect();
settle(fa);
s.authenticate('123456');
settle(fa);
}
test('cmd0 only: not ready, canWrite false', () {
fakeAsync((fa) {
connectAndAuth(fa, writes: true);
ble.dataRx.add(monitorFrame);
settle(fa);
expect(s.state.connectionStatus, ScooterConnectionStatus.authenticated);
expect(s.state.speed, 0.25, reason: 'scaling flag unknown yet -> raw/1000');
expect(s.state.batteryLevel, 75);
expect(s.canWrite, isFalse);
});
});
test('cmd1 only: not ready, canWrite false', () {
fakeAsync((fa) {
connectAndAuth(fa, writes: true);
ble.dataRx.add(baseFrame);
settle(fa);
expect(s.state.connectionStatus, ScooterConnectionStatus.authenticated);
expect(s.state.displayVersion, 'V2.5.17');
expect(s.canWrite, isFalse);
});
});
test('cmd0 + cmd1: ready, speed re-derived with scaling flag', () {
fakeAsync((fa) {
connectAndAuth(fa, writes: true);
ble.dataRx.add(monitorFrame);
ble.dataRx.add(baseFrame);
settle(fa);
expect(s.state.connectionStatus, ScooterConnectionStatus.ready);
expect(s.state.speed, 25.0);
expect(s.state.locked, isFalse);
expect(s.state.batteryCycles, 100);
expect(s.canWrite, isTrue);
expect(s.state.canWrite, isTrue);
});
});
test('frames before authentication do not make the scooter ready', () {
fakeAsync((fa) {
ble = FakeBleClient();
s = ApolloScooter(ble, device, controlWritesEnabled: true, keepaliveInterval: null);
s.connect();
settle(fa);
ble.dataRx.add(Uint8List.fromList([...monitorFrame, ...baseFrame]));
settle(fa);
expect(s.state.connectionStatus, ScooterConnectionStatus.connected);
expect(s.canWrite, isFalse);
s.authenticate('123456');
settle(fa);
expect(s.state.connectionStatus, ScooterConnectionStatus.ready);
});
});
test('write gate flag off: ready but canWrite stays false', () {
fakeAsync((fa) {
connectAndAuth(fa);
ble.dataRx.add(Uint8List.fromList([...monitorFrame, ...baseFrame]));
settle(fa);
expect(s.state.connectionStatus, ScooterConnectionStatus.ready);
expect(s.canWrite, isFalse);
Object? error;
s.lock().catchError((e) => error = e);
settle(fa);
expect(error, isStateError);
expect(ble.writes.where((w) => w.$1 == apolloDataTxUuid), isEmpty);
});
});
});
test('unexpected disconnect clears everything and fails pending auth', () {
fakeAsync((fa) {
final ble = FakeBleClient()..pinReply = null;
final s = ApolloScooter(ble, device, controlWritesEnabled: true, keepaliveInterval: null);
s.connect();
settle(fa);
ble.dataRx.add(Uint8List.fromList([...monitorFrame, ...baseFrame]));
settle(fa);
Object? error;
s.authenticate('123456').catchError((e) {
error = e;
return AuthenticationResult.invalidCredential;
});
settle(fa);
ble.dropLink();
settle(fa);
expect(error, isA<ScooterConnectionLostException>());
expect(s.state.connectionStatus, ScooterConnectionStatus.error);
expect(s.state.authenticated, isFalse);
expect(s.state.speed, isNull);
expect(s.state.displayVersion, isNull);
expect(s.canWrite, isFalse);
// Data from the dead link is ignored: the stream was unsubscribed.
ble.dataRx.add(monitorFrame);
settle(fa);
expect(s.state.speed, isNull);
});
});
group('control writes (enabled for test only)', () {
late FakeBleClient ble;
late ApolloScooter s;
void ready(FakeAsync fa) {
ble = FakeBleClient();
s = ApolloScooter(ble, device, controlWritesEnabled: true, keepaliveInterval: null);
s.connect();
settle(fa);
s.authenticate('123456');
settle(fa);
ble.dataRx.add(Uint8List.fromList([...monitorFrame, ...baseFrame]));
settle(fa);
ble.writes.clear();
expect(s.canWrite, isTrue);
}
List<Uint8List> dataWrites() =>
ble.writes.where((w) => w.$1 == apolloDataTxUuid).map((w) => w.$2).toList();
test('no-op when already in requested state', () {
fakeAsync((fa) {
ready(fa);
var done = false;
s.unlock().then((_) => done = true); // vector is already unlocked
settle(fa);
expect(done, isTrue);
expect(dataWrites(), isEmpty);
});
});
test('lock preserves everything else and confirms on a fresh cmd0', () {
fakeAsync((fa) {
ready(fa);
var done = false;
s.lock().then((_) => done = true);
settle(fa);
final w = dataWrites().single;
// monitor vector: gear 2, headlight on, atmosphere on, cruise on,
// imperial on, boot off, unlocked -> locked.
final expected = buildApolloSetBasePacket(
gearPosition: 2,
headlight: true,
atmosphereLight: true,
cruiseControl: true,
bootMode: false,
imperial: true,
unlocked: false,
limitCruise: 25,
limitMode1: 6,
limitMode2: 12,
limitMode3: 20,
);
expect(w, expected);
expect(w[3] & 0x80, 0, reason: 'unlocked bit cleared');
expect(done, isFalse, reason: 'GATT write alone is not confirmation');
// Re-sending the OLD (still unlocked) frame must not confirm.
ble.dataRx.add(monitorFrame);
settle(fa);
expect(done, isFalse);
// Fresh frame with unlocked bit (bit 3 of byte 21) cleared confirms.
ble.dataRx.add(withFlags(monitorFrame, flagsA: 0x8A & ~(1 << 3)));
settle(fa);
expect(done, isTrue);
expect(s.state.locked, isTrue);
});
});
test('unconfirmed write times out after 2 seconds with no retry', () {
fakeAsync((fa) {
ready(fa);
Object? error;
s.setHeadlight(false).catchError((e) => error = e);
settle(fa);
fa.elapse(const Duration(seconds: 2));
expect(error, isA<TimeoutException>());
expect(dataWrites().length, 1);
});
});
test('writes are serialized and the second reads fresh state', () {
fakeAsync((fa) {
ready(fa);
var lockDone = false;
var headlightDone = false;
s.lock().then((_) => lockDone = true);
s.setHeadlight(false).then((_) => headlightDone = true);
settle(fa);
// Only the lock packet has gone out so far.
expect(dataWrites().length, 1);
// Scooter confirms lock.
final locked = withFlags(monitorFrame, flagsA: 0x8A & ~(1 << 3));
ble.dataRx.add(locked);
settle(fa);
expect(lockDone, isTrue);
// Now the headlight packet is sent, built from the LOCKED state.
final packets = dataWrites();
expect(packets.length, 2);
expect(packets[1][3] & 0x80, 0, reason: 'must not re-unlock the scooter');
expect(packets[1][3] & 0x04, 0, reason: 'headlight bit cleared');
ble.dataRx.add(withFlags(monitorFrame, flagsA: 0x8A & ~(1 << 3) & ~(1 << 7)));
settle(fa);
expect(headlightDone, isTrue);
});
});
test('disconnect during a write fails it immediately and drains the queue', () {
fakeAsync((fa) {
ready(fa);
Object? e1, e2;
s.lock().catchError((e) => e1 = e);
s.setHeadlight(false).catchError((e) => e2 = e);
settle(fa);
ble.dropLink();
settle(fa);
expect(e1, isA<ScooterConnectionLostException>());
expect(e2, isStateError, reason: 'queued op fails on its turn, no write sent');
expect(dataWrites().length, 1);
});
});
});
test('keepalive is on by default at 1 s, can be disabled, and is a fixed packet', () {
fakeAsync((fa) {
final ble = FakeBleClient();
final s = ApolloScooter(ble, device);
s.connect();
settle(fa);
fa.elapse(const Duration(milliseconds: 3500));
expect(ble.writes.length, 3);
expect(ble.writes.every((w) => w.$1 == apolloDataTxUuid), isTrue);
s.setKeepaliveInterval(null);
fa.elapse(const Duration(seconds: 5));
expect(ble.writes.length, 3);
final ble0 = FakeBleClient();
ApolloScooter(ble0, device, keepaliveInterval: null).connect();
settle(fa);
fa.elapse(const Duration(minutes: 1));
expect(ble0.writes, isEmpty);
final ble2 = FakeBleClient();
final s2 = ApolloScooter(ble2, device, keepaliveInterval: const Duration(seconds: 5));
s2.connect();
settle(fa);
fa.elapse(const Duration(seconds: 11));
expect(ble2.writes.length, 2);
expect(ble2.writes.first.$2, hex('A5 02 FD 5A'));
s2.disconnect();
settle(fa);
fa.elapse(const Duration(seconds: 10));
expect(ble2.writes.length, 2, reason: 'timer cancelled on disconnect');
});
});
}