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((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((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((ref) { return ref.watch(sensorStateProvider).whenData((s) => s).value ?? SensorState.zero(); });