Fuel pump flag, confirmed ECT/IAT/BAT offsets, persisted ECU type

Work that was previously only in the local working tree:
- Rename fuelCut -> fuelPump (SW2 bit2, active-low, live-confirmed)
- ECT/IAT/BAT moved to live-confirmed offsets 33/34/36
- S300 template-frame detection with hold-last-good logic
- Persist ECU type in SharedPreferences; drop per-connect protocol dialog
- Official-protocol probe and DTC clear on the debug/DTC screens
- Sanity clamp for multi-byte fields; scrollable vertical dashboard
- docs/HANDOFF.md

Includes temporary [DBG]/[KDBG] prints, removed in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-12 21:02:05 -04:00
parent 77a47eb938
commit e9b1c0f8fd
14 changed files with 783 additions and 148 deletions

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.

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