hondavert-dev/test/s300_parser_test.dart
metatroncubeswdev 82b1d822f9 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>
2026-10-13 01:16:53 -04:00

349 lines
12 KiB
Dart

import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:hvbt_dash/core/protocol/neg8.dart';
import 'package:hvbt_dash/core/protocol/s300_parser.dart';
import 'package:hvbt_dash/core/protocol/temp_table.dart';
/// Builds a zeroed 128-byte S300 frame and stamps the NEG8 checksum at byte 127.
Uint8List _buildS300Frame({
int rpm = 0,
int vss = 0,
int mapRaw = 0,
int tps = 0,
int inj = 0,
int ign = 0,
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
int iat = 0x80,
int bat = 0,
int sw1 = 0,
int sw2 = 0,
int sw3 = 0,
int krtrd = 0,
int gear = 0,
Map<int, int> extra = const {},
}) {
final frame = Uint8List(128);
// Header
frame[0] = 0x1B;
frame[1] = 0x00;
// RPM: bytes +3..4, little-endian, raw
frame[3] = rpm & 0xFF;
frame[4] = (rpm >> 8) & 0xFF;
// VSS: bytes +5..6, little-endian
frame[5] = vss & 0xFF;
frame[6] = (vss >> 8) & 0xFF;
// MAP: bytes +7..8, little-endian
frame[7] = mapRaw & 0xFF;
frame[8] = (mapRaw >> 8) & 0xFF;
// TPS: byte +9
frame[9] = tps;
// INJ: bytes +10..11, little-endian
frame[10] = inj & 0xFF;
frame[11] = (inj >> 8) & 0xFF;
// IGN: byte +12
frame[12] = ign;
// KRtrd: byte +13
frame[13] = krtrd;
// SW bitmaps
frame[0x11] = sw1;
frame[0x12] = sw2;
frame[0x13] = sw3;
// Gear: decimal offset 27
frame[27] = gear;
// ECT: decimal offset 33
frame[33] = ect;
// IAT: decimal offset 34
frame[34] = iat;
// BAT: decimal offset 36
frame[36] = bat;
extra.forEach((offset, value) => frame[offset] = value);
// Stamp NEG8 checksum at byte 127
int neg8 = 0;
for (int i = 0; i < 127; i++) {
neg8 = (neg8 - frame[i]) & 0xFF;
}
frame[127] = neg8;
return frame;
}
void main() {
group('S300 parser', () {
test('parses RPM correctly', () {
// RPM raw = 3000
final frame = _buildS300Frame(rpm: 3000);
final state = parseS300(frame);
expect(state.rpm, equals(3000.0));
});
test('parses VSS = 0 when raw < 893', () {
final frame = _buildS300Frame(vss: 500);
final state = parseS300(frame);
expect(state.vss, equals(0.0));
});
test('parses VSS correctly when raw >= 893', () {
// vss = 228480 / 1000 = 228.48 km/h
final frame = _buildS300Frame(vss: 1000);
final state = parseS300(frame);
expect(state.vss, closeTo(228.48, 0.01));
});
test('parses MAP as raw/10 kPa', () {
// mapRaw = 1148 -> 114.8 kPa
final frame = _buildS300Frame(mapRaw: 1148);
final state = parseS300(frame);
expect(state.map, closeTo(114.8, 0.01));
});
test('parses TPS = 0 when raw = 0', () {
final frame = _buildS300Frame(tps: 0);
final state = parseS300(frame);
expect(state.tps, equals(0.0));
});
test('parses TPS = 100 when raw = 255', () {
final frame = _buildS300Frame(tps: 255);
final state = parseS300(frame);
expect(state.tps, equals(100.0));
});
test('parses TPS correctly when raw >= 25', () {
// (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', () {
final frame = _buildS300Frame(gear: 5);
final state = parseS300(frame);
expect(state.gear, equals(5));
});
test('parses ECT using temp lookup table', () {
// Confirmed via live capture: raw=0xD2(210) matched the reference
// tool's 28.4°F exactly at the same instant (RPM cross-checked).
// ect raw = 0xD2 = 210 → tempXlt[210]=38 → 38-40=-2°C
final frame = _buildS300Frame(ect: 0xD2);
final state = parseS300(frame);
expect(state.ect, equals((tempXlt[0xD2] - 40).toDouble()));
});
test('parses IAT using temp lookup table', () {
// Confirmed via the same live capture: raw=0xCA(202) was within
// ~1.3°F of the reference tool's 36.1°F reading.
// iat raw = 0xCA = 202 → tempXlt[202]=43 → 43-40=3°C
final frame = _buildS300Frame(iat: 0xCA);
final state = parseS300(frame);
expect(state.iat, equals((tempXlt[0xCA] - 40).toDouble()));
});
test('parses BAT = raw * 26 / 270', () {
// bat raw = 135 → 135*26/270 = 13.0 V
final frame = _buildS300Frame(bat: 135);
final state = parseS300(frame);
expect(state.bat, closeTo(13.0, 0.01));
});
test('IGN decode: (raw + 120) / 2', () {
// ign raw = 164 -> (164 + 120) / 2 = 142.0
final frame = _buildS300Frame(ign: 164);
final state = parseS300(frame);
expect(state.ign, equals(142.0));
});
test('MIL flag set from SW2 bit5', () {
final frame = _buildS300Frame(sw2: 0x20); // bit5 set
final state = parseS300(frame);
expect(state.mil, isTrue);
});
test('fuel pump flag is active-low: set when SW2 bit2 is CLEAR', () {
// SW2 bit 2 is the "Fuel" line in the reference tool's own channel
// list — confirmed live to be the fuel PUMP relay status, not fuel
// cut, but also confirmed live to be active-low: an extended session
// held RPM above the ~300 threshold where the pump should be on, and
// this bit read 0 for the entire session. Separately confirmed the
// app showed "on" below ~200 rpm and "off" above it — exactly
// inverted from a naive `!= 0` read, hence bit CLEAR = pump on.
final frame = _buildS300Frame(sw2: 0x38); // bit2 clear, other bits set
final state = parseS300(frame);
expect(state.fuelPump, isTrue);
});
test('fuel pump flag clear (pump off) when SW2 bit2 is set', () {
final frame = _buildS300Frame(sw2: 0x04); // bit2 set
final state = parseS300(frame);
expect(state.fuelPump, isFalse);
});
test('knock flag set when krtrd > 0', () {
final frame = _buildS300Frame(krtrd: 5);
final state = parseS300(frame);
expect(state.knock, isTrue);
});
test('knock flag clear when krtrd = 0', () {
final frame = _buildS300Frame(krtrd: 0);
final state = parseS300(frame);
expect(state.knock, isFalse);
});
test('throws on wrong frame length', () {
expect(
() => parseS300(Uint8List(64)),
throwsArgumentError,
);
});
// ── Offsets corrected 2026-10-12 (column-count mapping) ──────────────
test('fuel trims: bytes 29/31, 0x80 = 0 %', () {
final s = parseS300(_buildS300Frame(extra: {29: 0x80, 31: 0x90}));
expect(s.strim, closeTo(0.0, 0.001));
expect(s.ltrim, closeTo(12.5, 0.001));
});
test('DTC bytes are ERR00..ERR03 at 37..40', () {
final s = parseS300(
_buildS300Frame(extra: {37: 0x01, 38: 0x02, 39: 0x04, 40: 0x08}));
expect(s.errBytes, equals([0x01, 0x02, 0x04, 0x08]));
});
test('SW6 (byte 50) no longer produces phantom DTCs', () {
final s = parseS300(_buildS300Frame(extra: {50: 0x60, 49: 0x00}));
expect(s.errBytes, equals([0, 0, 0, 0]));
});
test('fan output = SW5 (byte 49) bit0 FANC, not SW2 ALTC', () {
expect(parseS300(_buildS300Frame(extra: {49: 0x01})).fanOut, isTrue);
expect(parseS300(_buildS300Frame(sw2: 0x80)).fanOut, isFalse);
});
test('ethanol from byte 41, AF raw from byte 55, PA from byte 32', () {
final s = parseS300(_buildS300Frame(extra: {41: 85, 55: 0x9A, 32: 0x93}));
expect(s.eth, equals(85.0));
expect(s.afr, equals(154.0));
expect(s.pa, closeTo(100.25, 0.01));
});
test('INJ read little-endian', () {
final s = parseS300(_buildS300Frame(inj: 3000));
expect(s.inj, closeTo(3.0, 0.001));
});
test('AIN0 read little-endian at 82', () {
final s = parseS300(_buildS300Frame(extra: {82: 0x34, 83: 0x02}));
expect(s.ain0, equals(0x0234.toDouble()));
});
test('adapter default frame decodes to the PDF example (sanity)', () {
final f = _fullDefaultFrame();
expect(isS300DefaultFrame(f), isTrue);
final s = parseS300(f);
expect(s.map, closeTo(101.0, 0.01)); // 0x03F2
expect(s.gear, equals(1));
expect(s.ect, equals(20.0)); // 0xA0
expect(s.iat, equals(20.0));
expect(s.bat, closeTo(12.04, 0.01)); // 0x7D
expect(s.strim, closeTo(0.0, 0.001));
expect(s.errBytes, equals([0, 0, 0, 0]));
});
test('throws on bad checksum', () {
final frame = _buildS300Frame(rpm: 1000);
frame[127] = 0x00; // corrupt checksum
expect(() => parseS300(frame), throwsArgumentError);
});
test('can parse bad checksum frame when validation is disabled', () {
final frame = _buildS300Frame(rpm: 1000);
frame[127] = 0x00; // corrupt checksum
final state = parseS300(frame, validateChecksum: false);
expect(state.rpm, equals(1000.0));
});
test('decodes RPM=0 for a frame matching the fixed event-frame template', () {
// parseS300 itself doesn't special-case this template — it just reads
// the normal offsets, which land on the template's fixed bytes.
// Holding the last known-good value across this frame is the caller's
// job (sensor_provider.dart), driven by isS300TemplateFrame below.
final frame = _templateFrame();
final state = parseS300(frame);
expect(state.rpm, equals(0.0));
});
test('isS300TemplateFrame detects the fixed event-frame byte pattern', () {
// Verified via live A/B capture: this exact bytes-0-16 pattern shows
// up whenever SW2 (byte 18, outside this span) reports fuel cut
// active, corroborated independently by the same frame's INJ pulse
// width reading 0 and TPS reading a closed-throttle value.
expect(isS300TemplateFrame(_templateFrame()), isTrue);
});
test('isS300TemplateFrame is false for a normal live-reading frame', () {
final frame = _buildS300Frame(rpm: 3000, tps: 127);
expect(isS300TemplateFrame(frame), isFalse);
});
test('default-frame head with real values at 32-36 is NOT a default frame',
() {
// Key-on/engine-off could coincide with bytes 0-16; the 32-36 span
// (PA/ECT/IAT/?/BAT) must also match before data is discarded.
final frame = _templateFrame();
frame[33] = 0xD2; // real ECT
frame[127] = calculateNeg8(frame.sublist(0, 127));
expect(isS300DefaultFrame(frame), isFalse);
});
});
}
/// Builds a 128-byte frame stamped with the S300's fixed event-frame
/// template in bytes 0-16 and 32-36, with a valid NEG8 checksum.
Uint8List _templateFrame() {
final frame = Uint8List(128);
final prefix = [
0x1B,
0x00,
0x14,
0x00,
0x00,
0xFF,
0xFF,
0xF2,
0x03,
0x18,
0x00,
0x00,
0x10,
0x00,
0x00,
0x00,
0xC4,
];
for (var i = 0; i < prefix.length; i++) {
frame[i] = prefix[i];
}
frame.setRange(32, 37, [0x93, 0xA0, 0xA0, 0xDC, 0x7D]);
frame[127] = calculateNeg8(frame.sublist(0, 127));
return frame;
}
/// The full PDF example row (hvbt_protocol.pdf), which the adapter sends as
/// its default frame when it has no ECU data. Offsets by column count.
Uint8List _fullDefaultFrame() {
final frame = Uint8List(128);
const bytes = <int>[
0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, 0x18, // 0-9
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4, 0x71, 0x1C, 0x05, // 10-19
0x40, 0xB4, 0x05, 0x41, 0x05, 0x66, 0x02, 0x01, 0x00, 0x80, // 20-29
0x00, 0x80, 0x93, 0xA0, 0xA0, 0xDC, 0x7D, 0x00, 0x00, 0x00, // 30-39
0x00, 0x00, 0x80, 0x00, 0x00, 0x8C, 0xB1, 0x00, 0x00, 0x00, // 40-49
0x60, 0xC0, 0x7D, 0x7F, 0x08, 0x9A, 0xC4, 0x00, 0x00, 0x00, // 50-59
];
for (var i = 0; i < bytes.length; i++) {
frame[i] = bytes[i];
}
frame[127] = calculateNeg8(frame.sublist(0, 127));
return frame;
}