hondavert-dev/lib/core/providers/sensor_provider.dart
2026-10-13 00:34:11 +00:00

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();
});