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 <noreply@anthropic.com>
This commit is contained in:
parent
6b8d069ef4
commit
82b1d822f9
93
lib/core/bluetooth/rpm_glitch_filter.dart
Normal file
93
lib/core/bluetooth/rpm_glitch_filter.dart
Normal file
@ -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<double>();
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<BtState> {
|
||||
}
|
||||
|
||||
void _resetCounters() {
|
||||
_rpmFilter.reset();
|
||||
_rpmFilter.dropped = 0;
|
||||
_glitchesLogged = 0;
|
||||
_lastAnyFrameAt = null;
|
||||
_lastLiveFrameAt = null;
|
||||
_defaultsSince = null;
|
||||
@ -242,6 +250,21 @@ class BtNotifier extends StateNotifier<BtState> {
|
||||
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<BtState> {
|
||||
}
|
||||
|
||||
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<BtState> {
|
||||
requestsSent: p?.requestsSent,
|
||||
frameCount: p?.validFrames,
|
||||
liveFrames: _liveFrames,
|
||||
rpmGlitches: _rpmFilter.dropped,
|
||||
defaultFrames: _defaultFrames,
|
||||
droppedFrames: p?.droppedFrames,
|
||||
timeouts: p?.timeouts,
|
||||
|
||||
@ -83,8 +83,11 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +94,7 @@ class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
|
||||
_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'),
|
||||
|
||||
@ -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);
|
||||
|
||||
100
test/rpm_glitch_filter_test.dart
Normal file
100
test/rpm_glitch_filter_test.dart
Normal file
@ -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 = <double>[2256, 245.5, 2315, 246, 246, 2319, 2315, 246, 2320];
|
||||
final shown = <double>[];
|
||||
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 = <double>[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);
|
||||
});
|
||||
}
|
||||
@ -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', () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user