Snapshot before poller/parser rework

This commit is contained in:
backup 2026-10-13 00:34:11 +00:00
parent 77a47eb938
commit fb87e79d41
79 changed files with 50584 additions and 49949 deletions

BIN
Bluetooth.pdf Normal file

Binary file not shown.

BIN
Requrements.pdf Normal file

Binary file not shown.

62
docs/HANDOFF.md Normal file
View File

@ -0,0 +1,62 @@
# HV BT Dashboard — Data Layer Handoff
Context for continuing work on the S300/KPro Bluetooth data layer. Paste this into a new chat to resume.
## To be continued tomorrow — debugging in progress, session ended mid-investigation
Was actively live-debugging a connection lockup with the phone connected and a `flutter run` session attached when the user had to leave. **Nothing is currently running** (flutter session and Monitor were both stopped cleanly), but the investigation is mid-thread — see "Today's session" below for exactly where it left off, and pick up from "Next steps for tomorrow" at the end.
The **temporary debug print is still active** in `sensor_provider.dart` (search for `TEMP: live-debugging session, remove after` — two spots, a `dbgFrameCount` counter and a `print(...)` call). It logs `template`/`rpm`/`gear`/`ect`/`fuelPump`/`mil`/`vtec`/`fan`/`sw2` for S300 frames, throttled to ~2/sec. Don't re-add it tomorrow — it's already there. Remove it once this investigation is actually closed out.
## Reference documents (in project root)
- `hvbt_protocol.pdf` — legacy fixed-offset frame protocol (what the app currently uses). Ground truth for byte offsets/formulas, though its own offset labels are internally inconsistent — don't trust them without a live capture.
- `Bluetooth.pdf` — Hondata's official, newer, self-describing channel-based protocol (v1.6, 2020). Confirmed via live probe that this specific hardware does NOT support it — legacy protocol is the only option.
- `Requrements.pdf` — original customer UI/UX requirements, references a "TunerView" app as the visual benchmark.
## Today's session: fuel pump held-during-template fix, then a fresh reproduction of the lockup
1. **Fixed**: `fuelPump`, `mil`, `vtec`, `fanOut` are now also held from last-known-good during S300 template frames (`sensor_provider.dart`), not just RPM/VSS/MAP/etc and gear/ECT/IAT/BAT from the previous round. This was prompted by the user seeing fuel pump flip to a wrong value after leaving the app idle without changing anything on the bench — consistent with the connection sitting in a run of template frames (where SW2 was still being decoded fresh and showing whatever the frozen pattern happened to contain) during that idle stretch.
2. **Reproduced a worse case live**: reconnected and watched a fresh session start with **100% template frames — zero live frames — for the first 103+ consecutive polls**. Since `lastGood` had never been populated yet (no live frame had ever arrived), the hold logic had nothing to fall back on and just displayed raw template garbage (RPM=0, gear=1) directly. A manual disconnect/reconnect fixed it — RPM immediately started tracking real changes again (535 of the next 1159 frames were live).
3. **User raised a valid concern**: will this lockup recur after sitting idle again? Proposed (but did not build, pending a decision) an auto-reconnect feature: detect N consecutive template frames with zero live data and trigger an automatic disconnect+reconnect, rather than requiring the user to notice and do it manually.
4. **It recurred within the same debugging session**, unprompted, while just sitting idle — caught a long run of `template=true, rpm=0` again in the log. It eventually broke free on its own (one live frame came through), but then the live debug session itself died: `flutter run` reported **"Lost connection to device"** and exited (the background bash task completed with exit code 0). This is the phone's USB/ADB link to the laptop dropping, not necessarily the phone's Bluetooth link to the ECU — those are two different connections and losing one doesn't necessarily mean the other dropped too.
5. **Unresolved when the session ended**: whether the app itself (on the phone, independent of the USB debug tether) is still running/connected to the ECU via Bluetooth, or whether it also crashed/disconnected. Debug builds tied to an active `flutter run` session sometimes get killed when that session ends — this needs to be checked first thing tomorrow. `adb devices` showed the phone reachable again by the end of the session, so the USB/ADB link itself recovered at least.
## Next steps for tomorrow, in order
1. **Check whether the app survived** the "Lost connection to device" event — is it still open and showing data on the phone, or did it close? If it closed, that's itself worth understanding (does losing the USB/ADB tether kill the debug-mode app process, or was this a coincidental separate crash?).
2. **Decide on the auto-reconnect feature** (see point 3 above) — now that the lockup has reproduced twice in one session, including a from-cold-start 100%-template case that the hold-last-good-value fix can't help with at all, this is probably worth building. Tradeoff to weigh: an automatic reconnect means a brief, visible disconnect/reconnect blip whenever it triggers, and if tuned too aggressively could interrupt a legitimately long-but-real stretch of template frames.
3. **Relaunch live debugging** (see Environment notes) to keep characterizing the lockup — specifically it would help to know: does it always eventually self-recover (like it did once today), or does it sometimes stay stuck indefinitely without a manual reconnect? How long does a "stuck" stretch typically last? Does it correlate with anything (idle time specifically, or could be triggered by anything)?
4. Once the lockup itself is on firmer ground, return to the still-open data fields below.
## Confirmed & fixed (shipped, working — from before today, still holding)
- **RPM, VSS, MAP, IGN formulas**: match the official doc / live captures. *(IGN's formula itself may be worth a second look — one live check showed a big mismatch — not investigated further, low priority.)*
- **TPS** (offset 9, `raw*100/255`), **Gear** (offset 27): confirmed correct against live USB captures.
- **ECT** (offset 33), **IAT** (offset 34), **BAT** (offset 36): all moved from earlier wrong offsets and confirmed via live capture matching USB closely.
- **Fuel Pump** (SW2 bit 2, active-low: `(sw2 & 0x04) == 0`): confirmed correct when frame is live (non-template).
- **S300 "template frame" detection and hold logic**: `isS300TemplateFrame()` in `s300_parser.dart` detects the frozen byte pattern (bytes 0-16, plus bytes 32-36 covering gear/ECT/IAT/BAT). `sensor_provider.dart` now holds RPM/VSS/MAP/TPS/INJ/IGN/knock/O2/gear/ECT/IAT/BAT/fuelPump/mil/vtec/fanOut from last-known-good during these frames. **This only works once at least one live frame has been seen** — a connection stuck in 100% template from the very start has nothing to hold and shows raw garbage (see today's session, point 2).
- **Root cause of the template-frame phenomenon itself is still unknown.** Most likely an adapter-side sampling/buffering limitation, possibly related to whatever's now also causing full lockups (100% template, no live frames at all) after idle periods — these might be the same underlying issue at different severities, not two separate problems.
- **Bluetooth ECU-type persistence**: connecting no longer shows a "S300 or KPro?" popup every time — it connects directly using whatever's set in Settings (persisted via `SharedPreferences`, survives app restart). `bt_picker_screen.dart` / `settings_provider.dart`.
- **Sanity clamp, dashboard scrolling, official protocol probe**: unchanged from before, still working.
- **Clear DTC command**: still not getting acknowledged over Bluetooth despite the ECU accepting it via USB — unresolved, lower priority than the connection lockup.
## Still open — data fields needing live data to resolve (lower priority than the lockup investigation above)
- **SW1 during template frames** — never actually confirmed either way for this specific frame type.
- **AFR**: confirmed wrong (aliased to ERR03's byte). No correct offset found.
- **PA**: candidate at offset 50 is close (~3 kPa off) but not confirmed.
- **IGN formula**: flagged as possibly wrong, not investigated.
## Verification method that has worked every time
1. Set a bench value to something specific and known.
2. Screenshot the USB tool's reading (fuller flag panel = more cross-checks per shot).
3. Attach a live debug session and read raw frame data straight from the console at the same moment — cross-check RPM between the screenshot and the log line to confirm they're actually simultaneous.
4. Compute the byte offset/formula that reproduces the known value.
5. Get a second data point or a stability check across many consecutive frames before changing code.
Do not guess offsets from the PDF's offset column labels alone. Always confirm against a live capture, and always check whether the frame in question is a template frame before trusting its bytes beyond 16.
## Not started yet (deferred until data layer + connection stability are solid)
UI/UX gaps from `Requrements.pdf`: dashboard tile count (need 10, have 6), missing "SubSensor" next to main gauge, flag pills should be selectable (spec says 4, currently a fixed 7), datalog playback not wired to live display screens.
## Environment notes
- Test device: Samsung SM_S948W, `adb -s R3GL40CPKMP` (transport ID changes across reconnects, device serial stays the same).
- Build/install cycle: `flutter build apk --debug` then `adb -s R3GL40CPKMP install -r build\app\outputs\flutter-apk\app-debug.apk`.
- Live debug session (preferred method, used heavily and successfully this whole project): `flutter run -d R3GL40CPKMP --debug` (run in background). Pair with a Monitor that dedupes or does stateful filtering (e.g. only alert on entering/leaving a "stuck" run of N consecutive template frames) rather than raw tail+grep, which gets rate-limited. **Known failure mode**: the session can die with "Lost connection to device" if the phone's USB/ADB link drops — check `adb devices` and relaunch if so.
- All parser changes should get a unit test in `test/s300_parser_test.dart` (or `kpro_parser_test.dart`) built from actual captured bytes.
- `git status` shows all data-layer, DTC/debug-screen, and BT-connection changes as uncommitted modifications, plus the three reference PDFs and this handoff doc as untracked. Nothing has been committed this project.

BIN
hvbt_protocol.pdf Normal file

Binary file not shown.

View File

@ -58,7 +58,7 @@ const List<SensorDef> sensorDefs = [
/// Master list of boolean flag sensors.
const List<FlagDef> flagDefs = [
FlagDef(id: 'mil', displayName: 'MIL'),
FlagDef(id: 'fuelcut', displayName: 'Fuel Cut'),
FlagDef(id: 'fuelpump', displayName: 'Fuel Pump'),
FlagDef(id: 'fanout', displayName: 'FAN Out'),
FlagDef(id: 'vtec', displayName: 'VTEC'),
FlagDef(id: 'knock', displayName: 'Knock'),

View File

@ -89,8 +89,11 @@ SensorState parseKPro(Uint8List frame) {
// Flags
// SW2 bit2 = MIL
final bool mil = (sw2 & 0x04) != 0;
// SW1 bit6 = FLR (fuel cut relay)
final bool fuelCut = (sw1 & 0x40) != 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)
@ -128,7 +131,7 @@ SensorState parseKPro(Uint8List frame) {
ain6: ain6,
ain7: ain7,
mil: mil,
fuelCut: fuelCut,
fuelPump: fuelPump,
fanOut: fanOut,
vtec: vtec,
knock: knock,

View File

@ -3,6 +3,55 @@ import 'sensor_state.dart';
import 'temp_table.dart';
import 'neg8.dart';
/// True when bytes 0-16 of the frame (header, RPM, VSS, MAP, TPS, INJ, IGN,
/// the knock byte, and O2) match a fixed pattern instead of a real reading,
/// with those fields pinned to fixed constants regardless of the true
/// physical signal.
///
/// The frozen region is now known to extend well past byte 16. Bytes 32-36
/// (gear, ECT, IAT, BAT at their current offsets) show the exact same fixed
/// byte sequence (`93 a0 a0 dc 7d`) across dozens of unrelated live captures
/// throughout this project. SW2's flags (fuelPump, mil, vtec, fanOut's ALTC
/// bit) were also seen going wrong during an idle period where nothing
/// changed on the bench but the rest of the frame was also frozen — the
/// earlier belief that SW2 stays live (based on an RPM sweep showing
/// fuelPump correlating with real changes) only holds while the frame
/// *isn't* templated; during a template frame it goes stale like everything
/// else tested so far. SW1 itself was never actually confirmed for template
/// frames specifically — the "matches USB exactly" validation that seemed
/// to support "bytes 17+ are live" was checked against a non-template frame.
/// At this point, don't assume any field is live during a template frame
/// unless it's been individually verified that way — the safer default is
/// that the whole frame is templated, with only a shrinking set of
/// individually-confirmed-live fields as exceptions (currently: none
/// confirmed with certainty — treat the entire byte-17+ region as suspect
/// until proven otherwise field by field).
///
/// Callers should hold the last known-good values for every field sourced
/// from the frozen span (bytes 0-16, gear/ECT/IAT/BAT, and the SW2 flags)
/// and decode only what's individually confirmed live.
bool isS300TemplateFrame(Uint8List frame) {
if (frame.length != 128) return false;
return frame[0] == 0x1B &&
frame[1] == 0x00 &&
frame[2] == 0x14 &&
frame[3] == 0x00 &&
frame[4] == 0x00 &&
frame[5] == 0xFF &&
frame[6] == 0xFF &&
frame[7] == 0xF2 &&
frame[8] == 0x03 &&
frame[9] == 0x18 &&
frame[10] == 0x00 &&
frame[11] == 0x00 &&
frame[12] == 0x10 &&
frame[13] == 0x00 &&
frame[14] == 0x00 &&
frame[15] == 0x00 &&
frame[16] == 0xC4;
}
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
/// is true and the NEG8 checksum fails.
@ -62,22 +111,35 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
// Ltrim: +2B hex = 43 decimal
final double ltrim = frame[0x2B].toDouble();
// PA: +2C hex = 44 decimal
// PA: +2C hex = 44 decimal. Unconfirmed — a live capture's closest
// candidate (byte 50, decimal) was off by ~3 kPa from the reference
// tool's 98.7 kPa reading, not a clean enough match to move to yet.
final double pa = frame[0x2C].toDouble();
// ECT: +2D hex = 45 decimal. Table is tempXlt[raw] - 40, not +40: the
// spec's own sensor range (section 3: ECT/IAT -40..150 degC) is only
// reachable with subtraction, since tempXlt entries span 0..190. The
// written "+40" formula can only ever produce 40..230 degC, which
// contradicts that declared range and explains the ~80 degC-too-hot
// readings seen against real hardware.
final double ect = (tempXlt[frame[0x2D]] - 40).toDouble();
// ECT: decimal offset 33. Confirmed via live capture matching the
// reference tool exactly: raw=0xD2(210), tempXlt[210]=38, 38-40=-2°C =
// 28.4°F, matching the reference tool's 28.4°F reading precisely at the
// same instant (RPM cross-checked to confirm same-moment capture). The
// previous offset (+2D hex = 45 decimal) gave physically impossible
// values (~210°F) against the same live data. Table is tempXlt[raw]-40,
// not +40: the spec's own sensor range (section 3: ECT/IAT -40..150 degC)
// is only reachable with subtraction, since tempXlt entries span 0..190.
final double ect = (tempXlt[frame[33]] - 40).toDouble();
// IAT: +2E hex = 46 decimal
final double iat = (tempXlt[frame[0x2E]] - 40).toDouble();
// IAT: decimal offset 34, immediately after ECT. Confirmed via the same
// live capture: raw=0xCA(202), tempXlt[202]=43, 43-40=3°C=37.4°F vs the
// reference tool's 36.1°F — within ~1.3°F, consistent with table
// granularity. The previous offset (+2E hex = 46 decimal) was part of the
// same wrong-offset block as the old ECT location.
final double iat = (tempXlt[frame[34]] - 40).toDouble();
// BAT: +30 hex = 48 decimal
final double bat = frame[0x30] * 26.0 / 270.0;
// BAT: decimal offset 36. Confirmed via live capture: raw=0x79(121),
// 121*26/270=11.66V vs the reference tool's 11.75V — within one raw unit
// of quantization (26/270 V per LSB ≈ 0.096V). This is also where BAT
// lived before an earlier, unverified "fix" in this project's history
// moved it to +30 hex = 48 decimal — that fix was wrong; this restores
// the original, now confirmed-correct, offset.
final double bat = frame[36] * 26.0 / 270.0;
// ERR bytes: +31..+34 hex = 49..52 decimal
final List<int> errBytes = [
@ -108,8 +170,18 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
final bool revLimit = (sw1 & 0x08) != 0;
// SW2 bit5 = MIL
final bool mil = (sw2 & 0x20) != 0;
// SW2 bit2 = Fuel (cut)
final bool fuelCut = (sw2 & 0x04) != 0;
// SW2 bit2 = Fuel — this is the fuel PUMP relay status, not fuel cut (the
// reference tool's own channel list labels it as such). There's no
// separate "fuel cut" bit identified in this frame — REVL/IGNC/BstC/
// LnchC/ShftC are the actual cut-type flags this ECU exposes.
//
// Active-LOW: confirmed via an extended live session (RPM swept 400-3842,
// continuously above the ~300 threshold where the pump should be on) that
// this bit read 0 the entire time — and separately confirmed live that the
// app showed "on" below ~200 rpm (pump should be off) and "off" above the
// threshold (pump should be on), i.e. exactly inverted from a `!= 0` read.
// Bit clear (0) means the pump relay is energized/on; bit set (1) means off.
final bool fuelPump = (sw2 & 0x04) == 0;
// SW2 bit7 = ALTC → fanOut; also SW5 bit0 = FANC
final bool fanOut = (sw2 & 0x80) != 0 || (sw5 & 0x01) != 0;
// SW2 bit1..0 = VTS (VTEC solenoid — nonzero = active)
@ -145,7 +217,7 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
ain6: ain6,
ain7: ain7,
mil: mil,
fuelCut: fuelCut,
fuelPump: fuelPump,
fanOut: fanOut,
vtec: vtec,
knock: knock,

View File

@ -27,7 +27,7 @@ class SensorState {
final double ain7;
// Boolean flags
final bool mil;
final bool fuelCut;
final bool fuelPump;
final bool fanOut;
final bool vtec;
final bool knock;
@ -64,7 +64,7 @@ class SensorState {
required this.ain6,
required this.ain7,
required this.mil,
required this.fuelCut,
required this.fuelPump,
required this.fanOut,
required this.vtec,
required this.knock,
@ -100,7 +100,7 @@ class SensorState {
ain6: 0,
ain7: 0,
mil: false,
fuelCut: false,
fuelPump: false,
fanOut: false,
vtec: false,
knock: false,
@ -136,7 +136,7 @@ class SensorState {
double? ain6,
double? ain7,
bool? mil,
bool? fuelCut,
bool? fuelPump,
bool? fanOut,
bool? vtec,
bool? knock,
@ -171,7 +171,7 @@ class SensorState {
ain6: ain6 ?? this.ain6,
ain7: ain7 ?? this.ain7,
mil: mil ?? this.mil,
fuelCut: fuelCut ?? this.fuelCut,
fuelPump: fuelPump ?? this.fuelPump,
fanOut: fanOut ?? this.fanOut,
vtec: vtec ?? this.vtec,
knock: knock ?? this.knock,

View File

@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bluetooth/bt_poller.dart';
import '../bluetooth/bt_service.dart';
import '../protocol/neg8.dart';
import 'settings_provider.dart';
// ─── Connection status enum ──────────────────────────────────────────────────
@ -112,6 +113,92 @@ class BtNotifier extends StateNotifier<BtState> {
}
}
/// One-off diagnostic: sends the official Hondata Bluetooth protocol's
/// DL_DeviceInfo command ({ Protocol=0x00, ID=0x02, Size=0x0004 LE }) and
/// returns whatever raw bytes come back within the response window. Used
/// to check whether this device/firmware understands the newer,
/// self-describing channel-based protocol (Bluetooth.pdf) as opposed to
/// only the legacy fixed-offset frame format this app currently speaks.
/// Pauses the legacy poller for the duration so its own request/response
/// traffic doesn't interleave with the probe's reply on the same link.
Future<Uint8List> probeOfficialProtocol() async {
final service = _ref.read(btServiceProvider);
if (!service.isConnected) return Uint8List(0);
_poller?.stop();
// stop() only cancels the poller's own timer and listener — it can't
// un-send a legacy request the poller already wrote moments earlier.
// That request's 128-byte reply is very likely still in flight, so
// drain and discard whatever arrives here before sending the probe, or
// that leftover legacy frame gets mistaken for the probe's response.
final drainSub = service.rawStream.listen((_) {});
await Future.delayed(const Duration(milliseconds: 500));
await drainSub.cancel();
final collected = <int>[];
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
try {
await service.write(Uint8List.fromList([0x00, 0x02, 0x04, 0x00]));
await Future.delayed(const Duration(milliseconds: 800));
} finally {
await sub.cancel();
_poller?.start();
}
return Uint8List.fromList(collected);
}
/// Sends the DTC-reset command from the legacy protocol notes
/// (hvbt_protocol.pdf): { 0x1B, 0x70, NEG8 }, NEG8 over the first two
/// bytes. The ECU echoes the same 3 bytes back to acknowledge.
///
/// Deliberately does NOT pause the poller first (unlike
/// probeOfficialProtocol()) — pausing-then-sending got zero response both
/// here and for the official-protocol probe, while the regular poll cycle
/// always gets answered. One untested explanation: this adapter may only
/// answer additional commands while it keeps seeing the normal poll
/// traffic, not when that traffic stops. So this injects the command
/// alongside ongoing polling and scans the stream for the 3-byte ack
/// appearing anywhere in it — it'll be interleaved with regular 128-byte
/// datalog frames rather than arriving in isolation — over several poll
/// cycles' worth of time, in case the reply is delayed by an EEPROM/flash
/// write on the ECU rather than a simple RAM read.
Future<({bool acknowledged, Uint8List raw})> clearDtcs() async {
final service = _ref.read(btServiceProvider);
if (!service.isConnected) return (acknowledged: false, raw: Uint8List(0));
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
final collected = <int>[];
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
bool acknowledged = false;
try {
await service.write(command);
final deadline = DateTime.now().add(const Duration(seconds: 3));
while (DateTime.now().isBefore(deadline) && !acknowledged) {
await Future.delayed(const Duration(milliseconds: 150));
acknowledged = _containsAck(collected);
}
} finally {
await sub.cancel();
}
return (acknowledged: acknowledged, raw: Uint8List.fromList(collected));
}
static bool _containsAck(List<int> bytes) {
for (var i = 0; i + 2 < bytes.length; i++) {
if (bytes[i] == 0x1B && bytes[i + 1] == 0x70 && bytes[i + 2] == 0x75) {
return true;
}
}
return false;
}
Future<void> disconnect() async {
await _pollSub?.cancel();
_poller?.stop();

View File

@ -1,12 +1,36 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bluetooth/bt_poller.dart';
import '../models/sensor_defs.dart';
import '../protocol/kpro_parser.dart';
import '../protocol/s300_parser.dart';
import '../protocol/sensor_state.dart';
import 'bt_provider.dart';
import 'settings_provider.dart';
// NEG8 is only an 8-bit checksum, so roughly 1 in 256 corrupted frames can
// still pass validation — rare, but enough to occasionally show an
// impossible value: a single flipped bit in the high byte of a 16-bit field
// can jump it into the tens of thousands. Clamp just the fields where that's
// possible (multi-byte reads whose units genuinely match sensorDefs — RPM,
// VSS, MAP, INJ) to their realistic range, the same bounds the dashboard's
// gauges already use for normalization. Single-byte fields (TPS, gear, ECT/
// IAT, etc.) are already bounded to 0-255 raw by construction and can't
// produce this kind of runaway spike, so they're left alone here.
SensorState _clampToRealisticRange(SensorState s) {
double clampFor(String id, double value) {
final def = sensorDefs.firstWhere((d) => d.id == id);
return value.clamp(def.min, def.max);
}
return s.copyWith(
rpm: clampFor('rpm', s.rpm),
vss: clampFor('vss', s.vss),
map: clampFor('map', s.map),
inj: clampFor('inj', s.inj),
);
}
/// Counts of what sensorStateProvider actually did with each frame it saw,
/// exposed for the debug screen so the live pipeline can be inspected
/// directly instead of inferred from symptoms.
@ -27,16 +51,6 @@ class DecodeStats {
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
// Bluetooth's round-trip jitter can make an individual request land outside
// the ECU's transient tach pulse-capture window, so a poll reports "no
// fresh count" (rpm=0) even though the engine/bench signal never actually
// changed, and jitter runs can span more than a couple of polls. Hold the
// last real RPM for this long (measured in wall-clock time, not frame count,
// since frame count at the same real gap would mean a different actual delay
// at every polling-interval setting) before accepting a run of zeros as a
// genuine stop.
const _rpmHoldDuration = Duration(milliseconds: 1000);
/// Emits a parsed [SensorState] for every live data frame received from the ECU.
/// Automatically picks S300 or KPro parser based on [settingsProvider].
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
@ -50,27 +64,113 @@ final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
// never sees them and the loop just keeps waiting for the next frame.
final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {});
double lastGoodRpm = 0;
DateTime? lastGoodRpmAt;
SensorState? lastGood;
int dbgFrameCount = 0; // TEMP: live-debugging session, remove after.
await for (final frame in safeStream) {
// A single bad frame must never take down the rest of the live stream.
try {
SensorState decoded =
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
decoded = _clampToRealisticRange(decoded);
if (decoded.rpm > 0) {
lastGoodRpm = decoded.rpm;
lastGoodRpmAt = decoded.timestamp;
} else if (lastGoodRpm > 0) {
// Anchored to the last real reading rather than refreshed on every
// held frame, so a genuine stop is accepted once this much wall-clock
// time has passed regardless of how many zero polls occurred in it.
if (decoded.timestamp.difference(lastGoodRpmAt!) < _rpmHoldDuration) {
decoded = decoded.copyWith(rpm: lastGoodRpm);
} else {
lastGoodRpm = 0;
// TEMP: live-debugging session, remove after. Throttled to ~2/sec.
if (ecuType == EcuType.s300 && (dbgFrameCount++ % 10 == 0)) {
// ignore: avoid_print
print('[DBG] template=${isS300TemplateFrame(frame)} '
'rpm=${decoded.rpm.toStringAsFixed(0)} '
'gear=${decoded.gear} ect=${decoded.ect.toStringAsFixed(1)} '
'fuelPump=${decoded.fuelPump} mil=${decoded.mil} '
'vtec=${decoded.vtec} fan=${decoded.fanOut} '
'sw2=0x${frame[18].toRadixString(16).padLeft(2, '0')}');
}
// TEMP: KPro live-debugging session, remove after. Compact decode line
// ~1/sec, plus a full raw hex dump ~every 3s so any offset/formula can
// be recomputed straight from logcat without a rebuild.
if (ecuType == EcuType.kpro) {
final n = dbgFrameCount++;
String h(int o) => o < frame.length
? frame[o].toRadixString(16).padLeft(2, '0')
: '--';
String bin(int o) =>
o < frame.length ? frame[o].toRadixString(2).padLeft(8, '0') : '--';
if (n % 20 == 0) {
// ignore: avoid_print
print('[KDBG] rpm=${decoded.rpm.toStringAsFixed(0)} '
'vss=${decoded.vss.toStringAsFixed(0)} '
'tps=${decoded.tps.toStringAsFixed(1)} '
'map=${decoded.map.toStringAsFixed(1)} '
'inj=${decoded.inj.toStringAsFixed(0)} '
'ign=${decoded.ign.toStringAsFixed(1)} '
'o2=${decoded.o2.toStringAsFixed(0)} '
'ect=${decoded.ect.toStringAsFixed(1)} '
'iat=${decoded.iat.toStringAsFixed(1)} '
'pa=${decoded.pa.toStringAsFixed(1)} '
'bat=${decoded.bat.toStringAsFixed(2)} '
'afr=${decoded.afr.toStringAsFixed(1)} '
'strim=${decoded.strim.toStringAsFixed(0)} '
'| mil=${decoded.mil} pump=${decoded.fuelPump} '
'fan=${decoded.fanOut} vtec=${decoded.vtec} knock=${decoded.knock} '
'| raw b5=${h(5)} b6=${h(6)} b7=${h(7)} b8=${h(8)} b11=${h(11)} '
'b12=${h(12)} b13=${h(13)} b14=${h(14)} b19=${h(19)} b20=${h(20)} '
'b21=${h(21)} b25=${h(25)} b33=${h(33)} b34=${h(34)} b49=${h(49)} '
'b50=${h(50)} b51=${h(51)} b52=${h(52)} '
'sw1=${bin(31)} sw2=${bin(32)} inf=${bin(60)}');
}
if (n % 60 == 0) {
final sb = StringBuffer('[KHEX] ');
for (var i = 0; i < frame.length; i++) {
sb.write(frame[i].toRadixString(16).padLeft(2, '0'));
if (i != frame.length - 1) sb.write(' ');
}
// ignore: avoid_print
print(sb.toString());
}
}
if (ecuType == EcuType.s300 && isS300TemplateFrame(frame)) {
// Bytes 0-16 in this frame are fixed template filler, not a real
// reading (see isS300TemplateFrame) — hold the last known-good
// values for the fields sourced from that span. The frozen region
// turned out to extend further than originally thought: a live
// session showed the literal byte sequence behind gear/ECT/IAT/BAT
// (`93 a0 a0 dc 7d` at offsets 32-36) recurring identically dozens
// of times regardless of the true values, so those are held too.
//
// SW2-derived flags (fuelPump, mil, vtec, fanOut's ALTC bit) are
// held now too: a user report of fuelPump flipping to a wrong value
// during an idle period with nothing changed on the bench — right
// when everything else was also frozen — indicates SW2 isn't
// reliably live during template frames either, similar to gear/ECT/
// IAT/BAT. This reverses the earlier assumption (based on SW1,
// itself later found to have been checked against a non-template
// frame) that "bytes 17+ stay live" — that no longer looks safe to
// rely on for anything not individually re-verified.
if (lastGood != null) {
decoded = decoded.copyWith(
rpm: lastGood.rpm,
vss: lastGood.vss,
map: lastGood.map,
tps: lastGood.tps,
inj: lastGood.inj,
ign: lastGood.ign,
knock: lastGood.knock,
o2: lastGood.o2,
gear: lastGood.gear,
ect: lastGood.ect,
iat: lastGood.iat,
bat: lastGood.bat,
fuelPump: lastGood.fuelPump,
mil: lastGood.mil,
vtec: lastGood.vtec,
fanOut: lastGood.fanOut,
);
}
} else {
// Clamped above, so a corrupted spike is never remembered as a good
// value to hold into a later template frame.
lastGood = decoded;
}
final s = ref.read(decodeStatsProvider);

View File

@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../bluetooth/bt_poller.dart';
class AppSettings {
@ -17,10 +18,27 @@ class AppSettings {
);
}
class SettingsNotifier extends StateNotifier<AppSettings> {
SettingsNotifier() : super(const AppSettings());
const _ecuTypeKey = 'ecuType';
class SettingsNotifier extends StateNotifier<AppSettings> {
SettingsNotifier() : super(const AppSettings()) {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_ecuTypeKey);
if (saved == EcuType.kpro.name) {
state = state.copyWith(ecuType: EcuType.kpro);
}
}
void setEcuType(EcuType type) {
state = state.copyWith(ecuType: type);
SharedPreferences.getInstance()
.then((prefs) => prefs.setString(_ecuTypeKey, type.name));
}
void setEcuType(EcuType type) => state = state.copyWith(ecuType: type);
void setPollingInterval(Duration d) =>
state = state.copyWith(pollingInterval: d);
}

View File

@ -2,9 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../core/bluetooth/bt_poller.dart';
import '../../../core/providers/bt_provider.dart';
import '../../../core/providers/settings_provider.dart';
import '../../widgets/main_nav.dart';
class BtPickerScreen extends ConsumerStatefulWidget {
@ -57,8 +55,12 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
ref.invalidate(pairedDevicesProvider);
}
Future<void> _connect(BluetoothDevice device, EcuType ecuType) async {
ref.read(settingsProvider.notifier).setEcuType(ecuType);
// ECU protocol is chosen once via Settings (or defaults to S300) and
// persisted from there — no need to ask again on every connection. A
// dialog here used to re-prompt "S300 or KPro?" on every single tap,
// which was pure friction once the user had already told the app which
// ECU they have.
Future<void> _connect(BluetoothDevice device) async {
await ref.read(btProvider.notifier).connect(device);
if (!mounted) return;
if (ref.read(btProvider).isConnected) {
@ -68,30 +70,6 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
}
}
void _showProtocolDialog(BluetoothDevice device) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(device.name ?? device.address),
content: const Text('Select ECU protocol:'),
actions: [
TextButton(
onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.s300); },
child: const Text('S300'),
),
TextButton(
onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.kpro); },
child: const Text('KPro'),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final btState = ref.watch(btProvider);
@ -153,9 +131,7 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
: const Icon(
Icons.arrow_forward_ios,
size: 14),
onTap: connecting
? null
: () => _showProtocolDialog(d),
onTap: connecting ? null : () => _connect(d),
);
},
),

View File

@ -21,6 +21,7 @@ class DashboardVertical extends ConsumerWidget {
return Container(
color: const Color(0xFF0A0A0A),
child: SingleChildScrollView(
child: Column(
children: [
Padding(
@ -38,10 +39,10 @@ class DashboardVertical extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
child: FlagRow(state: state, accentColor: colors.accent),
),
Expanded(
child: Padding(
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
mainAxisSpacing: 6,
crossAxisSpacing: 6,
@ -59,9 +60,9 @@ class DashboardVertical extends ConsumerWidget {
],
),
),
),
],
),
),
);
}

View File

@ -56,7 +56,7 @@ class FlagRow extends StatelessWidget {
FlagPill(label: 'MIL', active: state.mil, color: Colors.amber),
FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue),
FlagPill(label: 'KNOCK', active: state.knock, color: Colors.red),
FlagPill(label: 'FUEL CUT', active: state.fuelCut, color: Colors.red),
FlagPill(label: 'FUEL PUMP', active: state.fuelPump, color: Colors.cyan),
FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange),
FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green),
FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple),

View File

@ -9,11 +9,34 @@ import '../../../core/providers/settings_provider.dart';
import '../../../core/protocol/neg8.dart';
import '../../../core/protocol/sensor_state.dart';
class RawFrameDebugScreen extends ConsumerWidget {
class RawFrameDebugScreen extends ConsumerStatefulWidget {
const RawFrameDebugScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<RawFrameDebugScreen> createState() =>
_RawFrameDebugScreenState();
}
class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
Uint8List? _probeResult;
bool _probing = false;
Future<void> _runProbe() async {
setState(() {
_probing = true;
_probeResult = null;
});
final result =
await ref.read(btProvider.notifier).probeOfficialProtocol();
if (!mounted) return;
setState(() {
_probing = false;
_probeResult = result;
});
}
@override
Widget build(BuildContext context) {
final bt = ref.watch(btProvider);
final settings = ref.watch(settingsProvider);
final sensorAsync = ref.watch(sensorStateProvider);
@ -60,6 +83,13 @@ class RawFrameDebugScreen extends ConsumerWidget {
],
),
const SizedBox(height: 12),
_ProtocolProbeCard(
connected: bt.isConnected,
probing: _probing,
result: _probeResult,
onProbe: _runProbe,
),
const SizedBox(height: 12),
_Section(
title: 'Decode Pipeline',
rows: [
@ -121,6 +151,13 @@ class RawFrameDebugScreen extends ConsumerWidget {
_RowData('Battery', s.bat.toStringAsFixed(2)),
_RowData('O2', s.o2.toStringAsFixed(2)),
_RowData('AFR', s.afr.toStringAsFixed(2)),
_RowData('MIL (unverified)', s.mil.toString()),
_RowData('Fuel Pump', s.fuelPump.toString()),
_RowData('Fan Out (unverified)', s.fanOut.toString()),
_RowData('VTEC (unverified)', s.vtec.toString()),
_RowData('Knock', s.knock.toString()),
_RowData('Rev Limit (unverified)', s.revLimit.toString()),
_RowData('Launch (unverified)', s.launch.toString()),
];
List<_RowData> _s300CandidateRows(Uint8List f) {
@ -141,18 +178,25 @@ class RawFrameDebugScreen extends ConsumerWidget {
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()),
_RowData('IGN raw @12 (offset unverified)', f[12].toString()),
_RowData('O2 current raw (offset unverified)', f[16].toString()),
_RowData('ECT raw @45', f[0x2D].toString()),
_RowData('IAT raw @46', f[0x2E].toString()),
_RowData('BAT raw @48 (offset unverified)', f[0x30].toString()),
_RowData('ECT raw @33', f[33].toString()),
_RowData('IAT raw @34', f[34].toString()),
_RowData('BAT raw @36', f[36].toString()),
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
_RowData('PA candidate raw @50 (unconfirmed, ~3kPa off)', f[50].toString()),
_RowData('ERR bytes @49-52',
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'),
_RowData('SW1 @17 (bit7..0)', '${_b(f, 0x11)} ${_bin(f, 0x11)}'),
_RowData('SW2 @18 (bit7..0)', '${_b(f, 0x12)} ${_bin(f, 0x12)}'),
_RowData('SW3 @19 (bit7..0)', '${_b(f, 0x13)} ${_bin(f, 0x13)}'),
_RowData('SW5 @67 (bit7..0)', '${_b(f, 0x43)} ${_bin(f, 0x43)}'),
];
}
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
static String _b(Uint8List f, int o) =>
f[o].toRadixString(16).padLeft(2, '0');
static String _bin(Uint8List f, int o) =>
f[o].toRadixString(2).padLeft(8, '0');
static double _s300Vss(int raw) {
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
@ -176,6 +220,88 @@ class RawFrameDebugScreen extends ConsumerWidget {
}
}
/// Step 1 of the data-layer review: checks whether this device/firmware
/// understands the official, self-describing Hondata Bluetooth protocol
/// (Bluetooth.pdf) rather than only the legacy fixed-offset frame format
/// this app currently speaks. A real reply here (Protocol=0x00, ID=0x02,
/// with a recognizable DeviceType byte) means migrating to that protocol is
/// on the table; silence means this hardware is legacy-only.
class _ProtocolProbeCard extends StatelessWidget {
final bool connected;
final bool probing;
final Uint8List? result;
final VoidCallback onProbe;
const _ProtocolProbeCard({
required this.connected,
required this.probing,
required this.result,
required this.onProbe,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Official Protocol Probe',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 4),
const Text(
'Sends DL_DeviceInfo (00 02 04 00). Pauses normal polling for '
'the duration of the probe.',
style: TextStyle(color: Colors.grey, fontSize: 11),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: (connected && !probing) ? onProbe : null,
child: Text(probing ? 'Probing…' : 'Send Probe'),
),
),
if (result != null) ...[
const SizedBox(height: 8),
for (final row in _interpret(result!)) _InfoRow(row),
],
],
),
),
);
}
List<_RowData> _interpret(Uint8List r) {
if (r.isEmpty) {
return const [
_RowData('Result', 'No response — likely legacy protocol only'),
];
}
final hex = r.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
final rows = [_RowData('Raw bytes', hex)];
if (r.length >= 4) {
final size = r[2] | (r[3] << 8); // little-endian per TDLHeader.Size
rows.add(_RowData('Protocol / ID / Size', '${r[0]} / ${r[1]} / $size'));
}
if (r.length >= 6) {
final deviceType = switch (r[4]) {
0xC0 => 'S300',
0xC1 => 'KPro',
0xC2 => 'FlashPro',
_ => 'unknown (0x${r[4].toRadixString(16)})',
};
rows.add(_RowData('Device type', deviceType));
rows.add(_RowData('Ignition', r[5] != 0 ? 'On' : 'Off'));
}
return rows;
}
}
class _Section extends StatelessWidget {
final String title;
final List<_RowData> rows;

View File

@ -1,17 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/protocol/dtc_map.dart';
import '../../../core/providers/bt_provider.dart';
import '../../../core/providers/sensor_provider.dart';
import '../../../core/providers/settings_provider.dart';
import '../../../core/bluetooth/bt_poller.dart';
import 'widgets/dtc_row.dart';
class DtcScreen extends ConsumerWidget {
class DtcScreen extends ConsumerStatefulWidget {
const DtcScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<DtcScreen> createState() => _DtcScreenState();
}
class _DtcScreenState extends ConsumerState<DtcScreen> {
bool _clearing = false;
Uint8List? _lastRaw;
String _hex(Uint8List bytes) {
final parts = <String>[];
for (var i = 0; i < bytes.length; i++) {
if (i > 0 && i % 16 == 0) parts.add('\n');
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
}
return parts.join(' ');
}
void _showRawCapture() {
final raw = _lastRaw;
if (raw == null) return;
final hex = _hex(raw);
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text('Raw capture (${raw.length} bytes)'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: SelectableText(
hex,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
),
actions: [
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: hex));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied')),
);
}
},
child: const Text('Copy'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Future<void> _confirmAndClear() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Trouble Codes'),
content: const Text(
'This sends a reset command to the ECU and erases all stored '
'fault codes. This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear',
style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed != true) return;
setState(() => _clearing = true);
final result = await ref.read(btProvider.notifier).clearDtcs();
if (!mounted) return;
setState(() {
_clearing = false;
_lastRaw = result.raw;
});
final String message;
if (result.acknowledged) {
message = 'Trouble codes cleared';
} else if (result.raw.isEmpty) {
message = 'No response from ECU — codes may not have cleared';
} else {
// The scan runs alongside normal polling now, so "not acknowledged"
// can mean several seconds' worth of regular datalog traffic with no
// ack byte pattern found in it — dumping all of that raw would be
// unusable in a snackbar, so just report how much was seen. "View"
// opens the full hex to check by hand for anything our narrow 3-byte
// pattern match might have missed.
message =
'No ack seen — ${result.raw.length} bytes of other traffic during the window';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: (!result.acknowledged && result.raw.isNotEmpty)
? SnackBarAction(label: 'View', onPressed: _showRawCapture)
: null,
duration: const Duration(seconds: 6),
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(latestSensorProvider);
final bt = ref.watch(btProvider);
final ecuType = ref.watch(settingsProvider).ecuType;
final dtcMap =
@ -86,6 +203,29 @@ class DtcScreen extends ConsumerWidget {
},
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: (bt.isConnected && !_clearing)
? _confirmAndClear
: null,
child: Text(
_clearing ? 'Clearing…' : 'CLEAR TROUBLE CODES',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
),
],
);
}

View File

@ -13,6 +13,7 @@ Uint8List _buildS300Frame({
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,
@ -48,10 +49,12 @@ Uint8List _buildS300Frame({
frame[0x13] = sw3;
// Gear: decimal offset 27
frame[27] = gear;
// ECT: byte +2D
frame[0x2D] = ect;
// BAT: byte +30
frame[0x30] = bat;
// ECT: decimal offset 33
frame[33] = ect;
// IAT: decimal offset 34
frame[34] = iat;
// BAT: decimal offset 36
frame[36] = bat;
// Stamp NEG8 checksum at byte 127
int neg8 = 0;
@ -117,10 +120,21 @@ void main() {
});
test('parses ECT using temp lookup table', () {
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 73-40=33°C
final frame = _buildS300Frame(ect: 0x80);
// 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[0x80] - 40).toDouble()));
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', () {
@ -143,6 +157,25 @@ void main() {
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);
@ -175,13 +208,34 @@ void main() {
expect(state.rpm, equals(1000.0));
});
test('decodes RPM=0 for a frame matching the old "keep-alive" byte prefix', () {
// A fixed 17-byte prefix here was previously treated as a keep-alive
// signature to skip outright. Live testing showed it over-matched
// badly: with RPM actively changing, nearly every frame was being
// classified as a keep-alive and discarded, freezing the dashboard.
// Every checksum-valid frame is decoded normally now regardless of
// its leading bytes — see sensor_provider.dart.
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);
});
});
}
/// Builds a 128-byte frame stamped with the S300's fixed event-frame
/// template in bytes 0-16, with a valid NEG8 checksum.
Uint8List _templateFrame() {
final frame = Uint8List(128);
final prefix = [
0x1B,
@ -206,9 +260,5 @@ void main() {
frame[i] = prefix[i];
}
frame[127] = calculateNeg8(frame.sublist(0, 127));
final state = parseS300(frame);
expect(state.rpm, equals(0.0));
});
});
return frame;
}