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>
96 lines
3.7 KiB
Dart
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();
|
|
});
|