import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/models/bluetooth_device_info.dart'; import '../../data/models/bluetooth_power_state.dart'; import '../../data/models/obd_state.dart'; import '../../data/repositories/settings_repository.dart'; import '../../data/services/telemetry_bridge.dart'; import 'obd_economy.dart'; /// Connection lifecycle and rolling economy for the optional ELM327 adapter. /// /// Bluetooth Classic SPP only; the adapter must already be paired in Android /// Settings. The native side queues the read-only PID requests. class ObdController extends ChangeNotifier { ObdController(this._platform, this._settings, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now; final TelemetryPlatform _platform; final SettingsRepository _settings; final DateTime Function() _clock; final ObdEconomy _economy = ObdEconomy(); String? _selectedAddress; List _devices = const []; bool _devicesKnown = false; bool _loadingDevices = false; String? _devicesError; bool _needsBluetoothPermission = false; BluetoothPowerState _bluetoothState = BluetoothPowerState.unknown; DateTime? _lastDeviceRefreshAt; Future? _deviceRefresh; Timer? _bluetoothRetryTimer; int _bluetoothRetryAttempts = 0; ObdState _state = const ObdState(); String? _localError; Timer? _pollTimer; bool _active = false; bool _polling = false; bool _connectionFailed = false; bool _disposed = false; int? _lastEconomyTimestamp; String? get selectedAddress => _selectedAddress; List get devices => _devices; /// Paired devices that look like Bluetooth Classic OBD-II adapters. List get obdDevices => List.unmodifiable(_devices.where((device) => device.isLikelyObd)); /// Paired devices that do not advertise an OBD-II class or name. List get otherDevices => List.unmodifiable(_devices.where((device) => !device.isLikelyObd)); bool get loadingDevices => _loadingDevices; bool get devicesKnown => _devicesKnown; String? get devicesError => _devicesError; bool get needsBluetoothPermission => _needsBluetoothPermission; BluetoothPowerState get bluetoothState => _bluetoothState; bool get bluetoothOff => _bluetoothState.isOff; bool get bluetoothTransitioning => _bluetoothState.isTransitioning; bool get connected => _state.connected; bool get connecting => _state.connecting; bool get hasConfiguredAdapter => _selectedAddress != null; /// The native worker stopped retrying, or the saved device is not a likely /// OBD adapter. Offer an explicit retry action in either case. bool get canRetry => _selectedAddress != null && (_connectionFailed || _selectedDeviceIsUnsupported); String? get error { if (_localError != null) return _localError; if (_state.connected) return null; return _state.error; } String get statusLabel { if (_loadingDevices) return 'Checking paired devices…'; if (_needsBluetoothPermission) return 'Bluetooth permission needed'; if (_bluetoothState == BluetoothPowerState.turningOn) { return 'Bluetooth is starting…'; } if (_bluetoothState == BluetoothPowerState.turningOff) { return 'Bluetooth is turning off…'; } if (_bluetoothState == BluetoothPowerState.off) return 'Bluetooth is off'; if (_bluetoothState == BluetoothPowerState.unavailable) { return 'Bluetooth is unavailable'; } if (_bluetoothState == BluetoothPowerState.unknown) { return 'Bluetooth is not responding'; } if (connecting) return 'Connecting…'; if (connected) { return isFresh ? 'Connected' : 'Connected — waiting for data'; } if (_connectionFailed) return 'Not connected — adapter not responding'; if (error != null) return 'Not connected — $error'; if (_selectedAddress == null) return 'Off'; return 'Not connected'; } bool get isFresh { if (!_state.connected || _state.updatedAt <= 0) return false; final age = _clock().millisecondsSinceEpoch - _state.updatedAt; return age >= 0 && age < 5000; } bool get hasTelemetry => isFresh && (_state.speedKmh != null || _state.throttlePercent != null); double? get speedKmh => isFresh ? _state.speedKmh : null; double? get throttlePercent => isFresh ? _state.throttlePercent : null; double? get ambientCelsius => _state.connected && isFresh ? _state.ambientCelsius : null; double? get economyL100 => isFresh ? _economy.litresPer100Km : null; void initialize() { _selectedAddress = _settings.obdAdapterAddress; } Future start() async { if (_active) return; _active = true; var attempted = false; if (_selectedAddress != null && !_connectionFailed) { if (!_devicesKnown) { await refreshDevices(force: true); } if (!_active) { _notify(); return; } final address = _selectedAddress; final selected = _deviceFor(address); if (address == null) { // The user selected None while the device list was loading. } else if (selected == null) { if (_devicesKnown && _bluetoothState.isOn) { _localError = 'Saved adapter is no longer paired.'; } else if (!_devicesKnown) { _localError = _devicesError ?? 'Could not read paired Bluetooth devices.'; } } else if (!selected.isLikelyObd) { _localError = 'Saved adapter does not look like an OBD-II adapter. ' 'Use Retry connection to try it anyway.'; } else { attempted = await _connect(); } } if (!_active) { _notify(); return; } if (attempted) { _startPolling(); } else { _pollTimer?.cancel(); _pollTimer = null; } _notify(); } void stop() { _active = false; _pollTimer?.cancel(); _pollTimer = null; _bluetoothRetryTimer?.cancel(); _bluetoothRetryTimer = null; _bluetoothRetryAttempts = 0; _lastEconomyTimestamp = null; _economy.reset(); _state = const ObdState(); unawaited(_platform.disconnectObd()); _notify(); } Future selectAdapter(String? address) async { if (_selectedAddress == address) return; _selectedAddress = address; _localError = null; _connectionFailed = false; _lastEconomyTimestamp = null; _economy.reset(); _state = const ObdState(); await _settings.setObdAdapterAddress(address); await _platform.disconnectObd(); if (_active) { if (address == null) { _pollTimer?.cancel(); _pollTimer = null; } else { final started = await _connect(); if (started && _active) _startPolling(); } } _notify(); } /// Explicitly retries the saved adapter after the worker gave up. /// /// This bypasses the "looks like an OBD-II adapter" guard so an unusual /// adapter can still be used after the user deliberately selects Retry. Future retryConnection() async { if (_selectedAddress == null) return; _connectionFailed = false; _localError = null; _lastEconomyTimestamp = null; _economy.reset(); _state = const ObdState(); _notify(); await _platform.disconnectObd(); if (!_active) return; final started = await _connect(); if (started && _active) _startPolling(); _notify(); } /// Lists bonded devices. Pass [requestPermission] only when the user asked to /// find adapters; the Settings card can show a dedicated allow button. /// /// [dueToResume] performs a cheap adapter-state check but skips the bonded /// list when it is already cached. This keeps the app from querying the /// Bluetooth stack on every lifecycle resume. Future refreshDevices({ bool requestPermission = false, bool force = false, bool dueToResume = false, }) { final inFlight = _deviceRefresh; if (inFlight != null) return inFlight; final future = _refreshDevices( requestPermission: requestPermission, force: force, dueToResume: dueToResume, ); _deviceRefresh = future; return future.whenComplete(() { if (identical(_deviceRefresh, future)) _deviceRefresh = null; }); } Future _refreshDevices({ required bool requestPermission, required bool force, required bool dueToResume, }) async { final now = _clock(); final last = _lastDeviceRefreshAt; if (!force && last != null && now.difference(last) < const Duration(seconds: 2)) { return false; } _lastDeviceRefreshAt = now; _loadingDevices = true; _devicesError = null; _notify(); try { if (requestPermission) { final granted = await _platform.ensureBluetoothPermission(); if (!granted) { _needsBluetoothPermission = true; _devicesError = 'Bluetooth permission was denied.'; return false; } } final previousState = _bluetoothState; final state = await _platform.bluetoothState(); _bluetoothState = state; if (!previousState.isOn && state.isOn) { // Bluetooth was turned off and on; allow one fresh connection attempt. _connectionFailed = false; } if (state.isOn) { if (dueToResume && !force && _devicesKnown) { _needsBluetoothPermission = false; return true; } final found = await _platform.listBondedDevices(); _devices = List.unmodifiable(found); _devicesKnown = true; _needsBluetoothPermission = false; _bluetoothRetryAttempts = 0; _bluetoothRetryTimer?.cancel(); _bluetoothRetryTimer = null; return true; } if (state.isTransitioning) { _devicesKnown = false; _devicesError = null; _needsBluetoothPermission = false; _scheduleBluetoothRetry(); return false; } if (state.isOff) { _devices = const []; _devicesKnown = false; _devicesError = null; _needsBluetoothPermission = false; _bluetoothRetryAttempts = 0; _bluetoothRetryTimer?.cancel(); _bluetoothRetryTimer = null; return false; } if (state == BluetoothPowerState.unavailable) { _devicesError = 'Bluetooth is not available on this device.'; _needsBluetoothPermission = false; return false; } _devicesKnown = false; _devicesError = 'Bluetooth is not responding.'; _needsBluetoothPermission = false; _scheduleBluetoothRetry(); return false; } on TelemetryException catch (problem) { if (problem.code == 'turning_on') { _bluetoothState = BluetoothPowerState.turningOn; _devicesKnown = false; _devicesError = null; _needsBluetoothPermission = false; _scheduleBluetoothRetry(); } else if (problem.code == 'turning_off') { _bluetoothState = BluetoothPowerState.turningOff; _devicesKnown = false; _devicesError = null; _needsBluetoothPermission = false; _scheduleBluetoothRetry(); } else if (problem.code == 'disabled') { _bluetoothState = BluetoothPowerState.off; _devices = const []; _devicesKnown = false; _devicesError = null; _needsBluetoothPermission = false; } else if (problem.code == 'permission') { _needsBluetoothPermission = true; _devicesKnown = false; _devicesError = problem.message; } else if (problem.code == 'unavailable') { _bluetoothState = BluetoothPowerState.unavailable; _devicesKnown = false; _devicesError = problem.message; _needsBluetoothPermission = false; } else { _needsBluetoothPermission = problem.message.toLowerCase().contains( 'permission', ); _devicesKnown = false; _devicesError = problem.message; } return false; } finally { _loadingDevices = false; _notify(); } } Future openBluetoothSettings() { return _platform.openBluetoothSettings(); } Future _connect() async { final address = _selectedAddress; if (address == null) return false; _pollTimer?.cancel(); _pollTimer = null; _localError = null; _connectionFailed = false; var started = false; try { await _platform.connectObd(address); started = true; } on TelemetryException catch (problem) { _localError = problem.message; _needsBluetoothPermission = problem.code == 'permission'; if (problem.code == 'timeout' || problem.code == 'permission' || problem.code == 'disabled' || problem.code == 'unavailable') { _connectionFailed = problem.code == 'timeout'; _pollTimer?.cancel(); _pollTimer = null; } } catch (_) { _localError = 'Could not connect to the OBD adapter.'; } _notify(); return started; } void _startPolling() { _pollTimer?.cancel(); _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _poll()); } Future _poll() async { if (!_active || _polling) return; _polling = true; try { final next = await _platform.getObdState(); final lostConnection = _state.connected && !next.connected; if (lostConnection) { _economy.reset(); _lastEconomyTimestamp = null; } _state = next; if (next.connected) { _connectionFailed = false; } if (next.retryExhausted) { _connectionFailed = true; _pollTimer?.cancel(); _pollTimer = null; } if (next.connected && next.updatedAt > 0) { if (_lastEconomyTimestamp != next.updatedAt) { _lastEconomyTimestamp = next.updatedAt; _economy.add( at: _clock(), speedKmh: next.speedKmh, mafGps: next.mafGramsPerSecond, throttlePercent: next.throttlePercent, ); } } } catch (_) { // Keep the last known state; the next poll may recover. } finally { _polling = false; _notify(); } } BluetoothDeviceInfo? _deviceFor(String? address) { if (address == null) return null; for (final device in _devices) { if (device.address == address) return device; } return null; } bool get _selectedDeviceIsUnsupported { final device = _deviceFor(_selectedAddress); return device != null && !device.isLikelyObd; } void _scheduleBluetoothRetry() { _bluetoothRetryTimer?.cancel(); if (_disposed || _bluetoothRetryAttempts >= 3) return; _bluetoothRetryAttempts++; _bluetoothRetryTimer = Timer(const Duration(seconds: 2), () { _bluetoothRetryTimer = null; if (_disposed || !_active) return; unawaited(refreshDevices(force: true)); }); } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; _pollTimer?.cancel(); _bluetoothRetryTimer?.cancel(); unawaited(_platform.disconnectObd()); super.dispose(); } }