diff --git a/lib/app.dart b/lib/app.dart index 797a78001..3e58c40e0 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -128,6 +128,7 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver // a lock screen looking current. unawaited(WidgetService.refresh(app.repo)); if (app.isPaired) app.openSession(); + app.startScaleWatcher(); } else if (state == AppLifecycleState.hidden || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart new file mode 100644 index 000000000..d6cba9838 --- /dev/null +++ b/lib/ble/scale_presence.dart @@ -0,0 +1,195 @@ +// Hears a paired Mi scale wake up and syncs it, so a weighing needs no tap on +// the Devices page. +// +// A scale advertises only while it is awake (a foot on it, a tap), and the +// only thing that ever synced it on Android was the Devices-page button: the +// headless pass runs at boot, not on a schedule. So, while the app process +// lives (its foreground service keeps it), a short low-power scan filtered to +// the paired scale's address scans for [window] and rests [gap] between scans; a hit runs the normal +// `SessionLink.sync()` session, which reads the stored history too, so a +// missed window costs a delay and never a reading. +// +// The scan goes through `withScanLock`, so it queues behind a pairing scan +// instead of racing it. The band's own reconnect is an OS `autoConnect`, not a +// scan, so it is not held up by this. Nothing here connects: the session +// takes its own secondary-link slot. +// +// ponytail: fixed duty cycle and a flat [cooldown] after a hit (so a scale +// that stays awake is not synced in a loop). A second weighing inside the +// cooldown waits for the next window after it. Move to a long-lived filtered +// scan only if a real scale is seen missing its window. + +import 'dart:async'; + +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + +import '../data/db.dart'; +import 'adapters/_registry.dart' show kMiScale2, kMiScaleComposition; +import 'ble_state.dart' show withScanLock; +import 'session_link.dart'; + +/// The adapters this watches: the Mi scales (the thermometer is unverified). +final Set kAutoSyncAdapters = {kMiScaleComposition.id, kMiScale2.id}; + +class ScalePresenceWatcher { + ScalePresenceWatcher({ + Future> Function()? pairedScales, + Future Function(List remoteIds, Duration window)? hear, + Future Function(String adapterId)? sync, + Future Function(String adapterId)? abort, + this.window = const Duration(seconds: 8), + this.gap = const Duration(seconds: 20), + this.cooldown = const Duration(minutes: 3), + this.syncTimeout = const Duration(minutes: 2), + }) : _paired = pairedScales ?? _pairedScales, + _hear = hear ?? _scan, + _sync = sync ?? _syncSession, + _abort = abort ?? _abortSession; + + final Future> Function() _paired; + final Future Function(List, Duration) _hear; + final Future Function(String) _sync; + final Future Function(String) _abort; + final Duration window, gap, cooldown, syncTimeout; + + bool _stop = true; + bool _restartPending = false; + Future? _loop; + Completer? _nap; + + /// Start watching; a second call while running does nothing. + void start() { + if (_loop != null) { + // Still draining after a stop(): restart once it has ended, otherwise + // the watcher would stay dead for the rest of the process. + if (_stop) _restartPending = true; + return; + } + _stop = false; + _loop = _run().whenComplete(() { + _loop = null; + if (_restartPending) { + _restartPending = false; + start(); + } + }); + } + + /// Stop after the current cycle; completes once that loop has ended. A + /// start() made while it drains begins a new loop afterwards, which this + /// future does not wait for. + Future stop() async { + _stop = true; + _restartPending = false; + if (_nap case final n? when !n.isCompleted) n.complete(); + await _loop; + } + + Future _run() async { + while (!_stop) { + var wait = gap; + try { + final scales = await _paired(); // remote id (lower case) -> adapter id + if (scales.isNotEmpty) { + final hit = await _hear(scales.keys.toList(), window); + if (hit != null && !_stop) { + final adapterId = scales[hit.toLowerCase()]!; + await _sync(adapterId).timeout( + syncTimeout, + onTimeout: () { + // The timeout only stops the wait; end the session too, so a + // queued sync cannot connect after the watcher gave up on it. + unawaited( + _abort(adapterId).catchError( + (Object e) => debugPrint('[scale-watch] abort failed: $e'), + ), + ); + }, + ); + wait = cooldown; + } + } + } catch (e) { + // A missing scan permission or radio off: back off, never spin. + debugPrint('[scale-watch] cycle failed: $e'); + wait = cooldown; + } + if (_stop) break; + final nap = _nap = Completer(); + await Future.any([Future.delayed(wait), nap.future]); + } + } + + static Future> _pairedScales() async => { + for (final r in await LocalDb.deviceRows()) + if (kAutoSyncAdapters.contains(r['adapter_id']) && + r['id'] != LocalDb.kPrimaryDeviceId && + (r['remote_id'] as String?)?.isNotEmpty == true) + (r['remote_id'] as String).toLowerCase(): r['adapter_id'] as String, + }; + + static Future _syncSession(String adapterId) async { + await SessionLink.forId(adapterId)?.sync(); + } + + static Future _abortSession(String adapterId) async { + await SessionLink.forId(adapterId)?.stop(); + } + + static Future _scan(List ids, Duration window) => + withScanLock(() async { + String? hit; + final want = {for (final i in ids) i.toLowerCase()}; + final sub = FlutterBluePlus.onScanResults.listen((results) { + for (final r in results) { + final id = r.device.remoteId.str.toLowerCase(); + if (hit == null && want.contains(id)) { + hit = id; + unawaited( + FlutterBluePlus.stopScan().catchError( + (Object e) => debugPrint('[scale-watch] stopScan failed: $e'), + ), + ); + } + } + }); + try { + // startScan's own timeout only starts once the platform call has + // returned; a hung permission/startup call must not hold the lock. + final started = FlutterBluePlus.startScan( + withRemoteIds: [for (final i in ids) i.toUpperCase()], + timeout: window, + androidScanMode: AndroidScanMode.lowPower, + ); + try { + await started.timeout(const Duration(seconds: 10)); + } on TimeoutException { + // Quiet cycle. A scan that still starts late ends by itself after + // [window]; stopping it here could kill a scan another caller + // started once the lock was released. + unawaited(started.then((_) {}, onError: (Object _) {})); + return null; + } + // Bounded: a lost scan-stop event must not hold the process-wide + // scan lock (and with it every other scan) forever. + try { + await FlutterBluePlus.isScanning + .where((on) => !on) + .first + .timeout(window + const Duration(seconds: 5)); + } on TimeoutException { + // The stop event was lost: end the scan ourselves before the lock + // is released, and treat it as a quiet cycle (retry at [gap]). + await FlutterBluePlus.stopScan() + .timeout(const Duration(seconds: 3)) + .catchError( + (Object e) => debugPrint('[scale-watch] stopScan failed: $e'), + ); + } + } finally { + await sub.cancel(); + } + return hit; + }); +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 9d35685e3..0d6267716 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -44,6 +44,7 @@ import '../ble/live_step_runs.dart'; import '../ble/ble_state.dart' show AlarmConfirmation, AlarmEffect, BleUnavailableException, LiveStreamOwners; import '../ble/ios_ble_restore.dart'; +import '../ble/scale_presence.dart' show ScalePresenceWatcher; import '../ble/session_link.dart' show SessionLink; import '../ble/colmi_link.dart' show ColmiLink; import '../ble/coros_link.dart' show CorosLink; @@ -2005,6 +2006,15 @@ class AppState extends ChangeNotifier { /// cancelled there, but an already-suspended `await` cannot be, so every /// continuation past an await in this class needs to re-check this. bool _disposed = false; + ScalePresenceWatcher? _scaleWatcher; + + /// Android: start the scale watcher (idempotent). Also called when the app + /// resumes, so a process that was launched headless (boot wake) and opened + /// later gets it too. + void startScaleWatcher() { + if (_disposed || !Platform.isAndroid) return; + (_scaleWatcher ??= ScalePresenceWatcher()).start(); + } /// Central disposal guard. /// @@ -2023,6 +2033,7 @@ class AppState extends ChangeNotifier { @override void dispose() { + unawaited(_scaleWatcher?.stop()); _deriveCoordinator.cancelActivityReviewRetry(); if (IosShortcutSync.foregroundSync == syncForShortcut) { IosShortcutSync.foregroundSync = null; @@ -3053,6 +3064,8 @@ class AppState extends ChangeNotifier { // A scale's newest weighing becomes the profile's weight: after each // session, and now for one a background pass took. SessionLink.onSessionDone = adoptWeighing; + // Android: hear a paired scale wake up and sync it without a Devices tap. + if (!_background) startScaleWatcher(); // In the background: its re-derive must not hold the shell's launch. unawaited(adoptWeighing().catchError( (Object e) => debugPrint('[AppState] adoptWeighing failed: $e'))); diff --git a/test/ble/scale_presence_test.dart b/test/ble/scale_presence_test.dart new file mode 100644 index 000000000..171357d10 --- /dev/null +++ b/test/ble/scale_presence_test.dart @@ -0,0 +1,197 @@ +// The scale watcher: a hit syncs the scale once and then rests; no hit, no +// paired scale, or a failing scan never syncs and never kills the loop. +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/scale_presence.dart'; + +const _ms = Duration(milliseconds: 10); + +ScalePresenceWatcher _w({ + required Map paired, + required Future Function() hear, + required List synced, + Duration cooldown = const Duration(seconds: 5), + Future Function(String)? syncFn, + Duration syncTimeout = const Duration(minutes: 2), + Future Function(String)? abort, +}) => ScalePresenceWatcher( + pairedScales: () async => paired, + hear: (ids, window) => hear(), + sync: syncFn ?? (id) async => synced.add(id), + syncTimeout: syncTimeout, + abort: abort, + window: _ms, + gap: _ms, + cooldown: cooldown, +); + +void main() { + test( + 'a hit syncs that scale once, then the cooldown holds it back', + () async { + final synced = []; + final first = Completer(); + final w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async => 'AA:BB', + synced: synced, + syncFn: (id) async { + synced.add(id); + if (!first.isCompleted) first.complete(); + }, + ); + w.start(); + await first.future.timeout(const Duration(seconds: 2)); + // Many 10 ms gaps pass; the 5 s cooldown must hold every later hit back. + await Future.delayed(const Duration(milliseconds: 150)); + await w.stop(); + expect(synced, ['miscale_bc']); + }, + ); + + test( + 'nothing heard, or nothing paired: no sync, loop keeps running', + () async { + final synced = []; + var scans = 0; + final w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async { + scans++; + return null; + }, + synced: synced, + ); + w.start(); + await Future.delayed(const Duration(milliseconds: 200)); + await w.stop(); + expect(synced, isEmpty); + expect(scans, greaterThan(2)); + + var scanned = false; + final none = _w( + paired: {}, + hear: () async { + scanned = true; + return null; + }, + synced: synced, + ); + none.start(); + await Future.delayed(const Duration(milliseconds: 80)); + await none.stop(); + expect(scanned, isFalse); + }, + ); + + test( + 'a scan that throws backs off for the cooldown instead of spinning', + () async { + var scans = 0; + final w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async { + scans++; + throw StateError('bluetooth off'); + }, + synced: [], + cooldown: const Duration(milliseconds: 60), + ); + w.start(); + await Future.delayed(const Duration(milliseconds: 260)); + await w.stop().timeout(const Duration(seconds: 1)); + // Retried after the cooldown (so the loop is alive), but not every 10 ms + // gap (that would be ~25 scans). + expect(scans, inInclusiveRange(2, 8)); + }, + ); + + test( + 'a session that never finishes is aborted and abandoned, scanning resumes', + () async { + var scans = 0; + final aborted = []; + final w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async { + scans++; + return 'aa:bb'; + }, + synced: [], + syncFn: (_) => Completer().future, // hangs forever + syncTimeout: const Duration(milliseconds: 30), + abort: (id) async => aborted.add(id), + cooldown: const Duration(milliseconds: 40), + ); + w.start(); + await Future.delayed(const Duration(milliseconds: 250)); + await w.stop().timeout(const Duration(seconds: 1)); + expect(scans, greaterThan(1)); + expect(aborted, isNotEmpty); + expect(aborted.toSet(), {'miscale_bc'}); + }, + ); + + test( + 'a start() while a stopped loop is still draining restarts it, every time', + () async { + var scans = 0, inFlight = 0, maxInFlight = 0; + Completer? entered; + final w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async { + scans++; + inFlight++; + if (inFlight > maxInFlight) maxInFlight = inFlight; + if (entered?.isCompleted == false) entered!.complete(); + await Future.delayed(const Duration(milliseconds: 30)); + inFlight--; + return null; + }, + synced: [], + ); + entered = Completer(); + w.start(); + w.start(); // a second start() while running must not add a second loop + await entered.future.timeout(const Duration(seconds: 2)); + // Two stop/start rounds: a restart flag that is never cleared would show + // up as a spurious extra loop on the second round. + for (var round = 0; round < 2; round++) { + final stopped = w.stop(); + w.start(); // the old scan is still in flight + await stopped; + final before = scans; + entered = Completer(); + await entered.future.timeout(const Duration(seconds: 2)); + expect(scans, greaterThan(before), reason: 'round $round restarted'); + } + await w.stop(); + expect(maxInFlight, 1); + }, + ); + + test('stop() cancels a pending restart', () async { + var scans = 0; + final started = Completer(); + final w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async { + scans++; + if (!started.isCompleted) started.complete(); + await Future.delayed(const Duration(milliseconds: 40)); + return null; + }, + synced: [], + ); + w.start(); + await started.future.timeout(const Duration(seconds: 2)); + final stopped = w.stop(); + w.start(); + await w.stop(); + await stopped; + final after = scans; + await Future.delayed(const Duration(milliseconds: 120)); + expect(scans, after); + }); +} diff --git a/test/scan_lock_entry_points_test.dart b/test/scan_lock_entry_points_test.dart index 650365362..44126b4fd 100644 --- a/test/scan_lock_entry_points_test.dart +++ b/test/scan_lock_entry_points_test.dart @@ -3,7 +3,7 @@ // lock -- exactly the bug this milestone fixed at // lib/ui2/profile/pair_sensor.dart (spec-m0-m2.md ยง4). // -// Only two files may call FlutterBluePlus.startScan/stopScan or read +// Only these files may call FlutterBluePlus.startScan/stopScan or read // isScanning directly: lib/ble/ble_engine.dart and lib/ble/hrs_link.dart, // both of which route every call through withScanLock. Every other file must // go through HrsLink's public API (`scanFor`, `stopScanIfRunning`) or @@ -19,6 +19,9 @@ const _allowlist = { // BleEngine, not a new entry point. 'lib/ble/transport.dart', 'lib/ble/hrs_link.dart', + // The Android scale presence watcher: one low-power scan, filtered to the + // paired scale's address, run entirely inside `withScanLock`. + 'lib/ble/scale_presence.dart', }; final _pureComment = RegExp(r'^\s*(///|//|\*|/\*)');