the working code s300, with few glitch by resetting to 0

This commit is contained in:
metatroncubeswdev 2026-08-08 13:18:02 -04:00
parent 375c2510ae
commit 77a47eb938
12 changed files with 295 additions and 272 deletions

View File

@ -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

View File

@ -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);
}
}

View File

@ -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(

View File

@ -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();

View File

@ -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();
// AIN0AIN7: 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;
}

View File

@ -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,

View File

@ -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;
}
yield parseS300(frame, validateChecksum: false);
// `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 {
yield parseKPro(frame);
lastGoodRpm = 0;
}
}
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;
}
}
});

View File

@ -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,13 +49,34 @@ 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',
),
],
),
@ -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) {

View File

@ -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:

View File

@ -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', () {

View File

@ -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));
});
});
}

View File

@ -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));
});
});
}