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