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 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 = []; 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, ); }); }); }