diff --git a/docs/FIX_2026-10-12.md b/docs/FIX_2026-10-12.md new file mode 100644 index 0000000..2d2fec9 --- /dev/null +++ b/docs/FIX_2026-10-12.md @@ -0,0 +1,209 @@ +# HV BT Dashboard — Fix pass 2026-10-12 (client feedback round) + +**Status:** `flutter analyze` (0 errors) and `flutter test` pass. APKs not yet +built; the bench checklist in §6.2 must pass before anything is sent to the +client. + +**History:** the earlier uncommitted work and this pass are two commits on top +of `77a47eb`: first the earlier work (fuel pump, ECT/IAT/BAT, ECU-type +persistence), then this rework. To see this pass alone: `git show HEAD~1..HEAD` +(or `git diff HEAD~2 HEAD~1` for the earlier work). The original line-ending-heavy +snapshot commit is kept on branch `backup/snapshot-fb87e79`. + +Review follow-ups applied in this pass: a reconnect in flight is discarded if +the user disconnects meanwhile; default-frame detection also requires bytes +32-36; the friendly error widget applies to release builds only. + +--- + +## 1. Client feedback + +1. Phone 1 — app crashes (all permissions allowed). +2. Phone 2 — connects to S300, shows nothing correct. +3. Phone 3 — some data correct, some obviously wrong; RPM gauge steps and freezes. +4. "Too many requests from the app — use a timeout for the answer before sending a + new request; it affects our device MCU usage." + +## 2. Root causes found + +### 2.1 "Template frames" are the adapter's DEFAULT frame +The frozen frame the team has been working around (bytes 0-16 +`1B 00 14 00 00 FF FF F2 03 18 00 00 10 00 00 00 C4`, bytes 32-36 +`93 A0 A0 DC 7D`) is **byte-for-byte the example row printed in +`hvbt_protocol.pdf`**. It is the adapter's built-in default buffer, returned when +it has no fresh ECU data. Combined with the client's remark about MCU load, the +most likely cause is that the app asks again before the adapter has finished +reading the ECU. Holding "last good values" during these frames is what made the +RPM gauge freeze and then jump, and a session that only ever gets default frames +(seen live in HANDOFF.md) is exactly phone 2. + +### 2.2 The poller flooded the adapter +Old `bt_poller.dart`: a 50 ms periodic timer, plus an *extra* request whenever a +reply took longer than 200 ms (no waiting for the outstanding one), plus a request +that was never cleared after a bad-checksum reply (re-fired after 200 ms). Settings +even offered 20 ms (50 req/s). + +### 2.3 Wrong byte offsets +The PDF's column labels (`+26 +27 +28 +29 +2A … +2F +30 +31`) are **not hex**. +Real offsets are plain column counting 0..127. Proof: the four fields already +confirmed live (Gear 27, ECT 33, IAT 34, BAT 36) all match column counting, and +the default frame decodes to sensible defaults with it (MAP 101.0 kPa, ECT/IAT +20 °C, BAT 12.0 V, trims 0 %, gear 1). The code read several fields as hex: + +| Field | Old byte | New byte | Old symptom | +|---|---|---|---| +| Short trim | 41 (0x29) | 29 | showed ethanol byte | +| Long trim | 43 (0x2B) | 31 | wrong | +| PA (baro) | 44 (0x2C) | 32 | wrong | +| ERR00–ERR03 (DTCs) | 49–52 | 37–40 | phantom DTCs (e.g. SW6=0x60 → P0505/P0350) | +| Ethanol | 56 (0x38) | 41 | wrong | +| SW5 (FANC) | 67 (0x43) | 49 | always 0 (reserved area) | +| AF | 52 (0x34) | 55 | wrong | + +Plus byte-order: INJ and AIN were read big-endian; the adapter is little-endian +(RPM/VSS/MAP confirmed live; KPro table labels MAP2 / AFCMD as L/H pairs). + +### 2.4 Phone 1 crash — not proven yet +A Dart exception cannot close a Flutter app, so it is a native crash. Main +suspects: `flutter_bluetooth_serial 0.4.0` (unmaintained since ~2021, predates +Android 12 Bluetooth permissions, has a known "Reply already submitted" crash on a +failed connect) and the old picker screen calling `getBondedDevices` before the +Bluetooth permission was granted. **Need from client: phone model + Android +version, and a logcat of the crash.** + +--- + +## 3. Changes, file by file + +### Bluetooth / polling +- **`lib/core/bluetooth/bt_poller.dart` — rewritten.** + - Exactly one request outstanding. Next request only after a complete, + checksum-valid 128-byte reply **or** a 500 ms timeout. + - After a timeout: 150 ms quiet period + receive buffer flush, so a late reply + is never taken as the answer to the next request. + - Pacing: one request per polling interval (default **100 ms = 10 Hz**, minimum + 80 ms enforced), never less than 20 ms after a reply. + - Frame sync now requires the 2-byte header (`1B 00` S300 / `1B 01` KPro) + NEG8. + - 6 consecutive timeouts → `onLinkStalled` callback. + - DTC-reset and protocol-probe commands go through the same lock, so they never + overlap a datalog request (`sendCommandAwaitReply`, `sendCommandCollect`). + - Stats: requestsSent, validFrames, droppedFrames, timeouts, lastResponseTime. +- **`lib/core/bluetooth/bt_service.dart`** — connect guarded against double + attempts + 15 s timeout; write throws when not connected (so the poller counts + it); close wrapped in timeout/try; logging. +- **`lib/core/providers/bt_provider.dart` — rewritten.** + - New `BtStatus.reconnecting`; new `EcuDataStatus` (none / live / + adapterDefaults / noResponse). + - **Adapter default frames are filtered out here** — they never reach the + gauges, graph or datalog; they are counted and shown on the Debug screen. + - Auto-reconnect: on BT link loss (up to 5 attempts, 1-5 s backoff); on 6 + consecutive timeouts or >8 s of default-only frames, at most once per 45 s. + - Settings changes (poll interval) apply to a running poller. + - `clearDtcs()` / `probeOfficialProtocol()` reimplemented via the poller lock; + same return types as before. + +### Decoding +- **`lib/core/protocol/s300_parser.dart`** — offsets/formulas per §2.3; full offset + map in the file header; `isS300DefaultFrame()` (old name + `isS300TemplateFrame()` kept as an alias); fan output = SW5 bit0 FANC only + (SW2 bit7 is ALTC = alternator control). +- **`lib/core/protocol/kpro_parser.dart`** — RPM, MAP2, AIN read little-endian. + ⚠ KPro RPM endianness must be checked on the bench (2000 rpm must read 2000). +- **`lib/core/providers/sensor_provider.dart`** — removed hold-last-value logic and + the TEMP debug `print`s; decode errors go to the app log. +- **`lib/core/models/sensor_defs.dart`** — O2, AFR, AIN0-7 shown as `raw` (scales + not verified) instead of fake V/λ ranges. + +### UI / robustness +- **`lib/main.dart`** — `runZonedGuarded` + `FlutterError.onError` + + `PlatformDispatcher.onError` → app log; friendly error widget. +- **`lib/core/logging/app_log.dart` (new)** — 400-line in-memory log. +- **`lib/ui/screens/debug/raw_frame_debug_screen.dart`** — "Copy app log" button; + new link stats (requests, live vs default frames, timeouts, reply time, + reconnects); raw-candidate rows use the corrected offsets. +- **`lib/ui/widgets/data_status_banner.dart` (new)** + `main_nav.dart` — banner + under the app bar when data is not live ("Adapter connected, but no data from + the ECU", "Adapter is not responding", "Reconnecting…"). +- **`lib/ui/widgets/bt_status_chip.dart`** — reconnecting state. +- **`lib/ui/screens/bt_picker/bt_picker_screen.dart`** — requests permissions + **before** touching the paired-device list; only BLUETOOTH_CONNECT is required + (scan/location optional); try/catch around plugin calls; double-tap guard. +- **`lib/ui/screens/dashboard/widgets/analog_gauge.dart`** — 110 ms linear needle + animation (was 150 ms ease-out) so the needle glides between 10 Hz readings. +- **`lib/core/providers/settings_provider.dart` + settings screen** — interval + options 100/150/200/300 ms (removed 20 and 50), default 100, persisted. +- **`lib/core/providers/sensor_history_provider.dart`** — graph now actually + redraws live (`state = state` was swallowed by StateNotifier). + +### Tests +- `test/s300_parser_test.dart` — INJ builder LE; new tests for trims, DTC bytes, + no phantom DTC from SW6, FANC, Eth/AF/PA offsets, INJ/AIN LE, and decoding the + full PDF default frame. +- `test/kpro_parser_test.dart` — builder writes RPM and MAP2 little-endian. + +--- + +## 4. Still UNVERIFIED (need one bench check against the USB tool) +| Item | Current assumption | +|---|---| +| INJ scale | raw (LE) / 1000 → ms (assumes µs) | +| IGN formula | PDF `(raw+120)/2` — one earlier live check disagreed | +| PA formula | `raw*0.716 - 5` kPa (default 0x93 → 100.3 kPa) | +| O2, AF, AIN | shown raw | +| KPro RPM byte order | little-endian | +| DTC clear ack | sent between polls now; ack still unconfirmed | + +## 5. Questions for the client (device maker) +1. Max response time of the adapter to `1B 00 E5`? (sets our 500 ms timeout) +2. Recommended request interval? (we use 100 ms; DLrate byte = 0x14 = 20 — is that + 20 Hz?) +3. Confirm: the PDF example frame = "no fresh ECU data" default frame? +4. Phone models + Android versions; logcat from the crashing phone. + +--- + +## 6. Verification checklist + +### 6.1 Static (must all pass before building) +- [ ] `flutter pub get` +- [ ] `flutter analyze` — no errors (fix any compile errors in changed files only) +- [ ] `flutter test` — all tests green +- [ ] `flutter build apk --release` (and `--debug` for the bench) +- [ ] Release build: if it crashes on connect but debug doesn't, add R8 keep rule + `-keep class io.github.edufolly.flutterbluetoothserial.** { *; }` + +### 6.2 Bench (S300 + USB reference tool) +- [ ] Debug screen: *Requests sent* rises ≈10/s, never faster; *Timeouts* ≈ 0. +- [ ] *Adapter default frames* stays ≈ 0 while the ECU is on (main proof the + flooding was the cause). If it is still high → report to client with log. +- [ ] RPM sweep up/down: needle moves smoothly, no freezes/steps. +- [ ] Compare against USB tool: RPM, MAP, TPS, ECT, IAT, BAT, gear (already + confirmed) + trims, PA, INJ, IGN, ETH (new). +- [ ] DTC tab: no phantom codes; a forced fault shows the right code. +- [ ] Unplug adapter power → banner "Reconnecting…", then error; replug + + reconnect works. +- [ ] Ignition off with adapter powered → banner "no data from the ECU". +- [ ] Debug → "Copy app log" pastes a readable log. +- [ ] Fresh install on Android 12+: permission prompt first, device list after, + no crash. Deny "Nearby devices" → clear message, no crash. + +--- + +## 7. Prompt for Claude Code (paste as-is) + +``` +Read docs/FIX_2026-10-12.md fully. Then: +1. Run `git diff fb87e79 --stat` and review every changed file against the doc. + Flag anything in the diff that the doc does not describe, and any logic bug + (especially in lib/core/bluetooth/bt_poller.dart and + lib/core/providers/bt_provider.dart: one request outstanding at a time, + timeout handling, reconnect loop, stream/timer cleanup on disconnect). +2. Check the byte-offset table in lib/core/protocol/s300_parser.dart against + hvbt_protocol.pdf using plain column counting (0..127), as the doc explains. +3. Run `flutter pub get`, `flutter analyze`, `flutter test`. Fix compile/analyzer + errors and failing tests with minimal changes; do not change protocol logic + or offsets without telling me why. +4. Build: `flutter build apk --debug` and `flutter build apk --release`. +5. Report: what you fixed, analyzer/test results, APK paths, and any concerns. +Do not commit until I say so. +``` diff --git a/lib/core/bluetooth/bt_poller.dart b/lib/core/bluetooth/bt_poller.dart index 235f1f9..223ad3e 100644 --- a/lib/core/bluetooth/bt_poller.dart +++ b/lib/core/bluetooth/bt_poller.dart @@ -1,160 +1,308 @@ import 'dart:async'; import 'dart:typed_data'; +import '../logging/app_log.dart'; import '../protocol/neg8.dart'; import 'bt_service.dart'; enum EcuType { s300, kpro } -/// Sends the ECU request byte every [pollingInterval] ms, -/// accumulates raw bytes into 128-byte frames, validates NEG8, -/// and emits the newest usable frame on [frameStream]. +typedef _Matcher = Uint8List? Function(List rxBuf); + +class _Pending { + _Pending(this.match); + final _Matcher match; + final Completer completer = Completer(); +} + +/// Strict request/response poller for the HV BT adapter. +/// +/// Rules (agreed with the device maker's feedback — the adapter MCU must not +/// be flooded): +/// * Exactly ONE request is outstanding at any time. A new request is only +/// sent after the previous one was answered with a complete, checksum-valid +/// 128-byte frame, or after [responseTimeout] expired. +/// * After a timeout the link is left quiet for [quietAfterTimeout] and the +/// receive buffer is flushed, so a late reply can't be mistaken for the +/// answer to the next request. +/// * Requests are paced: at most one every [pollingInterval] (measured from +/// request to request) and never less than [minGap] after a reply. +/// * After [stallThreshold] consecutive timeouts, [onLinkStalled] fires so the +/// owner can reconnect. +/// +/// Other commands (DTC reset, protocol probe) go through the same lock, so they +/// never interleave with a datalog request. class BtPoller { + BtPoller( + this._service, { + this.ecuType = EcuType.s300, + Duration pollingInterval = defaultInterval, + this.responseTimeout = const Duration(milliseconds: 500), + this.onLinkStalled, + }) : _pollingInterval = _sanitize(pollingInterval); + + static const Duration defaultInterval = Duration(milliseconds: 100); + static const Duration minInterval = Duration(milliseconds: 80); + static const Duration minGap = Duration(milliseconds: 20); + static const Duration quietAfterTimeout = Duration(milliseconds: 150); + static const int stallThreshold = 6; + static const int frameLength = 128; + static const int _maxBuffer = 4096; + + // S300 request: [0x1B, 0x00, NEG8] / KPro request: [0x1B, 0x01, NEG8] + static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]); + static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]); + final BtService _service; EcuType ecuType; - final Duration pollingInterval; + final Duration responseTimeout; + final void Function()? onLinkStalled; - // S300 request: [0x1B, 0x00, 0xE5] - static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]); - // KPro request: [0x1B, 0x01, 0xE4] - static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]); + Duration _pollingInterval; + Duration get pollingInterval => _pollingInterval; + set pollingInterval(Duration d) => _pollingInterval = _sanitize(d); + + static Duration _sanitize(Duration d) => d < minInterval ? minInterval : d; final StreamController _frameController = StreamController.broadcast(); + /// Checksum-valid 128-byte frames, one per answered request. Stream get frameStream => _frameController.stream; final List _rxBuf = []; - Timer? _pollTimer; StreamSubscription? _rxSub; + _Pending? _pending; + bool _running = false; + int _generation = 0; - int droppedFrames = 0; + // Serialises every exchange on the link (polls and one-off commands). + Future _lockTail = Future.value(); + + // ── Stats (read by the debug screen) ────────────────────────────────────── + int requestsSent = 0; int validFrames = 0; + int droppedFrames = 0; // checksum failures + int timeouts = 0; + int consecutiveTimeouts = 0; + Duration? lastResponseTime; - // Tracks whether a request is still awaiting its response, so the poller - // never pipelines more than one request ahead of the device. Without this, - // a device that answers slower than [pollingInterval] falls permanently - // behind — every displayed frame ends up answering a stale, already-queued - // request instead of the most recent one, and the dashboard visibly lags - // real engine changes by a growing amount. - bool _awaitingResponse = false; - DateTime? _lastRequestSentAt; - - BtPoller( - this._service, { - this.ecuType = EcuType.s300, - this.pollingInterval = const Duration(milliseconds: 50), - }); + bool get isRunning => _running; void start() { + if (_running) return; + _running = true; + final gen = ++_generation; _rxBuf.clear(); - _awaitingResponse = false; - _lastRequestSentAt = null; - - // Listen to raw bytes from BT and accumulate into 128-byte frames + consecutiveTimeouts = 0; _rxSub = _service.rawStream.listen( - (Uint8List chunk) { - _rxBuf.addAll(chunk); - _processBuffer(); - }, + _onRx, onError: (Object e) { - _frameController.addError(e); - stop(); + AppLog.w('BT stream error: $e'); + if (!_frameController.isClosed) _frameController.addError(e); }, ); - - // Check every pollingInterval whether it's time to send the next - // request — but only actually send once the prior one has been - // answered (or has clearly been lost). - _pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest()); - _maybeSendRequest(); - } - - void _maybeSendRequest() { - if (!_service.isConnected) return; - - final stalled = _lastRequestSentAt != null && - DateTime.now().difference(_lastRequestSentAt!) > pollingInterval * 4; - if (_awaitingResponse && !stalled) return; - - _awaitingResponse = true; - _lastRequestSentAt = DateTime.now(); - _service - .write(ecuType == EcuType.s300 ? _s300Request : _kproRequest) - .catchError((Object _) { - // A failed write must not leave the poller permanently waiting for a - // response that was never actually requested — let the next tick retry. - _awaitingResponse = false; - }); - } - - void _processBuffer() { - // Extract as many 128-byte frames as possible. - // Frame start sync: first byte should be 0x1B (header marker). - // If we have a misaligned buffer, scan forward. - Uint8List? latestUsableFrame; - - while (_rxBuf.length >= 128) { - // Scan for 0x1B frame header - int startIdx = 0; - while (startIdx < _rxBuf.length && _rxBuf[startIdx] != 0x1B) { - startIdx++; - } - - // Not enough data after sync byte - if (_rxBuf.length - startIdx < 128) { - // Discard bytes before potential header - if (startIdx > 0) { - _rxBuf.removeRange(0, startIdx); - } - break; - } - - final Uint8List frame = - Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128)); - - final checksumOk = validateFrame(frame); - - if (checksumOk) { - validFrames++; - latestUsableFrame = frame; - _rxBuf.removeRange(0, startIdx + 128); - } else { - // Bad checksum — the window is misaligned (or the frame was - // corrupted in transit). Drop one byte and rescan for the next - // 0x1B so the stream resynchronizes instead of permanently - // sliding a fixed-size window over garbage. - droppedFrames++; - _rxBuf.removeRange(0, startIdx + 1); - } - } - - if (latestUsableFrame != null) { - _frameController.add(latestUsableFrame); - // The device answered — clear the road for the next request. Wait out - // the configured pollingInterval before sending it rather than firing - // immediately: back-to-back requests with no gap can outrun the ECU's - // own tach input-capture timing, which needs a moment to latch a fresh - // RPM count between reads — hammering it faster than that makes it - // fall back to reporting "no fresh count yet" on every subsequent - // request instead of a real reading. pollingInterval is also what the - // Settings screen exposes, so this makes that control actually pace - // the link instead of just being a stalled-request fallback. - _awaitingResponse = false; - Timer(pollingInterval, _maybeSendRequest); - } + AppLog.i('Poller started (${ecuType.name}, every ' + '${_pollingInterval.inMilliseconds} ms, timeout ' + '${responseTimeout.inMilliseconds} ms)'); + unawaited(_loop(gen)); } void stop() { - _pollTimer?.cancel(); - _pollTimer = null; + if (!_running) return; + _running = false; + _generation++; _rxSub?.cancel(); _rxSub = null; + final p = _pending; + if (p != null && !p.completer.isCompleted) p.completer.complete(null); + _pending = null; _rxBuf.clear(); + AppLog.i('Poller stopped (sent=$requestsSent ok=$validFrames ' + 'timeouts=$timeouts badChecksum=$droppedFrames)'); } void dispose() { stop(); _frameController.close(); } + + // ── Main loop ───────────────────────────────────────────────────────────── + + Future _loop(int gen) async { + bool alive() => _running && gen == _generation; + + while (alive()) { + final started = DateTime.now(); + + await _withLink(() async { + if (!alive() || !_service.isConnected) return; + final req = ecuType == EcuType.s300 ? _s300Request : _kproRequest; + final frame = await _transact(req, _matchDatalogFrame, responseTimeout); + if (!alive()) return; + + if (frame != null) { + validFrames++; + consecutiveTimeouts = 0; + lastResponseTime = DateTime.now().difference(started); + if (!_frameController.isClosed) _frameController.add(frame); + } else { + timeouts++; + consecutiveTimeouts++; + if (consecutiveTimeouts == 1 || consecutiveTimeouts % 10 == 0) { + AppLog.w('No reply within ${responseTimeout.inMilliseconds} ms ' + '(consecutive=$consecutiveTimeouts, total=$timeouts)'); + } + // Let any late reply arrive and be discarded before the next request. + await Future.delayed(quietAfterTimeout); + _rxBuf.clear(); + } + }); + + if (!alive()) break; + + if (consecutiveTimeouts >= stallThreshold) { + consecutiveTimeouts = 0; + AppLog.w('Link stalled: $stallThreshold requests in a row unanswered'); + onLinkStalled?.call(); + } + + final elapsed = DateTime.now().difference(started); + var wait = _pollingInterval - elapsed; + if (wait < minGap) wait = minGap; + await Future.delayed(wait); + } + } + + // ── One-off commands ────────────────────────────────────────────────────── + + /// Sends [command] (between two polls, never overlapping one) and waits up + /// to [timeout] for [expected] to appear in the reply bytes. + Future<({bool acknowledged, Uint8List raw})> sendCommandAwaitReply( + Uint8List command, + Uint8List expected, { + Duration timeout = const Duration(seconds: 2), + }) { + return _withLink(() async { + await Future.delayed(quietAfterTimeout); + _rxBuf.clear(); + final match = await _transact( + command, + (buf) => _indexOf(buf, expected) >= 0 ? expected : null, + timeout, + ); + final raw = Uint8List.fromList(_rxBuf); + _rxBuf.clear(); + return (acknowledged: match != null, raw: raw); + }); + } + + /// Sends [command] and returns every byte received during [window]. + Future sendCommandCollect(Uint8List command, Duration window) { + return _withLink(() async { + await Future.delayed(quietAfterTimeout); + _rxBuf.clear(); + await _transact(command, (_) => null, window); + final raw = Uint8List.fromList(_rxBuf); + _rxBuf.clear(); + return raw; + }); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + Future _withLink(Future Function() action) { + final previous = _lockTail; + final release = Completer(); + _lockTail = release.future; + return previous.then((_) => action()).whenComplete(release.complete); + } + + /// Writes [request] and waits for [match] to succeed or [timeout]. Must be + /// called while holding the link lock. + Future _transact( + Uint8List request, + _Matcher match, + Duration timeout, + ) async { + _rxBuf.clear(); + final pending = _Pending(match); + _pending = pending; + try { + await _service.write(request).timeout(const Duration(seconds: 1)); + requestsSent++; + } catch (e) { + AppLog.w('BT write failed: $e'); + if (identical(_pending, pending)) _pending = null; + // Don't spin on a broken link. + await Future.delayed(quietAfterTimeout); + return null; + } + _tryComplete(); + try { + return await pending.completer.future.timeout(timeout); + } on TimeoutException { + return null; + } finally { + if (identical(_pending, pending)) _pending = null; + } + } + + void _onRx(Uint8List chunk) { + if (_pending == null) { + // Nobody is waiting: stray/late bytes. Drop them. + _rxBuf.clear(); + return; + } + _rxBuf.addAll(chunk); + if (_rxBuf.length > _maxBuffer) { + _rxBuf.removeRange(0, _rxBuf.length - _maxBuffer); + } + _tryComplete(); + } + + void _tryComplete() { + final p = _pending; + if (p == null || p.completer.isCompleted) return; + final result = p.match(_rxBuf); + if (result != null) p.completer.complete(result); + } + + /// Finds a complete, checksum-valid 128-byte datalog frame starting with + /// the expected 2-byte header. Consumes bytes it has ruled out. + Uint8List? _matchDatalogFrame(List buf) { + final id2 = ecuType == EcuType.s300 ? 0x00 : 0x01; + while (true) { + var i = 0; + while (i + 1 < buf.length && !(buf[i] == 0x1B && buf[i + 1] == id2)) { + i++; + } + if (buf.length - i < frameLength) { + if (i > 0) buf.removeRange(0, i); + return null; + } + final frame = Uint8List.fromList(buf.sublist(i, i + frameLength)); + if (validateFrame(frame)) { + buf.removeRange(0, i + frameLength); + return frame; + } + // Bad checksum: either a false header inside data or a corrupted frame. + droppedFrames++; + buf.removeRange(0, i + 1); + } + } + + static int _indexOf(List buf, List pattern) { + for (var i = 0; i + pattern.length <= buf.length; i++) { + var ok = true; + for (var j = 0; j < pattern.length; j++) { + if (buf[i + j] != pattern[j]) { + ok = false; + break; + } + } + if (ok) return i; + } + return -1; + } } diff --git a/lib/core/bluetooth/bt_service.dart b/lib/core/bluetooth/bt_service.dart index a8c094c..997fdcf 100644 --- a/lib/core/bluetooth/bt_service.dart +++ b/lib/core/bluetooth/bt_service.dart @@ -3,13 +3,27 @@ import 'dart:typed_data'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; +import '../logging/app_log.dart'; + /// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT. +/// +/// Every platform call is wrapped in a timeout and try/catch: the plugin is +/// old and some Android builds throw (or never answer) on connect/close. class BtService { BluetoothConnection? _connection; + StreamSubscription? _inputSub; + bool _connecting = false; + final StreamController _rawFrameController = StreamController.broadcast(); - bool get isConnected => _connection?.isConnected ?? false; + bool get isConnected { + try { + return _connection?.isConnected ?? false; + } catch (_) { + return false; + } + } Stream get rawStream => _rawFrameController.stream; @@ -18,35 +32,64 @@ class BtService { } Future connect(BluetoothDevice device) async { - if (_connection != null) { - await disconnect(); + if (_connecting) { + throw StateError('A connection attempt is already in progress'); + } + _connecting = true; + try { + if (_connection != null) { + await disconnect(); + } + AppLog.i('Connecting to ${device.name ?? '?'} (${device.address})'); + final conn = await BluetoothConnection.toAddress(device.address) + .timeout(const Duration(seconds: 15)); + _connection = conn; + _inputSub = conn.input?.listen( + (Uint8List data) { + if (!_rawFrameController.isClosed) _rawFrameController.add(data); + }, + onDone: () { + AppLog.w('BT input stream closed by remote/OS'); + if (!_rawFrameController.isClosed && identical(_connection, conn)) { + _rawFrameController.addError('BT connection closed'); + } + }, + onError: (Object e) { + AppLog.w('BT input error: $e'); + if (!_rawFrameController.isClosed) _rawFrameController.addError(e); + }, + cancelOnError: false, + ); + AppLog.i('Connected'); + } on TimeoutException { + AppLog.w('Connect timed out'); + throw Exception('Connection timed out — is the adapter powered and ' + 'not connected to another phone?'); + } finally { + _connecting = false; } - _connection = await BluetoothConnection.toAddress(device.address); - _connection!.input!.listen( - (Uint8List data) { - if (!_rawFrameController.isClosed) { - _rawFrameController.add(data); - } - }, - onDone: () { - _rawFrameController.addError('BT connection closed'); - }, - onError: (Object e) { - _rawFrameController.addError(e); - }, - ); } Future write(Uint8List data) async { - if (_connection?.isConnected ?? false) { - _connection!.output.add(data); - await _connection!.output.allSent; + final conn = _connection; + if (conn == null || !isConnected) { + throw StateError('Not connected'); } + conn.output.add(data); + await conn.output.allSent; } Future disconnect() async { - await _connection?.close(); + final conn = _connection; _connection = null; + await _inputSub?.cancel(); + _inputSub = null; + if (conn == null) return; + try { + await conn.close().timeout(const Duration(seconds: 3)); + } catch (e) { + AppLog.w('BT close error (ignored): $e'); + } } void dispose() { diff --git a/lib/core/logging/app_log.dart b/lib/core/logging/app_log.dart new file mode 100644 index 0000000..03d5d52 --- /dev/null +++ b/lib/core/logging/app_log.dart @@ -0,0 +1,42 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +/// Small in-memory log the client can copy from the Debug screen and send +/// back with a bug report. Keeps the newest [_capacity] lines only. +class AppLog { + AppLog._(); + + static const int _capacity = 400; + static final ListQueue _lines = ListQueue(); + + static void i(String msg) => _add('I', msg); + static void w(String msg) => _add('W', msg); + static void e(String msg, [Object? error, StackTrace? stack]) { + final b = StringBuffer(msg); + if (error != null) b.write(' | $error'); + if (stack != null) { + // First few frames are enough to locate a crash. + final frames = stack.toString().split('\n').take(8).join('\n '); + b.write('\n $frames'); + } + _add('E', b.toString()); + } + + static void _add(String level, String msg) { + final t = DateTime.now(); + String two(int v) => v.toString().padLeft(2, '0'); + final ts = '${two(t.hour)}:${two(t.minute)}:${two(t.second)}.' + '${t.millisecond.toString().padLeft(3, '0')}'; + final line = '$ts $level $msg'; + _lines.addLast(line); + while (_lines.length > _capacity) { + _lines.removeFirst(); + } + debugPrint(line); + } + + static String dump() => _lines.join('\n'); + + static int get length => _lines.length; +} diff --git a/lib/core/models/sensor_defs.dart b/lib/core/models/sensor_defs.dart index 5ec88cd..081a366 100644 --- a/lib/core/models/sensor_defs.dart +++ b/lib/core/models/sensor_defs.dart @@ -27,32 +27,33 @@ const List sensorDefs = [ max: 150), SensorDef( id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20), - SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 1.5), + // O2/AFR/AIN scales not yet bench-verified: shown as raw values. + SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'raw', min: 0, max: 255), SensorDef(id: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6), SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100), SensorDef( id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120), - SensorDef(id: 'afr', displayName: 'AFR', unit: 'λ', min: 0.5, max: 2.0), + SensorDef(id: 'afr', displayName: 'AFR', unit: 'raw', min: 0, max: 255), SensorDef( id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30), SensorDef( id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30), SensorDef( - id: 'ain0', displayName: 'Analog input 0', unit: 'V', min: 0, max: 5), + id: 'ain0', displayName: 'Analog input 0', unit: 'raw', min: 0, max: 1023), SensorDef( - id: 'ain1', displayName: 'Analog input 1', unit: 'V', min: 0, max: 5), + id: 'ain1', displayName: 'Analog input 1', unit: 'raw', min: 0, max: 1023), SensorDef( - id: 'ain2', displayName: 'Analog input 2', unit: 'V', min: 0, max: 5), + id: 'ain2', displayName: 'Analog input 2', unit: 'raw', min: 0, max: 1023), SensorDef( - id: 'ain3', displayName: 'Analog input 3', unit: 'V', min: 0, max: 5), + id: 'ain3', displayName: 'Analog input 3', unit: 'raw', min: 0, max: 1023), SensorDef( - id: 'ain4', displayName: 'Analog input 4', unit: 'V', min: 0, max: 5), + id: 'ain4', displayName: 'Analog input 4', unit: 'raw', min: 0, max: 1023), SensorDef( - id: 'ain5', displayName: 'Analog input 5', unit: 'V', min: 0, max: 5), + id: 'ain5', displayName: 'Analog input 5', unit: 'raw', min: 0, max: 1023), SensorDef( - id: 'ain6', displayName: 'Analog input 6', unit: 'V', min: 0, max: 5), + id: 'ain6', displayName: 'Analog input 6', unit: 'raw', min: 0, max: 1023), SensorDef( - id: 'ain7', displayName: 'Analog input 7', unit: 'V', min: 0, max: 5), + id: 'ain7', displayName: 'Analog input 7', unit: 'raw', min: 0, max: 1023), ]; /// Master list of boolean flag sensors. diff --git a/lib/core/protocol/kpro_parser.dart b/lib/core/protocol/kpro_parser.dart index 2adbd31..483b23f 100644 --- a/lib/core/protocol/kpro_parser.dart +++ b/lib/core/protocol/kpro_parser.dart @@ -13,8 +13,11 @@ SensorState parseKPro(Uint8List frame) { throw ArgumentError('KPro frame NEG8 checksum failed'); } - // RPM: bytes 2..3 — raw / 4 - final int rpmRaw = _readUint16BE(frame, 2); + // RPM: bytes 2..3 — raw / 4. Little-endian like every other multi-byte + // field from this adapter (S300 RPM/VSS/MAP confirmed LE live; the KPro + // table itself labels MAP2 and AFCMD as L/H byte pairs). Was read BE. + // VERIFY on bench: 2000 rpm must show 2000. + final int rpmRaw = _readUint16LE(frame, 2); final double rpm = rpmRaw / 4.0; // VSS: byte 4 direct km/h @@ -25,7 +28,7 @@ SensorState parseKPro(Uint8List frame) { // MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H) final int map1Raw = frame[6]; - final int map2Raw = _readUint16BE(frame, 33); + final int map2Raw = _readUint16LE(frame, 33); // MAP2L @33, MAP2H @34 final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble(); // INJ: byte 11 raw @@ -73,15 +76,15 @@ SensorState parseKPro(Uint8List frame) { // ERR bytes: 18 bytes starting at offset 0x3E = 62 final List errBytes = List.generate(18, (i) => frame[62 + i]); - // AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each) - final double ain0 = _readUint16BE(frame, 82).toDouble(); - final double ain1 = _readUint16BE(frame, 84).toDouble(); - final double ain2 = _readUint16BE(frame, 86).toDouble(); - final double ain3 = _readUint16BE(frame, 88).toDouble(); - final double ain4 = _readUint16BE(frame, 90).toDouble(); - final double ain5 = _readUint16BE(frame, 92).toDouble(); - final double ain6 = _readUint16BE(frame, 94).toDouble(); - final double ain7 = _readUint16BE(frame, 96).toDouble(); + // AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 LE each) + final double ain0 = _readUint16LE(frame, 82).toDouble(); + final double ain1 = _readUint16LE(frame, 84).toDouble(); + final double ain2 = _readUint16LE(frame, 86).toDouble(); + final double ain3 = _readUint16LE(frame, 88).toDouble(); + final double ain4 = _readUint16LE(frame, 90).toDouble(); + final double ain5 = _readUint16LE(frame, 92).toDouble(); + final double ain6 = _readUint16LE(frame, 94).toDouble(); + final double ain7 = _readUint16LE(frame, 96).toDouble(); // Gear — not in KPro frame, default 0 const int gear = 0; @@ -142,6 +145,6 @@ SensorState parseKPro(Uint8List frame) { ); } -int _readUint16BE(Uint8List frame, int offset) { - return (frame[offset] << 8) | frame[offset + 1]; +int _readUint16LE(Uint8List frame, int offset) { + return frame[offset] | (frame[offset + 1] << 8); } diff --git a/lib/core/protocol/s300_parser.dart b/lib/core/protocol/s300_parser.dart index 83e4be0..a2f97e9 100644 --- a/lib/core/protocol/s300_parser.dart +++ b/lib/core/protocol/s300_parser.dart @@ -3,55 +3,57 @@ 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; +// ───────────────────────────────────────────────────────────────────────────── +// S300 datalog frame (128 bytes, response to request 1B 00 E5). +// +// BYTE OFFSETS: hvbt_protocol.pdf labels its columns inconsistently +// ("+26 +27 +28 +29 +2A … +2F +30 +31"). The labels are NOT hex offsets. +// The real offsets are simply the column count, 0..127. This is proven by +// the fields already confirmed against live USB captures — Gear=27, ECT=33, +// IAT=34, BAT=36 — which all match plain column counting, and by the adapter's +// default frame (below) reproducing the PDF example row byte-for-byte. +// +// 0 ID1(1B) 1 ID2(00) 2 DLrate 3-4 RPM 5-6 VSS 7-8 MAP 9 TPS 10-11 INJ +// 12 IGN 13 KRtrd 14-15 Klvl 16 O2 17 SW1 18 SW2 19 SW3 20 SW4 21-26 ? +// 27 Gear 28 ? 29 Strim 30 ? 31 Ltrim 32 PA 33 ECT 34 IAT 35 ? 36 BAT +// 37 ERR00 38 ERR01 39 ERR02 40 ERR03 41 Eth 42-44 ? 45 IATc 46 ECTc 47-48 ? +// 49 SW5 50 SW6 51-54 ? 55 AF 56 ? 57 TCrtrd 58 TClim 59 ? 60-81 reserved +// 82-97 AIN0..AIN7 (16-bit) 98-126 reserved 127 NEG8 +// +// BYTE ORDER: multi-byte values are LITTLE-endian on the wire (confirmed live +// for RPM/VSS/MAP; the PDF's "Big Endian" note only applies to the request). +// ───────────────────────────────────────────────────────────────────────────── - 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; +/// True when the frame is the adapter's built-in DEFAULT frame — the exact +/// example values printed in hvbt_protocol.pdf (DLrate 0x14, VSS 0xFFFF, +/// MAP 0x03F2, TPS 0x18, IGN 0x10, O2 0xC4 …; bytes 32-36 = 93 A0 A0 DC 7D). +/// The adapter sends it when it has no fresh data from the ECU (typically +/// because it was asked again before it could finish reading the ECU). +/// Such a frame carries NO real readings and must not be displayed or logged. +/// +/// Both spans must match: bytes 0-16 alone could, in principle, coincide with +/// a real key-on/engine-off reading (RPM 0, MAP 101.0, closed throttle), and a +/// false positive here hides real data and triggers auto-reconnects. +bool isS300DefaultFrame(Uint8List frame) { + if (frame.length != 128) return false; + const head = [ + 0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, // + 0x18, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4, + ]; + for (var i = 0; i < head.length; i++) { + if (frame[i] != head[i]) return false; + } + // PA, ECT, IAT, ?, BAT at 32-36. + const tail = [0x93, 0xA0, 0xA0, 0xDC, 0x7D]; + for (var i = 0; i < tail.length; i++) { + if (frame[32 + i] != tail[i]) return false; + } + return true; } +/// Former name, kept so older call sites/tests keep compiling. +bool isS300TemplateFrame(Uint8List frame) => isS300DefaultFrame(frame); + /// 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. @@ -63,132 +65,86 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) { throw ArgumentError('S300 frame NEG8 checksum failed'); } - // RPM: bytes +3..4, little-endian, use directly. Confirmed against a live - // capture: LE @ offset 3 matched a reference tool's RPM exactly, while - // REQUIREMENTS.md's blanket "all multi-byte values are Big Endian" claim - // did not hold for this field on real hardware. - final double rpm = _readUint16LE(frame, 3).toDouble(); + // ── Confirmed against live captures ──────────────────────────────────── + final double rpm = _u16le(frame, 3).toDouble(); - // VSS: bytes +5..6, little-endian (confirmed against a live capture). - final int vssRaw = _readUint16LE(frame, 5); + final int vssRaw = _u16le(frame, 5); final double vss = (vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw; - // MAP: bytes +7..8, little-endian (confirmed against a live capture — the - // big-endian read produced multi-thousand-kPa values, physically - // impossible for a 0-300 kPa sensor). - final int mapRaw = _readUint16LE(frame, 7); - final double map = mapRaw / 10.0; + final double map = _u16le(frame, 7) / 10.0; - // TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture - // (raw 127 matched a reference tool's ~50% reading; the spec's - // "raw*51/46" formula produced impossible >100% values on the same byte). - final int tpsRaw = frame[9]; - final double tps = (tpsRaw * 100.0 / 255.0).clamp(0.0, 100.0); + // Linear 0-255 → 0-100 % (live-confirmed; the PDF formula exceeds 100 %). + final double tps = (frame[9] * 100.0 / 255.0).clamp(0.0, 100.0); - // INJ: bytes +10..11, BE, raw ms. - final double inj = _readUint16BE(frame, 10).toDouble(); - - // IGN: byte +12. - final double ign = (frame[12] + 120) / 2.0; - - // O2: byte +16, raw. - final double o2 = frame[16].toDouble(); - - // SW bitmaps - final int sw1 = frame[0x11]; // +17 - final int sw2 = frame[0x12]; // +18 - final int sw3 = frame[0x13]; // +19 - final int sw5 = frame[0x43]; // +43 - - // Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39 - // read 0x00 there; decimal 27 matched a reference tool's gear exactly). final int gear = frame[27]; - // Strim: +29 hex = 41 decimal - final double strim = frame[0x29].toDouble(); - - // Ltrim: +2B hex = 43 decimal - final double ltrim = frame[0x2B].toDouble(); - - // 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: 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. + // Table value minus 40 (live-confirmed; also the default frame's 0xA0 + // decodes to a sensible 20 °C this way). final double ect = (tempXlt[frame[33]] - 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: 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 = [ - frame[0x31], - frame[0x32], - frame[0x33], - frame[0x34], - ]; + // ── Offsets corrected 2026-10-12 (column counting, see header) ───────── + // Formulas marked UNVERIFIED still need one bench check against the USB + // tool; they are sanity-checked against the default frame only. - // Eth: +38 hex = 56 decimal - final double eth = frame[0x38].toDouble(); + // INJ: 16-bit LE (was read big-endian). UNVERIFIED scale: assumes µs. + final double inj = _u16le(frame, 10) / 1000.0; - // AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table. - final double afr = frame[0x34].toDouble(); + // IGN: formula from the PDF. UNVERIFIED (one live check disagreed). + final double ign = (frame[12] + 120) / 2.0; - // AIN0–AIN7: decimal offsets 82..97 (uint16 BE each) - final double ain0 = _readUint16BE(frame, 82).toDouble(); - final double ain1 = _readUint16BE(frame, 84).toDouble(); - final double ain2 = _readUint16BE(frame, 86).toDouble(); - final double ain3 = _readUint16BE(frame, 88).toDouble(); - final double ain4 = _readUint16BE(frame, 90).toDouble(); - final double ain5 = _readUint16BE(frame, 92).toDouble(); - final double ain6 = _readUint16BE(frame, 94).toDouble(); - final double ain7 = _readUint16BE(frame, 96).toDouble(); + // O2: raw byte. UNVERIFIED scale — shown as raw. + final double o2 = frame[16].toDouble(); - // Flags - // SW1 bit3 = REVL + final int sw1 = frame[17]; + final int sw2 = frame[18]; + final int sw3 = frame[19]; + final int sw5 = frame[49]; // was read from byte 67 (reserved area) + + // Fuel trims: 0x80 = 0 %. Default frame has 0x80 in both → 0 %. + final double strim = (frame[29] - 128) * 100.0 / 128.0; // was byte 41 + final double ltrim = (frame[31] - 128) * 100.0 / 128.0; // was byte 43 + + // Baro: byte 32 (was 44). Honda OBD1 baro ADC formula; default frame's + // 0x93 → 100.3 kPa (sea level), which supports it. UNVERIFIED on bench. + final double pa = frame[32] * 0.716 - 5.0; + + // DTC bytes ERR00..ERR03 = 37..40 (were read from 49..52 = SW5/SW6/…, + // which produced phantom codes such as P0505/P0350 from SW6 = 0x60). + final List errBytes = [frame[37], frame[38], frame[39], frame[40]]; + + // Ethanol %: byte 41 (was 56). + final double eth = frame[41].toDouble(); + + // AF: byte 55 (was 52 = unknown byte). UNVERIFIED scale — shown as raw. + final double afr = frame[55].toDouble(); + + // AIN0–AIN7: 82..97, 16-bit LE (was big-endian). Raw counts. + final double ain0 = _u16le(frame, 82).toDouble(); + final double ain1 = _u16le(frame, 84).toDouble(); + final double ain2 = _u16le(frame, 86).toDouble(); + final double ain3 = _u16le(frame, 88).toDouble(); + final double ain4 = _u16le(frame, 90).toDouble(); + final double ain5 = _u16le(frame, 92).toDouble(); + final double ain6 = _u16le(frame, 94).toDouble(); + final double ain7 = _u16le(frame, 96).toDouble(); + + // ── Flags (bit maps from the PDF) ────────────────────────────────────── + // SW1: ?(7) DistErr(6) SCS(5) PSP(4) REVL(3) BKSW(2) ACSW(1) VTP(0) final bool revLimit = (sw1 & 0x08) != 0; - // SW2 bit5 = MIL + // SW2: ALTC(7) IAB(6) MIL(5) PCS(4) ACCL(3) Fuel(2) VTS(1..0) final bool mil = (sw2 & 0x20) != 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. + // Fuel pump relay, active-LOW (live-confirmed). 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) final bool vtec = (sw2 & 0x03) != 0; - // Knock = KRtrd > 0 (byte 13) - final bool knock = frame[13] > 0; - // SW3 bit7 = LnchC + // SW5: … CL(2) … FANC(0). Fan output is FANC only — SW2 bit7 is ALTC + // (alternator control), not the fan. + final bool fanOut = (sw5 & 0x01) != 0; + final bool knock = frame[13] > 0; // KRtrd + // SW3: LnchC(7) LnchR(6) IGNC(5) ShftC(4) BstC(3) final bool launch = (sw3 & 0x80) != 0; return SensorState( @@ -228,10 +184,5 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) { ); } -int _readUint16BE(Uint8List frame, int offset) { - return (frame[offset] << 8) | frame[offset + 1]; -} - -int _readUint16LE(Uint8List frame, int offset) { - return frame[offset] | (frame[offset + 1] << 8); -} +int _u16le(Uint8List frame, int offset) => + frame[offset] | (frame[offset + 1] << 8); diff --git a/lib/core/providers/bt_provider.dart b/lib/core/providers/bt_provider.dart index 5da5bbf..f808d71 100644 --- a/lib/core/providers/bt_provider.dart +++ b/lib/core/providers/bt_provider.dart @@ -6,12 +6,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../bluetooth/bt_poller.dart'; import '../bluetooth/bt_service.dart'; +import '../logging/app_log.dart'; import '../protocol/neg8.dart'; +import '../protocol/s300_parser.dart'; import 'settings_provider.dart'; -// ─── Connection status enum ────────────────────────────────────────────────── +// ─── Enums ─────────────────────────────────────────────────────────────────── -enum BtStatus { disconnected, connecting, connected, error } +enum BtStatus { disconnected, connecting, connected, reconnecting, error } + +/// What the adapter is actually giving us, independent of the BT link. +enum EcuDataStatus { + /// Not connected / nothing received yet. + none, + + /// Real ECU readings are arriving. + live, + + /// The adapter answers, but only with its built-in default frame + /// (hvbt_protocol.pdf example values) — it has no fresh ECU data. + adapterDefaults, + + /// The adapter is not answering requests. + noResponse, +} // ─── State ─────────────────────────────────────────────────────────────────── @@ -19,197 +37,354 @@ class BtState { final BtStatus status; final String? deviceName; final String? error; - final int frameCount; - final int droppedFrames; + final EcuDataStatus dataStatus; + final int requestsSent; + final int frameCount; // valid frames (live + default) + final int liveFrames; + final int defaultFrames; + final int droppedFrames; // bad checksum + final int timeouts; + final int reconnects; + final int? lastResponseMs; final Uint8List? lastFrame; const BtState({ required this.status, this.deviceName, this.error, + this.dataStatus = EcuDataStatus.none, + this.requestsSent = 0, this.frameCount = 0, + this.liveFrames = 0, + this.defaultFrames = 0, this.droppedFrames = 0, + this.timeouts = 0, + this.reconnects = 0, + this.lastResponseMs, this.lastFrame, }); const BtState.disconnected() : this(status: BtStatus.disconnected); - BtState connecting(String name) => - BtState(status: BtStatus.connecting, deviceName: name); - - BtState connected(String name) => - BtState(status: BtStatus.connected, deviceName: name); - - BtState withError(String msg) => BtState( - status: BtStatus.error, - deviceName: deviceName, - error: msg, - frameCount: frameCount, - droppedFrames: droppedFrames, - lastFrame: lastFrame, - ); - - BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState( - status: status, - deviceName: deviceName, - frameCount: frames, - droppedFrames: dropped, - lastFrame: Uint8List.fromList(frame), + BtState copyWith({ + BtStatus? status, + String? deviceName, + String? error, + bool clearError = false, + EcuDataStatus? dataStatus, + int? requestsSent, + int? frameCount, + int? liveFrames, + int? defaultFrames, + int? droppedFrames, + int? timeouts, + int? reconnects, + int? lastResponseMs, + Uint8List? lastFrame, + }) => + BtState( + status: status ?? this.status, + deviceName: deviceName ?? this.deviceName, + error: clearError ? null : (error ?? this.error), + dataStatus: dataStatus ?? this.dataStatus, + requestsSent: requestsSent ?? this.requestsSent, + frameCount: frameCount ?? this.frameCount, + liveFrames: liveFrames ?? this.liveFrames, + defaultFrames: defaultFrames ?? this.defaultFrames, + droppedFrames: droppedFrames ?? this.droppedFrames, + timeouts: timeouts ?? this.timeouts, + reconnects: reconnects ?? this.reconnects, + lastResponseMs: lastResponseMs ?? this.lastResponseMs, + lastFrame: lastFrame ?? this.lastFrame, ); bool get isConnected => status == BtStatus.connected; - bool get isConnecting => status == BtStatus.connecting; + bool get isConnecting => + status == BtStatus.connecting || status == BtStatus.reconnecting; } // ─── Notifier ──────────────────────────────────────────────────────────────── class BtNotifier extends StateNotifier { + BtNotifier(this._ref) : super(const BtState.disconnected()) { + // Apply polling-interval changes from Settings to a running poller. + _ref.listen(settingsProvider, (prev, next) { + _poller?.pollingInterval = next.pollingInterval; + _poller?.ecuType = next.ecuType; + }); + } + final Ref _ref; BtPoller? _poller; StreamSubscription? _pollSub; + Timer? _statusTimer; final StreamController _frameController = StreamController.broadcast(); - BtNotifier(this._ref) : super(const BtState.disconnected()); + BluetoothDevice? _device; + bool _userDisconnected = false; + bool _reconnecting = false; + // Bumped on every user connect/disconnect. A link opened by an older + // session (e.g. an auto-reconnect still in flight when the user tapped + // Disconnect) is closed instead of being adopted. + int _session = 0; + DateTime? _lastAnyFrameAt; + DateTime? _lastLiveFrameAt; + DateTime? _defaultsSince; + DateTime? _lastAutoReconnectAt; + int _liveFrames = 0; + int _defaultFrames = 0; + int _reconnects = 0; - /// Raw validated 128-byte frame stream — consumed by sensorStateProvider. + static const _staleAfter = Duration(milliseconds: 1500); + static const _defaultsReconnectAfter = Duration(seconds: 8); + static const _minTimeBetweenAutoReconnects = Duration(seconds: 45); + + /// Validated 128-byte frames carrying REAL ECU data (adapter default frames + /// are filtered out). Consumed by sensorStateProvider and the datalog. Stream get frameStream => _frameController.stream; + BtPoller? get poller => _poller; + Future connect(BluetoothDevice device) async { - state = state.connecting(device.name ?? device.address); - final service = _ref.read(btServiceProvider); - final settings = _ref.read(settingsProvider); - + if (state.isConnecting) return; // ignore double taps + final session = ++_session; + _device = device; + _userDisconnected = false; + _resetCounters(); + state = BtState( + status: BtStatus.connecting, + deviceName: device.name ?? device.address, + ); try { - await service.connect(device); - - _poller = BtPoller( - service, - ecuType: settings.ecuType, - pollingInterval: settings.pollingInterval, - ); - - _pollSub = _poller!.frameStream.listen( - (frame) { - _frameController.add(frame); - state = state.withFrameStats( - _poller!.validFrames, - _poller!.droppedFrames, - frame, - ); - }, - onError: (Object e) { - _frameController.addError(e); - state = state.withError(e.toString()); - }, - onDone: () => state = state.withError('BT stream closed'), - ); - - _poller!.start(); - state = state.connected(device.name ?? device.address); - } catch (e) { - await service.disconnect(); - state = state.withError(e.toString()); - } - } - - /// 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); + await _openLink(device); + if (session != _session || !mounted) { + AppLog.i('Connect finished after disconnect — closing link'); + await _closeLink(); + return; } - } finally { - await sub.cancel(); + state = state.copyWith(status: BtStatus.connected, clearError: true); + } catch (e, st) { + AppLog.e('Connect failed', e, st); + if (session != _session || !mounted) return; + await _closeLink(); + state = state.copyWith(status: BtStatus.error, error: _friendly(e)); } - - 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 { + _session++; + _userDisconnected = true; + await _closeLink(); + state = const BtState.disconnected(); + } + + // ── Link management ─────────────────────────────────────────────────────── + + Future _openLink(BluetoothDevice device) async { + final service = _ref.read(btServiceProvider); + final settings = _ref.read(settingsProvider); + await service.connect(device); + + final poller = BtPoller( + service, + ecuType: settings.ecuType, + pollingInterval: settings.pollingInterval, + onLinkStalled: () => _onLinkProblem('adapter stopped answering'), + ); + _poller = poller; + _pollSub = poller.frameStream.listen( + _onFrame, + onError: (Object e) => _onLinkProblem('$e'), + ); + poller.start(); + + _statusTimer?.cancel(); + _statusTimer = + Timer.periodic(const Duration(milliseconds: 500), (_) => _tick()); + } + + Future _closeLink() async { + _statusTimer?.cancel(); + _statusTimer = null; await _pollSub?.cancel(); - _poller?.stop(); + _pollSub = null; _poller?.dispose(); _poller = null; await _ref.read(btServiceProvider).disconnect(); - state = const BtState.disconnected(); + } + + void _resetCounters() { + _lastAnyFrameAt = null; + _lastLiveFrameAt = null; + _defaultsSince = null; + _liveFrames = 0; + _defaultFrames = 0; + } + + void _onFrame(Uint8List frame) { + if (!mounted) return; + final now = DateTime.now(); + _lastAnyFrameAt = now; + final ecuType = _ref.read(settingsProvider).ecuType; + final isDefault = ecuType == EcuType.s300 && isS300DefaultFrame(frame); + if (isDefault) { + _defaultFrames++; + if (_defaultFrames == 1 || _defaultFrames % 50 == 0) { + AppLog.w('Adapter returned its default (no-ECU-data) frame ' + '(count=$_defaultFrames)'); + } + } else { + _liveFrames++; + _lastLiveFrameAt = now; + _defaultsSince = null; + _frameController.add(frame); + } + _publishStats(frame); + } + + void _publishStats([Uint8List? frame]) { + final p = _poller; + state = state.copyWith( + requestsSent: p?.requestsSent, + frameCount: p?.validFrames, + liveFrames: _liveFrames, + defaultFrames: _defaultFrames, + droppedFrames: p?.droppedFrames, + timeouts: p?.timeouts, + reconnects: _reconnects, + lastResponseMs: p?.lastResponseTime?.inMilliseconds, + lastFrame: frame, + dataStatus: _computeDataStatus(), + ); + } + + EcuDataStatus _computeDataStatus() { + final now = DateTime.now(); + bool recent(DateTime? t) => t != null && now.difference(t) < _staleAfter; + if (recent(_lastLiveFrameAt)) return EcuDataStatus.live; + if (recent(_lastAnyFrameAt)) return EcuDataStatus.adapterDefaults; + if (_poller?.isRunning ?? false) return EcuDataStatus.noResponse; + return EcuDataStatus.none; + } + + void _tick() { + if (!mounted || _poller == null) return; + final status = _computeDataStatus(); + + // Adapter answering only with defaults for a long stretch: one reconnect + // has been seen to clear this (see docs/HANDOFF.md), but never loop on it. + if (status == EcuDataStatus.adapterDefaults) { + _defaultsSince ??= DateTime.now(); + if (DateTime.now().difference(_defaultsSince!) > _defaultsReconnectAfter && + _autoReconnectAllowed()) { + _onLinkProblem('only default frames for ' + '${_defaultsReconnectAfter.inSeconds}s'); + } + } else { + _defaultsSince = null; + } + _publishStats(); + } + + bool _autoReconnectAllowed() { + final last = _lastAutoReconnectAt; + return last == null || + DateTime.now().difference(last) > _minTimeBetweenAutoReconnects; + } + + Future _onLinkProblem(String reason) async { + if (_reconnecting || _userDisconnected || _device == null || !mounted) { + return; + } + final service = _ref.read(btServiceProvider); + // A stall on a link that is still up: only reconnect occasionally; the + // poller keeps trying at its timeout pace in between. + if (service.isConnected && !_autoReconnectAllowed()) { + AppLog.w('Link problem ($reason) — keeping link, recent reconnect'); + return; + } + final session = _session; + bool stillWanted() => session == _session && !_userDisconnected && mounted; + _reconnecting = true; + _lastAutoReconnectAt = DateTime.now(); + AppLog.w('Link problem: $reason — reconnecting'); + state = state.copyWith(status: BtStatus.reconnecting); + await _closeLink(); + + for (var attempt = 1; attempt <= 5; attempt++) { + await Future.delayed(Duration(seconds: attempt)); + if (!stillWanted()) break; + try { + _resetCounters(); + await _openLink(_device!); + if (!stillWanted()) { + // User disconnected while this attempt was in flight. + AppLog.i('Reconnect finished after disconnect — closing link'); + await _closeLink(); + break; + } + _reconnects++; + AppLog.i('Reconnected (attempt $attempt)'); + _reconnecting = false; + state = state.copyWith(status: BtStatus.connected, clearError: true); + return; + } catch (e) { + AppLog.w('Reconnect attempt $attempt failed: $e'); + // Don't tear down a link that a newer session may own. + if (!stillWanted()) break; + await _closeLink(); + } + } + _reconnecting = false; + if (stillWanted()) { + state = state.copyWith( + status: BtStatus.error, + error: 'Connection lost. Check the adapter and connect again.', + dataStatus: EcuDataStatus.none, + ); + } + } + + static String _friendly(Object e) { + final s = e.toString(); + if (s.contains('timed out')) return s.replaceFirst('Exception: ', ''); + if (s.contains('read failed') || s.contains('socket')) { + return 'Could not open the Bluetooth link. Make sure the adapter is ' + 'powered and not connected to another phone, then try again.'; + } + return s; + } + + // ── One-off commands ────────────────────────────────────────────────────── + + /// Diagnostic: Hondata official-protocol DL_DeviceInfo probe. Runs between + /// polls via the poller's link lock, so it never overlaps a request. + Future probeOfficialProtocol() async { + final p = _poller; + if (p == null) return Uint8List(0); + return p.sendCommandCollect( + Uint8List.fromList([0x00, 0x02, 0x04, 0x00]), + const Duration(milliseconds: 800), + ); + } + + /// DTC reset per hvbt_protocol.pdf: { 0x1B, 0x70, NEG8 }; the adapter echoes + /// the same 3 bytes as acknowledgement. + Future<({bool acknowledged, Uint8List raw})> clearDtcs() async { + final p = _poller; + if (p == null) return (acknowledged: false, raw: Uint8List(0)); + final command = Uint8List.fromList([0x1B, 0x70, 0x00]); + command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70])); + final result = await p.sendCommandAwaitReply(command, command); + AppLog.i('Clear DTC: ack=${result.acknowledged} ' + 'rxBytes=${result.raw.length}'); + return result; } @override void dispose() { + _statusTimer?.cancel(); _pollSub?.cancel(); _poller?.dispose(); _frameController.close(); @@ -230,7 +405,8 @@ final btServiceProvider = Provider((ref) { final btProvider = StateNotifierProvider((ref) => BtNotifier(ref)); -/// Paired BT devices list (re-fetchable via ref.refresh). +/// Paired BT devices list (re-fetchable via ref.refresh). Only watched after +/// Bluetooth permissions are granted. final pairedDevicesProvider = FutureProvider>((ref) { return FlutterBluetoothSerial.instance.getBondedDevices(); }); diff --git a/lib/core/providers/sensor_history_provider.dart b/lib/core/providers/sensor_history_provider.dart index dc3bb37..6a38d71 100644 --- a/lib/core/providers/sensor_history_provider.dart +++ b/lib/core/providers/sensor_history_provider.dart @@ -62,6 +62,11 @@ class SensorHistory { class SensorHistoryNotifier extends StateNotifier { SensorHistoryNotifier() : super(SensorHistory()); + // History is mutated in place; StateNotifier's default identical() check + // would swallow `state = state`, so the graph never redrew live. + @override + bool updateShouldNotify(SensorHistory old, SensorHistory current) => true; + void addState(SensorState s) { state.add(s); // trigger rebuild by reassigning same object diff --git a/lib/core/providers/sensor_provider.dart b/lib/core/providers/sensor_provider.dart index cd9ba4f..d2deb79 100644 --- a/lib/core/providers/sensor_provider.dart +++ b/lib/core/providers/sensor_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../bluetooth/bt_poller.dart'; +import '../logging/app_log.dart'; import '../models/sensor_defs.dart'; import '../protocol/kpro_parser.dart'; import '../protocol/s300_parser.dart'; @@ -54,134 +55,33 @@ final decodeStatsProvider = StateProvider((ref) => const DecodeStat /// 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* { - final ecuType = ref.watch(settingsProvider).ecuType; + final ecuType = ref.watch(settingsProvider.select((s) => s.ecuType)); final btNotifier = ref.watch(btProvider.notifier); - // `await for` treats any error *event* on the source stream as fatal and - // exits the loop permanently — a try/catch inside the loop body cannot - // intercept that, since it happens in the iteration mechanism itself, not - // the body. handleError() absorbs error events upstream so `await for` - // never sees them and the loop just keeps waiting for the next frame. - final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {}); - - SensorState? lastGood; - int dbgFrameCount = 0; // TEMP: live-debugging session, remove after. + // `await for` treats an error event as fatal; absorb errors upstream so the + // loop keeps waiting for the next frame. + final safeStream = + btNotifier.frameStream.handleError((Object _, StackTrace __) {}); + // Adapter default frames are already filtered out in BtNotifier, so every + // frame here is a real ECU reading. No "hold last value" logic any more: + // a missing reading simply means the gauge keeps its last position until + // the next real frame, and the UI shows a banner if data stops. 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); - // 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); ref.read(decodeStatsProvider.notifier).state = s.copyWith(decoded: s.decoded + 1); yield decoded; - } catch (_) { - // Skip this one frame and keep the stream alive for the next. + } catch (e) { final s = ref.read(decodeStatsProvider); ref.read(decodeStatsProvider.notifier).state = s.copyWith(decodeErrors: s.decodeErrors + 1); + if (s.decodeErrors < 5) AppLog.w('Frame decode error: $e'); continue; } } diff --git a/lib/core/providers/settings_provider.dart b/lib/core/providers/settings_provider.dart index ee546a7..8baa5a5 100644 --- a/lib/core/providers/settings_provider.dart +++ b/lib/core/providers/settings_provider.dart @@ -2,13 +2,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../bluetooth/bt_poller.dart'; +/// Polling choices offered in Settings. Never faster than 10 Hz: the adapter +/// MCU must have time to read the ECU between requests (device maker's +/// feedback). The poller additionally never sends a new request before the +/// previous one is answered or has timed out. +const List pollingIntervalOptionsMs = [100, 150, 200, 300]; + class AppSettings { final EcuType ecuType; final Duration pollingInterval; const AppSettings({ this.ecuType = EcuType.s300, - this.pollingInterval = const Duration(milliseconds: 50), + this.pollingInterval = BtPoller.defaultInterval, }); AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) => @@ -19,6 +25,7 @@ class AppSettings { } const _ecuTypeKey = 'ecuType'; +const _pollingKey = 'pollingIntervalMs'; class SettingsNotifier extends StateNotifier { SettingsNotifier() : super(const AppSettings()) { @@ -26,21 +33,37 @@ class SettingsNotifier extends StateNotifier { } Future _load() async { - final prefs = await SharedPreferences.getInstance(); - final saved = prefs.getString(_ecuTypeKey); - if (saved == EcuType.kpro.name) { - state = state.copyWith(ecuType: EcuType.kpro); + try { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_ecuTypeKey); + final ms = prefs.getInt(_pollingKey); + var next = state; + if (saved == EcuType.kpro.name) next = next.copyWith(ecuType: EcuType.kpro); + if (ms != null && pollingIntervalOptionsMs.contains(ms)) { + next = next.copyWith(pollingInterval: Duration(milliseconds: ms)); + } + if (mounted) state = next; + } catch (_) { + // Keep defaults if preferences can't be read. } } void setEcuType(EcuType type) { state = state.copyWith(ecuType: type); SharedPreferences.getInstance() - .then((prefs) => prefs.setString(_ecuTypeKey, type.name)); + .then((prefs) => prefs.setString(_ecuTypeKey, type.name)) + .catchError((Object _) => false); } - void setPollingInterval(Duration d) => - state = state.copyWith(pollingInterval: d); + void setPollingInterval(Duration d) { + final ms = pollingIntervalOptionsMs.contains(d.inMilliseconds) + ? d.inMilliseconds + : BtPoller.defaultInterval.inMilliseconds; + state = state.copyWith(pollingInterval: Duration(milliseconds: ms)); + SharedPreferences.getInstance() + .then((prefs) => prefs.setInt(_pollingKey, ms)) + .catchError((Object _) => false); + } } final settingsProvider = StateNotifierProvider( diff --git a/lib/main.dart b/lib/main.dart index f797288..4d03f16 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,48 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/logging/app_log.dart'; import 'core/providers/theme_provider.dart'; import 'ui/theme/app_theme.dart'; import 'ui/screens/bt_picker/bt_picker_screen.dart'; void main() { - runApp(const ProviderScope(child: HvbtApp())); + runZonedGuarded(() { + WidgetsFlutterBinding.ensureInitialized(); + + // Catch every Dart-side error, log it (copyable from the Debug screen) + // and keep the app running instead of showing a red/grey error screen. + FlutterError.onError = (FlutterErrorDetails details) { + AppLog.e('Flutter error', details.exception, details.stack); + }; + WidgetsBinding.instance.platformDispatcher.onError = (error, stack) { + AppLog.e('Uncaught platform error', error, stack); + return true; + }; + // Release only: keep Flutter's red error screen in debug builds so + // layout/build errors stay visible during development. + if (kReleaseMode) { + ErrorWidget.builder = (FlutterErrorDetails details) => const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Something went wrong on this screen.\n' + 'Open Debug → Copy log and send it to support.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ), + ); + } + + AppLog.i('App start'); + runApp(const ProviderScope(child: HvbtApp())); + }, (error, stack) { + AppLog.e('Uncaught zone error', error, stack); + }); } class HvbtApp extends ConsumerWidget { diff --git a/lib/ui/screens/bt_picker/bt_picker_screen.dart b/lib/ui/screens/bt_picker/bt_picker_screen.dart index 60de3d8..7ec5603 100644 --- a/lib/ui/screens/bt_picker/bt_picker_screen.dart +++ b/lib/ui/screens/bt_picker/bt_picker_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../../core/providers/bt_provider.dart'; import '../../widgets/main_nav.dart'; +import '../../../core/logging/app_log.dart'; class BtPickerScreen extends ConsumerStatefulWidget { const BtPickerScreen({super.key}); @@ -22,37 +23,54 @@ class _BtPickerScreenState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth()); } + bool _permissionsOk = false; + Future _initBluetooth() async { - setState(() => _error = null); + setState(() { + _error = null; + _initDone = false; + }); - final statuses = await [ - Permission.bluetoothConnect, - Permission.bluetoothScan, - Permission.locationWhenInUse, - ].request(); + try { + // Android 12+: BLUETOOTH_CONNECT is the only permission needed to list + // paired devices and open an RFCOMM socket. On Android 11 and older + // permission_handler reports it as granted. Scan/location are requested + // but not required (we never run discovery). + final statuses = await [ + Permission.bluetoothConnect, + Permission.bluetoothScan, + Permission.locationWhenInUse, + ].request(); - final denied = statuses.entries - .where((e) => e.value.isDenied || e.value.isPermanentlyDenied) - .map((e) => e.key.toString()) - .toList(); + final connect = statuses[Permission.bluetoothConnect]; + if (connect != null && !connect.isGranted && !connect.isLimited) { + AppLog.w('Bluetooth permission not granted: $connect'); + if (!mounted) return; + setState(() { + _permissionsOk = false; + _error = 'Bluetooth permission ("Nearby devices") was not granted.\n\n' + 'Go to Settings → Apps → HV BT Dashboard → Permissions.'; + _initDone = true; + }); + if (connect.isPermanentlyDenied) await openAppSettings(); + return; + } + _permissionsOk = true; - if (denied.isNotEmpty) { - setState(() { - _error = 'Permissions denied: ${denied.join(", ")}\n\n' - 'Go to Settings → Apps → HV BT Dashboard → Permissions.'; - _initDone = true; - }); - return; - } - - final btState = await FlutterBluetoothSerial.instance.state; - if (btState != BluetoothState.STATE_ON) { - await FlutterBluetoothSerial.instance.requestEnable(); - await Future.delayed(const Duration(seconds: 1)); + final btState = await FlutterBluetoothSerial.instance.state; + if (btState != BluetoothState.STATE_ON) { + await FlutterBluetoothSerial.instance.requestEnable(); + await Future.delayed(const Duration(seconds: 1)); + } + } catch (e, st) { + AppLog.e('Bluetooth init failed', e, st); + if (!mounted) return; + setState(() => _error = 'Bluetooth init failed: $e'); } + if (!mounted) return; setState(() => _initDone = true); - ref.invalidate(pairedDevicesProvider); + if (_permissionsOk) ref.invalidate(pairedDevicesProvider); } // ECU protocol is chosen once via Settings (or defaults to S300) and @@ -61,6 +79,7 @@ class _BtPickerScreenState extends ConsumerState { // which was pure friction once the user had already told the app which // ECU they have. Future _connect(BluetoothDevice device) async { + if (ref.read(btProvider).isConnecting) return; await ref.read(btProvider.notifier).connect(device); if (!mounted) return; if (ref.read(btProvider).isConnected) { @@ -73,7 +92,11 @@ class _BtPickerScreenState extends ConsumerState { @override Widget build(BuildContext context) { final btState = ref.watch(btProvider); - final devicesAsync = ref.watch(pairedDevicesProvider); + // Do NOT touch the paired-device list before permission is granted: + // on Android 12+ that call throws inside the Bluetooth plugin. + final AsyncValue> devicesAsync = _permissionsOk + ? ref.watch(pairedDevicesProvider) + : const AsyncValue>.data([]); return Scaffold( appBar: AppBar( diff --git a/lib/ui/screens/dashboard/widgets/analog_gauge.dart b/lib/ui/screens/dashboard/widgets/analog_gauge.dart index 949a82c..3076b8f 100644 --- a/lib/ui/screens/dashboard/widgets/analog_gauge.dart +++ b/lib/ui/screens/dashboard/widgets/analog_gauge.dart @@ -38,7 +38,10 @@ class _AnalogGaugeState extends State super.initState(); _ctrl = AnimationController( vsync: this, - duration: const Duration(milliseconds: 150), + // Roughly one polling interval: the needle glides from reading to + // reading instead of jumping and stopping (was 150 ms ease-out, which + // looked stepped at 10-20 Hz data). + duration: const Duration(milliseconds: 110), ); _anim = Tween(begin: 0, end: _normalized) .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); @@ -50,7 +53,7 @@ class _AnalogGaugeState extends State super.didUpdateWidget(old); if (old.value != widget.value) { _anim = Tween(begin: _anim.value, end: _normalized) - .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); + .animate(CurvedAnimation(parent: _ctrl, curve: Curves.linear)); _ctrl.forward(from: 0); } } diff --git a/lib/ui/screens/debug/raw_frame_debug_screen.dart b/lib/ui/screens/debug/raw_frame_debug_screen.dart index b874dca..d82e0c9 100644 --- a/lib/ui/screens/debug/raw_frame_debug_screen.dart +++ b/lib/ui/screens/debug/raw_frame_debug_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/bluetooth/bt_poller.dart'; +import '../../../core/logging/app_log.dart'; import '../../../core/providers/bt_provider.dart'; import '../../../core/providers/sensor_provider.dart'; import '../../../core/providers/settings_provider.dart'; @@ -49,6 +50,20 @@ class _RawFrameDebugScreenState extends ConsumerState { appBar: AppBar( title: const Text('ECU Frame Debug'), actions: [ + IconButton( + tooltip: 'Copy app log', + icon: const Icon(Icons.description_outlined), + onPressed: () async { + await Clipboard.setData(ClipboardData(text: AppLog.dump())); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'App log copied (${AppLog.length} lines) — paste it into an email/message')), + ); + } + }, + ), IconButton( tooltip: 'Copy raw frame', icon: const Icon(Icons.copy), @@ -72,8 +87,17 @@ class _RawFrameDebugScreenState extends ConsumerState { title: 'Connection', rows: [ _RowData('Protocol', settings.ecuType.name.toUpperCase()), - _RowData('Frames (raw, from poller)', bt.frameCount.toString()), + _RowData('Link status', bt.status.name), + _RowData('ECU data', bt.dataStatus.name), + _RowData('Poll interval', '${settings.pollingInterval.inMilliseconds} ms'), + _RowData('Requests sent', bt.requestsSent.toString()), + _RowData('Valid replies', bt.frameCount.toString()), + _RowData(' live ECU frames', bt.liveFrames.toString()), + _RowData(' adapter default frames', bt.defaultFrames.toString()), + _RowData('Timeouts (no reply)', bt.timeouts.toString()), _RowData('Dropped (bad checksum)', bt.droppedFrames.toString()), + _RowData('Last reply time', bt.lastResponseMs == null ? '-' : '${bt.lastResponseMs} ms'), + _RowData('Auto-reconnects', bt.reconnects.toString()), _RowData('Last frame bytes', frame?.length.toString() ?? 'none'), if (frame != null) _RowData( @@ -175,20 +199,23 @@ class _RawFrameDebugScreenState extends ConsumerState { _RowData('TPS raw @9', f[9].toString()), _RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)), _RowData('Gear raw @27 (decimal)', f[27].toString()), - _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('INJ raw @10 LE', _u16le(f, 10).toString()), + _RowData('IGN raw @12', f[12].toString()), + _RowData('O2 raw @16', f[16].toString()), + _RowData('Strim raw @29 / Ltrim raw @31', '${f[29]} / ${f[31]}'), + _RowData('PA raw @32', f[32].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)}'), + _RowData('ERR00-03 @37-40', + '${_b(f, 37)} ${_b(f, 38)} ${_b(f, 39)} ${_b(f, 40)}'), + _RowData('Eth raw @41', f[41].toString()), + _RowData('AF raw @55', f[55].toString()), + _RowData('SW1 @17 (bit7..0)', '${_b(f, 17)} ${_bin(f, 17)}'), + _RowData('SW2 @18 (bit7..0)', '${_b(f, 18)} ${_bin(f, 18)}'), + _RowData('SW3 @19 (bit7..0)', '${_b(f, 19)} ${_bin(f, 19)}'), + _RowData('SW5 @49 (bit7..0)', '${_b(f, 49)} ${_bin(f, 49)}'), + _RowData('SW6 @50 (bit7..0)', '${_b(f, 50)} ${_bin(f, 50)}'), ]; } diff --git a/lib/ui/screens/settings/settings_screen.dart b/lib/ui/screens/settings/settings_screen.dart index d56837f..add4f67 100644 --- a/lib/ui/screens/settings/settings_screen.dart +++ b/lib/ui/screens/settings/settings_screen.dart @@ -78,7 +78,7 @@ class SettingsScreen extends ConsumerWidget { // ── Polling Interval ───────────────────────────────────── _SectionHeader('POLLING INTERVAL'), - ...[20, 50, 100, 200].map((ms) => RadioListTile( + ...pollingIntervalOptionsMs.map((ms) => RadioListTile( title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'), value: ms, groupValue: settings.pollingInterval.inMilliseconds, diff --git a/lib/ui/widgets/bt_status_chip.dart b/lib/ui/widgets/bt_status_chip.dart index a8f91c1..d889dbb 100644 --- a/lib/ui/widgets/bt_status_chip.dart +++ b/lib/ui/widgets/bt_status_chip.dart @@ -7,13 +7,15 @@ class BtStatusChip extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final bt = ref.watch(btProvider); + final status = ref.watch(btProvider.select((s) => s.status)); + final name = ref.watch(btProvider.select((s) => s.deviceName)); - final (color, icon, label) = switch (bt.status) { - BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'), - BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'), - BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'), - BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'), + final (color, icon, label) = switch (status) { + BtStatus.connected => (Colors.green, Icons.bluetooth_connected, name ?? 'Connected'), + BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'), + BtStatus.reconnecting => (Colors.orange, Icons.bluetooth_searching, 'Reconnecting…'), + BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'), + BtStatus.disconnected => (Colors.grey, Icons.bluetooth, 'Disconnected'), }; return Padding( diff --git a/lib/ui/widgets/data_status_banner.dart b/lib/ui/widgets/data_status_banner.dart new file mode 100644 index 0000000..c89788e --- /dev/null +++ b/lib/ui/widgets/data_status_banner.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/bt_provider.dart'; + +/// Thin banner under the app bar that tells the driver when the numbers on +/// screen are NOT live, instead of silently showing stale or default values. +class DataStatusBanner extends ConsumerWidget { + const DataStatusBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch(btProvider.select((s) => s.status)); + final data = ref.watch(btProvider.select((s) => s.dataStatus)); + final error = ref.watch(btProvider.select((s) => s.error)); + + final String? msg; + Color color = const Color(0xFF8A5A00); + if (status == BtStatus.reconnecting) { + msg = 'Connection interrupted — reconnecting…'; + } else if (status == BtStatus.error) { + msg = error ?? 'Connection lost'; + color = const Color(0xFF7A0000); + } else if (status == BtStatus.disconnected) { + msg = 'Not connected'; + color = const Color(0xFF444444); + } else if (status != BtStatus.connected) { + msg = null; + } else { + msg = switch (data) { + EcuDataStatus.live => null, + EcuDataStatus.none => 'Connected — waiting for data…', + EcuDataStatus.adapterDefaults => + 'Adapter connected, but no data from the ECU (ignition on?)', + EcuDataStatus.noResponse => 'Adapter is not responding', + }; + } + + if (msg == null) return const SizedBox.shrink(); + return Material( + color: color, + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text( + msg, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/main_nav.dart b/lib/ui/widgets/main_nav.dart index 9d4ef07..71036f0 100644 --- a/lib/ui/widgets/main_nav.dart +++ b/lib/ui/widgets/main_nav.dart @@ -9,6 +9,7 @@ import '../screens/datalog/datalog_list_screen.dart'; import '../screens/debug/raw_frame_debug_screen.dart'; import '../screens/settings/settings_screen.dart'; import 'bt_status_chip.dart'; +import 'data_status_banner.dart'; import 'record_fab.dart'; final _navIndexProvider = StateProvider((ref) => 0); @@ -77,9 +78,16 @@ class MainNav extends ConsumerWidget { ), ], ), - body: IndexedStack( - index: index, - children: _screens, + body: Column( + children: [ + const DataStatusBanner(), + Expanded( + child: IndexedStack( + index: index, + children: _screens, + ), + ), + ], ), bottomNavigationBar: BottomNavigationBar( currentIndex: index, diff --git a/test/kpro_parser_test.dart b/test/kpro_parser_test.dart index 090221e..3801c24 100644 --- a/test/kpro_parser_test.dart +++ b/test/kpro_parser_test.dart @@ -21,18 +21,18 @@ Uint8List _buildKProFrame({ // Header frame[0] = 0x1B; frame[1] = 0x01; - // RPM: bytes 2..3 - frame[2] = (rpm >> 8) & 0xFF; - frame[3] = rpm & 0xFF; + // RPM: bytes 2..3, little-endian + frame[2] = rpm & 0xFF; + frame[3] = (rpm >> 8) & 0xFF; // VSS: byte 4 frame[4] = vss & 0xFF; // TPS: byte 5 frame[5] = tps & 0xFF; // MAP1: byte 6 frame[6] = map1 & 0xFF; - // MAP2: bytes 33..34 - frame[33] = (map2 >> 8) & 0xFF; - frame[34] = map2 & 0xFF; + // MAP2: bytes 33 (L), 34 (H) + frame[33] = map2 & 0xFF; + frame[34] = (map2 >> 8) & 0xFF; // IGN: byte 13 frame[13] = ign; // KRtrd: byte 21 diff --git a/test/s300_parser_test.dart b/test/s300_parser_test.dart index d8bf845..a36b116 100644 --- a/test/s300_parser_test.dart +++ b/test/s300_parser_test.dart @@ -20,6 +20,7 @@ Uint8List _buildS300Frame({ int sw3 = 0, int krtrd = 0, int gear = 0, + Map extra = const {}, }) { final frame = Uint8List(128); // Header @@ -36,9 +37,9 @@ Uint8List _buildS300Frame({ frame[8] = (mapRaw >> 8) & 0xFF; // TPS: byte +9 frame[9] = tps; - // INJ: bytes +10..11, BE - frame[10] = (inj >> 8) & 0xFF; - frame[11] = inj & 0xFF; + // INJ: bytes +10..11, little-endian + frame[10] = inj & 0xFF; + frame[11] = (inj >> 8) & 0xFF; // IGN: byte +12 frame[12] = ign; // KRtrd: byte +13 @@ -55,6 +56,7 @@ Uint8List _buildS300Frame({ frame[34] = iat; // BAT: decimal offset 36 frame[36] = bat; + extra.forEach((offset, value) => frame[offset] = value); // Stamp NEG8 checksum at byte 127 int neg8 = 0; @@ -195,6 +197,59 @@ void main() { ); }); + // ── Offsets corrected 2026-10-12 (column-count mapping) ────────────── + test('fuel trims: bytes 29/31, 0x80 = 0 %', () { + final s = parseS300(_buildS300Frame(extra: {29: 0x80, 31: 0x90})); + expect(s.strim, closeTo(0.0, 0.001)); + expect(s.ltrim, closeTo(12.5, 0.001)); + }); + + test('DTC bytes are ERR00..ERR03 at 37..40', () { + final s = parseS300( + _buildS300Frame(extra: {37: 0x01, 38: 0x02, 39: 0x04, 40: 0x08})); + expect(s.errBytes, equals([0x01, 0x02, 0x04, 0x08])); + }); + + test('SW6 (byte 50) no longer produces phantom DTCs', () { + final s = parseS300(_buildS300Frame(extra: {50: 0x60, 49: 0x00})); + expect(s.errBytes, equals([0, 0, 0, 0])); + }); + + test('fan output = SW5 (byte 49) bit0 FANC, not SW2 ALTC', () { + expect(parseS300(_buildS300Frame(extra: {49: 0x01})).fanOut, isTrue); + expect(parseS300(_buildS300Frame(sw2: 0x80)).fanOut, isFalse); + }); + + test('ethanol from byte 41, AF raw from byte 55, PA from byte 32', () { + final s = parseS300(_buildS300Frame(extra: {41: 85, 55: 0x9A, 32: 0x93})); + expect(s.eth, equals(85.0)); + expect(s.afr, equals(154.0)); + expect(s.pa, closeTo(100.25, 0.01)); + }); + + test('INJ read little-endian', () { + final s = parseS300(_buildS300Frame(inj: 3000)); + expect(s.inj, closeTo(3.0, 0.001)); + }); + + test('AIN0 read little-endian at 82', () { + final s = parseS300(_buildS300Frame(extra: {82: 0x34, 83: 0x02})); + expect(s.ain0, equals(0x0234.toDouble())); + }); + + test('adapter default frame decodes to the PDF example (sanity)', () { + final f = _fullDefaultFrame(); + expect(isS300DefaultFrame(f), isTrue); + final s = parseS300(f); + expect(s.map, closeTo(101.0, 0.01)); // 0x03F2 + expect(s.gear, equals(1)); + expect(s.ect, equals(20.0)); // 0xA0 + expect(s.iat, equals(20.0)); + expect(s.bat, closeTo(12.04, 0.01)); // 0x7D + expect(s.strim, closeTo(0.0, 0.001)); + expect(s.errBytes, equals([0, 0, 0, 0])); + }); + test('throws on bad checksum', () { final frame = _buildS300Frame(rpm: 1000); frame[127] = 0x00; // corrupt checksum @@ -230,11 +285,21 @@ void main() { final frame = _buildS300Frame(rpm: 3000, tps: 127); expect(isS300TemplateFrame(frame), isFalse); }); + + test('default-frame head with real values at 32-36 is NOT a default frame', + () { + // Key-on/engine-off could coincide with bytes 0-16; the 32-36 span + // (PA/ECT/IAT/?/BAT) must also match before data is discarded. + final frame = _templateFrame(); + frame[33] = 0xD2; // real ECT + frame[127] = calculateNeg8(frame.sublist(0, 127)); + expect(isS300DefaultFrame(frame), isFalse); + }); }); } /// Builds a 128-byte frame stamped with the S300's fixed event-frame -/// template in bytes 0-16, with a valid NEG8 checksum. +/// template in bytes 0-16 and 32-36, with a valid NEG8 checksum. Uint8List _templateFrame() { final frame = Uint8List(128); final prefix = [ @@ -259,6 +324,26 @@ Uint8List _templateFrame() { for (var i = 0; i < prefix.length; i++) { frame[i] = prefix[i]; } + frame.setRange(32, 37, [0x93, 0xA0, 0xA0, 0xDC, 0x7D]); + frame[127] = calculateNeg8(frame.sublist(0, 127)); + return frame; +} + +/// The full PDF example row (hvbt_protocol.pdf), which the adapter sends as +/// its default frame when it has no ECU data. Offsets by column count. +Uint8List _fullDefaultFrame() { + final frame = Uint8List(128); + const bytes = [ + 0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, 0x18, // 0-9 + 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4, 0x71, 0x1C, 0x05, // 10-19 + 0x40, 0xB4, 0x05, 0x41, 0x05, 0x66, 0x02, 0x01, 0x00, 0x80, // 20-29 + 0x00, 0x80, 0x93, 0xA0, 0xA0, 0xDC, 0x7D, 0x00, 0x00, 0x00, // 30-39 + 0x00, 0x00, 0x80, 0x00, 0x00, 0x8C, 0xB1, 0x00, 0x00, 0x00, // 40-49 + 0x60, 0xC0, 0x7D, 0x7F, 0x08, 0x9A, 0xC4, 0x00, 0x00, 0x00, // 50-59 + ]; + for (var i = 0; i < bytes.length; i++) { + frame[i] = bytes[i]; + } frame[127] = calculateNeg8(frame.sublist(0, 127)); return frame; }