obd_controller.dart 15 KB

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