import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../bluetooth/bt_poller.dart'; import '../logging/app_log.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.select((s) => s.ecuType)); final btNotifier = ref.watch(btProvider.notifier); // `await for` treats an error event as fatal; absorb errors upstream so the // loop keeps waiting for the next frame. final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {}); // 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) { try { SensorState decoded = ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame); decoded = _clampToRealisticRange(decoded); final s = ref.read(decodeStatsProvider); ref.read(decodeStatsProvider.notifier).state = s.copyWith(decoded: s.decoded + 1); yield decoded; } catch (e) { final s = ref.read(decodeStatsProvider); ref.read(decodeStatsProvider.notifier).state = s.copyWith(decodeErrors: s.decodeErrors + 1); if (s.decodeErrors < 5) AppLog.w('Frame decode error: $e'); 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(); });