hondavert-dev/lib/core/providers/sensor_provider.dart
metatroncubeswdev 9a56baef61 Rework BT poller and S300 decoding after client feedback
Client reported crashes, wrong/frozen data and that the app floods the
adapter MCU. See docs/FIX_2026-10-12.md for the full analysis.

- Poller: strictly one request outstanding; next request only after a valid
  128-byte reply or a 500 ms timeout, then a quiet period + buffer flush.
  Default 100 ms (10 Hz), minimum 80 ms. DTC clear/probe share the same lock.
- BtNotifier: filter the adapter's default (no-ECU-data) frame instead of
  holding last values; EcuDataStatus + status banner; auto-reconnect on link
  loss, stalls or long default-only runs (rate-limited). A reconnect still in
  flight when the user disconnects is discarded.
- S300 offsets corrected by column counting (trims, PA, ERR00-03, Eth, SW5,
  AF); INJ/AIN little-endian; fan = SW5 FANC only. Default-frame detection
  requires bytes 0-16 and 32-36.
- KPro RPM/MAP2/AIN little-endian (RPM still needs a bench check).
- Crash hardening: zone/Flutter/platform error handlers to an in-app log
  (copyable from Debug); permissions requested before listing devices.
- Remove temporary debug prints; graph redraws live; polling options
  100-300 ms, persisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:02:54 -04:00

96 lines
3.7 KiB
Dart

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