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
+571
View File
@@ -0,0 +1,571 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:openscooter/scooters/apollo_protocol.dart';
import 'package:openscooter/scooters/scooter.dart';
Uint8List hex(String s) => Uint8List.fromList(
s.trim().split(RegExp(r'\s+')).map((h) => int.parse(h, radix: 16)).toList(),
);
/// Synthetic monitor frame from the brief (section 35). CRC valid.
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');
/// Synthetic base frame from the brief (section 46). CRC valid.
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');
/// Re-signs a frame after editing its payload so parser tests only fail on
/// the field under test, never on CRC.
Uint8List resign(Uint8List frame) {
final out = Uint8List.fromList(frame);
final crc = apolloCrc16(out.sublist(0, out.length - 2));
out[out.length - 2] = crc & 0xFF;
out[out.length - 1] = crc >> 8;
return out;
}
Uint8List patched(Uint8List frame, Map<int, int> bytes) {
final out = Uint8List.fromList(frame);
bytes.forEach((i, v) => out[i] = v);
return resign(out);
}
/// LIVE captures from an Apollo Go, 2026-09-21, idle, full battery,
/// headlight off. Regression fixtures per brief section 81.
final realCmd0Idle =
hex('AA 00 19 01 02 64 00 00 00 00 01 9F 00 00 16 19 00 00 00 0C C5 0E 22 B3 13');
final realCmd0HeadlightCruise =
hex('AA 00 19 01 02 64 00 00 00 00 01 9D 00 00 16 19 00 00 00 0C C5 8E 26 2A D7');
final realCmd0RightSignal =
hex('AA 00 19 01 02 64 00 00 00 00 01 9E 00 00 16 19 00 00 00 0C C5 2E 2E 57 15');
final realCmd0LeftSignal =
hex('AA 00 19 01 02 64 00 00 00 00 01 9E 00 00 16 19 00 00 00 0C C5 4E 36 7F 1F');
final realCmd1Base =
hex('AA 01 19 03 0A 0F 1E D8 80 00 1F 00 00 00 00 00 00 00 00 00 00 00 00 68 61');
void main() {
group('real Apollo Go captures', () {
test('all fixtures are CRC valid', () {
for (final f in [realCmd0Idle, realCmd0HeadlightCruise, realCmd0RightSignal,
realCmd0LeftSignal, realCmd1Base]) {
expect(apolloValidateFrame(f), isTrue, reason: apolloHex(f));
}
});
test('real cmd1 base frame', () {
final b = parseApolloBaseFrame(realCmd1Base);
expect(b.limitCruise, 3);
expect(b.limitMode1, 10);
expect(b.limitMode2, 15);
expect(b.limitMode3, 30);
expect(b.batteryTemperature, -40);
expect(b.totalBatteryCapacity, 0);
expect(b.remainingBatteryCapacity, 0);
expect(b.batteryCycles, 0);
expect(b.displayId, isNull);
expect(b.displayVersion, 'V0.0.0');
expect(b.faultEnable, isTrue);
expect([b.ctrlSn, b.ctrlMp3, b.ctrlRgb, b.ctrlBms, b.internalSpeedScalingFlag],
everyElement(isTrue));
});
test('real cmd0 idle frame with the Go scaling flag', () {
final m = parseApolloMonitorFrame(realCmd0Idle, internalSpeedScalingFlag: true);
expect(m.gear, 2);
expect(m.batteryLevel, 100);
expect(m.rawSpeed, 0);
expect(m.speed, 0.0);
expect(m.voltage, 41.5);
expect(m.current, 0.0);
expect(m.power, 0.0);
expect(m.motorTemperature, 22);
expect(m.controllerTemperature, 25);
expect(m.tripDistance, 0.0);
expect(m.odometer, 326.9);
expect(m.atmosphereLight, isTrue);
expect(m.unlocked, isTrue);
expect(m.headlight, isFalse);
expect(m.leftTurnSignal, isFalse);
expect(m.rightTurnSignal, isFalse);
expect(m.cruiseControl, isFalse);
expect(m.imperial, isTrue);
expect(m.bootMode, isFalse);
});
test('real cmd0 variants: headlight+cruise, right signal, left signal', () {
final h = parseApolloMonitorFrame(realCmd0HeadlightCruise, internalSpeedScalingFlag: true);
expect(h.headlight, isTrue);
expect(h.cruiseControl, isTrue);
expect(h.voltage, 41.3);
final r = parseApolloMonitorFrame(realCmd0RightSignal, internalSpeedScalingFlag: true);
expect(r.rightTurnSignal, isTrue);
expect(r.leftTurnSignal, isFalse);
final l = parseApolloMonitorFrame(realCmd0LeftSignal, internalSpeedScalingFlag: true);
expect(l.leftTurnSignal, isTrue);
expect(l.rightTurnSignal, isFalse);
});
test('real 20 + 5 byte notification split reassembles', () {
final buf = ApolloFrameBuffer();
expect(buf.add(realCmd0Idle.sublist(0, 20)), isEmpty);
expect(buf.add(realCmd0Idle.sublist(20)), [realCmd0Idle]);
expect(buf.add(realCmd1Base.sublist(0, 20)), isEmpty);
expect(buf.add(realCmd1Base.sublist(20)), [realCmd1Base]);
});
});
group('CRC-16', () {
test('vector 1 from brief', () {
expect(apolloCrc16(hex('AB 00 0A 00 00 00 00 00')), 0x6A0B);
});
test('vector 2 from brief', () {
expect(apolloCrc16(hex('AB 00 0A 80 19 06 0C 14')), 0xE6E8);
});
test('wire order is little-endian and covers header through length-3', () {
final f = hex('AB 00 0A 00 00 00 00 00 0B 6A');
expect(apolloValidateFrame(f), isTrue);
expect(apolloValidateFrame(hex('AB 00 0A 00 00 00 00 00 6A 0B')), isFalse);
});
test('synthetic frames validate', () {
expect(apolloValidateFrame(monitorFrame), isTrue);
expect(apolloValidateFrame(baseFrame), isTrue);
});
test('corrupting one byte fails validation', () {
final bad = Uint8List.fromList(monitorFrame)..[5] ^= 0x01;
expect(apolloValidateFrame(bad), isFalse);
// Including unknown metadata bytes 2 and 3: they are CRC-covered.
final bad2 = Uint8List.fromList(monitorFrame)..[2] = 0x01;
expect(apolloValidateFrame(bad2), isFalse);
});
test('keepalive constant is not frame-CRC signed', () {
expect(apolloKeepalivePacket, hex('A5 02 FD 5A'));
expect(apolloValidateFrame(apolloKeepalivePacket), isFalse);
});
});
group('PIN command', () {
test('builds AT+PWD[pin] with no line ending', () {
expect(String.fromCharCodes(buildApolloPinCommand('123456')), 'AT+PWD[123456]');
expect(buildApolloPinCommand('123456').last, ']'.codeUnitAt(0));
});
test('rejects non six-digit input', () {
expect(() => buildApolloPinCommand('12345'), throwsArgumentError);
expect(() => buildApolloPinCommand('12345a'), throwsArgumentError);
expect(() => buildApolloPinCommand('1234567'), throwsArgumentError);
});
});
group('PIN response parser', () {
Uint8List ascii(String s) => Uint8List.fromList(s.codeUnits);
test('OK+PWD:Y is success', () {
expect(parseApolloPinResponse(ascii('OK+PWD:Y')), AuthenticationResult.success);
});
test('OK+PWD:N is invalid credential', () {
expect(parseApolloPinResponse(ascii('OK+PWD:N')), AuthenticationResult.invalidCredential);
});
test('search within garbage', () {
expect(parseApolloPinResponse(ascii('garbageOK+PWD:Y\r\n')), AuthenticationResult.success);
});
test('NUL bytes are ignored', () {
expect(parseApolloPinResponse(ascii('OK+PWD\x00:Y')), AuthenticationResult.success);
});
test('partial is null', () {
expect(parseApolloPinResponse(ascii('OK+PW')), isNull);
expect(parseApolloPinResponse(ascii('')), isNull);
});
test('fragmented response reassembles through the AT buffer', () {
final buf = ApolloAtBuffer();
buf.add(ascii('OK+PW'));
expect(parseApolloPinResponse(buf.bytes), isNull);
buf.add(ascii('D:Y'));
expect(parseApolloPinResponse(buf.bytes), AuthenticationResult.success);
});
test('AT buffer is capped', () {
final buf = ApolloAtBuffer(maxLength: 16);
buf.add(Uint8List.fromList(List.filled(100, 0x41)));
expect(buf.bytes.length, 16);
});
});
group('frame lengths', () {
test('A5 02 is 4, other A5 is 8, AA/AB cmd 0/1 is 25', () {
expect(apolloFrameLength(0xA5, 0x02), 4);
expect(apolloFrameLength(0xA5, 0x01), 8);
expect(apolloFrameLength(0xAA, 0x00), 25);
expect(apolloFrameLength(0xAA, 0x01), 25);
expect(apolloFrameLength(0xAB, 0x00), 25);
expect(apolloFrameLength(0xAB, 0x01), 25);
expect(apolloFrameLength(0xAB, 0x02), isNull);
expect(apolloFrameLength(0x00, 0x00), isNull);
});
});
group('ApolloFrameBuffer', () {
late ApolloFrameBuffer buf;
setUp(() => buf = ApolloFrameBuffer());
test('one complete frame', () {
expect(buf.add(monitorFrame), [monitorFrame]);
expect(buf.length, 0);
});
test('frame split into two chunks', () {
expect(buf.add(monitorFrame.sublist(0, 10)), isEmpty);
expect(buf.add(monitorFrame.sublist(10)), [monitorFrame]);
});
test('frame split byte-by-byte', () {
final out = <Uint8List>[];
for (final b in monitorFrame) {
out.addAll(buf.add(Uint8List.fromList([b])));
}
expect(out, [monitorFrame]);
});
test('two frames in one notification', () {
final both = Uint8List.fromList([...monitorFrame, ...baseFrame]);
expect(buf.add(both), [monitorFrame, baseFrame]);
});
test('one complete plus half of next', () {
final data = Uint8List.fromList([...monitorFrame, ...baseFrame.sublist(0, 12)]);
expect(buf.add(data), [monitorFrame]);
expect(buf.add(baseFrame.sublist(12)), [baseFrame]);
});
test('garbage before a valid frame', () {
final data = Uint8List.fromList([0x00, 0x11, 0xFF, 0xAA, ...monitorFrame]);
expect(buf.add(data), [monitorFrame]);
});
test('garbage between valid frames', () {
final data = Uint8List.fromList([...monitorFrame, 0x13, 0xAB, 0x00, ...baseFrame]);
expect(buf.add(data), [monitorFrame, baseFrame]);
});
test('bad CRC frame followed by a valid frame', () {
final bad = Uint8List.fromList(monitorFrame)..[24] ^= 0xFF;
final data = Uint8List.fromList([...bad, ...baseFrame]);
expect(buf.add(data), [baseFrame]);
});
test('unsupported head byte and unsupported command are skipped', () {
final data = Uint8List.fromList([0xCC, 0xAB, 0x07, ...monitorFrame]);
expect(buf.add(data), [monitorFrame]);
});
test('exact keepalive is emitted, other A5 sequences are skipped', () {
expect(buf.add(apolloKeepalivePacket), [apolloKeepalivePacket]);
final data = Uint8List.fromList([0xA5, 0x02, 0x00, 0x00, ...monitorFrame]);
expect(buf.add(data), [monitorFrame]);
});
test('buffer size cap keeps the newest bytes', () {
final small = ApolloFrameBuffer(maxLength: 64);
small.add(Uint8List.fromList(List.filled(1000, 0x01)));
expect(small.length, lessThanOrEqualTo(64));
// Still recovers afterwards.
expect(small.add(monitorFrame), [monitorFrame]);
});
test('a garbage byte does not wedge parsing permanently', () {
buf.add(Uint8List.fromList([0xAA]));
buf.add(Uint8List.fromList([0x00]));
// 23 more junk bytes complete a fake 25-byte candidate with a bad CRC.
buf.add(Uint8List.fromList(List.filled(23, 0x55)));
expect(buf.add(monitorFrame), [monitorFrame]);
});
});
group('monitor parser', () {
test('synthetic vector, scaling flag true', () {
final m = parseApolloMonitorFrame(monitorFrame, internalSpeedScalingFlag: true);
expect(m.gear, 2);
expect(m.batteryLevel, 75);
expect(m.rawSpeed, 250);
expect(m.speed, 25.0);
expect(m.voltage, 48.0);
expect(m.current, 5.0);
expect(m.power, 240.0);
expect(m.motorTemperature, 35);
expect(m.controllerTemperature, 40);
expect(m.tripDistance, 12.3);
expect(m.odometer, 1234.5);
expect(m.atmosphereLight, isTrue);
expect(m.unlocked, isTrue);
expect(m.headlight, isTrue);
expect(m.rightTurnSignal, isFalse);
expect(m.leftTurnSignal, isFalse);
expect(m.cruiseControl, isTrue);
expect(m.imperial, isTrue);
expect(m.bootMode, isFalse);
});
test('scaling flag false gives raw/1000', () {
final m = parseApolloMonitorFrame(monitorFrame, internalSpeedScalingFlag: false);
expect(m.speed, 0.25);
});
test('speed uses the max of source A and B', () {
// A = 0x00FA (250), B = 0x00C8 (200) in the vector. Swap them.
final swapped = patched(monitorFrame, {6: 0x00, 7: 0xC8, 8: 0x00, 9: 0xFA});
expect(parseApolloMonitorFrame(swapped, internalSpeedScalingFlag: true).rawSpeed, 250);
final bBigger = patched(monitorFrame, {8: 0x01, 9: 0x00});
expect(parseApolloMonitorFrame(bBigger, internalSpeedScalingFlag: true).rawSpeed, 256);
});
test('negative current and derived power', () {
// -5.0 A = -320 = 0xFEC0
final f = patched(monitorFrame, {12: 0xFE, 13: 0xC0});
final m = parseApolloMonitorFrame(f, internalSpeedScalingFlag: true);
expect(m.current, -5.0);
expect(m.power, -240.0);
});
test('power is rounded to one decimal', () {
// voltage 48.1 (0x01E1), current 1/64 A = 0.015625 -> 0.7515625 -> 0.8
final f = patched(monitorFrame, {10: 0x01, 11: 0xE1, 12: 0x00, 13: 0x01});
expect(parseApolloMonitorFrame(f, internalSpeedScalingFlag: true).power, 0.8);
});
test('negative temperatures', () {
final f = patched(monitorFrame, {14: 0xF6, 15: 0xEC}); // -10, -20
final m = parseApolloMonitorFrame(f, internalSpeedScalingFlag: true);
expect(m.motorTemperature, -10);
expect(m.controllerTemperature, -20);
});
test('24-bit odometer uses all three bytes', () {
final f = patched(monitorFrame, {18: 0x12, 19: 0x34, 20: 0x56});
expect(parseApolloMonitorFrame(f, internalSpeedScalingFlag: true).odometer, 0x123456 / 10.0);
});
test('each switch flag is independent', () {
final base = patched(monitorFrame, {21: 0x00, 22: 0x00});
var m = parseApolloMonitorFrame(base, internalSpeedScalingFlag: true);
expect([m.atmosphereLight, m.unlocked, m.rightTurnSignal, m.leftTurnSignal, m.headlight,
m.cruiseControl, m.imperial, m.bootMode],
everyElement(isFalse));
m = parseApolloMonitorFrame(patched(base, {21: 1 << 1}), internalSpeedScalingFlag: true);
expect(m.atmosphereLight, isTrue);
m = parseApolloMonitorFrame(patched(base, {21: 1 << 3}), internalSpeedScalingFlag: true);
expect(m.unlocked, isTrue);
m = parseApolloMonitorFrame(patched(base, {21: 1 << 5}), internalSpeedScalingFlag: true);
expect(m.rightTurnSignal, isTrue);
m = parseApolloMonitorFrame(patched(base, {21: 1 << 6}), internalSpeedScalingFlag: true);
expect(m.leftTurnSignal, isTrue);
m = parseApolloMonitorFrame(patched(base, {21: 1 << 7}), internalSpeedScalingFlag: true);
expect(m.headlight, isTrue);
m = parseApolloMonitorFrame(patched(base, {22: 1 << 2}), internalSpeedScalingFlag: true);
expect(m.cruiseControl, isTrue);
m = parseApolloMonitorFrame(patched(base, {22: 1 << 5}), internalSpeedScalingFlag: true);
expect(m.imperial, isTrue);
m = parseApolloMonitorFrame(patched(base, {22: 1 << 6}), internalSpeedScalingFlag: true);
expect(m.bootMode, isTrue);
});
test('corrupt byte is rejected by CRC', () {
final bad = Uint8List.fromList(monitorFrame)..[4] = 0x03;
expect(() => parseApolloMonitorFrame(bad, internalSpeedScalingFlag: true),
throwsFormatException);
});
test('rejects wrong command or length', () {
expect(() => parseApolloMonitorFrame(baseFrame, internalSpeedScalingFlag: true),
throwsFormatException);
expect(() => parseApolloMonitorFrame(monitorFrame.sublist(1), internalSpeedScalingFlag: true),
throwsFormatException);
});
});
group('base parser', () {
test('synthetic vector', () {
final b = parseApolloBaseFrame(baseFrame);
expect(b.limitCruise, 25);
expect(b.limitMode1, 6);
expect(b.limitMode2, 12);
expect(b.limitMode3, 20);
expect(b.batteryTemperature, 30);
expect(b.totalBatteryCapacity, 10000);
expect(b.remainingBatteryCapacity, 5000);
expect(b.batteryCycles, 100);
expect(b.displayId, '12ab');
expect(b.displayVersion, 'V2.5.17');
expect(b.ctrlSn, isTrue);
expect(b.ctrlMp3, isTrue);
expect(b.ctrlRgb, isTrue);
expect(b.ctrlBms, isTrue);
expect(b.internalSpeedScalingFlag, isTrue);
expect(b.faultEnable, isTrue); // frame[8] = 0x80
expect(b.e9, isFalse);
});
test('negative battery temperature', () {
expect(parseApolloBaseFrame(patched(baseFrame, {7: 0xF1})).batteryTemperature, -15);
});
test('capacity fields are big-endian', () {
final b = parseApolloBaseFrame(patched(baseFrame, {12: 0x01, 13: 0x02, 14: 0x03, 15: 0x04, 16: 0x05, 17: 0x06}));
expect(b.totalBatteryCapacity, 0x0102);
expect(b.remainingBatteryCapacity, 0x0304);
expect(b.batteryCycles, 0x0506);
});
test('display id 0000 is absent, otherwise lowercase hex padded', () {
expect(parseApolloBaseFrame(patched(baseFrame, {18: 0, 19: 0})).displayId, isNull);
expect(parseApolloBaseFrame(patched(baseFrame, {18: 0x0A, 19: 0x0B})).displayId, '0a0b');
});
test('display version formats V%d.%d.%d', () {
expect(parseApolloBaseFrame(patched(baseFrame, {20: 0, 21: 10, 22: 255})).displayVersion, 'V0.10.255');
});
test('capability bits are independent', () {
final none = parseApolloBaseFrame(patched(baseFrame, {10: 0x00}));
expect([none.ctrlSn, none.ctrlMp3, none.ctrlRgb, none.ctrlBms, none.internalSpeedScalingFlag],
everyElement(isFalse));
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 0})).ctrlSn, isTrue);
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 1})).ctrlMp3, isTrue);
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 2})).ctrlRgb, isTrue);
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 3})).ctrlBms, isTrue);
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 4})).internalSpeedScalingFlag, isTrue);
});
test('fault flags, including the duplicated frame[8] bit 3 mapping', () {
final a = parseApolloBaseFrame(patched(baseFrame, {8: (1 << 1) | (1 << 2), 9: 0}));
expect(a.e9, isTrue);
expect(a.f1, isTrue);
expect(a.f2, isFalse);
expect(a.ctrlFaultEarlyWarning, isFalse);
expect(a.faultEnable, isFalse);
// Apollo 4.8.18340 maps both f2 and ctrlFaultEarlyWarning to bit 3.
final b = parseApolloBaseFrame(patched(baseFrame, {8: 1 << 3}));
expect(b.f2, isTrue);
expect(b.ctrlFaultEarlyWarning, isTrue);
final c = parseApolloBaseFrame(patched(baseFrame, {9: (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4) | (1 << 7)}));
expect([c.e1, c.e2, c.e3, c.e4, c.e7], everyElement(isTrue));
final d = parseApolloBaseFrame(patched(baseFrame, {9: 0}));
expect([d.e1, d.e2, d.e3, d.e4, d.e7], everyElement(isFalse));
});
test('corrupt byte is rejected by CRC', () {
final bad = Uint8List.fromList(baseFrame)..[3] = 0x20;
expect(() => parseApolloBaseFrame(bad), throwsFormatException);
});
});
group('set-base packet builder', () {
Uint8List build({
int gear = 2,
bool headlight = true,
bool atmosphereLight = false,
bool cruiseControl = false,
bool bootMode = false,
bool imperial = true,
bool unlocked = false,
}) =>
buildApolloSetBasePacket(
gearPosition: gear,
headlight: headlight,
atmosphereLight: atmosphereLight,
cruiseControl: cruiseControl,
bootMode: bootMode,
imperial: imperial,
unlocked: unlocked,
limitCruise: 25,
limitMode1: 6,
limitMode2: 12,
limitMode3: 20,
);
test('exact packet for brief vector 1 (all zero)', () {
final p = buildApolloSetBasePacket(
gearPosition: 0,
headlight: false,
atmosphereLight: false,
cruiseControl: false,
bootMode: false,
imperial: false,
unlocked: false,
limitCruise: 0,
limitMode1: 0,
limitMode2: 0,
limitMode3: 0,
);
expect(p, hex('AB 00 0A 00 00 00 00 00 0B 6A'));
});
test('exact packet for brief vector 2 (unlocked with limits)', () {
final p = buildApolloSetBasePacket(
gearPosition: 0,
headlight: false,
atmosphereLight: false,
cruiseControl: false,
bootMode: false,
imperial: false,
unlocked: true,
limitCruise: 25,
limitMode1: 6,
limitMode2: 12,
limitMode3: 20,
);
expect(p, hex('AB 00 0A 80 19 06 0C 14 E8 E6'));
expect(apolloValidateFrame(p), isTrue);
});
test('is exactly 10 bytes with header AB 00 0A and the four limits', () {
final p = build();
expect(p.length, 10);
expect(p.sublist(0, 3), hex('AB 00 0A'));
expect(p.sublist(4, 8), [25, 6, 12, 20]);
});
/// Asserts that toggling one control changes exactly one FLAGS bit and
/// nothing else before the CRC.
void expectOnlyBitChanges(Uint8List a, Uint8List b, int bit) {
expect(a.sublist(0, 3), b.sublist(0, 3));
expect(a.sublist(4, 8), b.sublist(4, 8), reason: 'speed limits must be preserved');
expect(a[3] ^ b[3], 1 << bit, reason: 'only FLAGS bit $bit may change');
expect(apolloValidateFrame(a), isTrue);
expect(apolloValidateFrame(b), isTrue);
}
test('unlock changes only FLAGS bit 7', () {
expectOnlyBitChanges(build(unlocked: false), build(unlocked: true), 7);
});
test('headlight changes only FLAGS bit 2', () {
expectOnlyBitChanges(build(headlight: false), build(headlight: true), 2);
});
test('atmosphere light changes only FLAGS bit 3', () {
expectOnlyBitChanges(build(atmosphereLight: false), build(atmosphereLight: true), 3);
});
test('cruise changes only FLAGS bit 4', () {
expectOnlyBitChanges(build(cruiseControl: false), build(cruiseControl: true), 4);
});
test('boot mode bit 5 and imperial bit 6', () {
expectOnlyBitChanges(build(bootMode: false), build(bootMode: true), 5);
expectOnlyBitChanges(build(imperial: false), build(imperial: true), 6);
});
test('gear occupies bits 0-1 only', () {
expect(build(gear: 0)[3] & 0x03, 0);
expect(build(gear: 3)[3] & 0x03, 3);
expect(build(gear: 7)[3] & 0x03, 3);
});
test('rejects limits outside one byte', () {
expect(
() => buildApolloSetBasePacket(
gearPosition: 0, headlight: false, atmosphereLight: false, cruiseControl: false,
bootMode: false, imperial: false, unlocked: false,
limitCruise: 256, limitMode1: 0, limitMode2: 0, limitMode3: 0,
),
throwsArgumentError,
);
});
});
}
+499
View File
@@ -0,0 +1,499 @@
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');
});
});
}