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