Work that was previously only in the local working tree: - Rename fuelCut -> fuelPump (SW2 bit2, active-low, live-confirmed) - ECT/IAT/BAT moved to live-confirmed offsets 33/34/36 - S300 template-frame detection with hold-last-good logic - Persist ECU type in SharedPreferences; drop per-connect protocol dialog - Official-protocol probe and DTC clear on the debug/DTC screens - Sanity clamp for multi-byte fields; scrollable vertical dashboard - docs/HANDOFF.md Includes temporary [DBG]/[KDBG] prints, removed in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
8.4 KiB
Dart
196 lines
8.4 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../bluetooth/bt_poller.dart';
|
|
import '../models/sensor_defs.dart';
|
|
import '../protocol/kpro_parser.dart';
|
|
import '../protocol/s300_parser.dart';
|
|
import '../protocol/sensor_state.dart';
|
|
import 'bt_provider.dart';
|
|
import 'settings_provider.dart';
|
|
|
|
// NEG8 is only an 8-bit checksum, so roughly 1 in 256 corrupted frames can
|
|
// still pass validation — rare, but enough to occasionally show an
|
|
// impossible value: a single flipped bit in the high byte of a 16-bit field
|
|
// can jump it into the tens of thousands. Clamp just the fields where that's
|
|
// possible (multi-byte reads whose units genuinely match sensorDefs — RPM,
|
|
// VSS, MAP, INJ) to their realistic range, the same bounds the dashboard's
|
|
// gauges already use for normalization. Single-byte fields (TPS, gear, ECT/
|
|
// IAT, etc.) are already bounded to 0-255 raw by construction and can't
|
|
// produce this kind of runaway spike, so they're left alone here.
|
|
SensorState _clampToRealisticRange(SensorState s) {
|
|
double clampFor(String id, double value) {
|
|
final def = sensorDefs.firstWhere((d) => d.id == id);
|
|
return value.clamp(def.min, def.max);
|
|
}
|
|
|
|
return s.copyWith(
|
|
rpm: clampFor('rpm', s.rpm),
|
|
vss: clampFor('vss', s.vss),
|
|
map: clampFor('map', s.map),
|
|
inj: clampFor('inj', s.inj),
|
|
);
|
|
}
|
|
|
|
/// Counts of what sensorStateProvider actually did with each frame it saw,
|
|
/// exposed for the debug screen so the live pipeline can be inspected
|
|
/// directly instead of inferred from symptoms.
|
|
class DecodeStats {
|
|
final int decoded;
|
|
final int decodeErrors;
|
|
|
|
const DecodeStats({
|
|
this.decoded = 0,
|
|
this.decodeErrors = 0,
|
|
});
|
|
|
|
DecodeStats copyWith({int? decoded, int? decodeErrors}) => DecodeStats(
|
|
decoded: decoded ?? this.decoded,
|
|
decodeErrors: decodeErrors ?? this.decodeErrors,
|
|
);
|
|
}
|
|
|
|
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
|
|
|
/// Emits a parsed [SensorState] for every live data frame received from the ECU.
|
|
/// Automatically picks S300 or KPro parser based on [settingsProvider].
|
|
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
|
final ecuType = ref.watch(settingsProvider).ecuType;
|
|
final btNotifier = ref.watch(btProvider.notifier);
|
|
|
|
// `await for` treats any error *event* on the source stream as fatal and
|
|
// exits the loop permanently — a try/catch inside the loop body cannot
|
|
// intercept that, since it happens in the iteration mechanism itself, not
|
|
// the body. handleError() absorbs error events upstream so `await for`
|
|
// never sees them and the loop just keeps waiting for the next frame.
|
|
final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {});
|
|
|
|
SensorState? lastGood;
|
|
int dbgFrameCount = 0; // TEMP: live-debugging session, remove after.
|
|
|
|
await for (final frame in safeStream) {
|
|
// A single bad frame must never take down the rest of the live stream.
|
|
try {
|
|
SensorState decoded =
|
|
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
|
|
decoded = _clampToRealisticRange(decoded);
|
|
|
|
// TEMP: live-debugging session, remove after. Throttled to ~2/sec.
|
|
if (ecuType == EcuType.s300 && (dbgFrameCount++ % 10 == 0)) {
|
|
// ignore: avoid_print
|
|
print('[DBG] template=${isS300TemplateFrame(frame)} '
|
|
'rpm=${decoded.rpm.toStringAsFixed(0)} '
|
|
'gear=${decoded.gear} ect=${decoded.ect.toStringAsFixed(1)} '
|
|
'fuelPump=${decoded.fuelPump} mil=${decoded.mil} '
|
|
'vtec=${decoded.vtec} fan=${decoded.fanOut} '
|
|
'sw2=0x${frame[18].toRadixString(16).padLeft(2, '0')}');
|
|
}
|
|
|
|
// TEMP: KPro live-debugging session, remove after. Compact decode line
|
|
// ~1/sec, plus a full raw hex dump ~every 3s so any offset/formula can
|
|
// be recomputed straight from logcat without a rebuild.
|
|
if (ecuType == EcuType.kpro) {
|
|
final n = dbgFrameCount++;
|
|
String h(int o) => o < frame.length
|
|
? frame[o].toRadixString(16).padLeft(2, '0')
|
|
: '--';
|
|
String bin(int o) =>
|
|
o < frame.length ? frame[o].toRadixString(2).padLeft(8, '0') : '--';
|
|
if (n % 20 == 0) {
|
|
// ignore: avoid_print
|
|
print('[KDBG] rpm=${decoded.rpm.toStringAsFixed(0)} '
|
|
'vss=${decoded.vss.toStringAsFixed(0)} '
|
|
'tps=${decoded.tps.toStringAsFixed(1)} '
|
|
'map=${decoded.map.toStringAsFixed(1)} '
|
|
'inj=${decoded.inj.toStringAsFixed(0)} '
|
|
'ign=${decoded.ign.toStringAsFixed(1)} '
|
|
'o2=${decoded.o2.toStringAsFixed(0)} '
|
|
'ect=${decoded.ect.toStringAsFixed(1)} '
|
|
'iat=${decoded.iat.toStringAsFixed(1)} '
|
|
'pa=${decoded.pa.toStringAsFixed(1)} '
|
|
'bat=${decoded.bat.toStringAsFixed(2)} '
|
|
'afr=${decoded.afr.toStringAsFixed(1)} '
|
|
'strim=${decoded.strim.toStringAsFixed(0)} '
|
|
'| mil=${decoded.mil} pump=${decoded.fuelPump} '
|
|
'fan=${decoded.fanOut} vtec=${decoded.vtec} knock=${decoded.knock} '
|
|
'| raw b5=${h(5)} b6=${h(6)} b7=${h(7)} b8=${h(8)} b11=${h(11)} '
|
|
'b12=${h(12)} b13=${h(13)} b14=${h(14)} b19=${h(19)} b20=${h(20)} '
|
|
'b21=${h(21)} b25=${h(25)} b33=${h(33)} b34=${h(34)} b49=${h(49)} '
|
|
'b50=${h(50)} b51=${h(51)} b52=${h(52)} '
|
|
'sw1=${bin(31)} sw2=${bin(32)} inf=${bin(60)}');
|
|
}
|
|
if (n % 60 == 0) {
|
|
final sb = StringBuffer('[KHEX] ');
|
|
for (var i = 0; i < frame.length; i++) {
|
|
sb.write(frame[i].toRadixString(16).padLeft(2, '0'));
|
|
if (i != frame.length - 1) sb.write(' ');
|
|
}
|
|
// ignore: avoid_print
|
|
print(sb.toString());
|
|
}
|
|
}
|
|
|
|
if (ecuType == EcuType.s300 && isS300TemplateFrame(frame)) {
|
|
// Bytes 0-16 in this frame are fixed template filler, not a real
|
|
// reading (see isS300TemplateFrame) — hold the last known-good
|
|
// values for the fields sourced from that span. The frozen region
|
|
// turned out to extend further than originally thought: a live
|
|
// session showed the literal byte sequence behind gear/ECT/IAT/BAT
|
|
// (`93 a0 a0 dc 7d` at offsets 32-36) recurring identically dozens
|
|
// of times regardless of the true values, so those are held too.
|
|
//
|
|
// SW2-derived flags (fuelPump, mil, vtec, fanOut's ALTC bit) are
|
|
// held now too: a user report of fuelPump flipping to a wrong value
|
|
// during an idle period with nothing changed on the bench — right
|
|
// when everything else was also frozen — indicates SW2 isn't
|
|
// reliably live during template frames either, similar to gear/ECT/
|
|
// IAT/BAT. This reverses the earlier assumption (based on SW1,
|
|
// itself later found to have been checked against a non-template
|
|
// frame) that "bytes 17+ stay live" — that no longer looks safe to
|
|
// rely on for anything not individually re-verified.
|
|
if (lastGood != null) {
|
|
decoded = decoded.copyWith(
|
|
rpm: lastGood.rpm,
|
|
vss: lastGood.vss,
|
|
map: lastGood.map,
|
|
tps: lastGood.tps,
|
|
inj: lastGood.inj,
|
|
ign: lastGood.ign,
|
|
knock: lastGood.knock,
|
|
o2: lastGood.o2,
|
|
gear: lastGood.gear,
|
|
ect: lastGood.ect,
|
|
iat: lastGood.iat,
|
|
bat: lastGood.bat,
|
|
fuelPump: lastGood.fuelPump,
|
|
mil: lastGood.mil,
|
|
vtec: lastGood.vtec,
|
|
fanOut: lastGood.fanOut,
|
|
);
|
|
}
|
|
} else {
|
|
// Clamped above, so a corrupted spike is never remembered as a good
|
|
// value to hold into a later template frame.
|
|
lastGood = decoded;
|
|
}
|
|
|
|
final s = ref.read(decodeStatsProvider);
|
|
ref.read(decodeStatsProvider.notifier).state =
|
|
s.copyWith(decoded: s.decoded + 1);
|
|
yield decoded;
|
|
} catch (_) {
|
|
// Skip this one frame and keep the stream alive for the next.
|
|
final s = ref.read(decodeStatsProvider);
|
|
ref.read(decodeStatsProvider.notifier).state =
|
|
s.copyWith(decodeErrors: s.decodeErrors + 1);
|
|
continue;
|
|
}
|
|
}
|
|
});
|
|
|
|
/// Last successfully parsed sensor state (never null after first frame).
|
|
/// Falls back to SensorState.zero() before any data arrives.
|
|
final latestSensorProvider = Provider<SensorState>((ref) {
|
|
return ref.watch(sensorStateProvider).whenData((s) => s).value ??
|
|
SensorState.zero();
|
|
});
|