the working code s300, with few glitch by resetting to 0
This commit is contained in:
parent
375c2510ae
commit
77a47eb938
@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=512m
|
||||
android.useAndroidX=true
|
||||
kotlin.incremental=false
|
||||
org.gradle.daemon=false
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@ -31,6 +31,15 @@ class BtPoller {
|
||||
int droppedFrames = 0;
|
||||
int validFrames = 0;
|
||||
|
||||
// Tracks whether a request is still awaiting its response, so the poller
|
||||
// never pipelines more than one request ahead of the device. Without this,
|
||||
// a device that answers slower than [pollingInterval] falls permanently
|
||||
// behind — every displayed frame ends up answering a stale, already-queued
|
||||
// request instead of the most recent one, and the dashboard visibly lags
|
||||
// real engine changes by a growing amount.
|
||||
bool _awaitingResponse = false;
|
||||
DateTime? _lastRequestSentAt;
|
||||
|
||||
BtPoller(
|
||||
this._service, {
|
||||
this.ecuType = EcuType.s300,
|
||||
@ -39,6 +48,8 @@ class BtPoller {
|
||||
|
||||
void start() {
|
||||
_rxBuf.clear();
|
||||
_awaitingResponse = false;
|
||||
_lastRequestSentAt = null;
|
||||
|
||||
// Listen to raw bytes from BT and accumulate into 128-byte frames
|
||||
_rxSub = _service.rawStream.listen(
|
||||
@ -52,13 +63,28 @@ class BtPoller {
|
||||
},
|
||||
);
|
||||
|
||||
// Send request at polling interval
|
||||
_pollTimer = Timer.periodic(pollingInterval, (_) async {
|
||||
if (_service.isConnected) {
|
||||
await _service.write(
|
||||
ecuType == EcuType.s300 ? _s300Request : _kproRequest,
|
||||
);
|
||||
}
|
||||
// Check every pollingInterval whether it's time to send the next
|
||||
// request — but only actually send once the prior one has been
|
||||
// answered (or has clearly been lost).
|
||||
_pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest());
|
||||
_maybeSendRequest();
|
||||
}
|
||||
|
||||
void _maybeSendRequest() {
|
||||
if (!_service.isConnected) return;
|
||||
|
||||
final stalled = _lastRequestSentAt != null &&
|
||||
DateTime.now().difference(_lastRequestSentAt!) > pollingInterval * 4;
|
||||
if (_awaitingResponse && !stalled) return;
|
||||
|
||||
_awaitingResponse = true;
|
||||
_lastRequestSentAt = DateTime.now();
|
||||
_service
|
||||
.write(ecuType == EcuType.s300 ? _s300Request : _kproRequest)
|
||||
.catchError((Object _) {
|
||||
// A failed write must not leave the poller permanently waiting for a
|
||||
// response that was never actually requested — let the next tick retry.
|
||||
_awaitingResponse = false;
|
||||
});
|
||||
}
|
||||
|
||||
@ -88,15 +114,16 @@ class BtPoller {
|
||||
Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128));
|
||||
|
||||
final checksumOk = validateFrame(frame);
|
||||
final canUseFrame = checksumOk || ecuType == EcuType.s300;
|
||||
|
||||
if (canUseFrame) {
|
||||
if (!checksumOk) droppedFrames++;
|
||||
if (checksumOk) {
|
||||
validFrames++;
|
||||
latestUsableFrame = frame;
|
||||
_rxBuf.removeRange(0, startIdx + 128);
|
||||
} else {
|
||||
// Bad checksum — skip this byte and try again
|
||||
// Bad checksum — the window is misaligned (or the frame was
|
||||
// corrupted in transit). Drop one byte and rescan for the next
|
||||
// 0x1B so the stream resynchronizes instead of permanently
|
||||
// sliding a fixed-size window over garbage.
|
||||
droppedFrames++;
|
||||
_rxBuf.removeRange(0, startIdx + 1);
|
||||
}
|
||||
@ -104,6 +131,17 @@ class BtPoller {
|
||||
|
||||
if (latestUsableFrame != null) {
|
||||
_frameController.add(latestUsableFrame);
|
||||
// The device answered — clear the road for the next request. Wait out
|
||||
// the configured pollingInterval before sending it rather than firing
|
||||
// immediately: back-to-back requests with no gap can outrun the ECU's
|
||||
// own tach input-capture timing, which needs a moment to latch a fresh
|
||||
// RPM count between reads — hammering it faster than that makes it
|
||||
// fall back to reporting "no fresh count yet" on every subsequent
|
||||
// request instead of a real reading. pollingInterval is also what the
|
||||
// Settings screen exposes, so this makes that control actually pace
|
||||
// the link instead of just being a stalled-request fallback.
|
||||
_awaitingResponse = false;
|
||||
Timer(pollingInterval, _maybeSendRequest);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,13 +4,13 @@ import 'flag_def.dart';
|
||||
/// Master list of all analog sensor definitions.
|
||||
const List<SensorDef> sensorDefs = [
|
||||
SensorDef(id: 'rpm', displayName: 'RPM', unit: 'revs', min: 0, max: 9000),
|
||||
SensorDef(id: 'vss', displayName: 'Speed', unit: 'mph', min: 0, max: 180),
|
||||
SensorDef(id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280),
|
||||
SensorDef(
|
||||
id: 'map',
|
||||
displayName: 'Manifold pressure',
|
||||
unit: 'psi',
|
||||
min: -30,
|
||||
max: 30),
|
||||
unit: 'kPa',
|
||||
min: 0,
|
||||
max: 300),
|
||||
SensorDef(
|
||||
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
||||
SensorDef(
|
||||
|
||||
@ -44,11 +44,13 @@ SensorState parseKPro(Uint8List frame) {
|
||||
final int sw1 = frame[31];
|
||||
final int sw2 = frame[32];
|
||||
|
||||
// ECT: byte 49
|
||||
final double ect = (tempXlt[frame[49]] + 40).toDouble();
|
||||
// ECT: byte 49. Table is tempXlt[raw] - 40, not +40 — see s300_parser.dart
|
||||
// for the reasoning (the declared -40..150 degC sensor range is only
|
||||
// reachable with subtraction).
|
||||
final double ect = (tempXlt[frame[49]] - 40).toDouble();
|
||||
|
||||
// IAT: byte 50
|
||||
final double iat = (tempXlt[frame[50]] + 40).toDouble();
|
||||
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
||||
|
||||
// PA: byte 51
|
||||
final double pa = frame[51].toDouble();
|
||||
|
||||
@ -3,35 +3,6 @@ import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
|
||||
bool isS300PlaceholderFrame(Uint8List frame) {
|
||||
if (frame.length != 128) return false;
|
||||
|
||||
// Diagnostic signature observed in the S300 Bluetooth stream. Do not use
|
||||
// this to drop frames globally; current captures show real changing data can
|
||||
// still be carried in frames with this prefix.
|
||||
return frame[0] == 0x1B &&
|
||||
frame[1] == 0x00 &&
|
||||
frame[2] == 0x14 &&
|
||||
frame[3] == 0x00 &&
|
||||
frame[4] == 0x00 &&
|
||||
frame[5] == 0xFF &&
|
||||
frame[6] == 0xFF &&
|
||||
frame[7] == 0xF2 &&
|
||||
frame[8] == 0x03 &&
|
||||
frame[9] == 0x18 &&
|
||||
frame[10] == 0x00 &&
|
||||
frame[11] == 0x00 &&
|
||||
frame[12] == 0x10 &&
|
||||
frame[13] == 0x00 &&
|
||||
frame[14] == 0x00 &&
|
||||
frame[15] == 0x00 &&
|
||||
frame[16] == 0xC4;
|
||||
}
|
||||
|
||||
bool hasS300TailRpmPayload(Uint8List frame) {
|
||||
return _s300TailRpmRaw(frame) != null;
|
||||
}
|
||||
|
||||
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||||
/// is true and the NEG8 checksum fails.
|
||||
@ -43,67 +14,72 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
||||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// Some S300 Bluetooth frames carry live RPM in the tail payload,
|
||||
// while the header bytes stay fixed at 1b 00 14 00 and decode to a bogus
|
||||
// 4864 rpm. Prefer the tail value when it looks like real engine speed.
|
||||
final int? tailRpmRaw = _s300TailRpmRaw(frame);
|
||||
final int rpmRaw = tailRpmRaw ?? _readUint16BE(frame, 2);
|
||||
final double rpm = (tailRpmRaw != null ? rpmRaw - 1089 : rpmRaw - 256)
|
||||
.clamp(0, 12000)
|
||||
.toDouble();
|
||||
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live
|
||||
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while
|
||||
// REQUIREMENTS.md's blanket "all multi-byte values are Big Endian" claim
|
||||
// did not hold for this field on real hardware.
|
||||
final double rpm = _readUint16LE(frame, 3).toDouble();
|
||||
|
||||
// VSS: bytes 5..6 little-endian pulse period.
|
||||
// VSS: bytes +5..6, little-endian (confirmed against a live capture).
|
||||
final int vssRaw = _readUint16LE(frame, 5);
|
||||
final double vss =
|
||||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 144256.0 / vssRaw;
|
||||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
||||
|
||||
// MAP: bytes 7..8 little-endian. SManager displays this as gauge pressure
|
||||
// in its "/psi column rather than raw absolute kPa.
|
||||
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the
|
||||
// big-endian read produced multi-thousand-kPa values, physically
|
||||
// impossible for a 0-300 kPa sensor).
|
||||
final int mapRaw = _readUint16LE(frame, 7);
|
||||
final double map = ((mapRaw / 10.0) - 103.8) * 0.1450377377;
|
||||
final double map = mapRaw / 10.0;
|
||||
|
||||
// TPS: byte 9
|
||||
// TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture
|
||||
// (raw 127 matched a reference tool's ~50% reading; the spec's
|
||||
// "raw*51/46" formula produced impossible >100% values on the same byte).
|
||||
final int tpsRaw = frame[9];
|
||||
final double tps = ((tpsRaw - 25) * 100.0 / (239 - 25)).clamp(0.0, 100.0);
|
||||
final double tps = (tpsRaw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
|
||||
// INJ: bytes 10..11 little-endian timer ticks.
|
||||
final int injRaw = _readUint16LE(frame, 10);
|
||||
final double inj = injRaw / 240.5;
|
||||
// INJ: bytes +10..11, BE, raw ms.
|
||||
final double inj = _readUint16BE(frame, 10).toDouble();
|
||||
|
||||
// IGN: byte 12 — SManager-aligned degrees.
|
||||
final double ign = (frame[12] - 66) / 2.0;
|
||||
// IGN: byte +12.
|
||||
final double ign = (frame[12] + 120) / 2.0;
|
||||
|
||||
// O2: byte 16, 0..5V scaled over 8-bit ADC range.
|
||||
final double o2 = frame[16] * 5.0 / 256.0;
|
||||
// O2: byte +16, raw.
|
||||
final double o2 = frame[16].toDouble();
|
||||
|
||||
// SW bitmaps
|
||||
final int sw1 = frame[0x11]; // +17
|
||||
final int sw2 = frame[0x12]; // +18
|
||||
final int sw3 = frame[0x13]; // +19
|
||||
final int sw5 = frame[0x43]; // +67
|
||||
final int sw5 = frame[0x43]; // +43
|
||||
|
||||
// Gear: byte 0x27 = +39 decimal... wait, +27 hex = decimal 39
|
||||
final int gear = frame[0x27];
|
||||
// Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39
|
||||
// read 0x00 there; decimal 27 matched a reference tool's gear exactly).
|
||||
final int gear = frame[27];
|
||||
|
||||
// Strim: byte 0x29 hex = 41 decimal
|
||||
// Strim: +29 hex = 41 decimal
|
||||
final double strim = frame[0x29].toDouble();
|
||||
|
||||
// Ltrim: byte 0x2B hex = 43 decimal
|
||||
// Ltrim: +2B hex = 43 decimal
|
||||
final double ltrim = frame[0x2B].toDouble();
|
||||
|
||||
// PA: byte 0x3C hex = 60 decimal
|
||||
final double pa = frame[0x3C].toDouble();
|
||||
// PA: +2C hex = 44 decimal
|
||||
final double pa = frame[0x2C].toDouble();
|
||||
|
||||
// ECT: byte 0x2D hex = 45 decimal
|
||||
final double ect = (tempXlt[frame[0x2D]] + 40).toDouble();
|
||||
// ECT: +2D hex = 45 decimal. Table is tempXlt[raw] - 40, not +40: the
|
||||
// spec's own sensor range (section 3: ECT/IAT -40..150 degC) is only
|
||||
// reachable with subtraction, since tempXlt entries span 0..190. The
|
||||
// written "+40" formula can only ever produce 40..230 degC, which
|
||||
// contradicts that declared range and explains the ~80 degC-too-hot
|
||||
// readings seen against real hardware.
|
||||
final double ect = (tempXlt[frame[0x2D]] - 40).toDouble();
|
||||
|
||||
// IAT: byte 0x2E hex = 46 decimal
|
||||
final double iat = (tempXlt[frame[0x2E]] + 40).toDouble();
|
||||
// IAT: +2E hex = 46 decimal
|
||||
final double iat = (tempXlt[frame[0x2E]] - 40).toDouble();
|
||||
|
||||
// BAT: byte 0x24 hex = 36 decimal
|
||||
final double bat = frame[0x24] * 26.0 / 270.0;
|
||||
// BAT: +30 hex = 48 decimal
|
||||
final double bat = frame[0x30] * 26.0 / 270.0;
|
||||
|
||||
// ERR bytes: 0x31..0x34 (4 bytes for S300)
|
||||
// ERR bytes: +31..+34 hex = 49..52 decimal
|
||||
final List<int> errBytes = [
|
||||
frame[0x31],
|
||||
frame[0x32],
|
||||
@ -111,10 +87,10 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
||||
frame[0x34],
|
||||
];
|
||||
|
||||
// Eth: byte 0x38 hex = 56 decimal
|
||||
// Eth: +38 hex = 56 decimal
|
||||
final double eth = frame[0x38].toDouble();
|
||||
|
||||
// AFR: byte 0x34 overlaps with ERR03 in the spec — use it as raw AFR
|
||||
// AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table.
|
||||
final double afr = frame[0x34].toDouble();
|
||||
|
||||
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each)
|
||||
@ -187,12 +163,3 @@ int _readUint16BE(Uint8List frame, int offset) {
|
||||
int _readUint16LE(Uint8List frame, int offset) {
|
||||
return frame[offset] | (frame[offset + 1] << 8);
|
||||
}
|
||||
|
||||
int? _s300TailRpmRaw(Uint8List frame) {
|
||||
if (!isS300PlaceholderFrame(frame)) return null;
|
||||
for (final offset in const [82, 86]) {
|
||||
final rpm = _readUint16LE(frame, offset);
|
||||
if (rpm > 300 && rpm < 12000) return rpm;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
// Shared temperature lookup table for S300 and KPro ECU protocols.
|
||||
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] + 40 (°C).
|
||||
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C).
|
||||
// (Subtraction, not addition: table entries span 0..190, and only
|
||||
// subtracting 40 reaches the sensor's declared -40..150°C range.)
|
||||
const List<int> tempXlt = [
|
||||
190, 190, 188, 186, 183, 181, 179, 177, 175, 172, 170, 168, 166, 163, 161, 159,
|
||||
157, 155, 153, 151, 149, 146, 144, 142, 140, 138, 137, 136, 134, 133, 132, 130,
|
||||
|
||||
@ -7,20 +7,82 @@ import '../protocol/sensor_state.dart';
|
||||
import 'bt_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
/// 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());
|
||||
|
||||
// Bluetooth's round-trip jitter can make an individual request land outside
|
||||
// the ECU's transient tach pulse-capture window, so a poll reports "no
|
||||
// fresh count" (rpm=0) even though the engine/bench signal never actually
|
||||
// changed, and jitter runs can span more than a couple of polls. Hold the
|
||||
// last real RPM for this long (measured in wall-clock time, not frame count,
|
||||
// since frame count at the same real gap would mean a different actual delay
|
||||
// at every polling-interval setting) before accepting a run of zeros as a
|
||||
// genuine stop.
|
||||
const _rpmHoldDuration = Duration(milliseconds: 1000);
|
||||
|
||||
/// 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).ecuType;
|
||||
final btNotifier = ref.watch(btProvider.notifier);
|
||||
|
||||
await for (final frame in btNotifier.frameStream) {
|
||||
if (ecuType == EcuType.s300) {
|
||||
if (isS300PlaceholderFrame(frame) && !hasS300TailRpmPayload(frame)) {
|
||||
continue;
|
||||
// `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 __) {});
|
||||
|
||||
double lastGoodRpm = 0;
|
||||
DateTime? lastGoodRpmAt;
|
||||
|
||||
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);
|
||||
|
||||
if (decoded.rpm > 0) {
|
||||
lastGoodRpm = decoded.rpm;
|
||||
lastGoodRpmAt = decoded.timestamp;
|
||||
} else if (lastGoodRpm > 0) {
|
||||
// Anchored to the last real reading rather than refreshed on every
|
||||
// held frame, so a genuine stop is accepted once this much wall-clock
|
||||
// time has passed regardless of how many zero polls occurred in it.
|
||||
if (decoded.timestamp.difference(lastGoodRpmAt!) < _rpmHoldDuration) {
|
||||
decoded = decoded.copyWith(rpm: lastGoodRpm);
|
||||
} else {
|
||||
lastGoodRpm = 0;
|
||||
}
|
||||
}
|
||||
yield parseS300(frame, validateChecksum: false);
|
||||
} else {
|
||||
yield parseKPro(frame);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -16,7 +16,9 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bt = ref.watch(btProvider);
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final sensorAsync = ref.watch(sensorStateProvider);
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final decodeStats = ref.watch(decodeStatsProvider);
|
||||
final frame = bt.lastFrame;
|
||||
final hex = frame == null ? '' : _hex(frame);
|
||||
|
||||
@ -47,17 +49,38 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
||||
title: 'Connection',
|
||||
rows: [
|
||||
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
||||
_RowData('Frames', bt.frameCount.toString()),
|
||||
_RowData('Dropped', bt.droppedFrames.toString()),
|
||||
_RowData('Frames (raw, from poller)', bt.frameCount.toString()),
|
||||
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
||||
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
||||
if (frame != null)
|
||||
_RowData(
|
||||
'Checksum',
|
||||
validateFrame(frame) ? 'valid' : 'bad, accepted for S300',
|
||||
validateFrame(frame) ? 'valid' : 'bad',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Decode Pipeline',
|
||||
rows: [
|
||||
_RowData('Decoded -> UI', decodeStats.decoded.toString()),
|
||||
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
|
||||
_RowData(
|
||||
'sensorStateProvider raw',
|
||||
sensorAsync.when(
|
||||
data: (s) =>
|
||||
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
|
||||
loading: () => 'loading (no frame yet)',
|
||||
error: (e, st) => 'error: $e',
|
||||
),
|
||||
),
|
||||
_RowData(
|
||||
'latestSensorProvider age',
|
||||
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Current App Decode',
|
||||
rows: _decodedRows(state),
|
||||
@ -90,8 +113,8 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
||||
|
||||
List<_RowData> _decodedRows(SensorState s) => [
|
||||
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
||||
_RowData('Speed mph', s.vss.toStringAsFixed(2)),
|
||||
_RowData('MAP psi', s.map.toStringAsFixed(2)),
|
||||
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
||||
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
||||
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
||||
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
||||
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
||||
@ -101,53 +124,46 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
||||
];
|
||||
|
||||
List<_RowData> _s300CandidateRows(Uint8List f) {
|
||||
final rpmBe = _u16be(f, 2);
|
||||
final rpmShiftedBe = _u16be(f, 3);
|
||||
final vssBe = _u16be(f, 5);
|
||||
final rpmLe = _u16le(f, 3);
|
||||
final vssLe = _u16le(f, 5);
|
||||
final mapBe = _u16be(f, 7);
|
||||
final mapLe = _u16le(f, 7);
|
||||
|
||||
return [
|
||||
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
||||
_RowData('RPM @2 BE', rpmBe.toString()),
|
||||
_RowData('RPM shifted @3 BE', rpmShiftedBe.toString()),
|
||||
_RowData('VSS raw @5 BE / LE', '$vssBe / $vssLe'),
|
||||
_RowData('VSS current LE mph', _s300Vss(vssLe).toStringAsFixed(2)),
|
||||
_RowData('MAP raw @7 BE / LE', '$mapBe / $mapLe'),
|
||||
_RowData('MAP current psi', _s300Map(mapLe).toStringAsFixed(2)),
|
||||
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
||||
_RowData('VSS raw @5 LE', vssLe.toString()),
|
||||
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
||||
_RowData('MAP raw @7 LE', mapLe.toString()),
|
||||
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
||||
_RowData('TPS raw @9', f[9].toString()),
|
||||
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
||||
_RowData('INJ raw @10 LE', _u16le(f, 10).toString()),
|
||||
_RowData('INJ current ms', (_u16le(f, 10) / 240.5).toStringAsFixed(2)),
|
||||
_RowData('IGN raw @12', f[12].toString()),
|
||||
_RowData('IGN current deg', ((f[12] - 66) / 2.0).toStringAsFixed(2)),
|
||||
_RowData('O2 current V', (f[16] * 5.0 / 256.0).toStringAsFixed(2)),
|
||||
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
||||
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()),
|
||||
_RowData('IGN raw @12 (offset unverified)', f[12].toString()),
|
||||
_RowData('O2 current raw (offset unverified)', f[16].toString()),
|
||||
_RowData('ECT raw @45', f[0x2D].toString()),
|
||||
_RowData('IAT raw @46', f[0x2E].toString()),
|
||||
_RowData('BAT raw @36', f[0x24].toString()),
|
||||
_RowData('BAT current V', (f[0x24] * 26.0 / 270.0).toStringAsFixed(2)),
|
||||
_RowData('PA raw @60', f[0x3C].toString()),
|
||||
_RowData('BAT raw @48 (offset unverified)', f[0x30].toString()),
|
||||
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
|
||||
_RowData('ERR bytes @49-52',
|
||||
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'),
|
||||
];
|
||||
}
|
||||
|
||||
static int _u16be(Uint8List f, int o) => (f[o] << 8) | f[o + 1];
|
||||
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
||||
static String _b(Uint8List f, int o) =>
|
||||
f[o].toRadixString(16).padLeft(2, '0');
|
||||
|
||||
static double _s300Vss(int raw) {
|
||||
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 144256.0 / raw;
|
||||
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
|
||||
}
|
||||
|
||||
static double _s300Map(int raw) {
|
||||
return ((raw / 10.0) - 103.8) * 0.1450377377;
|
||||
return raw / 10.0;
|
||||
}
|
||||
|
||||
static double _s300Tps(int raw) {
|
||||
return raw < 25 ? 0.0 : raw * 51.0 / 46.0;
|
||||
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
static String _hex(Uint8List frame) {
|
||||
|
||||
@ -244,10 +244,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -569,10 +569,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -11,7 +11,7 @@ Uint8List _buildKProFrame({
|
||||
int map1 = 0,
|
||||
int map2 = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 113°C
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 33°C
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
@ -89,10 +89,10 @@ void main() {
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 113°C
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 33°C
|
||||
final frame = _buildKProFrame(ect: 0x80);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.ect, equals((tempXlt[0x80] + 40).toDouble()));
|
||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw / 10', () {
|
||||
|
||||
@ -12,44 +12,46 @@ Uint8List _buildS300Frame({
|
||||
int tps = 0,
|
||||
int inj = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 73+40=113°C
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int sw3 = 0,
|
||||
int krtrd = 0,
|
||||
int gear = 0,
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x00;
|
||||
// RPM: bytes 2..3, raw stream is display RPM + 256
|
||||
final rpmRaw = rpm + 256;
|
||||
frame[2] = (rpmRaw >> 8) & 0xFF;
|
||||
frame[3] = rpmRaw & 0xFF;
|
||||
// VSS: bytes 5..6 little-endian
|
||||
// RPM: bytes +3..4, little-endian, raw
|
||||
frame[3] = rpm & 0xFF;
|
||||
frame[4] = (rpm >> 8) & 0xFF;
|
||||
// VSS: bytes +5..6, little-endian
|
||||
frame[5] = vss & 0xFF;
|
||||
frame[6] = (vss >> 8) & 0xFF;
|
||||
// MAP: bytes 7..8 little-endian
|
||||
// MAP: bytes +7..8, little-endian
|
||||
frame[7] = mapRaw & 0xFF;
|
||||
frame[8] = (mapRaw >> 8) & 0xFF;
|
||||
// TPS: byte 9
|
||||
// TPS: byte +9
|
||||
frame[9] = tps;
|
||||
// INJ: bytes 10..11 little-endian
|
||||
frame[10] = inj & 0xFF;
|
||||
frame[11] = (inj >> 8) & 0xFF;
|
||||
// IGN: byte 12
|
||||
// INJ: bytes +10..11, BE
|
||||
frame[10] = (inj >> 8) & 0xFF;
|
||||
frame[11] = inj & 0xFF;
|
||||
// IGN: byte +12
|
||||
frame[12] = ign;
|
||||
// KRtrd: byte 13
|
||||
// KRtrd: byte +13
|
||||
frame[13] = krtrd;
|
||||
// SW bitmaps
|
||||
frame[0x11] = sw1;
|
||||
frame[0x12] = sw2;
|
||||
frame[0x13] = sw3;
|
||||
// ECT: byte 0x2D
|
||||
// Gear: decimal offset 27
|
||||
frame[27] = gear;
|
||||
// ECT: byte +2D
|
||||
frame[0x2D] = ect;
|
||||
// BAT: byte 0x24
|
||||
frame[0x24] = bat;
|
||||
// BAT: byte +30
|
||||
frame[0x30] = bat;
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
@ -76,37 +78,49 @@ void main() {
|
||||
});
|
||||
|
||||
test('parses VSS correctly when raw >= 893', () {
|
||||
// vss = 144256 / 1000 = 144.256 mph
|
||||
// vss = 228480 / 1000 = 228.48 km/h
|
||||
final frame = _buildS300Frame(vss: 1000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, closeTo(144.26, 0.01));
|
||||
expect(state.vss, closeTo(228.48, 0.01));
|
||||
});
|
||||
|
||||
test('parses MAP as SManager-style psi', () {
|
||||
// mapRaw = 1148 -> 114.8 kPa absolute -> about 1.6 psi displayed
|
||||
test('parses MAP as raw/10 kPa', () {
|
||||
// mapRaw = 1148 -> 114.8 kPa
|
||||
final frame = _buildS300Frame(mapRaw: 1148);
|
||||
final state = parseS300(frame);
|
||||
expect(state.map, closeTo(1.6, 0.01));
|
||||
expect(state.map, closeTo(114.8, 0.01));
|
||||
});
|
||||
|
||||
test('parses TPS = 0 when raw < 25', () {
|
||||
final frame = _buildS300Frame(tps: 10);
|
||||
test('parses TPS = 0 when raw = 0', () {
|
||||
final frame = _buildS300Frame(tps: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses TPS correctly when raw >= 25', () {
|
||||
// tps raw=134 -> approximately 51%
|
||||
final frame = _buildS300Frame(tps: 134);
|
||||
test('parses TPS = 100 when raw = 255', () {
|
||||
final frame = _buildS300Frame(tps: 255);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, closeTo(50.93, 0.01));
|
||||
expect(state.tps, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses TPS correctly when raw >= 25', () {
|
||||
// tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture)
|
||||
final frame = _buildS300Frame(tps: 127);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, closeTo(49.8, 0.1));
|
||||
});
|
||||
|
||||
test('parses Gear from decimal offset 27', () {
|
||||
final frame = _buildS300Frame(gear: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.gear, equals(5));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 73+40=113°C
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 73-40=33°C
|
||||
final frame = _buildS300Frame(ect: 0x80);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ect, equals((tempXlt[0x80] + 40).toDouble()));
|
||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw * 26 / 270', () {
|
||||
@ -117,10 +131,10 @@ void main() {
|
||||
});
|
||||
|
||||
test('IGN decode: (raw + 120) / 2', () {
|
||||
// ign raw = 164 -> 49.0°
|
||||
// ign raw = 164 -> (164 + 120) / 2 = 142.0
|
||||
final frame = _buildS300Frame(ign: 164);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ign, equals(49.0));
|
||||
expect(state.ign, equals(142.0));
|
||||
});
|
||||
|
||||
test('MIL flag set from SW2 bit5', () {
|
||||
@ -161,36 +175,13 @@ void main() {
|
||||
expect(state.rpm, equals(1000.0));
|
||||
});
|
||||
|
||||
test('detects known S300 prefix signature', () {
|
||||
final frame = Uint8List(128);
|
||||
final signature = [
|
||||
0x1B,
|
||||
0x00,
|
||||
0x14,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0xF2,
|
||||
0x03,
|
||||
0x18,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xC4,
|
||||
];
|
||||
for (var i = 0; i < signature.length; i++) {
|
||||
frame[i] = signature[i];
|
||||
}
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
|
||||
expect(isS300PlaceholderFrame(frame), isTrue);
|
||||
});
|
||||
|
||||
test('uses calibrated tail payload RPM at offset 82', () {
|
||||
test('decodes RPM=0 for a frame matching the old "keep-alive" byte prefix', () {
|
||||
// A fixed 17-byte prefix here was previously treated as a keep-alive
|
||||
// signature to skip outright. Live testing showed it over-matched
|
||||
// badly: with RPM actively changing, nearly every frame was being
|
||||
// classified as a keep-alive and discarded, freezing the dashboard.
|
||||
// Every checksum-valid frame is decoded normally now regardless of
|
||||
// its leading bytes — see sensor_provider.dart.
|
||||
final frame = Uint8List(128);
|
||||
final prefix = [
|
||||
0x1B,
|
||||
@ -214,75 +205,10 @@ void main() {
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
frame[i] = prefix[i];
|
||||
}
|
||||
frame[82] = 0x32;
|
||||
frame[83] = 0x0C;
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
|
||||
final state = parseS300(frame);
|
||||
expect(state.rpm, equals(2033.0));
|
||||
});
|
||||
|
||||
test('uses calibrated tail payload RPM at offset 86', () {
|
||||
final frame = Uint8List(128);
|
||||
final prefix = [
|
||||
0x1B,
|
||||
0x00,
|
||||
0x14,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0xF2,
|
||||
0x03,
|
||||
0x18,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xC4,
|
||||
];
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
frame[i] = prefix[i];
|
||||
}
|
||||
frame[86] = 0x31;
|
||||
frame[87] = 0x0C;
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
|
||||
expect(hasS300TailRpmPayload(frame), isTrue);
|
||||
final state = parseS300(frame);
|
||||
expect(state.rpm, equals(2032.0));
|
||||
});
|
||||
|
||||
test('detects empty S300 Bluetooth prefix frames', () {
|
||||
final frame = Uint8List(128);
|
||||
final prefix = [
|
||||
0x1B,
|
||||
0x00,
|
||||
0x14,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0xF2,
|
||||
0x03,
|
||||
0x18,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xC4,
|
||||
];
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
frame[i] = prefix[i];
|
||||
}
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
|
||||
expect(isS300PlaceholderFrame(frame), isTrue);
|
||||
expect(hasS300TailRpmPayload(frame), isFalse);
|
||||
expect(state.rpm, equals(0.0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/temp_table.dart';
|
||||
|
||||
double decodeTemp(int raw) => (tempXlt[raw] + 40).toDouble();
|
||||
double decodeTemp(int raw) => (tempXlt[raw] - 40).toDouble();
|
||||
|
||||
void main() {
|
||||
group('Temperature lookup table', () {
|
||||
@ -9,22 +9,22 @@ void main() {
|
||||
expect(tempXlt.length, equals(256));
|
||||
});
|
||||
|
||||
test('decodeTemp(0x00) = tempXlt[0] + 40 = 190 + 40 = 230°C', () {
|
||||
expect(decodeTemp(0x00), equals(230.0));
|
||||
test('decodeTemp(0x00) = tempXlt[0] - 40 = 190 - 40 = 150°C', () {
|
||||
expect(decodeTemp(0x00), equals(150.0));
|
||||
});
|
||||
|
||||
test('decodeTemp(0xA0) = tempXlt[160] + 40', () {
|
||||
test('decodeTemp(0xA0) = tempXlt[160] - 40', () {
|
||||
// 0xA0 = 160 decimal; tempXlt[160] = 60
|
||||
expect(decodeTemp(0xA0), equals(60 + 40.0));
|
||||
expect(decodeTemp(0xA0), equals(60 - 40.0));
|
||||
});
|
||||
|
||||
test('decodeTemp(0xFF) = tempXlt[255] + 40 = 0 + 40 = 40°C', () {
|
||||
expect(decodeTemp(0xFF), equals(40.0));
|
||||
test('decodeTemp(0xFF) = tempXlt[255] - 40 = 0 - 40 = -40°C', () {
|
||||
expect(decodeTemp(0xFF), equals(-40.0));
|
||||
});
|
||||
|
||||
test('decodeTemp(0x80) = tempXlt[128] + 40', () {
|
||||
test('decodeTemp(0x80) = tempXlt[128] - 40', () {
|
||||
// 0x80 = 128; tempXlt[128] = 73
|
||||
expect(decodeTemp(0x80), equals(73 + 40.0));
|
||||
expect(decodeTemp(0x80), equals(73 - 40.0));
|
||||
});
|
||||
|
||||
test('all table values are non-negative', () {
|
||||
@ -32,5 +32,11 @@ void main() {
|
||||
expect(v, greaterThanOrEqualTo(0));
|
||||
}
|
||||
});
|
||||
|
||||
test('decoded range matches the declared -40..150°C sensor range', () {
|
||||
final decoded = tempXlt.map((v) => v - 40).toList();
|
||||
expect(decoded.reduce((a, b) => a > b ? a : b), equals(150));
|
||||
expect(decoded.reduce((a, b) => a < b ? a : b), equals(-40));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user