Dashboard - Selectable main sensor (gauge) + sub sensor, 10 selectable values (landscape 5 left + 5 right, portrait 2x5 on one screen) and 4 selectable flags; long-press to change; persisted. - Flags: the requirement examples (MIL, FAN Out, VTEC Out, Knock, WBO / IAT / CLT Error - errors derived from the stored DTC bits) plus the other S300 status bits (cuts, launch, closed loop, VTP, AC, brake). - Record button moved to the app bar; debug screen moved to Settings. Sensor list: 1 column on phone portrait, 2 on tablet/landscape; name, unit, min/max, segmented bar and value. Graph: auto-scaled Y, window max/min, current value, seconds axis and a touch cursor; RPM/MAP/TPS/ECT by default. Units: km/h|mph, degC|degF, kPa|bar|psi (display only), persisted. Light/dark: palette is a ThemeExtension used by every screen; theme persisted. SafeArea keeps content off the side navigation bar. Datalog playback now drives the dashboard, list, graph and DTC screen, with the playback bar on the main screen and seek updating the displays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
3.9 KiB
Dart
99 lines
3.9 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 'datalog_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 — or, while a datalog plays back, for every recorded frame. Picks the
|
|
/// S300 or KPro parser via [effectiveEcuTypeProvider].
|
|
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
|
final ecuType = ref.watch(effectiveEcuTypeProvider);
|
|
final playing = ref.watch(playbackProvider.select((p) => p.isActive));
|
|
final source = playing
|
|
? ref.watch(datalogPlayerProvider).frameStream
|
|
: ref.watch(btProvider.notifier).frameStream;
|
|
|
|
// `await for` treats an error event as fatal; absorb errors upstream so the
|
|
// loop keeps waiting for the next frame.
|
|
final safeStream = source.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();
|
|
});
|