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:
parent
e9b1c0f8fd
commit
9a56baef61
209
docs/FIX_2026-10-12.md
Normal file
209
docs/FIX_2026-10-12.md
Normal 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.
|
||||||
|
```
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
42
lib/core/logging/app_log.dart
Normal file
42
lib/core/logging/app_log.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,55 +3,57 @@ 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]
|
||||||
/// is true and the NEG8 checksum fails.
|
/// is true and the NEG8 checksum fails.
|
||||||
@ -63,132 +65,86 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
|||||||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>(
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)}'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -7,13 +7,15 @@ 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'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
53
lib/ui/widgets/data_status_banner.dart
Normal file
53
lib/ui/widgets/data_status_banner.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user