Initial version
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user