Dashboard - Selectable main sensor (gauge) + sub sensor, 10 selectable values (landscape 5 left + 5 right, portrait 2x5 on one screen) and 4 selectable flags; long-press to change; persisted. - Flags: the requirement examples (MIL, FAN Out, VTEC Out, Knock, WBO / IAT / CLT Error - errors derived from the stored DTC bits) plus the other S300 status bits (cuts, launch, closed loop, VTP, AC, brake). - Record button moved to the app bar; debug screen moved to Settings. Sensor list: 1 column on phone portrait, 2 on tablet/landscape; name, unit, min/max, segmented bar and value. Graph: auto-scaled Y, window max/min, current value, seconds axis and a touch cursor; RPM/MAP/TPS/ECT by default. Units: km/h|mph, degC|degF, kPa|bar|psi (display only), persisted. Light/dark: palette is a ThemeExtension used by every screen; theme persisted. SafeArea keeps content off the side navigation bar. Datalog playback now drives the dashboard, list, graph and DTC screen, with the playback bar on the main screen and seek updating the displays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
8.5 KiB
Dart
209 lines
8.5 KiB
Dart
import 'dart:typed_data';
|
||
import 'sensor_state.dart';
|
||
import 'temp_table.dart';
|
||
import 'neg8.dart';
|
||
import 'dtc_map.dart';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// S300 datalog frame (128 bytes, response to request 1B 00 E5).
|
||
//
|
||
// BYTE OFFSETS: hvbt_protocol.pdf labels its columns inconsistently
|
||
// ("+26 +27 +28 +29 +2A … +2F +30 +31"). The labels are NOT hex offsets.
|
||
// The real offsets are simply the column count, 0..127. This is proven by
|
||
// the fields already confirmed against live USB captures — Gear=27, ECT=33,
|
||
// IAT=34, BAT=36 — which all match plain column counting, and by the adapter's
|
||
// default frame (below) reproducing the PDF example row byte-for-byte.
|
||
//
|
||
// 0 ID1(1B) 1 ID2(00) 2 DLrate 3-4 RPM 5-6 VSS 7-8 MAP 9 TPS 10-11 INJ
|
||
// 12 IGN 13 KRtrd 14-15 Klvl 16 O2 17 SW1 18 SW2 19 SW3 20 SW4 21-26 ?
|
||
// 27 Gear 28 ? 29 Strim 30 ? 31 Ltrim 32 PA 33 ECT 34 IAT 35 ? 36 BAT
|
||
// 37 ERR00 38 ERR01 39 ERR02 40 ERR03 41 Eth 42-44 ? 45 IATc 46 ECTc 47-48 ?
|
||
// 49 SW5 50 SW6 51-54 ? 55 AF 56 ? 57 TCrtrd 58 TClim 59 ? 60-81 reserved
|
||
// 82-97 AIN0..AIN7 (16-bit) 98-126 reserved 127 NEG8
|
||
//
|
||
// BYTE ORDER: multi-byte values are LITTLE-endian on the wire (confirmed live
|
||
// for RPM/VSS/MAP; the PDF's "Big Endian" note only applies to the request).
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// True when the frame is the adapter's built-in DEFAULT frame — the exact
|
||
/// example values printed in hvbt_protocol.pdf (DLrate 0x14, VSS 0xFFFF,
|
||
/// MAP 0x03F2, TPS 0x18, IGN 0x10, O2 0xC4 …; bytes 32-36 = 93 A0 A0 DC 7D).
|
||
/// The adapter sends it when it has no fresh data from the ECU (typically
|
||
/// because it was asked again before it could finish reading the ECU).
|
||
/// Such a frame carries NO real readings and must not be displayed or logged.
|
||
///
|
||
/// Both spans must match: bytes 0-16 alone could, in principle, coincide with
|
||
/// a real key-on/engine-off reading (RPM 0, MAP 101.0, closed throttle), and a
|
||
/// false positive here hides real data and triggers auto-reconnects.
|
||
bool isS300DefaultFrame(Uint8List frame) {
|
||
if (frame.length != 128) return false;
|
||
const head = <int>[
|
||
0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, //
|
||
0x18, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4,
|
||
];
|
||
for (var i = 0; i < head.length; i++) {
|
||
if (frame[i] != head[i]) return false;
|
||
}
|
||
// PA, ECT, IAT, ?, BAT at 32-36.
|
||
const tail = <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].
|
||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||
/// is true and the NEG8 checksum fails.
|
||
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
||
if (frame.length != 128) {
|
||
throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}');
|
||
}
|
||
if (validateChecksum && !validateFrame(frame)) {
|
||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||
}
|
||
|
||
// ── Confirmed against live captures ────────────────────────────────────
|
||
final double rpm = _u16le(frame, 3).toDouble();
|
||
|
||
final int vssRaw = _u16le(frame, 5);
|
||
final double vss =
|
||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
||
|
||
final double map = _u16le(frame, 7) / 10.0;
|
||
|
||
// Linear 0-255 → 0-100 % (live-confirmed; the PDF formula exceeds 100 %).
|
||
final double tps = (frame[9] * 100.0 / 255.0).clamp(0.0, 100.0);
|
||
|
||
final int gear = frame[27];
|
||
|
||
// Table value minus 40 (live-confirmed; also the default frame's 0xA0
|
||
// decodes to a sensible 20 °C this way).
|
||
final double ect = (tempXlt[frame[33]] - 40).toDouble();
|
||
final double iat = (tempXlt[frame[34]] - 40).toDouble();
|
||
|
||
final double bat = frame[36] * 26.0 / 270.0;
|
||
|
||
// ── Offsets corrected 2026-10-12 (column counting, see header) ─────────
|
||
// Formulas marked UNVERIFIED still need one bench check against the USB
|
||
// tool; they are sanity-checked against the default frame only.
|
||
|
||
// INJ: 16-bit LE (was read big-endian). UNVERIFIED scale: assumes µs.
|
||
final double inj = _u16le(frame, 10) / 1000.0;
|
||
|
||
// IGN: formula from the PDF. UNVERIFIED (one live check disagreed).
|
||
final double ign = (frame[12] + 120) / 2.0;
|
||
|
||
// O2: raw byte. UNVERIFIED scale — shown as raw.
|
||
final double o2 = frame[16].toDouble();
|
||
|
||
final int sw1 = frame[17];
|
||
final int sw2 = frame[18];
|
||
final int sw3 = frame[19];
|
||
final int sw5 = frame[49]; // was read from byte 67 (reserved area)
|
||
|
||
// Fuel trims: 0x80 = 0 %. Default frame has 0x80 in both → 0 %.
|
||
final double strim = (frame[29] - 128) * 100.0 / 128.0; // was byte 41
|
||
final double ltrim = (frame[31] - 128) * 100.0 / 128.0; // was byte 43
|
||
|
||
// Baro: byte 32 (was 44). Honda OBD1 baro ADC formula; default frame's
|
||
// 0x93 → 100.3 kPa (sea level), which supports it. UNVERIFIED on bench.
|
||
final double pa = frame[32] * 0.716 - 5.0;
|
||
|
||
// DTC bytes ERR00..ERR03 = 37..40 (were read from 49..52 = SW5/SW6/…,
|
||
// which produced phantom codes such as P0505/P0350 from SW6 = 0x60).
|
||
final List<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;
|
||
// SW2: ALTC(7) IAB(6) MIL(5) PCS(4) ACCL(3) Fuel(2) VTS(1..0)
|
||
final bool mil = (sw2 & 0x20) != 0;
|
||
// Fuel pump relay, active-LOW (live-confirmed).
|
||
final bool fuelPump = (sw2 & 0x04) == 0;
|
||
final bool vtec = (sw2 & 0x03) != 0;
|
||
// SW5: … CL(2) … FANC(0). Fan output is FANC only — SW2 bit7 is ALTC
|
||
// (alternator control), not the fan.
|
||
final bool fanOut = (sw5 & 0x01) != 0;
|
||
final bool knock = frame[13] > 0; // KRtrd
|
||
// SW3: LnchC(7) LnchR(6) IGNC(5) ShftC(4) BstC(3)
|
||
final bool launch = (sw3 & 0x80) != 0;
|
||
final bool launchRetard = (sw3 & 0x40) != 0;
|
||
final bool ignCut = (sw3 & 0x20) != 0;
|
||
final bool shiftCut = (sw3 & 0x10) != 0;
|
||
final bool boostCut = (sw3 & 0x08) != 0;
|
||
final bool closedLoop = (sw5 & 0x04) != 0; // SW5 CL
|
||
final bool brakeSwitch = (sw1 & 0x04) != 0; // SW1 BKSW
|
||
final bool acSwitch = (sw1 & 0x02) != 0; // SW1 ACSW
|
||
final bool vtp = (sw1 & 0x01) != 0; // SW1 VTP (VTEC pressure)
|
||
|
||
return SensorState(
|
||
rpm: rpm,
|
||
vss: vss,
|
||
map: map,
|
||
tps: tps,
|
||
inj: inj,
|
||
ign: ign,
|
||
ect: ect,
|
||
iat: iat,
|
||
bat: bat,
|
||
o2: o2,
|
||
gear: gear,
|
||
eth: eth,
|
||
pa: pa,
|
||
afr: afr,
|
||
strim: strim,
|
||
ltrim: ltrim,
|
||
ain0: ain0,
|
||
ain1: ain1,
|
||
ain2: ain2,
|
||
ain3: ain3,
|
||
ain4: ain4,
|
||
ain5: ain5,
|
||
ain6: ain6,
|
||
ain7: ain7,
|
||
mil: mil,
|
||
fuelPump: fuelPump,
|
||
fanOut: fanOut,
|
||
vtec: vtec,
|
||
knock: knock,
|
||
revLimit: revLimit,
|
||
launch: launch,
|
||
launchRetard: launchRetard,
|
||
ignCut: ignCut,
|
||
shiftCut: shiftCut,
|
||
boostCut: boostCut,
|
||
closedLoop: closedLoop,
|
||
brakeSwitch: brakeSwitch,
|
||
acSwitch: acSwitch,
|
||
vtp: vtp,
|
||
iatError: anyDtcBit(errBytes, s300IatErrorBits),
|
||
ectError: anyDtcBit(errBytes, s300EctErrorBits),
|
||
o2Error: anyDtcBit(errBytes, s300O2ErrorBits),
|
||
errBytes: errBytes,
|
||
timestamp: DateTime.now(),
|
||
);
|
||
}
|
||
|
||
int _u16le(Uint8List frame, int offset) =>
|
||
frame[offset] | (frame[offset + 1] << 8);
|