import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../bluetooth/bt_poller.dart'; import '../bluetooth/rpm_glitch_filter.dart'; import '../bluetooth/bt_service.dart'; import '../logging/app_log.dart'; import '../protocol/neg8.dart'; import '../protocol/s300_parser.dart'; import 'settings_provider.dart'; // ─── Enums ─────────────────────────────────────────────────────────────────── enum BtStatus { disconnected, connecting, connected, reconnecting, error } /// What the adapter is actually giving us, independent of the BT link. enum EcuDataStatus { /// Not connected / nothing received yet. none, /// Real ECU readings are arriving. live, /// The adapter answers, but only with its built-in default frame /// (hvbt_protocol.pdf example values) — it has no fresh ECU data. adapterDefaults, /// The adapter is not answering requests. noResponse, } // ─── State ─────────────────────────────────────────────────────────────────── class BtState { final BtStatus status; final String? deviceName; final String? error; final EcuDataStatus dataStatus; final int requestsSent; final int frameCount; // valid frames (live + default) final int liveFrames; final int rpmGlitches; // frames dropped for an implausible RPM jump final int defaultFrames; final int droppedFrames; // bad checksum final int timeouts; final int reconnects; final int? lastResponseMs; final Uint8List? lastFrame; const BtState({ required this.status, this.deviceName, this.error, this.dataStatus = EcuDataStatus.none, this.requestsSent = 0, this.frameCount = 0, this.liveFrames = 0, this.rpmGlitches = 0, this.defaultFrames = 0, this.droppedFrames = 0, this.timeouts = 0, this.reconnects = 0, this.lastResponseMs, this.lastFrame, }); const BtState.disconnected() : this(status: BtStatus.disconnected); BtState copyWith({ BtStatus? status, String? deviceName, String? error, bool clearError = false, EcuDataStatus? dataStatus, int? requestsSent, int? frameCount, int? liveFrames, int? rpmGlitches, int? defaultFrames, int? droppedFrames, int? timeouts, int? reconnects, int? lastResponseMs, Uint8List? lastFrame, }) => BtState( status: status ?? this.status, deviceName: deviceName ?? this.deviceName, error: clearError ? null : (error ?? this.error), dataStatus: dataStatus ?? this.dataStatus, requestsSent: requestsSent ?? this.requestsSent, frameCount: frameCount ?? this.frameCount, liveFrames: liveFrames ?? this.liveFrames, rpmGlitches: rpmGlitches ?? this.rpmGlitches, defaultFrames: defaultFrames ?? this.defaultFrames, droppedFrames: droppedFrames ?? this.droppedFrames, timeouts: timeouts ?? this.timeouts, reconnects: reconnects ?? this.reconnects, lastResponseMs: lastResponseMs ?? this.lastResponseMs, lastFrame: lastFrame ?? this.lastFrame, ); bool get isConnected => status == BtStatus.connected; bool get isConnecting => status == BtStatus.connecting || status == BtStatus.reconnecting; } // ─── Notifier ──────────────────────────────────────────────────────────────── class BtNotifier extends StateNotifier { BtNotifier(this._ref) : super(const BtState.disconnected()) { // Apply polling-interval changes from Settings to a running poller. _ref.listen(settingsProvider, (prev, next) { _poller?.pollingInterval = next.pollingInterval; _poller?.ecuType = next.ecuType; }); } final Ref _ref; BtPoller? _poller; StreamSubscription? _pollSub; Timer? _statusTimer; final StreamController _frameController = StreamController.broadcast(); BluetoothDevice? _device; bool _userDisconnected = false; bool _reconnecting = false; // Bumped on every user connect/disconnect. A link opened by an older // session (e.g. an auto-reconnect still in flight when the user tapped // Disconnect) is closed instead of being adopted. int _session = 0; DateTime? _lastAnyFrameAt; DateTime? _lastLiveFrameAt; DateTime? _defaultsSince; DateTime? _lastAutoReconnectAt; int _liveFrames = 0; int _defaultFrames = 0; int _reconnects = 0; static const _staleAfter = Duration(milliseconds: 1500); static const _defaultsReconnectAfter = Duration(seconds: 8); static const _minTimeBetweenAutoReconnects = Duration(seconds: 45); /// Validated 128-byte frames carrying REAL ECU data (adapter default frames /// are filtered out). Consumed by sensorStateProvider and the datalog. Stream get frameStream => _frameController.stream; BtPoller? get poller => _poller; Future connect(BluetoothDevice device) async { if (state.isConnecting) return; // ignore double taps final session = ++_session; // The adapter's name says which ECU it is wired to; a stale saved // setting for the other ECU makes every request go unanswered. final inferred = ecuTypeFromDeviceName(device.name); if (inferred != null && inferred != _ref.read(settingsProvider).ecuType) { AppLog.i('ECU protocol set to ${inferred.name} from adapter name ' '"${device.name}"'); _ref.read(settingsProvider.notifier).setEcuType(inferred); } _device = device; _userDisconnected = false; _resetCounters(); state = BtState( status: BtStatus.connecting, deviceName: device.name ?? device.address, ); try { await _openLink(device); if (session != _session || !mounted) { AppLog.i('Connect finished after disconnect — closing link'); await _closeLink(); return; } state = state.copyWith(status: BtStatus.connected, clearError: true); } catch (e, st) { AppLog.e('Connect failed', e, st); if (session != _session || !mounted) return; await _closeLink(); state = state.copyWith(status: BtStatus.error, error: _friendly(e)); } } Future disconnect() async { _session++; _userDisconnected = true; await _closeLink(); state = const BtState.disconnected(); } // ── Link management ─────────────────────────────────────────────────────── Future _openLink(BluetoothDevice device) async { final service = _ref.read(btServiceProvider); final settings = _ref.read(settingsProvider); await service.connect(device); final poller = BtPoller( service, ecuType: settings.ecuType, pollingInterval: settings.pollingInterval, onLinkStalled: () => _onLinkProblem('adapter stopped answering'), ); _poller = poller; _pollSub = poller.frameStream.listen( _onFrame, onError: (Object e) => _onLinkProblem('$e'), ); poller.start(); _statusTimer?.cancel(); _statusTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => _tick()); } Future _closeLink() async { _statusTimer?.cancel(); _statusTimer = null; await _pollSub?.cancel(); _pollSub = null; _poller?.dispose(); _poller = null; await _ref.read(btServiceProvider).disconnect(); } void _resetCounters() { _rpmFilter.reset(); _rpmFilter.dropped = 0; _glitchesLogged = 0; _lastAnyFrameAt = null; _lastLiveFrameAt = null; _defaultsSince = null; _liveFrames = 0; _defaultFrames = 0; } void _onFrame(Uint8List frame) { if (!mounted) return; final now = DateTime.now(); _lastAnyFrameAt = now; final ecuType = _ref.read(settingsProvider).ecuType; final isDefault = ecuType == EcuType.s300 && isS300DefaultFrame(frame); if (isDefault) { _defaultFrames++; if (_defaultFrames == 1 || _defaultFrames % 50 == 0) { AppLog.w('Adapter returned its default (no-ECU-data) frame ' '(count=$_defaultFrames)'); } } else if (!_rpmFilter.accept(frame, now)) { // Implausible RPM jump (e.g. crank-signal dropout, P0336 on the KPro // bench): drop the whole frame and keep showing the last good one, // as the USB tool does. Counted for the Debug screen. final n = _rpmFilter.dropped; if (n == 1 || n % 100 == 0) { AppLog.w('RPM glitch frame dropped ' '(${RpmGlitchFilter.rpmOf(frame).round()} rpm, count=$n)'); } if (_glitchesLogged < 2) { _glitchesLogged++; AppLog.w('Glitch frame: ${[ for (final v in frame) v.toRadixString(16).padLeft(2, '0') ].join(' ')}'); } } else { _liveFrames++; _lastLiveFrameAt = now; _maybeLogFrame(frame, now); _defaultsSince = null; _frameController.add(frame); } _publishStats(frame); } DateTime? _lastFrameLogAt; late final _rpmFilter = RpmGlitchFilter( onConfirmed: (from, to) => AppLog.i( 'RPM change ${from.round()} -> ${to.round()} accepted (majority of recent frames)'), ); int _glitchesLogged = 0; /// Raw hex of one live frame every 5 s, so bench readings from the USB /// tool can be matched to exact bytes (Debug → Copy app log, or logcat). void _maybeLogFrame(Uint8List frame, DateTime now) { final last = _lastFrameLogAt; if (last != null && now.difference(last) < const Duration(seconds: 5)) { return; } _lastFrameLogAt = now; final hex = StringBuffer(); for (var i = 0; i < 64; i++) { if (i > 0) hex.write(i % 16 == 0 ? ' | ' : ' '); hex.write(frame[i].toRadixString(16).padLeft(2, '0')); } AppLog.i('FRAME[0..63] $hex'); } void _publishStats([Uint8List? frame]) { final p = _poller; state = state.copyWith( requestsSent: p?.requestsSent, frameCount: p?.validFrames, liveFrames: _liveFrames, rpmGlitches: _rpmFilter.dropped, defaultFrames: _defaultFrames, droppedFrames: p?.droppedFrames, timeouts: p?.timeouts, reconnects: _reconnects, lastResponseMs: p?.lastResponseTime?.inMilliseconds, lastFrame: frame, dataStatus: _computeDataStatus(), ); } EcuDataStatus _computeDataStatus() { final now = DateTime.now(); bool recent(DateTime? t) => t != null && now.difference(t) < _staleAfter; if (recent(_lastLiveFrameAt)) return EcuDataStatus.live; if (recent(_lastAnyFrameAt)) return EcuDataStatus.adapterDefaults; if (_poller?.isRunning ?? false) return EcuDataStatus.noResponse; return EcuDataStatus.none; } void _tick() { if (!mounted || _poller == null) return; final status = _computeDataStatus(); // Adapter answering only with defaults for a long stretch: one reconnect // has been seen to clear this (see docs/HANDOFF.md), but never loop on it. if (status == EcuDataStatus.adapterDefaults) { _defaultsSince ??= DateTime.now(); if (DateTime.now().difference(_defaultsSince!) > _defaultsReconnectAfter && _autoReconnectAllowed()) { _onLinkProblem('only default frames for ' '${_defaultsReconnectAfter.inSeconds}s'); } } else { _defaultsSince = null; } _publishStats(); } bool _autoReconnectAllowed() { final last = _lastAutoReconnectAt; return last == null || DateTime.now().difference(last) > _minTimeBetweenAutoReconnects; } Future _onLinkProblem(String reason) async { if (_reconnecting || _userDisconnected || _device == null || !mounted) { return; } final service = _ref.read(btServiceProvider); // A stall on a link that is still up: only reconnect occasionally; the // poller keeps trying at its timeout pace in between. if (service.isConnected && !_autoReconnectAllowed()) { AppLog.w('Link problem ($reason) — keeping link, recent reconnect'); return; } final session = _session; bool stillWanted() => session == _session && !_userDisconnected && mounted; _reconnecting = true; _lastAutoReconnectAt = DateTime.now(); AppLog.w('Link problem: $reason — reconnecting'); state = state.copyWith(status: BtStatus.reconnecting); await _closeLink(); for (var attempt = 1; attempt <= 5; attempt++) { await Future.delayed(Duration(seconds: attempt)); if (!stillWanted()) break; try { _resetCounters(); await _openLink(_device!); if (!stillWanted()) { // User disconnected while this attempt was in flight. AppLog.i('Reconnect finished after disconnect — closing link'); await _closeLink(); break; } _reconnects++; AppLog.i('Reconnected (attempt $attempt)'); _reconnecting = false; state = state.copyWith(status: BtStatus.connected, clearError: true); return; } catch (e) { AppLog.w('Reconnect attempt $attempt failed: $e'); // Don't tear down a link that a newer session may own. if (!stillWanted()) break; await _closeLink(); } } _reconnecting = false; if (stillWanted()) { state = state.copyWith( status: BtStatus.error, error: 'Connection lost. Check the adapter and connect again.', dataStatus: EcuDataStatus.none, ); } } static String _friendly(Object e) { final s = e.toString(); if (s.contains('timed out')) return s.replaceFirst('Exception: ', ''); if (s.contains('read failed') || s.contains('socket')) { return 'Could not open the Bluetooth link. Make sure the adapter is ' 'powered and not connected to another phone, then try again.'; } return s; } // ── One-off commands ────────────────────────────────────────────────────── /// Diagnostic: Hondata official-protocol DL_DeviceInfo probe. Runs between /// polls via the poller's link lock, so it never overlaps a request. Future probeOfficialProtocol() async { final p = _poller; if (p == null) return Uint8List(0); return p.sendCommandCollect( Uint8List.fromList([0x00, 0x02, 0x04, 0x00]), const Duration(milliseconds: 800), ); } /// DTC reset per hvbt_protocol.pdf: { 0x1B, 0x70, NEG8 }; the adapter should /// echo the same 3 bytes as acknowledgement. /// /// The adapter never sends that ack (confirmed on the bench), so success is /// judged from the data: after sending, polling resumes and the ERR bytes /// of the live frames are watched for [_dtcWatch]. If any code that was /// stored before disappears, the ECU cleared. Codes for faults that are /// still present come straight back (often before the first frame), just /// as with the USB tool, so "all zero" is not required. The ack wait is /// kept short because the adapter stops reading the ECU after ~1 s without /// datalog requests (Bluetooth.pdf §2.4). /// /// [codesBefore]/[codesCleared] count stored code bits before the command /// and how many of them were seen cleared. Future< ({ bool acknowledged, int codesBefore, int codesCleared, Uint8List raw, })> clearDtcs() async { final p = _poller; if (p == null) { return ( acknowledged: false, codesBefore: 0, codesCleared: 0, raw: Uint8List(0), ); } final ecuType = _ref.read(settingsProvider).ecuType; // ERR00.. bytes: S300 37-40, KPro 62-79 (hvbt_protocol.pdf). List errOf(Uint8List f) => ecuType == EcuType.s300 ? f.sublist(37, 41) : f.sublist(62, 80); String hex(List b) => b.map((v) => v.toRadixString(16).padLeft(2, '0')).join(' '); int bits(int v) => v.toRadixString(2).replaceAll('0', '').length; final beforeFrame = state.lastFrame; final before = beforeFrame == null ? null : errOf(beforeFrame); final codesBefore = before == null ? 0 : before.fold(0, (n, b) => n + bits(b)); AppLog.i('Clear DTC: ERR bytes before = ${before == null ? "?" : hex(before)}'); // Bits stored before that have been seen clear at least once. final clearedMask = List.filled(before?.length ?? 0, 0); String? lastErr = before == null ? null : hex(before); final watchStart = DateTime.now(); final sub = frameStream.listen((f) { final err = errOf(f); final h = hex(err); if (h != lastErr) { final ms = DateTime.now().difference(watchStart).inMilliseconds; AppLog.i('Clear DTC: +$ms ms ERR bytes = $h'); lastErr = h; } if (before != null) { for (var i = 0; i < before.length; i++) { clearedMask[i] |= before[i] & ~err[i]; } } }); final command = Uint8List.fromList([0x1B, 0x70, 0x00]); command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70])); final result = await p.sendCommandAwaitReply( command, command, timeout: const Duration(milliseconds: 600), ); await Future.delayed(_dtcWatch); await sub.cancel(); final codesCleared = clearedMask.fold(0, (n, b) => n + bits(b)); AppLog.i('Clear DTC: ack=${result.acknowledged} ' 'rxBytes=${result.raw.length} ' 'codesCleared=$codesCleared/$codesBefore ' 'ERR bytes after = ${lastErr ?? "?"}'); return ( acknowledged: result.acknowledged, codesBefore: codesBefore, codesCleared: codesCleared, raw: result.raw, ); } static const _dtcWatch = Duration(seconds: 5); @override void dispose() { _statusTimer?.cancel(); _pollSub?.cancel(); _poller?.dispose(); _frameController.close(); super.dispose(); } } // ─── Providers ─────────────────────────────────────────────────────────────── /// Singleton BtService — lives as long as the app. final btServiceProvider = Provider((ref) { final service = BtService(); ref.onDispose(service.dispose); return service; }); /// BT connection state notifier. final btProvider = StateNotifierProvider((ref) => BtNotifier(ref)); /// Paired BT devices list (re-fetchable via ref.refresh). Only watched after /// Bluetooth permissions are granted. final pairedDevicesProvider = FutureProvider>((ref) { return FlutterBluetoothSerial.instance.getBondedDevices(); });