Rework BT poller and S300 decoding after client feedback

Client reported crashes, wrong/frozen data and that the app floods the
adapter MCU. See docs/FIX_2026-10-12.md for the full analysis.

- Poller: strictly one request outstanding; next request only after a valid
  128-byte reply or a 500 ms timeout, then a quiet period + buffer flush.
  Default 100 ms (10 Hz), minimum 80 ms. DTC clear/probe share the same lock.
- BtNotifier: filter the adapter's default (no-ECU-data) frame instead of
  holding last values; EcuDataStatus + status banner; auto-reconnect on link
  loss, stalls or long default-only runs (rate-limited). A reconnect still in
  flight when the user disconnects is discarded.
- S300 offsets corrected by column counting (trims, PA, ERR00-03, Eth, SW5,
  AF); INJ/AIN little-endian; fan = SW5 FANC only. Default-frame detection
  requires bytes 0-16 and 32-36.
- KPro RPM/MAP2/AIN little-endian (RPM still needs a bench check).
- Crash hardening: zone/Flutter/platform error handlers to an in-app log
  (copyable from Debug); permissions requested before listing devices.
- Remove temporary debug prints; graph redraws live; polling options
  100-300 ms, persisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-12 21:02:54 -04:00
parent e9b1c0f8fd
commit 9a56baef61
21 changed files with 1387 additions and 649 deletions

209
docs/FIX_2026-10-12.md Normal file
View File

@ -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.
```

View File

@ -1,160 +1,308 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import '../logging/app_log.dart';
import '../protocol/neg8.dart'; import '../protocol/neg8.dart';
import 'bt_service.dart'; import 'bt_service.dart';
enum EcuType { s300, kpro } enum EcuType { s300, kpro }
/// Sends the ECU request byte every [pollingInterval] ms, typedef _Matcher = Uint8List? Function(List<int> rxBuf);
/// accumulates raw bytes into 128-byte frames, validates NEG8,
/// and emits the newest usable frame on [frameStream]. class _Pending {
_Pending(this.match);
final _Matcher match;
final Completer<Uint8List?> completer = Completer<Uint8List?>();
}
/// 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 { 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; final BtService _service;
EcuType ecuType; EcuType ecuType;
final Duration pollingInterval; final Duration responseTimeout;
final void Function()? onLinkStalled;
// S300 request: [0x1B, 0x00, 0xE5] Duration _pollingInterval;
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]); Duration get pollingInterval => _pollingInterval;
// KPro request: [0x1B, 0x01, 0xE4] set pollingInterval(Duration d) => _pollingInterval = _sanitize(d);
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
static Duration _sanitize(Duration d) => d < minInterval ? minInterval : d;
final StreamController<Uint8List> _frameController = final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast(); StreamController<Uint8List>.broadcast();
/// Checksum-valid 128-byte frames, one per answered request.
Stream<Uint8List> get frameStream => _frameController.stream; Stream<Uint8List> get frameStream => _frameController.stream;
final List<int> _rxBuf = []; final List<int> _rxBuf = [];
Timer? _pollTimer;
StreamSubscription<Uint8List>? _rxSub; StreamSubscription<Uint8List>? _rxSub;
_Pending? _pending;
bool _running = false;
int _generation = 0;
int droppedFrames = 0; // Serialises every exchange on the link (polls and one-off commands).
Future<void> _lockTail = Future<void>.value();
// ── Stats (read by the debug screen) ──────────────────────────────────────
int requestsSent = 0;
int validFrames = 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 bool get isRunning => _running;
// 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),
});
void start() { void start() {
if (_running) return;
_running = true;
final gen = ++_generation;
_rxBuf.clear(); _rxBuf.clear();
_awaitingResponse = false; consecutiveTimeouts = 0;
_lastRequestSentAt = null;
// Listen to raw bytes from BT and accumulate into 128-byte frames
_rxSub = _service.rawStream.listen( _rxSub = _service.rawStream.listen(
(Uint8List chunk) { _onRx,
_rxBuf.addAll(chunk);
_processBuffer();
},
onError: (Object e) { onError: (Object e) {
_frameController.addError(e); AppLog.w('BT stream error: $e');
stop(); if (!_frameController.isClosed) _frameController.addError(e);
}, },
); );
AppLog.i('Poller started (${ecuType.name}, every '
// Check every pollingInterval whether it's time to send the next '${_pollingInterval.inMilliseconds} ms, timeout '
// request — but only actually send once the prior one has been '${responseTimeout.inMilliseconds} ms)');
// answered (or has clearly been lost). unawaited(_loop(gen));
_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);
}
} }
void stop() { void stop() {
_pollTimer?.cancel(); if (!_running) return;
_pollTimer = null; _running = false;
_generation++;
_rxSub?.cancel(); _rxSub?.cancel();
_rxSub = null; _rxSub = null;
final p = _pending;
if (p != null && !p.completer.isCompleted) p.completer.complete(null);
_pending = null;
_rxBuf.clear(); _rxBuf.clear();
AppLog.i('Poller stopped (sent=$requestsSent ok=$validFrames '
'timeouts=$timeouts badChecksum=$droppedFrames)');
} }
void dispose() { void dispose() {
stop(); stop();
_frameController.close(); _frameController.close();
} }
// ── Main loop ─────────────────────────────────────────────────────────────
Future<void> _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<void>.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<void>.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<void>.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<Uint8List> sendCommandCollect(Uint8List command, Duration window) {
return _withLink(() async {
await Future<void>.delayed(quietAfterTimeout);
_rxBuf.clear();
await _transact(command, (_) => null, window);
final raw = Uint8List.fromList(_rxBuf);
_rxBuf.clear();
return raw;
});
}
// ── Internals ─────────────────────────────────────────────────────────────
Future<T> _withLink<T>(Future<T> Function() action) {
final previous = _lockTail;
final release = Completer<void>();
_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<Uint8List?> _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<void>.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<int> 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<int> buf, List<int> 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;
}
} }

View File

@ -3,13 +3,27 @@ import 'dart:typed_data';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import '../logging/app_log.dart';
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT. /// 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 { class BtService {
BluetoothConnection? _connection; BluetoothConnection? _connection;
StreamSubscription<Uint8List>? _inputSub;
bool _connecting = false;
final StreamController<Uint8List> _rawFrameController = final StreamController<Uint8List> _rawFrameController =
StreamController<Uint8List>.broadcast(); StreamController<Uint8List>.broadcast();
bool get isConnected => _connection?.isConnected ?? false; bool get isConnected {
try {
return _connection?.isConnected ?? false;
} catch (_) {
return false;
}
}
Stream<Uint8List> get rawStream => _rawFrameController.stream; Stream<Uint8List> get rawStream => _rawFrameController.stream;
@ -18,35 +32,64 @@ class BtService {
} }
Future<void> connect(BluetoothDevice device) async { Future<void> connect(BluetoothDevice device) async {
if (_connecting) {
throw StateError('A connection attempt is already in progress');
}
_connecting = true;
try {
if (_connection != null) { if (_connection != null) {
await disconnect(); await disconnect();
} }
_connection = await BluetoothConnection.toAddress(device.address); AppLog.i('Connecting to ${device.name ?? '?'} (${device.address})');
_connection!.input!.listen( final conn = await BluetoothConnection.toAddress(device.address)
.timeout(const Duration(seconds: 15));
_connection = conn;
_inputSub = conn.input?.listen(
(Uint8List data) { (Uint8List data) {
if (!_rawFrameController.isClosed) { if (!_rawFrameController.isClosed) _rawFrameController.add(data);
_rawFrameController.add(data);
}
}, },
onDone: () { onDone: () {
AppLog.w('BT input stream closed by remote/OS');
if (!_rawFrameController.isClosed && identical(_connection, conn)) {
_rawFrameController.addError('BT connection closed'); _rawFrameController.addError('BT connection closed');
}
}, },
onError: (Object e) { onError: (Object e) {
_rawFrameController.addError(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;
}
} }
Future<void> write(Uint8List data) async { Future<void> write(Uint8List data) async {
if (_connection?.isConnected ?? false) { final conn = _connection;
_connection!.output.add(data); if (conn == null || !isConnected) {
await _connection!.output.allSent; throw StateError('Not connected');
} }
conn.output.add(data);
await conn.output.allSent;
} }
Future<void> disconnect() async { Future<void> disconnect() async {
await _connection?.close(); final conn = _connection;
_connection = null; _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() { void dispose() {

View File

@ -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<String> _lines = ListQueue<String>();
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;
}

View File

@ -27,32 +27,33 @@ const List<SensorDef> sensorDefs = [
max: 150), max: 150),
SensorDef( SensorDef(
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20), 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: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6),
SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100), SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100),
SensorDef( SensorDef(
id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120), 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( SensorDef(
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30), id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
SensorDef( SensorDef(
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30), id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
SensorDef( 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( 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( 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( 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( 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( 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( 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( 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. /// Master list of boolean flag sensors.

View File

@ -13,8 +13,11 @@ SensorState parseKPro(Uint8List frame) {
throw ArgumentError('KPro frame NEG8 checksum failed'); throw ArgumentError('KPro frame NEG8 checksum failed');
} }
// RPM: bytes 2..3 — raw / 4 // RPM: bytes 2..3 — raw / 4. Little-endian like every other multi-byte
final int rpmRaw = _readUint16BE(frame, 2); // 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; final double rpm = rpmRaw / 4.0;
// VSS: byte 4 direct km/h // VSS: byte 4 direct km/h
@ -25,7 +28,7 @@ SensorState parseKPro(Uint8List frame) {
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H) // MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
final int map1Raw = frame[6]; 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(); final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
// INJ: byte 11 raw // INJ: byte 11 raw
@ -73,15 +76,15 @@ SensorState parseKPro(Uint8List frame) {
// ERR bytes: 18 bytes starting at offset 0x3E = 62 // ERR bytes: 18 bytes starting at offset 0x3E = 62
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]); final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each) // AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 LE each)
final double ain0 = _readUint16BE(frame, 82).toDouble(); final double ain0 = _readUint16LE(frame, 82).toDouble();
final double ain1 = _readUint16BE(frame, 84).toDouble(); final double ain1 = _readUint16LE(frame, 84).toDouble();
final double ain2 = _readUint16BE(frame, 86).toDouble(); final double ain2 = _readUint16LE(frame, 86).toDouble();
final double ain3 = _readUint16BE(frame, 88).toDouble(); final double ain3 = _readUint16LE(frame, 88).toDouble();
final double ain4 = _readUint16BE(frame, 90).toDouble(); final double ain4 = _readUint16LE(frame, 90).toDouble();
final double ain5 = _readUint16BE(frame, 92).toDouble(); final double ain5 = _readUint16LE(frame, 92).toDouble();
final double ain6 = _readUint16BE(frame, 94).toDouble(); final double ain6 = _readUint16LE(frame, 94).toDouble();
final double ain7 = _readUint16BE(frame, 96).toDouble(); final double ain7 = _readUint16LE(frame, 96).toDouble();
// Gear — not in KPro frame, default 0 // Gear — not in KPro frame, default 0
const int gear = 0; const int gear = 0;
@ -142,6 +145,6 @@ SensorState parseKPro(Uint8List frame) {
); );
} }
int _readUint16BE(Uint8List frame, int offset) { int _readUint16LE(Uint8List frame, int offset) {
return (frame[offset] << 8) | frame[offset + 1]; return frame[offset] | (frame[offset + 1] << 8);
} }

View File

@ -3,54 +3,56 @@ import 'sensor_state.dart';
import 'temp_table.dart'; import 'temp_table.dart';
import 'neg8.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, // S300 datalog frame (128 bytes, response to request 1B 00 E5).
/// with those fields pinned to fixed constants regardless of the true //
/// physical signal. // BYTE OFFSETS: hvbt_protocol.pdf labels its columns inconsistently
/// // ("+26 +27 +28 +29 +2A … +2F +30 +31"). The labels are NOT hex offsets.
/// The frozen region is now known to extend well past byte 16. Bytes 32-36 // The real offsets are simply the column count, 0..127. This is proven by
/// (gear, ECT, IAT, BAT at their current offsets) show the exact same fixed // the fields already confirmed against live USB captures — Gear=27, ECT=33,
/// byte sequence (`93 a0 a0 dc 7d`) across dozens of unrelated live captures // IAT=34, BAT=36 — which all match plain column counting, and by the adapter's
/// throughout this project. SW2's flags (fuelPump, mil, vtec, fanOut's ALTC // default frame (below) reproducing the PDF example row byte-for-byte.
/// 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 // 0 ID1(1B) 1 ID2(00) 2 DLrate 3-4 RPM 5-6 VSS 7-8 MAP 9 TPS 10-11 INJ
/// earlier belief that SW2 stays live (based on an RPM sweep showing // 12 IGN 13 KRtrd 14-15 Klvl 16 O2 17 SW1 18 SW2 19 SW3 20 SW4 21-26 ?
/// fuelPump correlating with real changes) only holds while the frame // 27 Gear 28 ? 29 Strim 30 ? 31 Ltrim 32 PA 33 ECT 34 IAT 35 ? 36 BAT
/// *isn't* templated; during a template frame it goes stale like everything // 37 ERR00 38 ERR01 39 ERR02 40 ERR03 41 Eth 42-44 ? 45 IATc 46 ECTc 47-48 ?
/// else tested so far. SW1 itself was never actually confirmed for template // 49 SW5 50 SW6 51-54 ? 55 AF 56 ? 57 TCrtrd 58 TClim 59 ? 60-81 reserved
/// frames specifically — the "matches USB exactly" validation that seemed // 82-97 AIN0..AIN7 (16-bit) 98-126 reserved 127 NEG8
/// 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 // BYTE ORDER: multi-byte values are LITTLE-endian on the wire (confirmed live
/// unless it's been individually verified that way — the safer default is // for RPM/VSS/MAP; the PDF's "Big Endian" note only applies to the request).
/// that the whole frame is templated, with only a shrinking set of // ─────────────────────────────────────────────────────────────────────────────
/// individually-confirmed-live fields as exceptions (currently: none
/// confirmed with certainty — treat the entire byte-17+ region as suspect
/// until proven otherwise field by field).
///
/// Callers should hold the last known-good values for every field sourced
/// from the frozen span (bytes 0-16, gear/ECT/IAT/BAT, and the SW2 flags)
/// and decode only what's individually confirmed live.
bool isS300TemplateFrame(Uint8List frame) {
if (frame.length != 128) return false;
return frame[0] == 0x1B && /// True when the frame is the adapter's built-in DEFAULT frame — the exact
frame[1] == 0x00 && /// example values printed in hvbt_protocol.pdf (DLrate 0x14, VSS 0xFFFF,
frame[2] == 0x14 && /// MAP 0x03F2, TPS 0x18, IGN 0x10, O2 0xC4 …; bytes 32-36 = 93 A0 A0 DC 7D).
frame[3] == 0x00 && /// The adapter sends it when it has no fresh data from the ECU (typically
frame[4] == 0x00 && /// because it was asked again before it could finish reading the ECU).
frame[5] == 0xFF && /// Such a frame carries NO real readings and must not be displayed or logged.
frame[6] == 0xFF && ///
frame[7] == 0xF2 && /// Both spans must match: bytes 0-16 alone could, in principle, coincide with
frame[8] == 0x03 && /// a real key-on/engine-off reading (RPM 0, MAP 101.0, closed throttle), and a
frame[9] == 0x18 && /// false positive here hides real data and triggers auto-reconnects.
frame[10] == 0x00 && bool isS300DefaultFrame(Uint8List frame) {
frame[11] == 0x00 && if (frame.length != 128) return false;
frame[12] == 0x10 && const head = <int>[
frame[13] == 0x00 && 0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, //
frame[14] == 0x00 && 0x18, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4,
frame[15] == 0x00 && ];
frame[16] == 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 = <int>[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]. /// Parses a 128-byte S300 ECU response frame into a [SensorState].
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum] /// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
@ -63,132 +65,86 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
throw ArgumentError('S300 frame NEG8 checksum failed'); throw ArgumentError('S300 frame NEG8 checksum failed');
} }
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live // ── Confirmed against live captures ────────────────────────────────────
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while final double rpm = _u16le(frame, 3).toDouble();
// 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();
// VSS: bytes +5..6, little-endian (confirmed against a live capture). final int vssRaw = _u16le(frame, 5);
final int vssRaw = _readUint16LE(frame, 5);
final double vss = final double vss =
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw; (vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the final double map = _u16le(frame, 7) / 10.0;
// 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;
// TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture // Linear 0-255 → 0-100 % (live-confirmed; the PDF formula exceeds 100 %).
// (raw 127 matched a reference tool's ~50% reading; the spec's final double tps = (frame[9] * 100.0 / 255.0).clamp(0.0, 100.0);
// "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);
// 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]; final int gear = frame[27];
// Strim: +29 hex = 41 decimal // Table value minus 40 (live-confirmed; also the default frame's 0xA0
final double strim = frame[0x29].toDouble(); // decodes to a sensible 20 °C this way).
// 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.
final double ect = (tempXlt[frame[33]] - 40).toDouble(); 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(); 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; final double bat = frame[36] * 26.0 / 270.0;
// ERR bytes: +31..+34 hex = 49..52 decimal // ── Offsets corrected 2026-10-12 (column counting, see header) ─────────
final List<int> errBytes = [ // Formulas marked UNVERIFIED still need one bench check against the USB
frame[0x31], // tool; they are sanity-checked against the default frame only.
frame[0x32],
frame[0x33],
frame[0x34],
];
// Eth: +38 hex = 56 decimal // INJ: 16-bit LE (was read big-endian). UNVERIFIED scale: assumes µs.
final double eth = frame[0x38].toDouble(); final double inj = _u16le(frame, 10) / 1000.0;
// AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table. // IGN: formula from the PDF. UNVERIFIED (one live check disagreed).
final double afr = frame[0x34].toDouble(); final double ign = (frame[12] + 120) / 2.0;
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each) // O2: raw byte. UNVERIFIED scale — shown as raw.
final double ain0 = _readUint16BE(frame, 82).toDouble(); final double o2 = frame[16].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();
// Flags final int sw1 = frame[17];
// SW1 bit3 = REVL 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<int> 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; 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; final bool mil = (sw2 & 0x20) != 0;
// SW2 bit2 = Fuel — this is the fuel PUMP relay status, not fuel cut (the // Fuel pump relay, active-LOW (live-confirmed).
// reference tool's own channel list labels it as such). There's no
// separate "fuel cut" bit identified in this frame — REVL/IGNC/BstC/
// LnchC/ShftC are the actual cut-type flags this ECU exposes.
//
// Active-LOW: confirmed via an extended live session (RPM swept 400-3842,
// continuously above the ~300 threshold where the pump should be on) that
// this bit read 0 the entire time — and separately confirmed live that the
// app showed "on" below ~200 rpm (pump should be off) and "off" above the
// threshold (pump should be on), i.e. exactly inverted from a `!= 0` read.
// Bit clear (0) means the pump relay is energized/on; bit set (1) means off.
final bool fuelPump = (sw2 & 0x04) == 0; 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; final bool vtec = (sw2 & 0x03) != 0;
// Knock = KRtrd > 0 (byte 13) // SW5: … CL(2) … FANC(0). Fan output is FANC only — SW2 bit7 is ALTC
final bool knock = frame[13] > 0; // (alternator control), not the fan.
// SW3 bit7 = LnchC 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; final bool launch = (sw3 & 0x80) != 0;
return SensorState( return SensorState(
@ -228,10 +184,5 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
); );
} }
int _readUint16BE(Uint8List frame, int offset) { int _u16le(Uint8List frame, int offset) =>
return (frame[offset] << 8) | frame[offset + 1]; frame[offset] | (frame[offset + 1] << 8);
}
int _readUint16LE(Uint8List frame, int offset) {
return frame[offset] | (frame[offset + 1] << 8);
}

View File

@ -6,12 +6,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bluetooth/bt_poller.dart'; import '../bluetooth/bt_poller.dart';
import '../bluetooth/bt_service.dart'; import '../bluetooth/bt_service.dart';
import '../logging/app_log.dart';
import '../protocol/neg8.dart'; import '../protocol/neg8.dart';
import '../protocol/s300_parser.dart';
import 'settings_provider.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 ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
@ -19,197 +37,354 @@ class BtState {
final BtStatus status; final BtStatus status;
final String? deviceName; final String? deviceName;
final String? error; final String? error;
final int frameCount; final EcuDataStatus dataStatus;
final int droppedFrames; 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; final Uint8List? lastFrame;
const BtState({ const BtState({
required this.status, required this.status,
this.deviceName, this.deviceName,
this.error, this.error,
this.dataStatus = EcuDataStatus.none,
this.requestsSent = 0,
this.frameCount = 0, this.frameCount = 0,
this.liveFrames = 0,
this.defaultFrames = 0,
this.droppedFrames = 0, this.droppedFrames = 0,
this.timeouts = 0,
this.reconnects = 0,
this.lastResponseMs,
this.lastFrame, this.lastFrame,
}); });
const BtState.disconnected() : this(status: BtStatus.disconnected); const BtState.disconnected() : this(status: BtStatus.disconnected);
BtState connecting(String name) => BtState copyWith({
BtState(status: BtStatus.connecting, deviceName: name); BtStatus? status,
String? deviceName,
BtState connected(String name) => String? error,
BtState(status: BtStatus.connected, deviceName: name); bool clearError = false,
EcuDataStatus? dataStatus,
BtState withError(String msg) => BtState( int? requestsSent,
status: BtStatus.error, int? frameCount,
deviceName: deviceName, int? liveFrames,
error: msg, int? defaultFrames,
frameCount: frameCount, int? droppedFrames,
droppedFrames: droppedFrames, int? timeouts,
lastFrame: lastFrame, int? reconnects,
); int? lastResponseMs,
Uint8List? lastFrame,
BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState( }) =>
status: status, BtState(
deviceName: deviceName, status: status ?? this.status,
frameCount: frames, deviceName: deviceName ?? this.deviceName,
droppedFrames: dropped, error: clearError ? null : (error ?? this.error),
lastFrame: Uint8List.fromList(frame), 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 isConnected => status == BtStatus.connected;
bool get isConnecting => status == BtStatus.connecting; bool get isConnecting =>
status == BtStatus.connecting || status == BtStatus.reconnecting;
} }
// ─── Notifier ──────────────────────────────────────────────────────────────── // ─── Notifier ────────────────────────────────────────────────────────────────
class BtNotifier extends StateNotifier<BtState> { class BtNotifier extends StateNotifier<BtState> {
BtNotifier(this._ref) : super(const BtState.disconnected()) {
// Apply polling-interval changes from Settings to a running poller.
_ref.listen<AppSettings>(settingsProvider, (prev, next) {
_poller?.pollingInterval = next.pollingInterval;
_poller?.ecuType = next.ecuType;
});
}
final Ref _ref; final Ref _ref;
BtPoller? _poller; BtPoller? _poller;
StreamSubscription<Uint8List>? _pollSub; StreamSubscription<Uint8List>? _pollSub;
Timer? _statusTimer;
final StreamController<Uint8List> _frameController = final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast(); StreamController<Uint8List>.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<Uint8List> get frameStream => _frameController.stream; Stream<Uint8List> get frameStream => _frameController.stream;
BtPoller? get poller => _poller;
Future<void> connect(BluetoothDevice device) async { Future<void> connect(BluetoothDevice device) async {
state = state.connecting(device.name ?? device.address); if (state.isConnecting) return; // ignore double taps
final service = _ref.read(btServiceProvider); final session = ++_session;
final settings = _ref.read(settingsProvider); _device = device;
_userDisconnected = false;
try { _resetCounters();
await service.connect(device); state = BtState(
status: BtStatus.connecting,
_poller = BtPoller( deviceName: device.name ?? device.address,
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<Uint8List> probeOfficialProtocol() async {
final service = _ref.read(btServiceProvider);
if (!service.isConnected) return Uint8List(0);
_poller?.stop();
// stop() only cancels the poller's own timer and listener — it can't
// un-send a legacy request the poller already wrote moments earlier.
// That request's 128-byte reply is very likely still in flight, so
// drain and discard whatever arrives here before sending the probe, or
// that leftover legacy frame gets mistaken for the probe's response.
final drainSub = service.rawStream.listen((_) {});
await Future.delayed(const Duration(milliseconds: 500));
await drainSub.cancel();
final collected = <int>[];
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
try { try {
await service.write(Uint8List.fromList([0x00, 0x02, 0x04, 0x00])); await _openLink(device);
await Future.delayed(const Duration(milliseconds: 800)); if (session != _session || !mounted) {
} finally { AppLog.i('Connect finished after disconnect — closing link');
await sub.cancel(); await _closeLink();
_poller?.start(); return;
} }
state = state.copyWith(status: BtStatus.connected, clearError: true);
return Uint8List.fromList(collected); } 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));
} }
/// Sends the DTC-reset command from the legacy protocol notes
/// (hvbt_protocol.pdf): { 0x1B, 0x70, NEG8 }, NEG8 over the first two
/// bytes. The ECU echoes the same 3 bytes back to acknowledge.
///
/// Deliberately does NOT pause the poller first (unlike
/// probeOfficialProtocol()) — pausing-then-sending got zero response both
/// here and for the official-protocol probe, while the regular poll cycle
/// always gets answered. One untested explanation: this adapter may only
/// answer additional commands while it keeps seeing the normal poll
/// traffic, not when that traffic stops. So this injects the command
/// alongside ongoing polling and scans the stream for the 3-byte ack
/// appearing anywhere in it — it'll be interleaved with regular 128-byte
/// datalog frames rather than arriving in isolation — over several poll
/// cycles' worth of time, in case the reply is delayed by an EEPROM/flash
/// write on the ECU rather than a simple RAM read.
Future<({bool acknowledged, Uint8List raw})> clearDtcs() async {
final service = _ref.read(btServiceProvider);
if (!service.isConnected) return (acknowledged: false, raw: Uint8List(0));
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
final collected = <int>[];
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
bool acknowledged = false;
try {
await service.write(command);
final deadline = DateTime.now().add(const Duration(seconds: 3));
while (DateTime.now().isBefore(deadline) && !acknowledged) {
await Future.delayed(const Duration(milliseconds: 150));
acknowledged = _containsAck(collected);
}
} finally {
await sub.cancel();
}
return (acknowledged: acknowledged, raw: Uint8List.fromList(collected));
}
static bool _containsAck(List<int> bytes) {
for (var i = 0; i + 2 < bytes.length; i++) {
if (bytes[i] == 0x1B && bytes[i + 1] == 0x70 && bytes[i + 2] == 0x75) {
return true;
}
}
return false;
} }
Future<void> disconnect() async { Future<void> disconnect() async {
_session++;
_userDisconnected = true;
await _closeLink();
state = const BtState.disconnected();
}
// ── Link management ───────────────────────────────────────────────────────
Future<void> _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<void> _closeLink() async {
_statusTimer?.cancel();
_statusTimer = null;
await _pollSub?.cancel(); await _pollSub?.cancel();
_poller?.stop(); _pollSub = null;
_poller?.dispose(); _poller?.dispose();
_poller = null; _poller = null;
await _ref.read(btServiceProvider).disconnect(); 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<void> _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<void>.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<Uint8List> 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 @override
void dispose() { void dispose() {
_statusTimer?.cancel();
_pollSub?.cancel(); _pollSub?.cancel();
_poller?.dispose(); _poller?.dispose();
_frameController.close(); _frameController.close();
@ -230,7 +405,8 @@ final btServiceProvider = Provider<BtService>((ref) {
final btProvider = final btProvider =
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref)); StateNotifierProvider<BtNotifier, BtState>((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<List<BluetoothDevice>>((ref) { final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
return FlutterBluetoothSerial.instance.getBondedDevices(); return FlutterBluetoothSerial.instance.getBondedDevices();
}); });

View File

@ -62,6 +62,11 @@ class SensorHistory {
class SensorHistoryNotifier extends StateNotifier<SensorHistory> { class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
SensorHistoryNotifier() : super(SensorHistory()); 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) { void addState(SensorState s) {
state.add(s); state.add(s);
// trigger rebuild by reassigning same object // trigger rebuild by reassigning same object

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bluetooth/bt_poller.dart'; import '../bluetooth/bt_poller.dart';
import '../logging/app_log.dart';
import '../models/sensor_defs.dart'; import '../models/sensor_defs.dart';
import '../protocol/kpro_parser.dart'; import '../protocol/kpro_parser.dart';
import '../protocol/s300_parser.dart'; import '../protocol/s300_parser.dart';
@ -54,134 +55,33 @@ final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStat
/// Emits a parsed [SensorState] for every live data frame received from the ECU. /// Emits a parsed [SensorState] for every live data frame received from the ECU.
/// Automatically picks S300 or KPro parser based on [settingsProvider]. /// Automatically picks S300 or KPro parser based on [settingsProvider].
final sensorStateProvider = StreamProvider<SensorState>((ref) async* { final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
final ecuType = ref.watch(settingsProvider).ecuType; final ecuType = ref.watch(settingsProvider.select((s) => s.ecuType));
final btNotifier = ref.watch(btProvider.notifier); final btNotifier = ref.watch(btProvider.notifier);
// `await for` treats any error *event* on the source stream as fatal and // `await for` treats an error event as fatal; absorb errors upstream so the
// exits the loop permanently — a try/catch inside the loop body cannot // loop keeps waiting for the next frame.
// intercept that, since it happens in the iteration mechanism itself, not final safeStream =
// the body. handleError() absorbs error events upstream so `await for` btNotifier.frameStream.handleError((Object _, StackTrace __) {});
// 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.
// 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) { await for (final frame in safeStream) {
// A single bad frame must never take down the rest of the live stream.
try { try {
SensorState decoded = SensorState decoded =
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame); ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
decoded = _clampToRealisticRange(decoded); 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); final s = ref.read(decodeStatsProvider);
ref.read(decodeStatsProvider.notifier).state = ref.read(decodeStatsProvider.notifier).state =
s.copyWith(decoded: s.decoded + 1); s.copyWith(decoded: s.decoded + 1);
yield decoded; yield decoded;
} catch (_) { } catch (e) {
// Skip this one frame and keep the stream alive for the next.
final s = ref.read(decodeStatsProvider); final s = ref.read(decodeStatsProvider);
ref.read(decodeStatsProvider.notifier).state = ref.read(decodeStatsProvider.notifier).state =
s.copyWith(decodeErrors: s.decodeErrors + 1); s.copyWith(decodeErrors: s.decodeErrors + 1);
if (s.decodeErrors < 5) AppLog.w('Frame decode error: $e');
continue; continue;
} }
} }

View File

@ -2,13 +2,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../bluetooth/bt_poller.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<int> pollingIntervalOptionsMs = [100, 150, 200, 300];
class AppSettings { class AppSettings {
final EcuType ecuType; final EcuType ecuType;
final Duration pollingInterval; final Duration pollingInterval;
const AppSettings({ const AppSettings({
this.ecuType = EcuType.s300, this.ecuType = EcuType.s300,
this.pollingInterval = const Duration(milliseconds: 50), this.pollingInterval = BtPoller.defaultInterval,
}); });
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) => AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
@ -19,6 +25,7 @@ class AppSettings {
} }
const _ecuTypeKey = 'ecuType'; const _ecuTypeKey = 'ecuType';
const _pollingKey = 'pollingIntervalMs';
class SettingsNotifier extends StateNotifier<AppSettings> { class SettingsNotifier extends StateNotifier<AppSettings> {
SettingsNotifier() : super(const AppSettings()) { SettingsNotifier() : super(const AppSettings()) {
@ -26,21 +33,37 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
} }
Future<void> _load() async { Future<void> _load() async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_ecuTypeKey); final saved = prefs.getString(_ecuTypeKey);
if (saved == EcuType.kpro.name) { final ms = prefs.getInt(_pollingKey);
state = state.copyWith(ecuType: EcuType.kpro); 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) { void setEcuType(EcuType type) {
state = state.copyWith(ecuType: type); state = state.copyWith(ecuType: type);
SharedPreferences.getInstance() SharedPreferences.getInstance()
.then((prefs) => prefs.setString(_ecuTypeKey, type.name)); .then((prefs) => prefs.setString(_ecuTypeKey, type.name))
.catchError((Object _) => false);
} }
void setPollingInterval(Duration d) => void setPollingInterval(Duration d) {
state = state.copyWith(pollingInterval: 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<SettingsNotifier, AppSettings>( final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(

View File

@ -1,12 +1,48 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/logging/app_log.dart';
import 'core/providers/theme_provider.dart'; import 'core/providers/theme_provider.dart';
import 'ui/theme/app_theme.dart'; import 'ui/theme/app_theme.dart';
import 'ui/screens/bt_picker/bt_picker_screen.dart'; import 'ui/screens/bt_picker/bt_picker_screen.dart';
void main() { void main() {
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())); runApp(const ProviderScope(child: HvbtApp()));
}, (error, stack) {
AppLog.e('Uncaught zone error', error, stack);
});
} }
class HvbtApp extends ConsumerWidget { class HvbtApp extends ConsumerWidget {

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import '../../../core/providers/bt_provider.dart'; import '../../../core/providers/bt_provider.dart';
import '../../widgets/main_nav.dart'; import '../../widgets/main_nav.dart';
import '../../../core/logging/app_log.dart';
class BtPickerScreen extends ConsumerStatefulWidget { class BtPickerScreen extends ConsumerStatefulWidget {
const BtPickerScreen({super.key}); const BtPickerScreen({super.key});
@ -22,37 +23,54 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth()); WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
} }
Future<void> _initBluetooth() async { bool _permissionsOk = false;
setState(() => _error = null);
Future<void> _initBluetooth() async {
setState(() {
_error = null;
_initDone = false;
});
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 [ final statuses = await [
Permission.bluetoothConnect, Permission.bluetoothConnect,
Permission.bluetoothScan, Permission.bluetoothScan,
Permission.locationWhenInUse, Permission.locationWhenInUse,
].request(); ].request();
final denied = statuses.entries final connect = statuses[Permission.bluetoothConnect];
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied) if (connect != null && !connect.isGranted && !connect.isLimited) {
.map((e) => e.key.toString()) AppLog.w('Bluetooth permission not granted: $connect');
.toList(); if (!mounted) return;
if (denied.isNotEmpty) {
setState(() { setState(() {
_error = 'Permissions denied: ${denied.join(", ")}\n\n' _permissionsOk = false;
_error = 'Bluetooth permission ("Nearby devices") was not granted.\n\n'
'Go to Settings → Apps → HV BT Dashboard → Permissions.'; 'Go to Settings → Apps → HV BT Dashboard → Permissions.';
_initDone = true; _initDone = true;
}); });
if (connect.isPermanentlyDenied) await openAppSettings();
return; return;
} }
_permissionsOk = true;
final btState = await FlutterBluetoothSerial.instance.state; final btState = await FlutterBluetoothSerial.instance.state;
if (btState != BluetoothState.STATE_ON) { if (btState != BluetoothState.STATE_ON) {
await FlutterBluetoothSerial.instance.requestEnable(); await FlutterBluetoothSerial.instance.requestEnable();
await Future<void>.delayed(const Duration(seconds: 1)); await Future<void>.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); setState(() => _initDone = true);
ref.invalidate(pairedDevicesProvider); if (_permissionsOk) ref.invalidate(pairedDevicesProvider);
} }
// ECU protocol is chosen once via Settings (or defaults to S300) and // ECU protocol is chosen once via Settings (or defaults to S300) and
@ -61,6 +79,7 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
// which was pure friction once the user had already told the app which // which was pure friction once the user had already told the app which
// ECU they have. // ECU they have.
Future<void> _connect(BluetoothDevice device) async { Future<void> _connect(BluetoothDevice device) async {
if (ref.read(btProvider).isConnecting) return;
await ref.read(btProvider.notifier).connect(device); await ref.read(btProvider.notifier).connect(device);
if (!mounted) return; if (!mounted) return;
if (ref.read(btProvider).isConnected) { if (ref.read(btProvider).isConnected) {
@ -73,7 +92,11 @@ class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final btState = ref.watch(btProvider); 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<List<BluetoothDevice>> devicesAsync = _permissionsOk
? ref.watch(pairedDevicesProvider)
: const AsyncValue<List<BluetoothDevice>>.data([]);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View File

@ -38,7 +38,10 @@ class _AnalogGaugeState extends State<AnalogGauge>
super.initState(); super.initState();
_ctrl = AnimationController( _ctrl = AnimationController(
vsync: this, 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<double>(begin: 0, end: _normalized) _anim = Tween<double>(begin: 0, end: _normalized)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
@ -50,7 +53,7 @@ class _AnalogGaugeState extends State<AnalogGauge>
super.didUpdateWidget(old); super.didUpdateWidget(old);
if (old.value != widget.value) { if (old.value != widget.value) {
_anim = Tween<double>(begin: _anim.value, end: _normalized) _anim = Tween<double>(begin: _anim.value, end: _normalized)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); .animate(CurvedAnimation(parent: _ctrl, curve: Curves.linear));
_ctrl.forward(from: 0); _ctrl.forward(from: 0);
} }
} }

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/bluetooth/bt_poller.dart'; import '../../../core/bluetooth/bt_poller.dart';
import '../../../core/logging/app_log.dart';
import '../../../core/providers/bt_provider.dart'; import '../../../core/providers/bt_provider.dart';
import '../../../core/providers/sensor_provider.dart'; import '../../../core/providers/sensor_provider.dart';
import '../../../core/providers/settings_provider.dart'; import '../../../core/providers/settings_provider.dart';
@ -49,6 +50,20 @@ class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('ECU Frame Debug'), title: const Text('ECU Frame Debug'),
actions: [ 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( IconButton(
tooltip: 'Copy raw frame', tooltip: 'Copy raw frame',
icon: const Icon(Icons.copy), icon: const Icon(Icons.copy),
@ -72,8 +87,17 @@ class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
title: 'Connection', title: 'Connection',
rows: [ rows: [
_RowData('Protocol', settings.ecuType.name.toUpperCase()), _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('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'), _RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
if (frame != null) if (frame != null)
_RowData( _RowData(
@ -175,20 +199,23 @@ class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
_RowData('TPS raw @9', f[9].toString()), _RowData('TPS raw @9', f[9].toString()),
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)), _RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
_RowData('Gear raw @27 (decimal)', f[27].toString()), _RowData('Gear raw @27 (decimal)', f[27].toString()),
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()), _RowData('INJ raw @10 LE', _u16le(f, 10).toString()),
_RowData('IGN raw @12 (offset unverified)', f[12].toString()), _RowData('IGN raw @12', f[12].toString()),
_RowData('O2 current raw (offset unverified)', f[16].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('ECT raw @33', f[33].toString()),
_RowData('IAT raw @34', f[34].toString()), _RowData('IAT raw @34', f[34].toString()),
_RowData('BAT raw @36', f[36].toString()), _RowData('BAT raw @36', f[36].toString()),
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()), _RowData('ERR00-03 @37-40',
_RowData('PA candidate raw @50 (unconfirmed, ~3kPa off)', f[50].toString()), '${_b(f, 37)} ${_b(f, 38)} ${_b(f, 39)} ${_b(f, 40)}'),
_RowData('ERR bytes @49-52', _RowData('Eth raw @41', f[41].toString()),
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'), _RowData('AF raw @55', f[55].toString()),
_RowData('SW1 @17 (bit7..0)', '${_b(f, 0x11)} ${_bin(f, 0x11)}'), _RowData('SW1 @17 (bit7..0)', '${_b(f, 17)} ${_bin(f, 17)}'),
_RowData('SW2 @18 (bit7..0)', '${_b(f, 0x12)} ${_bin(f, 0x12)}'), _RowData('SW2 @18 (bit7..0)', '${_b(f, 18)} ${_bin(f, 18)}'),
_RowData('SW3 @19 (bit7..0)', '${_b(f, 0x13)} ${_bin(f, 0x13)}'), _RowData('SW3 @19 (bit7..0)', '${_b(f, 19)} ${_bin(f, 19)}'),
_RowData('SW5 @67 (bit7..0)', '${_b(f, 0x43)} ${_bin(f, 0x43)}'), _RowData('SW5 @49 (bit7..0)', '${_b(f, 49)} ${_bin(f, 49)}'),
_RowData('SW6 @50 (bit7..0)', '${_b(f, 50)} ${_bin(f, 50)}'),
]; ];
} }

View File

@ -78,7 +78,7 @@ class SettingsScreen extends ConsumerWidget {
// ── Polling Interval ───────────────────────────────────── // ── Polling Interval ─────────────────────────────────────
_SectionHeader('POLLING INTERVAL'), _SectionHeader('POLLING INTERVAL'),
...[20, 50, 100, 200].map((ms) => RadioListTile<int>( ...pollingIntervalOptionsMs.map((ms) => RadioListTile<int>(
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'), title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
value: ms, value: ms,
groupValue: settings.pollingInterval.inMilliseconds, groupValue: settings.pollingInterval.inMilliseconds,

View File

@ -7,11 +7,13 @@ class BtStatusChip extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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) { final (color, icon, label) = switch (status) {
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'), BtStatus.connected => (Colors.green, Icons.bluetooth_connected, name ?? 'Connected'),
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'), 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.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
BtStatus.disconnected => (Colors.grey, Icons.bluetooth, 'Disconnected'), BtStatus.disconnected => (Colors.grey, Icons.bluetooth, 'Disconnected'),
}; };

View File

@ -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),
),
),
),
);
}
}

View File

@ -9,6 +9,7 @@ import '../screens/datalog/datalog_list_screen.dart';
import '../screens/debug/raw_frame_debug_screen.dart'; import '../screens/debug/raw_frame_debug_screen.dart';
import '../screens/settings/settings_screen.dart'; import '../screens/settings/settings_screen.dart';
import 'bt_status_chip.dart'; import 'bt_status_chip.dart';
import 'data_status_banner.dart';
import 'record_fab.dart'; import 'record_fab.dart';
final _navIndexProvider = StateProvider<int>((ref) => 0); final _navIndexProvider = StateProvider<int>((ref) => 0);
@ -77,10 +78,17 @@ class MainNav extends ConsumerWidget {
), ),
], ],
), ),
body: IndexedStack( body: Column(
children: [
const DataStatusBanner(),
Expanded(
child: IndexedStack(
index: index, index: index,
children: _screens, children: _screens,
), ),
),
],
),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
currentIndex: index, currentIndex: index,
items: _items, items: _items,

View File

@ -21,18 +21,18 @@ Uint8List _buildKProFrame({
// Header // Header
frame[0] = 0x1B; frame[0] = 0x1B;
frame[1] = 0x01; frame[1] = 0x01;
// RPM: bytes 2..3 // RPM: bytes 2..3, little-endian
frame[2] = (rpm >> 8) & 0xFF; frame[2] = rpm & 0xFF;
frame[3] = rpm & 0xFF; frame[3] = (rpm >> 8) & 0xFF;
// VSS: byte 4 // VSS: byte 4
frame[4] = vss & 0xFF; frame[4] = vss & 0xFF;
// TPS: byte 5 // TPS: byte 5
frame[5] = tps & 0xFF; frame[5] = tps & 0xFF;
// MAP1: byte 6 // MAP1: byte 6
frame[6] = map1 & 0xFF; frame[6] = map1 & 0xFF;
// MAP2: bytes 33..34 // MAP2: bytes 33 (L), 34 (H)
frame[33] = (map2 >> 8) & 0xFF; frame[33] = map2 & 0xFF;
frame[34] = map2 & 0xFF; frame[34] = (map2 >> 8) & 0xFF;
// IGN: byte 13 // IGN: byte 13
frame[13] = ign; frame[13] = ign;
// KRtrd: byte 21 // KRtrd: byte 21

View File

@ -20,6 +20,7 @@ Uint8List _buildS300Frame({
int sw3 = 0, int sw3 = 0,
int krtrd = 0, int krtrd = 0,
int gear = 0, int gear = 0,
Map<int, int> extra = const {},
}) { }) {
final frame = Uint8List(128); final frame = Uint8List(128);
// Header // Header
@ -36,9 +37,9 @@ Uint8List _buildS300Frame({
frame[8] = (mapRaw >> 8) & 0xFF; frame[8] = (mapRaw >> 8) & 0xFF;
// TPS: byte +9 // TPS: byte +9
frame[9] = tps; frame[9] = tps;
// INJ: bytes +10..11, BE // INJ: bytes +10..11, little-endian
frame[10] = (inj >> 8) & 0xFF; frame[10] = inj & 0xFF;
frame[11] = inj & 0xFF; frame[11] = (inj >> 8) & 0xFF;
// IGN: byte +12 // IGN: byte +12
frame[12] = ign; frame[12] = ign;
// KRtrd: byte +13 // KRtrd: byte +13
@ -55,6 +56,7 @@ Uint8List _buildS300Frame({
frame[34] = iat; frame[34] = iat;
// BAT: decimal offset 36 // BAT: decimal offset 36
frame[36] = bat; frame[36] = bat;
extra.forEach((offset, value) => frame[offset] = value);
// Stamp NEG8 checksum at byte 127 // Stamp NEG8 checksum at byte 127
int neg8 = 0; 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', () { test('throws on bad checksum', () {
final frame = _buildS300Frame(rpm: 1000); final frame = _buildS300Frame(rpm: 1000);
frame[127] = 0x00; // corrupt checksum frame[127] = 0x00; // corrupt checksum
@ -230,11 +285,21 @@ void main() {
final frame = _buildS300Frame(rpm: 3000, tps: 127); final frame = _buildS300Frame(rpm: 3000, tps: 127);
expect(isS300TemplateFrame(frame), isFalse); 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 /// 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() { Uint8List _templateFrame() {
final frame = Uint8List(128); final frame = Uint8List(128);
final prefix = [ final prefix = [
@ -259,6 +324,26 @@ Uint8List _templateFrame() {
for (var i = 0; i < prefix.length; i++) { for (var i = 0; i < prefix.length; i++) {
frame[i] = prefix[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 = <int>[
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)); frame[127] = calculateNeg8(frame.sublist(0, 127));
return frame; return frame;
} }