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
|
android.useAndroidX=true
|
||||||
kotlin.incremental=false
|
kotlin.incremental=false
|
||||||
org.gradle.daemon=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 droppedFrames = 0;
|
||||||
int validFrames = 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(
|
BtPoller(
|
||||||
this._service, {
|
this._service, {
|
||||||
this.ecuType = EcuType.s300,
|
this.ecuType = EcuType.s300,
|
||||||
@ -39,6 +48,8 @@ class BtPoller {
|
|||||||
|
|
||||||
void start() {
|
void start() {
|
||||||
_rxBuf.clear();
|
_rxBuf.clear();
|
||||||
|
_awaitingResponse = false;
|
||||||
|
_lastRequestSentAt = null;
|
||||||
|
|
||||||
// Listen to raw bytes from BT and accumulate into 128-byte frames
|
// Listen to raw bytes from BT and accumulate into 128-byte frames
|
||||||
_rxSub = _service.rawStream.listen(
|
_rxSub = _service.rawStream.listen(
|
||||||
@ -52,13 +63,28 @@ class BtPoller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send request at polling interval
|
// Check every pollingInterval whether it's time to send the next
|
||||||
_pollTimer = Timer.periodic(pollingInterval, (_) async {
|
// request — but only actually send once the prior one has been
|
||||||
if (_service.isConnected) {
|
// answered (or has clearly been lost).
|
||||||
await _service.write(
|
_pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest());
|
||||||
ecuType == EcuType.s300 ? _s300Request : _kproRequest,
|
_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));
|
Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128));
|
||||||
|
|
||||||
final checksumOk = validateFrame(frame);
|
final checksumOk = validateFrame(frame);
|
||||||
final canUseFrame = checksumOk || ecuType == EcuType.s300;
|
|
||||||
|
|
||||||
if (canUseFrame) {
|
if (checksumOk) {
|
||||||
if (!checksumOk) droppedFrames++;
|
|
||||||
validFrames++;
|
validFrames++;
|
||||||
latestUsableFrame = frame;
|
latestUsableFrame = frame;
|
||||||
_rxBuf.removeRange(0, startIdx + 128);
|
_rxBuf.removeRange(0, startIdx + 128);
|
||||||
} else {
|
} 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++;
|
droppedFrames++;
|
||||||
_rxBuf.removeRange(0, startIdx + 1);
|
_rxBuf.removeRange(0, startIdx + 1);
|
||||||
}
|
}
|
||||||
@ -104,6 +131,17 @@ class BtPoller {
|
|||||||
|
|
||||||
if (latestUsableFrame != null) {
|
if (latestUsableFrame != null) {
|
||||||
_frameController.add(latestUsableFrame);
|
_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.
|
/// Master list of all analog sensor definitions.
|
||||||
const List<SensorDef> sensorDefs = [
|
const List<SensorDef> sensorDefs = [
|
||||||
SensorDef(id: 'rpm', displayName: 'RPM', unit: 'revs', min: 0, max: 9000),
|
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(
|
SensorDef(
|
||||||
id: 'map',
|
id: 'map',
|
||||||
displayName: 'Manifold pressure',
|
displayName: 'Manifold pressure',
|
||||||
unit: 'psi',
|
unit: 'kPa',
|
||||||
min: -30,
|
min: 0,
|
||||||
max: 30),
|
max: 300),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
||||||
SensorDef(
|
SensorDef(
|
||||||
|
|||||||
@ -44,11 +44,13 @@ SensorState parseKPro(Uint8List frame) {
|
|||||||
final int sw1 = frame[31];
|
final int sw1 = frame[31];
|
||||||
final int sw2 = frame[32];
|
final int sw2 = frame[32];
|
||||||
|
|
||||||
// ECT: byte 49
|
// ECT: byte 49. Table is tempXlt[raw] - 40, not +40 — see s300_parser.dart
|
||||||
final double ect = (tempXlt[frame[49]] + 40).toDouble();
|
// 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
|
// IAT: byte 50
|
||||||
final double iat = (tempXlt[frame[50]] + 40).toDouble();
|
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
||||||
|
|
||||||
// PA: byte 51
|
// PA: byte 51
|
||||||
final double pa = frame[51].toDouble();
|
final double pa = frame[51].toDouble();
|
||||||
|
|||||||
@ -3,35 +3,6 @@ import 'sensor_state.dart';
|
|||||||
import 'temp_table.dart';
|
import 'temp_table.dart';
|
||||||
import 'neg8.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].
|
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
||||||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||||||
/// is true and the NEG8 checksum fails.
|
/// 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');
|
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some S300 Bluetooth frames carry live RPM in the tail payload,
|
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live
|
||||||
// while the header bytes stay fixed at 1b 00 14 00 and decode to a bogus
|
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while
|
||||||
// 4864 rpm. Prefer the tail value when it looks like real engine speed.
|
// REQUIREMENTS.md's blanket "all multi-byte values are Big Endian" claim
|
||||||
final int? tailRpmRaw = _s300TailRpmRaw(frame);
|
// did not hold for this field on real hardware.
|
||||||
final int rpmRaw = tailRpmRaw ?? _readUint16BE(frame, 2);
|
final double rpm = _readUint16LE(frame, 3).toDouble();
|
||||||
final double rpm = (tailRpmRaw != null ? rpmRaw - 1089 : rpmRaw - 256)
|
|
||||||
.clamp(0, 12000)
|
|
||||||
.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 int vssRaw = _readUint16LE(frame, 5);
|
||||||
final double vss =
|
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
|
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the
|
||||||
// in its "/psi column rather than raw absolute kPa.
|
// big-endian read produced multi-thousand-kPa values, physically
|
||||||
|
// impossible for a 0-300 kPa sensor).
|
||||||
final int mapRaw = _readUint16LE(frame, 7);
|
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 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.
|
// INJ: bytes +10..11, BE, raw ms.
|
||||||
final int injRaw = _readUint16LE(frame, 10);
|
final double inj = _readUint16BE(frame, 10).toDouble();
|
||||||
final double inj = injRaw / 240.5;
|
|
||||||
|
|
||||||
// IGN: byte 12 — SManager-aligned degrees.
|
// IGN: byte +12.
|
||||||
final double ign = (frame[12] - 66) / 2.0;
|
final double ign = (frame[12] + 120) / 2.0;
|
||||||
|
|
||||||
// O2: byte 16, 0..5V scaled over 8-bit ADC range.
|
// O2: byte +16, raw.
|
||||||
final double o2 = frame[16] * 5.0 / 256.0;
|
final double o2 = frame[16].toDouble();
|
||||||
|
|
||||||
// SW bitmaps
|
// SW bitmaps
|
||||||
final int sw1 = frame[0x11]; // +17
|
final int sw1 = frame[0x11]; // +17
|
||||||
final int sw2 = frame[0x12]; // +18
|
final int sw2 = frame[0x12]; // +18
|
||||||
final int sw3 = frame[0x13]; // +19
|
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
|
// Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39
|
||||||
final int gear = frame[0x27];
|
// 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();
|
final double strim = frame[0x29].toDouble();
|
||||||
|
|
||||||
// Ltrim: byte 0x2B hex = 43 decimal
|
// Ltrim: +2B hex = 43 decimal
|
||||||
final double ltrim = frame[0x2B].toDouble();
|
final double ltrim = frame[0x2B].toDouble();
|
||||||
|
|
||||||
// PA: byte 0x3C hex = 60 decimal
|
// PA: +2C hex = 44 decimal
|
||||||
final double pa = frame[0x3C].toDouble();
|
final double pa = frame[0x2C].toDouble();
|
||||||
|
|
||||||
// ECT: byte 0x2D hex = 45 decimal
|
// ECT: +2D hex = 45 decimal. Table is tempXlt[raw] - 40, not +40: the
|
||||||
final double ect = (tempXlt[frame[0x2D]] + 40).toDouble();
|
// 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
|
// IAT: +2E hex = 46 decimal
|
||||||
final double iat = (tempXlt[frame[0x2E]] + 40).toDouble();
|
final double iat = (tempXlt[frame[0x2E]] - 40).toDouble();
|
||||||
|
|
||||||
// BAT: byte 0x24 hex = 36 decimal
|
// BAT: +30 hex = 48 decimal
|
||||||
final double bat = frame[0x24] * 26.0 / 270.0;
|
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 = [
|
final List<int> errBytes = [
|
||||||
frame[0x31],
|
frame[0x31],
|
||||||
frame[0x32],
|
frame[0x32],
|
||||||
@ -111,10 +87,10 @@ SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
|||||||
frame[0x34],
|
frame[0x34],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Eth: byte 0x38 hex = 56 decimal
|
// Eth: +38 hex = 56 decimal
|
||||||
final double eth = frame[0x38].toDouble();
|
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();
|
final double afr = frame[0x34].toDouble();
|
||||||
|
|
||||||
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each)
|
// 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) {
|
int _readUint16LE(Uint8List frame, int offset) {
|
||||||
return frame[offset] | (frame[offset + 1] << 8);
|
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.
|
// 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 = [
|
const List<int> tempXlt = [
|
||||||
190, 190, 188, 186, 183, 181, 179, 177, 175, 172, 170, 168, 166, 163, 161, 159,
|
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,
|
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 'bt_provider.dart';
|
||||||
import 'settings_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.
|
/// Emits a parsed [SensorState] for every live data frame received from the ECU.
|
||||||
/// Automatically picks S300 or KPro parser based on [settingsProvider].
|
/// Automatically picks S300 or KPro parser based on [settingsProvider].
|
||||||
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
||||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
final ecuType = ref.watch(settingsProvider).ecuType;
|
||||||
final btNotifier = ref.watch(btProvider.notifier);
|
final btNotifier = ref.watch(btProvider.notifier);
|
||||||
|
|
||||||
await for (final frame in btNotifier.frameStream) {
|
// `await for` treats any error *event* on the source stream as fatal and
|
||||||
if (ecuType == EcuType.s300) {
|
// exits the loop permanently — a try/catch inside the loop body cannot
|
||||||
if (isS300PlaceholderFrame(frame) && !hasS300TailRpmPayload(frame)) {
|
// intercept that, since it happens in the iteration mechanism itself, not
|
||||||
continue;
|
// 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 {
|
final s = ref.read(decodeStatsProvider);
|
||||||
yield parseKPro(frame);
|
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) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bt = ref.watch(btProvider);
|
final bt = ref.watch(btProvider);
|
||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
|
final sensorAsync = ref.watch(sensorStateProvider);
|
||||||
final state = ref.watch(latestSensorProvider);
|
final state = ref.watch(latestSensorProvider);
|
||||||
|
final decodeStats = ref.watch(decodeStatsProvider);
|
||||||
final frame = bt.lastFrame;
|
final frame = bt.lastFrame;
|
||||||
final hex = frame == null ? '' : _hex(frame);
|
final hex = frame == null ? '' : _hex(frame);
|
||||||
|
|
||||||
@ -47,17 +49,38 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
title: 'Connection',
|
title: 'Connection',
|
||||||
rows: [
|
rows: [
|
||||||
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
||||||
_RowData('Frames', bt.frameCount.toString()),
|
_RowData('Frames (raw, from poller)', bt.frameCount.toString()),
|
||||||
_RowData('Dropped', bt.droppedFrames.toString()),
|
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
||||||
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
||||||
if (frame != null)
|
if (frame != null)
|
||||||
_RowData(
|
_RowData(
|
||||||
'Checksum',
|
'Checksum',
|
||||||
validateFrame(frame) ? 'valid' : 'bad, accepted for S300',
|
validateFrame(frame) ? 'valid' : 'bad',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
_Section(
|
||||||
title: 'Current App Decode',
|
title: 'Current App Decode',
|
||||||
rows: _decodedRows(state),
|
rows: _decodedRows(state),
|
||||||
@ -90,8 +113,8 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
List<_RowData> _decodedRows(SensorState s) => [
|
List<_RowData> _decodedRows(SensorState s) => [
|
||||||
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
||||||
_RowData('Speed mph', s.vss.toStringAsFixed(2)),
|
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
||||||
_RowData('MAP psi', s.map.toStringAsFixed(2)),
|
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
||||||
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
||||||
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
||||||
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
||||||
@ -101,53 +124,46 @@ class RawFrameDebugScreen extends ConsumerWidget {
|
|||||||
];
|
];
|
||||||
|
|
||||||
List<_RowData> _s300CandidateRows(Uint8List f) {
|
List<_RowData> _s300CandidateRows(Uint8List f) {
|
||||||
final rpmBe = _u16be(f, 2);
|
final rpmLe = _u16le(f, 3);
|
||||||
final rpmShiftedBe = _u16be(f, 3);
|
|
||||||
final vssBe = _u16be(f, 5);
|
|
||||||
final vssLe = _u16le(f, 5);
|
final vssLe = _u16le(f, 5);
|
||||||
final mapBe = _u16be(f, 7);
|
|
||||||
final mapLe = _u16le(f, 7);
|
final mapLe = _u16le(f, 7);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
||||||
_RowData('RPM @2 BE', rpmBe.toString()),
|
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
||||||
_RowData('RPM shifted @3 BE', rpmShiftedBe.toString()),
|
_RowData('VSS raw @5 LE', vssLe.toString()),
|
||||||
_RowData('VSS raw @5 BE / LE', '$vssBe / $vssLe'),
|
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
||||||
_RowData('VSS current LE mph', _s300Vss(vssLe).toStringAsFixed(2)),
|
_RowData('MAP raw @7 LE', mapLe.toString()),
|
||||||
_RowData('MAP raw @7 BE / LE', '$mapBe / $mapLe'),
|
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
||||||
_RowData('MAP current psi', _s300Map(mapLe).toStringAsFixed(2)),
|
|
||||||
_RowData('TPS raw @9', f[9].toString()),
|
_RowData('TPS raw @9', f[9].toString()),
|
||||||
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
|
||||||
_RowData('INJ raw @10 LE', _u16le(f, 10).toString()),
|
_RowData('Gear raw @27 (decimal)', f[27].toString()),
|
||||||
_RowData('INJ current ms', (_u16le(f, 10) / 240.5).toStringAsFixed(2)),
|
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()),
|
||||||
_RowData('IGN raw @12', f[12].toString()),
|
_RowData('IGN raw @12 (offset unverified)', f[12].toString()),
|
||||||
_RowData('IGN current deg', ((f[12] - 66) / 2.0).toStringAsFixed(2)),
|
_RowData('O2 current raw (offset unverified)', f[16].toString()),
|
||||||
_RowData('O2 current V', (f[16] * 5.0 / 256.0).toStringAsFixed(2)),
|
|
||||||
_RowData('ECT raw @45', f[0x2D].toString()),
|
_RowData('ECT raw @45', f[0x2D].toString()),
|
||||||
_RowData('IAT raw @46', f[0x2E].toString()),
|
_RowData('IAT raw @46', f[0x2E].toString()),
|
||||||
_RowData('BAT raw @36', f[0x24].toString()),
|
_RowData('BAT raw @48 (offset unverified)', f[0x30].toString()),
|
||||||
_RowData('BAT current V', (f[0x24] * 26.0 / 270.0).toStringAsFixed(2)),
|
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
|
||||||
_RowData('PA raw @60', f[0x3C].toString()),
|
|
||||||
_RowData('ERR bytes @49-52',
|
_RowData('ERR bytes @49-52',
|
||||||
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'),
|
'${_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 int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
||||||
static String _b(Uint8List f, int o) =>
|
static String _b(Uint8List f, int o) =>
|
||||||
f[o].toRadixString(16).padLeft(2, '0');
|
f[o].toRadixString(16).padLeft(2, '0');
|
||||||
|
|
||||||
static double _s300Vss(int raw) {
|
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) {
|
static double _s300Map(int raw) {
|
||||||
return ((raw / 10.0) - 103.8) * 0.1450377377;
|
return raw / 10.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static double _s300Tps(int raw) {
|
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) {
|
static String _hex(Uint8List frame) {
|
||||||
|
|||||||
@ -244,10 +244,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
native_toolchain_c:
|
native_toolchain_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -569,10 +569,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.11"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -11,7 +11,7 @@ Uint8List _buildKProFrame({
|
|||||||
int map1 = 0,
|
int map1 = 0,
|
||||||
int map2 = 0,
|
int map2 = 0,
|
||||||
int ign = 0,
|
int ign = 0,
|
||||||
int ect = 0x80, // tempXlt[0x80]=73 → 113°C
|
int ect = 0x80, // tempXlt[0x80]=73 → 33°C
|
||||||
int bat = 0,
|
int bat = 0,
|
||||||
int sw1 = 0,
|
int sw1 = 0,
|
||||||
int sw2 = 0,
|
int sw2 = 0,
|
||||||
@ -89,10 +89,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parses ECT using temp lookup table', () {
|
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 frame = _buildKProFrame(ect: 0x80);
|
||||||
final state = parseKPro(frame);
|
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', () {
|
test('parses BAT = raw / 10', () {
|
||||||
|
|||||||
@ -12,44 +12,46 @@ Uint8List _buildS300Frame({
|
|||||||
int tps = 0,
|
int tps = 0,
|
||||||
int inj = 0,
|
int inj = 0,
|
||||||
int ign = 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 bat = 0,
|
||||||
int sw1 = 0,
|
int sw1 = 0,
|
||||||
int sw2 = 0,
|
int sw2 = 0,
|
||||||
int sw3 = 0,
|
int sw3 = 0,
|
||||||
int krtrd = 0,
|
int krtrd = 0,
|
||||||
|
int gear = 0,
|
||||||
}) {
|
}) {
|
||||||
final frame = Uint8List(128);
|
final frame = Uint8List(128);
|
||||||
// Header
|
// Header
|
||||||
frame[0] = 0x1B;
|
frame[0] = 0x1B;
|
||||||
frame[1] = 0x00;
|
frame[1] = 0x00;
|
||||||
// RPM: bytes 2..3, raw stream is display RPM + 256
|
// RPM: bytes +3..4, little-endian, raw
|
||||||
final rpmRaw = rpm + 256;
|
frame[3] = rpm & 0xFF;
|
||||||
frame[2] = (rpmRaw >> 8) & 0xFF;
|
frame[4] = (rpm >> 8) & 0xFF;
|
||||||
frame[3] = rpmRaw & 0xFF;
|
// VSS: bytes +5..6, little-endian
|
||||||
// VSS: bytes 5..6 little-endian
|
|
||||||
frame[5] = vss & 0xFF;
|
frame[5] = vss & 0xFF;
|
||||||
frame[6] = (vss >> 8) & 0xFF;
|
frame[6] = (vss >> 8) & 0xFF;
|
||||||
// MAP: bytes 7..8 little-endian
|
// MAP: bytes +7..8, little-endian
|
||||||
frame[7] = mapRaw & 0xFF;
|
frame[7] = mapRaw & 0xFF;
|
||||||
frame[8] = (mapRaw >> 8) & 0xFF;
|
frame[8] = (mapRaw >> 8) & 0xFF;
|
||||||
// TPS: byte 9
|
// TPS: byte +9
|
||||||
frame[9] = tps;
|
frame[9] = tps;
|
||||||
// INJ: bytes 10..11 little-endian
|
// INJ: bytes +10..11, BE
|
||||||
frame[10] = inj & 0xFF;
|
frame[10] = (inj >> 8) & 0xFF;
|
||||||
frame[11] = (inj >> 8) & 0xFF;
|
frame[11] = inj & 0xFF;
|
||||||
// IGN: byte 12
|
// IGN: byte +12
|
||||||
frame[12] = ign;
|
frame[12] = ign;
|
||||||
// KRtrd: byte 13
|
// KRtrd: byte +13
|
||||||
frame[13] = krtrd;
|
frame[13] = krtrd;
|
||||||
// SW bitmaps
|
// SW bitmaps
|
||||||
frame[0x11] = sw1;
|
frame[0x11] = sw1;
|
||||||
frame[0x12] = sw2;
|
frame[0x12] = sw2;
|
||||||
frame[0x13] = sw3;
|
frame[0x13] = sw3;
|
||||||
// ECT: byte 0x2D
|
// Gear: decimal offset 27
|
||||||
|
frame[27] = gear;
|
||||||
|
// ECT: byte +2D
|
||||||
frame[0x2D] = ect;
|
frame[0x2D] = ect;
|
||||||
// BAT: byte 0x24
|
// BAT: byte +30
|
||||||
frame[0x24] = bat;
|
frame[0x30] = bat;
|
||||||
|
|
||||||
// Stamp NEG8 checksum at byte 127
|
// Stamp NEG8 checksum at byte 127
|
||||||
int neg8 = 0;
|
int neg8 = 0;
|
||||||
@ -76,37 +78,49 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parses VSS correctly when raw >= 893', () {
|
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 frame = _buildS300Frame(vss: 1000);
|
||||||
final state = parseS300(frame);
|
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', () {
|
test('parses MAP as raw/10 kPa', () {
|
||||||
// mapRaw = 1148 -> 114.8 kPa absolute -> about 1.6 psi displayed
|
// mapRaw = 1148 -> 114.8 kPa
|
||||||
final frame = _buildS300Frame(mapRaw: 1148);
|
final frame = _buildS300Frame(mapRaw: 1148);
|
||||||
final state = parseS300(frame);
|
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', () {
|
test('parses TPS = 0 when raw = 0', () {
|
||||||
final frame = _buildS300Frame(tps: 10);
|
final frame = _buildS300Frame(tps: 0);
|
||||||
final state = parseS300(frame);
|
final state = parseS300(frame);
|
||||||
expect(state.tps, equals(0.0));
|
expect(state.tps, equals(0.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses TPS correctly when raw >= 25', () {
|
test('parses TPS = 100 when raw = 255', () {
|
||||||
// tps raw=134 -> approximately 51%
|
final frame = _buildS300Frame(tps: 255);
|
||||||
final frame = _buildS300Frame(tps: 134);
|
|
||||||
final state = parseS300(frame);
|
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', () {
|
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 frame = _buildS300Frame(ect: 0x80);
|
||||||
final state = parseS300(frame);
|
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', () {
|
test('parses BAT = raw * 26 / 270', () {
|
||||||
@ -117,10 +131,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('IGN decode: (raw + 120) / 2', () {
|
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 frame = _buildS300Frame(ign: 164);
|
||||||
final state = parseS300(frame);
|
final state = parseS300(frame);
|
||||||
expect(state.ign, equals(49.0));
|
expect(state.ign, equals(142.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MIL flag set from SW2 bit5', () {
|
test('MIL flag set from SW2 bit5', () {
|
||||||
@ -161,36 +175,13 @@ void main() {
|
|||||||
expect(state.rpm, equals(1000.0));
|
expect(state.rpm, equals(1000.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('detects known S300 prefix signature', () {
|
test('decodes RPM=0 for a frame matching the old "keep-alive" byte prefix', () {
|
||||||
final frame = Uint8List(128);
|
// A fixed 17-byte prefix here was previously treated as a keep-alive
|
||||||
final signature = [
|
// signature to skip outright. Live testing showed it over-matched
|
||||||
0x1B,
|
// badly: with RPM actively changing, nearly every frame was being
|
||||||
0x00,
|
// classified as a keep-alive and discarded, freezing the dashboard.
|
||||||
0x14,
|
// Every checksum-valid frame is decoded normally now regardless of
|
||||||
0x00,
|
// its leading bytes — see sensor_provider.dart.
|
||||||
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', () {
|
|
||||||
final frame = Uint8List(128);
|
final frame = Uint8List(128);
|
||||||
final prefix = [
|
final prefix = [
|
||||||
0x1B,
|
0x1B,
|
||||||
@ -214,75 +205,10 @@ void main() {
|
|||||||
for (var i = 0; i < prefix.length; i++) {
|
for (var i = 0; i < prefix.length; i++) {
|
||||||
frame[i] = prefix[i];
|
frame[i] = prefix[i];
|
||||||
}
|
}
|
||||||
frame[82] = 0x32;
|
|
||||||
frame[83] = 0x0C;
|
|
||||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||||
|
|
||||||
final state = parseS300(frame);
|
final state = parseS300(frame);
|
||||||
expect(state.rpm, equals(2033.0));
|
expect(state.rpm, equals(0.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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hvbt_dash/core/protocol/temp_table.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() {
|
void main() {
|
||||||
group('Temperature lookup table', () {
|
group('Temperature lookup table', () {
|
||||||
@ -9,22 +9,22 @@ void main() {
|
|||||||
expect(tempXlt.length, equals(256));
|
expect(tempXlt.length, equals(256));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decodeTemp(0x00) = tempXlt[0] + 40 = 190 + 40 = 230°C', () {
|
test('decodeTemp(0x00) = tempXlt[0] - 40 = 190 - 40 = 150°C', () {
|
||||||
expect(decodeTemp(0x00), equals(230.0));
|
expect(decodeTemp(0x00), equals(150.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decodeTemp(0xA0) = tempXlt[160] + 40', () {
|
test('decodeTemp(0xA0) = tempXlt[160] - 40', () {
|
||||||
// 0xA0 = 160 decimal; tempXlt[160] = 60
|
// 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', () {
|
test('decodeTemp(0xFF) = tempXlt[255] - 40 = 0 - 40 = -40°C', () {
|
||||||
expect(decodeTemp(0xFF), equals(40.0));
|
expect(decodeTemp(0xFF), equals(-40.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decodeTemp(0x80) = tempXlt[128] + 40', () {
|
test('decodeTemp(0x80) = tempXlt[128] - 40', () {
|
||||||
// 0x80 = 128; tempXlt[128] = 73
|
// 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', () {
|
test('all table values are non-negative', () {
|
||||||
@ -32,5 +32,11 @@ void main() {
|
|||||||
expect(v, greaterThanOrEqualTo(0));
|
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