obd_controller.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import 'dart:async';
  2. import 'package:flutter/foundation.dart';
  3. import '../../data/models/bluetooth_device_info.dart';
  4. import '../../data/models/bluetooth_power_state.dart';
  5. import '../../data/models/obd_state.dart';
  6. import '../../data/repositories/settings_repository.dart';
  7. import '../../data/services/telemetry_bridge.dart';
  8. /// Connection lifecycle for the optional ELM327 adapter.
  9. ///
  10. /// Bluetooth Classic SPP only; the adapter must already be paired in Android
  11. /// Settings. The native side queues the read-only PID requests.
  12. class ObdController extends ChangeNotifier {
  13. ObdController(this._platform, this._settings, {DateTime Function()? clock})
  14. : _clock = clock ?? DateTime.now;
  15. final TelemetryPlatform _platform;
  16. final SettingsRepository _settings;
  17. final DateTime Function() _clock;
  18. String? _selectedAddress;
  19. List<BluetoothDeviceInfo> _devices = const [];
  20. bool _devicesKnown = false;
  21. bool _loadingDevices = false;
  22. String? _devicesError;
  23. bool _needsBluetoothPermission = false;
  24. BluetoothPowerState _bluetoothState = BluetoothPowerState.unknown;
  25. DateTime? _lastDeviceRefreshAt;
  26. Future<bool>? _deviceRefresh;
  27. Timer? _bluetoothRetryTimer;
  28. int _bluetoothRetryAttempts = 0;
  29. ObdState _state = const ObdState();
  30. String? _localError;
  31. Timer? _pollTimer;
  32. bool _active = false;
  33. bool _polling = false;
  34. bool _connectionFailed = false;
  35. bool _disposed = false;
  36. String? get selectedAddress => _selectedAddress;
  37. List<BluetoothDeviceInfo> get devices => _devices;
  38. /// Paired devices that look like Bluetooth Classic OBD-II adapters.
  39. List<BluetoothDeviceInfo> get obdDevices =>
  40. List.unmodifiable(_devices.where((device) => device.isLikelyObd));
  41. /// Paired devices that do not advertise an OBD-II class or name.
  42. List<BluetoothDeviceInfo> get otherDevices =>
  43. List.unmodifiable(_devices.where((device) => !device.isLikelyObd));
  44. bool get loadingDevices => _loadingDevices;
  45. bool get devicesKnown => _devicesKnown;
  46. String? get devicesError => _devicesError;
  47. bool get needsBluetoothPermission => _needsBluetoothPermission;
  48. BluetoothPowerState get bluetoothState => _bluetoothState;
  49. bool get bluetoothOff => _bluetoothState.isOff;
  50. bool get bluetoothTransitioning => _bluetoothState.isTransitioning;
  51. bool get connected => _state.connected;
  52. bool get connecting => _state.connecting;
  53. bool get hasConfiguredAdapter => _selectedAddress != null;
  54. /// The native worker stopped retrying, or the saved device is not a likely
  55. /// OBD adapter. Offer an explicit retry action in either case.
  56. bool get canRetry =>
  57. _selectedAddress != null &&
  58. (_connectionFailed || _selectedDeviceIsUnsupported);
  59. String? get error {
  60. if (_localError != null) return _localError;
  61. if (_state.connected) return null;
  62. return _state.error;
  63. }
  64. String get statusLabel {
  65. if (_loadingDevices) return 'Checking paired devices…';
  66. if (_needsBluetoothPermission) return 'Bluetooth permission needed';
  67. if (_bluetoothState == BluetoothPowerState.turningOn) {
  68. return 'Bluetooth is starting…';
  69. }
  70. if (_bluetoothState == BluetoothPowerState.turningOff) {
  71. return 'Bluetooth is turning off…';
  72. }
  73. if (_bluetoothState == BluetoothPowerState.off) return 'Bluetooth is off';
  74. if (_bluetoothState == BluetoothPowerState.unavailable) {
  75. return 'Bluetooth is unavailable';
  76. }
  77. if (_bluetoothState == BluetoothPowerState.unknown) {
  78. return 'Bluetooth is not responding';
  79. }
  80. if (connecting) return 'Connecting…';
  81. if (connected) {
  82. return isFresh ? 'Connected' : 'Connected — waiting for data';
  83. }
  84. if (_connectionFailed) return 'Not connected — adapter not responding';
  85. if (error != null) return 'Not connected — $error';
  86. if (_selectedAddress == null) return 'Off';
  87. return 'Not connected';
  88. }
  89. bool get isFresh {
  90. if (!_state.connected || _state.updatedAt <= 0) return false;
  91. final age = _clock().millisecondsSinceEpoch - _state.updatedAt;
  92. return age >= 0 && age < 5000;
  93. }
  94. bool get hasTelemetry =>
  95. isFresh && (_state.speedKmh != null || _state.throttlePercent != null);
  96. double? get speedKmh => isFresh ? _state.speedKmh : null;
  97. double? get throttlePercent => isFresh ? _state.throttlePercent : null;
  98. double? get ambientCelsius =>
  99. _state.connected && isFresh ? _state.ambientCelsius : null;
  100. void initialize() {
  101. _selectedAddress = _settings.obdAdapterAddress;
  102. }
  103. Future<void> start() async {
  104. if (_active) {
  105. // The native worker keeps polling while the app is backgrounded. Refresh
  106. // the Flutter snapshot immediately on resume so the speed card does not
  107. // flash the GPS fallback for a second.
  108. unawaited(_poll());
  109. return;
  110. }
  111. _active = true;
  112. var attempted = false;
  113. if (_selectedAddress != null && !_connectionFailed) {
  114. if (!_devicesKnown) {
  115. await refreshDevices(force: true);
  116. }
  117. if (!_active) {
  118. _notify();
  119. return;
  120. }
  121. final address = _selectedAddress;
  122. final selected = _deviceFor(address);
  123. if (address == null) {
  124. // The user selected None while the device list was loading.
  125. } else if (selected == null) {
  126. if (_devicesKnown && _bluetoothState.isOn) {
  127. _localError = 'Saved adapter is no longer paired.';
  128. } else if (!_devicesKnown) {
  129. _localError =
  130. _devicesError ?? 'Could not read paired Bluetooth devices.';
  131. }
  132. } else if (!selected.isLikelyObd) {
  133. _localError =
  134. 'Saved adapter does not look like an OBD-II adapter. '
  135. 'Use Retry connection to try it anyway.';
  136. } else {
  137. attempted = await _connect();
  138. }
  139. }
  140. if (!_active) {
  141. _notify();
  142. return;
  143. }
  144. if (attempted) {
  145. _startPolling();
  146. } else {
  147. _pollTimer?.cancel();
  148. _pollTimer = null;
  149. }
  150. _notify();
  151. }
  152. void stop() {
  153. _active = false;
  154. _pollTimer?.cancel();
  155. _pollTimer = null;
  156. _bluetoothRetryTimer?.cancel();
  157. _bluetoothRetryTimer = null;
  158. _bluetoothRetryAttempts = 0;
  159. _state = const ObdState();
  160. unawaited(_platform.disconnectObd());
  161. _notify();
  162. }
  163. Future<void> selectAdapter(String? address) async {
  164. if (_selectedAddress == address) return;
  165. _selectedAddress = address;
  166. _localError = null;
  167. _connectionFailed = false;
  168. _state = const ObdState();
  169. await _settings.setObdAdapterAddress(address);
  170. await _platform.disconnectObd();
  171. if (_active) {
  172. if (address == null) {
  173. _pollTimer?.cancel();
  174. _pollTimer = null;
  175. } else {
  176. final started = await _connect();
  177. if (started && _active) _startPolling();
  178. }
  179. }
  180. _notify();
  181. }
  182. /// Explicitly retries the saved adapter after the worker gave up.
  183. ///
  184. /// This bypasses the "looks like an OBD-II adapter" guard so an unusual
  185. /// adapter can still be used after the user deliberately selects Retry.
  186. Future<void> retryConnection() async {
  187. if (_selectedAddress == null) return;
  188. _connectionFailed = false;
  189. _localError = null;
  190. _state = const ObdState();
  191. _notify();
  192. await _platform.disconnectObd();
  193. if (!_active) return;
  194. final started = await _connect();
  195. if (started && _active) _startPolling();
  196. _notify();
  197. }
  198. /// Lists bonded devices. Pass [requestPermission] only when the user asked to
  199. /// find adapters; the Settings card can show a dedicated allow button.
  200. ///
  201. /// [dueToResume] performs a cheap adapter-state check but skips the bonded
  202. /// list when it is already cached. This keeps the app from querying the
  203. /// Bluetooth stack on every lifecycle resume.
  204. Future<bool> refreshDevices({
  205. bool requestPermission = false,
  206. bool force = false,
  207. bool dueToResume = false,
  208. }) {
  209. final inFlight = _deviceRefresh;
  210. if (inFlight != null) return inFlight;
  211. final future = _refreshDevices(
  212. requestPermission: requestPermission,
  213. force: force,
  214. dueToResume: dueToResume,
  215. );
  216. _deviceRefresh = future;
  217. return future.whenComplete(() {
  218. if (identical(_deviceRefresh, future)) _deviceRefresh = null;
  219. });
  220. }
  221. Future<bool> _refreshDevices({
  222. required bool requestPermission,
  223. required bool force,
  224. required bool dueToResume,
  225. }) async {
  226. final now = _clock();
  227. final last = _lastDeviceRefreshAt;
  228. if (!force &&
  229. last != null &&
  230. now.difference(last) < const Duration(seconds: 2)) {
  231. return false;
  232. }
  233. _lastDeviceRefreshAt = now;
  234. _loadingDevices = true;
  235. _devicesError = null;
  236. _notify();
  237. try {
  238. if (requestPermission) {
  239. final granted = await _platform.ensureBluetoothPermission();
  240. if (!granted) {
  241. _needsBluetoothPermission = true;
  242. _devicesError = 'Bluetooth permission was denied.';
  243. return false;
  244. }
  245. }
  246. final previousState = _bluetoothState;
  247. final state = await _platform.bluetoothState();
  248. _bluetoothState = state;
  249. if (!previousState.isOn && state.isOn) {
  250. // Bluetooth was turned off and on; allow one fresh connection attempt.
  251. _connectionFailed = false;
  252. }
  253. if (state.isOn) {
  254. if (dueToResume && !force && _devicesKnown) {
  255. _needsBluetoothPermission = false;
  256. return true;
  257. }
  258. final found = await _platform.listBondedDevices();
  259. _devices = List.unmodifiable(found);
  260. _devicesKnown = true;
  261. _needsBluetoothPermission = false;
  262. _bluetoothRetryAttempts = 0;
  263. _bluetoothRetryTimer?.cancel();
  264. _bluetoothRetryTimer = null;
  265. return true;
  266. }
  267. if (state.isTransitioning) {
  268. _devicesKnown = false;
  269. _devicesError = null;
  270. _needsBluetoothPermission = false;
  271. _scheduleBluetoothRetry();
  272. return false;
  273. }
  274. if (state.isOff) {
  275. _devices = const [];
  276. _devicesKnown = false;
  277. _devicesError = null;
  278. _needsBluetoothPermission = false;
  279. _bluetoothRetryAttempts = 0;
  280. _bluetoothRetryTimer?.cancel();
  281. _bluetoothRetryTimer = null;
  282. return false;
  283. }
  284. if (state == BluetoothPowerState.unavailable) {
  285. _devicesError = 'Bluetooth is not available on this device.';
  286. _needsBluetoothPermission = false;
  287. return false;
  288. }
  289. _devicesKnown = false;
  290. _devicesError = 'Bluetooth is not responding.';
  291. _needsBluetoothPermission = false;
  292. _scheduleBluetoothRetry();
  293. return false;
  294. } on TelemetryException catch (problem) {
  295. if (problem.code == 'turning_on') {
  296. _bluetoothState = BluetoothPowerState.turningOn;
  297. _devicesKnown = false;
  298. _devicesError = null;
  299. _needsBluetoothPermission = false;
  300. _scheduleBluetoothRetry();
  301. } else if (problem.code == 'turning_off') {
  302. _bluetoothState = BluetoothPowerState.turningOff;
  303. _devicesKnown = false;
  304. _devicesError = null;
  305. _needsBluetoothPermission = false;
  306. _scheduleBluetoothRetry();
  307. } else if (problem.code == 'disabled') {
  308. _bluetoothState = BluetoothPowerState.off;
  309. _devices = const [];
  310. _devicesKnown = false;
  311. _devicesError = null;
  312. _needsBluetoothPermission = false;
  313. } else if (problem.code == 'permission') {
  314. _needsBluetoothPermission = true;
  315. _devicesKnown = false;
  316. _devicesError = problem.message;
  317. } else if (problem.code == 'unavailable') {
  318. _bluetoothState = BluetoothPowerState.unavailable;
  319. _devicesKnown = false;
  320. _devicesError = problem.message;
  321. _needsBluetoothPermission = false;
  322. } else {
  323. _needsBluetoothPermission = problem.message.toLowerCase().contains(
  324. 'permission',
  325. );
  326. _devicesKnown = false;
  327. _devicesError = problem.message;
  328. }
  329. return false;
  330. } finally {
  331. _loadingDevices = false;
  332. _notify();
  333. }
  334. }
  335. Future<bool> openBluetoothSettings() {
  336. return _platform.openBluetoothSettings();
  337. }
  338. Future<bool> _connect() async {
  339. final address = _selectedAddress;
  340. if (address == null) return false;
  341. _pollTimer?.cancel();
  342. _pollTimer = null;
  343. _localError = null;
  344. _connectionFailed = false;
  345. var started = false;
  346. try {
  347. await _platform.connectObd(address);
  348. started = true;
  349. } on TelemetryException catch (problem) {
  350. _localError = problem.message;
  351. _needsBluetoothPermission = problem.code == 'permission';
  352. if (problem.code == 'timeout' ||
  353. problem.code == 'permission' ||
  354. problem.code == 'disabled' ||
  355. problem.code == 'unavailable') {
  356. _connectionFailed = problem.code == 'timeout';
  357. _pollTimer?.cancel();
  358. _pollTimer = null;
  359. }
  360. } catch (_) {
  361. _localError = 'Could not connect to the OBD adapter.';
  362. }
  363. _notify();
  364. return started;
  365. }
  366. void _startPolling() {
  367. _pollTimer?.cancel();
  368. _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _poll());
  369. }
  370. Future<void> _poll() async {
  371. if (!_active || _polling) return;
  372. _polling = true;
  373. try {
  374. final next = await _platform.getObdState();
  375. _state = next;
  376. if (next.connected) {
  377. _connectionFailed = false;
  378. }
  379. if (next.retryExhausted) {
  380. _connectionFailed = true;
  381. _pollTimer?.cancel();
  382. _pollTimer = null;
  383. }
  384. } catch (_) {
  385. // Keep the last known state; the next poll may recover.
  386. } finally {
  387. _polling = false;
  388. _notify();
  389. }
  390. }
  391. BluetoothDeviceInfo? _deviceFor(String? address) {
  392. if (address == null) return null;
  393. for (final device in _devices) {
  394. if (device.address == address) return device;
  395. }
  396. return null;
  397. }
  398. bool get _selectedDeviceIsUnsupported {
  399. final device = _deviceFor(_selectedAddress);
  400. return device != null && !device.isLikelyObd;
  401. }
  402. void _scheduleBluetoothRetry() {
  403. _bluetoothRetryTimer?.cancel();
  404. if (_disposed || _bluetoothRetryAttempts >= 3) return;
  405. _bluetoothRetryAttempts++;
  406. _bluetoothRetryTimer = Timer(const Duration(seconds: 2), () {
  407. _bluetoothRetryTimer = null;
  408. if (_disposed || !_active) return;
  409. unawaited(refreshDevices(force: true));
  410. });
  411. }
  412. void _notify() {
  413. if (!_disposed) notifyListeners();
  414. }
  415. @override
  416. void dispose() {
  417. _disposed = true;
  418. _pollTimer?.cancel();
  419. _bluetoothRetryTimer?.cancel();
  420. unawaited(_platform.disconnectObd());
  421. super.dispose();
  422. }
  423. }