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:
metatroncubeswdev 2026-10-13 01:16:53 -04:00
parent 6b8d069ef4
commit 82b1d822f9
9 changed files with 313 additions and 41 deletions

View 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;
}
}

View File

@ -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),

View File

@ -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);

View File

@ -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,

View File

@ -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,
);
}
}

View File

@ -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'),

View File

@ -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);

View 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);
});
}

View File

@ -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', () {