From 82b1d822f98eeaf4d3ce03d5fcc4dc1bd9000fc6 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Tue, 13 Oct 2026 01:16:53 -0400 Subject: [PATCH] KPro values from KPro Manager, TPS formula, RPM glitch filter, nav fix KPro, checked against KPro Manager on the bench (7267 rpm capture; a test replays that frame): - TPS (raw - 25) / 2.04 (68 %), IGN (raw - 128) / 2 (5 deg), short trim (raw - 128) / 1.28, injector 16-bit LE at 11-12. - BAT is byte 51 / 10 (11.40 V) and PA byte 53 kPa (95.0); the PDF table had them at 52 / 51. - SW1/SW2 bits are listed lowest-first: FLR bit6, FANC bit7, VTP bit0, VTS bit1, MIL bit3 (VTEC Out no longer shows VTP). S300: the same TPS formula matches the USB tool (31 % at raw ~88). RPM glitch filter: frames whose RPM jumps more than an engine can are dropped (KPro bench crank fault P0336 sent ~300 rpm frames between real ones); a real change is accepted once 6 of the last 10 readings agree. Dropped count shown on the Debug screen. Connecting from Settings now replaces the whole navigation stack, so no stale dashboard is left underneath. Co-Authored-By: Claude Opus 5.5 --- lib/core/bluetooth/rpm_glitch_filter.dart | 93 ++++++++++++++++ lib/core/protocol/kpro_parser.dart | 64 ++++++----- lib/core/protocol/s300_parser.dart | 9 +- lib/core/providers/bt_provider.dart | 29 +++++ .../screens/bt_picker/bt_picker_screen.dart | 5 +- .../screens/debug/raw_frame_debug_screen.dart | 1 + test/kpro_parser_test.dart | 46 ++++++-- test/rpm_glitch_filter_test.dart | 100 ++++++++++++++++++ test/s300_parser_test.dart | 7 +- 9 files changed, 313 insertions(+), 41 deletions(-) create mode 100644 lib/core/bluetooth/rpm_glitch_filter.dart create mode 100644 test/rpm_glitch_filter_test.dart diff --git a/lib/core/bluetooth/rpm_glitch_filter.dart b/lib/core/bluetooth/rpm_glitch_filter.dart new file mode 100644 index 0000000..eafdfd9 --- /dev/null +++ b/lib/core/bluetooth/rpm_glitch_filter.dart @@ -0,0 +1,93 @@ +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:typed_data'; + +/// Drops frames whose RPM jumps further than an engine can in the time since +/// the last accepted frame — the same thing the USB tool appears to do. +/// +/// Seen on the KPro bench with a crank-signal fault (P0336): roughly every +/// third frame the ECU reported ~250-330 rpm (with matching long injection +/// and low-RPM knock threshold) between the real readings, sometimes several +/// in a row, so the dial flickered. The USB tool shows a steady value there. +/// +/// A real change still gets through by majority: once [confirmVotes] of the +/// last [window] readings agree (within 20 %) on a value, it is accepted even +/// if glitch frames are mixed in. (A "consecutive frames" rule never +/// confirmed a real change while glitches kept interrupting it, which froze +/// the dial.) [onConfirmed] reports each such accepted jump. +class RpmGlitchFilter { + RpmGlitchFilter({ + this.baseDelta = 300, + this.maxRate = 4000, + this.window = 10, + this.confirmVotes = 6, + this.onConfirmed, + }); + + /// Allowed jump regardless of elapsed time (rpm). + final double baseDelta; + + /// Allowed rate of change (rpm per second). + final double maxRate; + + /// Number of recent readings kept for the majority vote. + final int window; + + /// Readings out of [window] that must agree to accept a new value. + final int confirmVotes; + + final void Function(double from, double to)? onConfirmed; + + double? _lastRpm; + DateTime? _lastAt; + final _recent = ListQueue(); + + /// Frames dropped so far. + int dropped = 0; + + void reset() { + _lastRpm = null; + _lastAt = null; + _recent.clear(); + } + + /// RPM in a raw frame: KPro (`1B 01`) bytes 2-3 LE / 4, S300 bytes 3-4 LE. + static double rpmOf(Uint8List frame) => frame[1] == 0x01 + ? (frame[2] | (frame[3] << 8)) / 4.0 + : (frame[3] | (frame[4] << 8)).toDouble(); + + bool _agree(double a, double b) => + (a - b).abs() <= 0.2 * math.max(a.abs(), b.abs()) + baseDelta; + + /// True if [frame] should be shown/recorded, false if it is a glitch. + bool accept(Uint8List frame, DateTime at) { + final rpm = rpmOf(frame); + _recent.addLast(rpm); + while (_recent.length > window) { + _recent.removeFirst(); + } + + final last = _lastRpm; + if (last == null) return _take(rpm, at); + + // Capped: while frames are being dropped the last accepted one ages, and + // an uncapped allowance would eventually let a glitch through. Larger + // real changes are taken by the majority vote below. + final dt = math.min(at.difference(_lastAt!).inMicroseconds / 1e6, 0.25); + if ((rpm - last).abs() <= baseDelta + maxRate * dt) return _take(rpm, at); + + final votes = _recent.where((r) => _agree(r, rpm)).length; + if (votes >= confirmVotes) { + onConfirmed?.call(last, rpm); + return _take(rpm, at); + } + dropped++; + return false; + } + + bool _take(double rpm, DateTime at) { + _lastRpm = rpm; + _lastAt = at; + return true; + } +} diff --git a/lib/core/protocol/kpro_parser.dart b/lib/core/protocol/kpro_parser.dart index 253871a..27db878 100644 --- a/lib/core/protocol/kpro_parser.dart +++ b/lib/core/protocol/kpro_parser.dart @@ -3,6 +3,7 @@ import 'sensor_state.dart'; import 'temp_table.dart'; import 'neg8.dart'; import 'dtc_map.dart'; +import 's300_parser.dart' show tpsPercent; /// Parses a 128-byte KPro ECU response frame into a [SensorState]. /// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails. @@ -14,29 +15,31 @@ SensorState parseKPro(Uint8List frame) { throw ArgumentError('KPro frame NEG8 checksum failed'); } - // RPM: bytes 2..3 — raw / 4. Little-endian like every other multi-byte - // field from this adapter (S300 RPM/VSS/MAP confirmed LE live; the KPro - // table itself labels MAP2 and AFCMD as L/H byte pairs). Was read BE. - // VERIFY on bench: 2000 rpm must show 2000. + // Values marked KMGR were checked against KPro Manager on the bench + // (2026-10-13, 7267 rpm frame); the PDF table's offsets for PA and BAT + // turned out to be wrong, see below. + + // RPM: bytes 2..3 little-endian / 4. KMGR: 7267 rpm. final int rpmRaw = _readUint16LE(frame, 2); final double rpm = rpmRaw / 4.0; // VSS: byte 4 direct km/h final double vss = frame[4].toDouble(); - // TPS: byte 5 direct % - final double tps = frame[5].toDouble(); + // TPS: byte 5, (raw - 25) / 2.04. KMGR: raw 163 -> 68 %. + final double tps = tpsPercent(frame[5]); // MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H) final int map1Raw = frame[6]; final int map2Raw = _readUint16LE(frame, 33); // MAP2L @33, MAP2H @34 final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble(); - // INJ: byte 11 raw - final double inj = frame[11].toDouble(); + // INJ: bytes 11..12 little-endian, µs -> ms. KMGR: 00 00 -> 0.00 ms (in + // fuel cut). Byte order follows the L/H pairs; scale UNVERIFIED. + final double inj = _readUint16LE(frame, 11) / 1000.0; - // IGN: byte 13 raw degrees - final double ign = frame[13].toDouble(); + // IGN: byte 13, (raw - 128) / 2 degrees. KMGR: raw 138 -> 5°. + final double ign = (frame[13] - 128) / 2.0; // O2: byte 14 raw // O2: volts, raw × 5/256 as on the S300. UNVERIFIED for KPro. @@ -57,14 +60,15 @@ SensorState parseKPro(Uint8List frame) { // IAT: byte 50 final double iat = (tempXlt[frame[50]] - 40).toDouble(); - // PA: byte 51 - final double pa = frame[51].toDouble(); + // PA: byte 53, kPa. KMGR: raw 95 -> 95.0 kPa. (The PDF puts PA at 51.) + final double pa = frame[53].toDouble(); - // BAT: byte 52 — raw / 10 - final double bat = frame[52] / 10.0; + // BAT: byte 51, raw / 10. KMGR: raw 114 -> 11.40 V. (The PDF puts BAT at + // 52; that byte read 232 -> "23.2 V".) + final double bat = frame[51] / 10.0; - // STRIM: byte 32 (SW2 shares offset 32, STRIM is byte 20 per spec offset +20) - final double strim = frame[20].toDouble(); + // STRIM: byte 20, (raw - 128) / 1.28 %. KMGR: raw 128 -> 0 %. + final double strim = (frame[20] - 128) / 1.28; // LTRIM — not explicitly listed in KPro; use 0 const double ltrim = 0.0; @@ -91,18 +95,19 @@ SensorState parseKPro(Uint8List frame) { // Gear — not in KPro frame, default 0 const int gear = 0; - // Flags - // SW2 bit2 = MIL - final bool mil = (sw2 & 0x04) != 0; - // SW1 bit6 = FLR. Unverified: never live-tested for KPro, and the - // equivalent S300 bit turned out to be fuel PUMP relay status, not fuel - // cut, despite an initially plausible-looking name — treat "FLR" the - // same way until confirmed. - final bool fuelPump = (sw1 & 0x40) != 0; - // SW1 bit0 = FANC - final bool fanOut = (sw1 & 0x01) != 0; - // SW2 bit1..0 = VTS (VTEC) - final bool vtec = (sw2 & 0x03) != 0; + // Flags. The PDF lists the bits LOWEST first. KMGR: SW1 = 0x50 with SCS + // and FLR on -> SCS bit4, FLR bit6. + // SW1: RVSLCK(0) BKSW(1) ACSW(2) ACCL(3) SCS(4) EPS(5) FLR(6) FANC(7) + final bool brakeSwitch = (sw1 & 0x02) != 0; + final bool acSwitch = (sw1 & 0x04) != 0; + final bool fuelPump = (sw1 & 0x40) != 0; // FLR = fuel pump relay + final bool fanOut = (sw1 & 0x80) != 0; // FANC + // SW2: VTP(0) VTS(1) … MIL(3). KMGR: SW2 = 0x01/0x09 with VTP on, VTS + // off, MIL on -> MIL is bit3 (the PDF list has a gap). MIL UNVERIFIED + // beyond this one capture. + final bool vtp = (sw2 & 0x01) != 0; + final bool vtec = (sw2 & 0x02) != 0; // VTS = VTEC solenoid + final bool mil = (sw2 & 0x08) != 0; // Knock = KRtrd > 0 final bool knock = krtrd > 0; // Rev limit — not directly mapped in KPro SW; false @@ -142,6 +147,9 @@ SensorState parseKPro(Uint8List frame) { knock: knock, revLimit: revLimit, launch: launch, + brakeSwitch: brakeSwitch, + acSwitch: acSwitch, + vtp: vtp, iatError: anyDtcBit(errBytes, kproIatErrorBits), ectError: anyDtcBit(errBytes, kproEctErrorBits), o2Error: anyDtcBit(errBytes, kproO2ErrorBits), diff --git a/lib/core/protocol/s300_parser.dart b/lib/core/protocol/s300_parser.dart index 6c6a80f..9def323 100644 --- a/lib/core/protocol/s300_parser.dart +++ b/lib/core/protocol/s300_parser.dart @@ -75,8 +75,7 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) { final double map = _u16le(frame, 7) / 10.0; - // Linear 0-255 → 0-100 % (live-confirmed; the PDF formula exceeds 100 %). - final double tps = (frame[9] * 100.0 / 255.0).clamp(0.0, 100.0); + final double tps = tpsPercent(frame[9]); final int gear = frame[27]; @@ -204,5 +203,11 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) { ); } +/// Throttle %, both ECUs: (raw - 25) / 2.04, clamped to 0..100 (the PDF's +/// "TPS < 25 ? 0" threshold). Checked against the USB tools: S300 raw ≈ 88 +/// -> 31 %, KPro raw 163 -> 68 %. (The previous raw × 100/255 matched only +/// near 50 %.) +double tpsPercent(int raw) => ((raw - 25) / 2.04).clamp(0.0, 100.0); + int _u16le(Uint8List frame, int offset) => frame[offset] | (frame[offset + 1] << 8); diff --git a/lib/core/providers/bt_provider.dart b/lib/core/providers/bt_provider.dart index 97e74c7..e182ae9 100644 --- a/lib/core/providers/bt_provider.dart +++ b/lib/core/providers/bt_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../bluetooth/bt_poller.dart'; +import '../bluetooth/rpm_glitch_filter.dart'; import '../bluetooth/bt_service.dart'; import '../logging/app_log.dart'; import '../protocol/neg8.dart'; @@ -41,6 +42,7 @@ class BtState { final int requestsSent; final int frameCount; // valid frames (live + default) final int liveFrames; + final int rpmGlitches; // frames dropped for an implausible RPM jump final int defaultFrames; final int droppedFrames; // bad checksum final int timeouts; @@ -56,6 +58,7 @@ class BtState { this.requestsSent = 0, this.frameCount = 0, this.liveFrames = 0, + this.rpmGlitches = 0, this.defaultFrames = 0, this.droppedFrames = 0, this.timeouts = 0, @@ -75,6 +78,7 @@ class BtState { int? requestsSent, int? frameCount, int? liveFrames, + int? rpmGlitches, int? defaultFrames, int? droppedFrames, int? timeouts, @@ -90,6 +94,7 @@ class BtState { requestsSent: requestsSent ?? this.requestsSent, frameCount: frameCount ?? this.frameCount, liveFrames: liveFrames ?? this.liveFrames, + rpmGlitches: rpmGlitches ?? this.rpmGlitches, defaultFrames: defaultFrames ?? this.defaultFrames, droppedFrames: droppedFrames ?? this.droppedFrames, timeouts: timeouts ?? this.timeouts, @@ -223,6 +228,9 @@ class BtNotifier extends StateNotifier { } void _resetCounters() { + _rpmFilter.reset(); + _rpmFilter.dropped = 0; + _glitchesLogged = 0; _lastAnyFrameAt = null; _lastLiveFrameAt = null; _defaultsSince = null; @@ -242,6 +250,21 @@ class BtNotifier extends StateNotifier { AppLog.w('Adapter returned its default (no-ECU-data) frame ' '(count=$_defaultFrames)'); } + } else if (!_rpmFilter.accept(frame, now)) { + // Implausible RPM jump (e.g. crank-signal dropout, P0336 on the KPro + // bench): drop the whole frame and keep showing the last good one, + // as the USB tool does. Counted for the Debug screen. + final n = _rpmFilter.dropped; + if (n == 1 || n % 100 == 0) { + AppLog.w('RPM glitch frame dropped ' + '(${RpmGlitchFilter.rpmOf(frame).round()} rpm, count=$n)'); + } + if (_glitchesLogged < 2) { + _glitchesLogged++; + AppLog.w('Glitch frame: ${[ + for (final v in frame) v.toRadixString(16).padLeft(2, '0') + ].join(' ')}'); + } } else { _liveFrames++; _lastLiveFrameAt = now; @@ -253,6 +276,11 @@ class BtNotifier extends StateNotifier { } DateTime? _lastFrameLogAt; + late final _rpmFilter = RpmGlitchFilter( + onConfirmed: (from, to) => AppLog.i( + 'RPM change ${from.round()} -> ${to.round()} accepted (majority of recent frames)'), + ); + int _glitchesLogged = 0; /// Raw hex of one live frame every 5 s, so bench readings from the USB /// tool can be matched to exact bytes (Debug → Copy app log, or logcat). @@ -276,6 +304,7 @@ class BtNotifier extends StateNotifier { requestsSent: p?.requestsSent, frameCount: p?.validFrames, liveFrames: _liveFrames, + rpmGlitches: _rpmFilter.dropped, defaultFrames: _defaultFrames, droppedFrames: p?.droppedFrames, timeouts: p?.timeouts, diff --git a/lib/ui/screens/bt_picker/bt_picker_screen.dart b/lib/ui/screens/bt_picker/bt_picker_screen.dart index 7ec5603..47602d3 100644 --- a/lib/ui/screens/bt_picker/bt_picker_screen.dart +++ b/lib/ui/screens/bt_picker/bt_picker_screen.dart @@ -83,8 +83,11 @@ class _BtPickerScreenState extends ConsumerState { await ref.read(btProvider.notifier).connect(device); if (!mounted) return; if (ref.read(btProvider).isConnected) { - Navigator.of(context).pushReplacement( + // Replace the whole stack: when reached via Settings, a plain + // replacement left the old dashboard and Settings underneath. + Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const MainNav()), + (_) => false, ); } } diff --git a/lib/ui/screens/debug/raw_frame_debug_screen.dart b/lib/ui/screens/debug/raw_frame_debug_screen.dart index d82e0c9..571e3fb 100644 --- a/lib/ui/screens/debug/raw_frame_debug_screen.dart +++ b/lib/ui/screens/debug/raw_frame_debug_screen.dart @@ -94,6 +94,7 @@ class _RawFrameDebugScreenState extends ConsumerState { _RowData('Valid replies', bt.frameCount.toString()), _RowData(' live ECU frames', bt.liveFrames.toString()), _RowData(' adapter default frames', bt.defaultFrames.toString()), + _RowData(' RPM glitch frames dropped', bt.rpmGlitches.toString()), _RowData('Timeouts (no reply)', bt.timeouts.toString()), _RowData('Dropped (bad checksum)', bt.droppedFrames.toString()), _RowData('Last reply time', bt.lastResponseMs == null ? '-' : '${bt.lastResponseMs} ms'), diff --git a/test/kpro_parser_test.dart b/test/kpro_parser_test.dart index 3801c24..3120507 100644 --- a/test/kpro_parser_test.dart +++ b/test/kpro_parser_test.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:hvbt_dash/core/protocol/kpro_parser.dart'; +import 'package:hvbt_dash/core/protocol/neg8.dart'; import 'package:hvbt_dash/core/protocol/temp_table.dart'; /// Builds a zeroed 128-byte KPro frame and stamps the NEG8 checksum at byte 127. @@ -43,7 +44,7 @@ Uint8List _buildKProFrame({ // ECT: byte 49 frame[49] = ect; // BAT: byte 52 - frame[52] = bat; + frame[51] = bat; // KMGR: BAT is byte 51 (the PDF says 52) // Stamp NEG8 checksum at byte 127 int neg8 = 0; @@ -69,10 +70,10 @@ void main() { expect(state.vss, equals(120.0)); }); - test('parses TPS directly', () { - final frame = _buildKProFrame(tps: 75); + test('parses TPS = (raw - 25) / 2.04', () { + final frame = _buildKProFrame(tps: 163); // KMGR: 68 % final state = parseKPro(frame); - expect(state.tps, equals(75.0)); + expect(state.tps, closeTo(67.6, 0.1)); }); test('parses MAP from MAP1 when MAP2 = 0', () { @@ -102,12 +103,45 @@ void main() { expect(state.bat, equals(13.0)); }); - test('MIL flag from SW2 bit2', () { - final frame = _buildKProFrame(sw2: 0x04); // bit2 + test('MIL flag from SW2 bit3', () { + final frame = _buildKProFrame(sw2: 0x08); // bit3 final state = parseKPro(frame); expect(state.mil, isTrue); }); + test('real bench frame matches KPro Manager (7267 rpm capture)', () { + // First 64 bytes logged at 00:40:25 on 2026-10-13, while KPro Manager + // showed: RPM 7267, VSS 0, TPS 68 %, IGN 5°, S.TRIM 0 %, BAT 11.40 V, + // PA 95.0 kPa, INJ 0.00 ms, SCS/FLR on, VTP on, VTS off, MIL on. + const head = [ + 0x1b, 0x01, 0x8d, 0x71, 0x00, 0xa3, 0x75, 0xff, // + 0x28, 0x00, 0x4d, 0x00, 0x00, 0x8a, 0x00, 0xd2, + 0xd9, 0xaf, 0xf6, 0x97, 0x80, 0x00, 0x03, 0x8b, + 0x00, 0x00, 0x12, 0x0c, 0x07, 0x09, 0x00, 0x50, + 0x09, 0xa0, 0x04, 0x01, 0x00, 0x00, 0xff, 0x00, + 0x00, 0xbf, 0xa3, 0xbf, 0xe8, 0x10, 0xf0, 0xff, + 0x8d, 0xd8, 0xd2, 0x72, 0xe8, 0x5f, 0xfe, 0x80, + 0x04, 0x00, 0x00, 0x00, 0x65, + ]; + final frame = Uint8List(128)..setRange(0, head.length, head); + frame[127] = calculateNeg8(frame.sublist(0, 127)); + final s = parseKPro(frame); + expect(s.rpm, closeTo(7267, 1)); + expect(s.vss, 0); + expect(s.tps.round(), 68); + expect(s.ign, 5); + expect(s.strim, 0); + expect(s.bat, closeTo(11.4, 0.001)); + expect(s.pa, 95); + expect(s.inj, 0); + expect(s.map, closeTo(118.4, 0.001)); // 2.5 psi boost + expect(s.fuelPump, isTrue); // FLR + expect(s.fanOut, isFalse); // FANC + expect(s.vtp, isTrue); + expect(s.vtec, isFalse); // VTS + expect(s.mil, isTrue); + }); + test('knock flag when krtrd > 0', () { final frame = _buildKProFrame(krtrd: 3); final state = parseKPro(frame); diff --git a/test/rpm_glitch_filter_test.dart b/test/rpm_glitch_filter_test.dart new file mode 100644 index 0000000..0fa1a06 --- /dev/null +++ b/test/rpm_glitch_filter_test.dart @@ -0,0 +1,100 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hvbt_dash/core/bluetooth/rpm_glitch_filter.dart'; + +/// KPro frame carrying [rpm] (bytes 2-3 LE, raw = rpm × 4). +Uint8List _kpro(double rpm) { + final f = Uint8List(128); + f[0] = 0x1B; + f[1] = 0x01; + final raw = (rpm * 4).round(); + f[2] = raw & 0xFF; + f[3] = raw >> 8; + return f; +} + +void main() { + final t0 = DateTime(2026, 10, 13); + DateTime at(int frame) => t0.add(Duration(milliseconds: 100 * frame)); + + test('drops the KPro bench glitch frames (2300 rpm with ~246 rpm dips)', () { + final f = RpmGlitchFilter(); + // Pattern from the bench log: normal, odd, normal, odd, odd, normal … + final seq = [2256, 245.5, 2315, 246, 246, 2319, 2315, 246, 2320]; + final shown = []; + for (var i = 0; i < seq.length; i++) { + if (f.accept(_kpro(seq[i]), at(i))) shown.add(seq[i]); + } + expect(shown.every((r) => r > 2000), isTrue, reason: '$shown'); + expect(f.dropped, 4); + }); + + test('idle glitch 1003 -> 250 is dropped too', () { + final f = RpmGlitchFilter(); + expect(f.accept(_kpro(1003), at(0)), isTrue); + expect(f.accept(_kpro(250), at(1)), isFalse); + expect(f.accept(_kpro(1005), at(2)), isTrue); + }); + + test('normal acceleration passes every frame', () { + final f = RpmGlitchFilter(); + for (var i = 0; i < 30; i++) { + // +300 rpm per 100 ms = 3000 rpm/s + expect(f.accept(_kpro(1000 + 300.0 * i), at(i)), isTrue); + } + expect(f.dropped, 0); + }); + + test('a burst of 5 glitch frames is still dropped', () { + final f = RpmGlitchFilter(); + expect(f.accept(_kpro(3117), at(0)), isTrue); + for (var i = 1; i <= 5; i++) { + expect(f.accept(_kpro(311), at(i)), isFalse); + } + expect(f.accept(_kpro(3125), at(6)), isTrue); + }); + + test('a real sudden change is accepted by majority (~0.6 s)', () { + double? from, to; + final f = RpmGlitchFilter(onConfirmed: (a, b) { + from = a; + to = b; + }); + expect(f.accept(_kpro(3000), at(0)), isTrue); + var i = 1; + while (!f.accept(_kpro(800), at(i))) { + i++; + } + expect(i, lessThanOrEqualTo(10)); + expect((from, to), (3000, 800)); + expect(f.accept(_kpro(790), at(i + 1)), isTrue); + }); + + test('new RPM wins even with glitch frames mixed in (bench, after DTC ' + 'clear: 7267 -> 3405 with ~311 dips)', () { + final f = RpmGlitchFilter(); + expect(f.accept(_kpro(7267), at(0)), isTrue); + // 3405, 3405, 311, 3405, 311, 3405, 3405, 311 … repeating + const pattern = [3405, 3405, 311]; + var shown = 7267.0; + for (var i = 1; i <= 30; i++) { + final r = pattern[i % 3]; + if (f.accept(_kpro(r), at(i))) shown = r; + } + expect(shown, 3405); + // and the 311 dips are never shown afterwards + for (var i = 31; i <= 60; i++) { + final r = pattern[i % 3]; + if (f.accept(_kpro(r), at(i))) expect(r, 3405); + } + }); + + test('S300 RPM is read from bytes 3-4', () { + final s = Uint8List(128) + ..[0] = 0x1B + ..[3] = 0x8A + ..[4] = 0x0C; // 3210 + expect(RpmGlitchFilter.rpmOf(s), 3210); + }); +} diff --git a/test/s300_parser_test.dart b/test/s300_parser_test.dart index a36b116..73bea2b 100644 --- a/test/s300_parser_test.dart +++ b/test/s300_parser_test.dart @@ -109,10 +109,9 @@ void main() { }); test('parses TPS correctly when raw >= 25', () { - // tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture) - final frame = _buildS300Frame(tps: 127); - final state = parseS300(frame); - expect(state.tps, closeTo(49.8, 0.1)); + // (raw - 25) / 2.04: raw 127 -> 50 %; USB tool showed 31 % at raw ≈ 88. + expect(parseS300(_buildS300Frame(tps: 127)).tps, closeTo(50.0, 0.1)); + expect(parseS300(_buildS300Frame(tps: 88)).tps.round(), 31); }); test('parses Gear from decimal offset 27', () {