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.broadcast(); final dataRx = StreamController.broadcast(); final atRx = StreamController.broadcast(); Map> services = { apolloDataServiceUuid: {apolloDataTxUuid, apolloDataRxUuid}, apolloAtServiceUuid: {apolloAtTxUuid, apolloAtRxUuid}, }; final subscribed = []; 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> scan() => const Stream.empty(); @override Future stopScan() async {} @override Future connect(String deviceId) async => connectedId = deviceId; @override Future disconnect() async { disconnectCalls++; connectedId = null; } @override Stream get connectionState => connState.stream; @override Future>> discoverServices() async => services; @override Future> 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 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()); 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()); 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 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()); 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()); 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'); }); }); }