diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..0d8beef --- /dev/null +++ b/docs/HANDOFF.md @@ -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. diff --git a/lib/core/models/sensor_defs.dart b/lib/core/models/sensor_defs.dart index 3620273..5ec88cd 100644 --- a/lib/core/models/sensor_defs.dart +++ b/lib/core/models/sensor_defs.dart @@ -58,7 +58,7 @@ const List sensorDefs = [ /// Master list of boolean flag sensors. const List 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'), diff --git a/lib/core/protocol/kpro_parser.dart b/lib/core/protocol/kpro_parser.dart index df6b6a9..2adbd31 100644 --- a/lib/core/protocol/kpro_parser.dart +++ b/lib/core/protocol/kpro_parser.dart @@ -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, diff --git a/lib/core/protocol/s300_parser.dart b/lib/core/protocol/s300_parser.dart index 6ede90b..83e4be0 100644 --- a/lib/core/protocol/s300_parser.dart +++ b/lib/core/protocol/s300_parser.dart @@ -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 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, diff --git a/lib/core/protocol/sensor_state.dart b/lib/core/protocol/sensor_state.dart index 80440c7..6ae293c 100644 --- a/lib/core/protocol/sensor_state.dart +++ b/lib/core/protocol/sensor_state.dart @@ -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, diff --git a/lib/core/providers/bt_provider.dart b/lib/core/providers/bt_provider.dart index 1e8d5f3..5da5bbf 100644 --- a/lib/core/providers/bt_provider.dart +++ b/lib/core/providers/bt_provider.dart @@ -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 { } } + /// 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 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 = []; + 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 = []; + 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 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 disconnect() async { await _pollSub?.cancel(); _poller?.stop(); diff --git a/lib/core/providers/sensor_provider.dart b/lib/core/providers/sensor_provider.dart index 584ef2a..cd9ba4f 100644 --- a/lib/core/providers/sensor_provider.dart +++ b/lib/core/providers/sensor_provider.dart @@ -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((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((ref) async* { @@ -50,27 +64,113 @@ final sensorStateProvider = StreamProvider((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); diff --git a/lib/core/providers/settings_provider.dart b/lib/core/providers/settings_provider.dart index 5adad0d..ee546a7 100644 --- a/lib/core/providers/settings_provider.dart +++ b/lib/core/providers/settings_provider.dart @@ -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 { - SettingsNotifier() : super(const AppSettings()); +const _ecuTypeKey = 'ecuType'; + +class SettingsNotifier extends StateNotifier { + SettingsNotifier() : super(const AppSettings()) { + _load(); + } + + Future _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); } diff --git a/lib/ui/screens/bt_picker/bt_picker_screen.dart b/lib/ui/screens/bt_picker/bt_picker_screen.dart index b2b4b21..60de3d8 100644 --- a/lib/ui/screens/bt_picker/bt_picker_screen.dart +++ b/lib/ui/screens/bt_picker/bt_picker_screen.dart @@ -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 { ref.invalidate(pairedDevicesProvider); } - Future _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 _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 { } } - void _showProtocolDialog(BluetoothDevice device) { - showDialog( - 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 { : const Icon( Icons.arrow_forward_ios, size: 14), - onTap: connecting - ? null - : () => _showProtocolDialog(d), + onTap: connecting ? null : () => _connect(d), ); }, ), diff --git a/lib/ui/screens/dashboard/dashboard_vertical.dart b/lib/ui/screens/dashboard/dashboard_vertical.dart index b9a1c3c..43b9a1c 100644 --- a/lib/ui/screens/dashboard/dashboard_vertical.dart +++ b/lib/ui/screens/dashboard/dashboard_vertical.dart @@ -21,27 +21,28 @@ class DashboardVertical extends ConsumerWidget { return Container( color: const Color(0xFF0A0A0A), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), - child: AnalogGauge( - value: state.rpm, - minValue: 0, - maxValue: 9000, - label: 'RPM', - unit: 'rpm', - accentColor: colors.accent, + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), + child: AnalogGauge( + value: state.rpm, + minValue: 0, + maxValue: 9000, + label: 'RPM', + unit: 'rpm', + accentColor: colors.accent, + ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: FlagRow(state: state, accentColor: colors.accent), - ), - Expanded( - child: Padding( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: FlagRow(state: state, accentColor: colors.accent), + ), + Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), child: GridView.count( + shrinkWrap: true, crossAxisCount: 2, mainAxisSpacing: 6, crossAxisSpacing: 6, @@ -59,8 +60,8 @@ class DashboardVertical extends ConsumerWidget { ], ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/ui/screens/dashboard/widgets/flag_pill.dart b/lib/ui/screens/dashboard/widgets/flag_pill.dart index 38c5b0a..efed949 100644 --- a/lib/ui/screens/dashboard/widgets/flag_pill.dart +++ b/lib/ui/screens/dashboard/widgets/flag_pill.dart @@ -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), diff --git a/lib/ui/screens/debug/raw_frame_debug_screen.dart b/lib/ui/screens/debug/raw_frame_debug_screen.dart index 3ce9cd3..b874dca 100644 --- a/lib/ui/screens/debug/raw_frame_debug_screen.dart +++ b/lib/ui/screens/debug/raw_frame_debug_screen.dart @@ -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 createState() => + _RawFrameDebugScreenState(); +} + +class _RawFrameDebugScreenState extends ConsumerState { + Uint8List? _probeResult; + bool _probing = false; + + Future _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; diff --git a/lib/ui/screens/dtc/dtc_screen.dart b/lib/ui/screens/dtc/dtc_screen.dart index dba7906..f4b670f 100644 --- a/lib/ui/screens/dtc/dtc_screen.dart +++ b/lib/ui/screens/dtc/dtc_screen.dart @@ -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 createState() => _DtcScreenState(); +} + +class _DtcScreenState extends ConsumerState { + bool _clearing = false; + Uint8List? _lastRaw; + + String _hex(Uint8List bytes) { + final parts = []; + 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( + 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 _confirmAndClear() async { + final confirmed = await showDialog( + 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), + ), + ), + ), + ), + ), ], ); } diff --git a/test/s300_parser_test.dart b/test/s300_parser_test.dart index 1cfe081..d8bf845 100644 --- a/test/s300_parser_test.dart +++ b/test/s300_parser_test.dart @@ -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,40 +208,57 @@ 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. - final frame = Uint8List(128); - final prefix = [ - 0x1B, - 0x00, - 0x14, - 0x00, - 0x00, - 0xFF, - 0xFF, - 0xF2, - 0x03, - 0x18, - 0x00, - 0x00, - 0x10, - 0x00, - 0x00, - 0x00, - 0xC4, - ]; - for (var i = 0; i < prefix.length; i++) { - frame[i] = prefix[i]; - } - frame[127] = calculateNeg8(frame.sublist(0, 127)); - + 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, + 0x00, + 0x14, + 0x00, + 0x00, + 0xFF, + 0xFF, + 0xF2, + 0x03, + 0x18, + 0x00, + 0x00, + 0x10, + 0x00, + 0x00, + 0x00, + 0xC4, + ]; + for (var i = 0; i < prefix.length; i++) { + frame[i] = prefix[i]; + } + frame[127] = calculateNeg8(frame.sublist(0, 127)); + return frame; +}