| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500 |
- 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<BluetoothDeviceInfo> _devices = const [];
- bool _devicesKnown = false;
- bool _loadingDevices = false;
- String? _devicesError;
- bool _needsBluetoothPermission = false;
- BluetoothPowerState _bluetoothState = BluetoothPowerState.unknown;
- DateTime? _lastDeviceRefreshAt;
- Future<bool>? _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<BluetoothDeviceInfo> get devices => _devices;
- /// Paired devices that look like Bluetooth Classic OBD-II adapters.
- List<BluetoothDeviceInfo> get obdDevices =>
- List.unmodifiable(_devices.where((device) => device.isLikelyObd));
- /// Paired devices that do not advertise an OBD-II class or name.
- List<BluetoothDeviceInfo> 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<void> 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<void> 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<void> 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<bool> 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<bool> _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<bool> openBluetoothSettings() {
- return _platform.openBluetoothSettings();
- }
- Future<bool> _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<void> _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,
- fuelRateLph: next.fuelRateLph,
- );
- }
- }
- } 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();
- }
- }
|