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/bt_service.dart'; import '../protocol/neg8.dart'; import 'settings_provider.dart'; // ─── Connection status enum ────────────────────────────────────────────────── enum BtStatus { disconnected, connecting, connected, error } // ─── State ─────────────────────────────────────────────────────────────────── class BtState { final BtStatus status; final String? deviceName; final String? error; final int frameCount; final int droppedFrames; final Uint8List? lastFrame; const BtState({ required this.status, this.deviceName, this.error, this.frameCount = 0, this.droppedFrames = 0, this.lastFrame, }); const BtState.disconnected() : this(status: BtStatus.disconnected); BtState connecting(String name) => BtState(status: BtStatus.connecting, deviceName: name); BtState connected(String name) => BtState(status: BtStatus.connected, deviceName: name); BtState withError(String msg) => BtState( status: BtStatus.error, deviceName: deviceName, error: msg, frameCount: frameCount, droppedFrames: droppedFrames, lastFrame: lastFrame, ); BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState( status: status, deviceName: deviceName, frameCount: frames, droppedFrames: dropped, lastFrame: Uint8List.fromList(frame), ); bool get isConnected => status == BtStatus.connected; bool get isConnecting => status == BtStatus.connecting; } // ─── Notifier ──────────────────────────────────────────────────────────────── class BtNotifier extends StateNotifier { final Ref _ref; BtPoller? _poller; StreamSubscription? _pollSub; final StreamController _frameController = StreamController.broadcast(); BtNotifier(this._ref) : super(const BtState.disconnected()); /// Raw validated 128-byte frame stream — consumed by sensorStateProvider. Stream get frameStream => _frameController.stream; Future connect(BluetoothDevice device) async { state = state.connecting(device.name ?? device.address); final service = _ref.read(btServiceProvider); final settings = _ref.read(settingsProvider); try { await service.connect(device); _poller = BtPoller( service, ecuType: settings.ecuType, pollingInterval: settings.pollingInterval, ); _pollSub = _poller!.frameStream.listen( (frame) { _frameController.add(frame); state = state.withFrameStats( _poller!.validFrames, _poller!.droppedFrames, frame, ); }, onError: (Object e) { _frameController.addError(e); state = state.withError(e.toString()); }, onDone: () => state = state.withError('BT stream closed'), ); _poller!.start(); state = state.connected(device.name ?? device.address); } catch (e) { await service.disconnect(); state = state.withError(e.toString()); } } /// One-off diagnostic: sends the official Hondata Bluetooth protocol's /// DL_DeviceInfo command ({ Protocol=0x00, ID=0x02, Size=0x0004 LE }) and /// returns whatever raw bytes come back within the response window. Used /// to check whether this device/firmware understands the newer, /// self-describing channel-based protocol (Bluetooth.pdf) as opposed to /// only the legacy fixed-offset frame format this app currently speaks. /// Pauses the legacy poller for the duration so its own request/response /// traffic doesn't interleave with the probe's reply on the same link. Future probeOfficialProtocol() async { final service = _ref.read(btServiceProvider); if (!service.isConnected) return Uint8List(0); _poller?.stop(); // stop() only cancels the poller's own timer and listener — it can't // un-send a legacy request the poller already wrote moments earlier. // That request's 128-byte reply is very likely still in flight, so // drain and discard whatever arrives here before sending the probe, or // that leftover legacy frame gets mistaken for the probe's response. final drainSub = service.rawStream.listen((_) {}); await Future.delayed(const Duration(milliseconds: 500)); await drainSub.cancel(); final collected = []; final sub = service.rawStream.listen((chunk) => collected.addAll(chunk)); try { await service.write(Uint8List.fromList([0x00, 0x02, 0x04, 0x00])); await Future.delayed(const Duration(milliseconds: 800)); } finally { await sub.cancel(); _poller?.start(); } return Uint8List.fromList(collected); } /// Sends the DTC-reset command from the legacy protocol notes /// (hvbt_protocol.pdf): { 0x1B, 0x70, NEG8 }, NEG8 over the first two /// bytes. The ECU echoes the same 3 bytes back to acknowledge. /// /// Deliberately does NOT pause the poller first (unlike /// probeOfficialProtocol()) — pausing-then-sending got zero response both /// here and for the official-protocol probe, while the regular poll cycle /// always gets answered. One untested explanation: this adapter may only /// answer additional commands while it keeps seeing the normal poll /// traffic, not when that traffic stops. So this injects the command /// alongside ongoing polling and scans the stream for the 3-byte ack /// appearing anywhere in it — it'll be interleaved with regular 128-byte /// datalog frames rather than arriving in isolation — over several poll /// cycles' worth of time, in case the reply is delayed by an EEPROM/flash /// write on the ECU rather than a simple RAM read. Future<({bool acknowledged, Uint8List raw})> clearDtcs() async { final service = _ref.read(btServiceProvider); if (!service.isConnected) return (acknowledged: false, raw: Uint8List(0)); final command = Uint8List.fromList([0x1B, 0x70, 0x00]); command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70])); final collected = []; final sub = service.rawStream.listen((chunk) => collected.addAll(chunk)); bool acknowledged = false; try { await service.write(command); final deadline = DateTime.now().add(const Duration(seconds: 3)); while (DateTime.now().isBefore(deadline) && !acknowledged) { await Future.delayed(const Duration(milliseconds: 150)); acknowledged = _containsAck(collected); } } finally { await sub.cancel(); } return (acknowledged: acknowledged, raw: Uint8List.fromList(collected)); } static bool _containsAck(List bytes) { for (var i = 0; i + 2 < bytes.length; i++) { if (bytes[i] == 0x1B && bytes[i + 1] == 0x70 && bytes[i + 2] == 0x75) { return true; } } return false; } Future disconnect() async { await _pollSub?.cancel(); _poller?.stop(); _poller?.dispose(); _poller = null; await _ref.read(btServiceProvider).disconnect(); state = const BtState.disconnected(); } @override void dispose() { _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). final pairedDevicesProvider = FutureProvider>((ref) { return FlutterBluetoothSerial.instance.getBondedDevices(); });