import 'dart:async'; import 'dart:typed_data'; import '../protocol/neg8.dart'; import 'bt_service.dart'; enum EcuType { s300, kpro } /// Sends the ECU request byte every [pollingInterval] ms, /// accumulates raw bytes into 128-byte frames, validates NEG8, /// and emits the newest usable frame on [frameStream]. class BtPoller { final BtService _service; EcuType ecuType; final Duration pollingInterval; // S300 request: [0x1B, 0x00, 0xE5] static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]); // KPro request: [0x1B, 0x01, 0xE4] static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]); final StreamController _frameController = StreamController.broadcast(); Stream get frameStream => _frameController.stream; final List _rxBuf = []; Timer? _pollTimer; StreamSubscription? _rxSub; 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, this.pollingInterval = const Duration(milliseconds: 50), }); void start() { _rxBuf.clear(); _awaitingResponse = false; _lastRequestSentAt = null; // Listen to raw bytes from BT and accumulate into 128-byte frames _rxSub = _service.rawStream.listen( (Uint8List chunk) { _rxBuf.addAll(chunk); _processBuffer(); }, onError: (Object e) { _frameController.addError(e); stop(); }, ); // 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; }); } void _processBuffer() { // Extract as many 128-byte frames as possible. // Frame start sync: first byte should be 0x1B (header marker). // If we have a misaligned buffer, scan forward. Uint8List? latestUsableFrame; while (_rxBuf.length >= 128) { // Scan for 0x1B frame header int startIdx = 0; while (startIdx < _rxBuf.length && _rxBuf[startIdx] != 0x1B) { startIdx++; } // Not enough data after sync byte if (_rxBuf.length - startIdx < 128) { // Discard bytes before potential header if (startIdx > 0) { _rxBuf.removeRange(0, startIdx); } break; } final Uint8List frame = Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128)); final checksumOk = validateFrame(frame); if (checksumOk) { validFrames++; latestUsableFrame = frame; _rxBuf.removeRange(0, startIdx + 128); } else { // 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); } } 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); } } void stop() { _pollTimer?.cancel(); _pollTimer = null; _rxSub?.cancel(); _rxSub = null; _rxBuf.clear(); } void dispose() { stop(); _frameController.close(); } }