From f8bcd7f0bfcdb617370c9a2b8721507ed60802fd Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:46:18 +0300 Subject: [PATCH 01/12] Android: sync a paired Mi scale when it wakes up A filtered low-power scan for the paired scale's remote id runs while the app process is alive; on a hit the scale syncs through its SessionLink, then waits a cooldown. Android only, not in the headless pass. --- lib/ble/scale_presence.dart | 129 ++++++++++++++++++++++++++++++ lib/state/app_state.dart | 7 ++ test/ble/scale_presence_test.dart | 80 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 lib/ble/scale_presence.dart create mode 100644 test/ble/scale_presence_test.dart diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart new file mode 100644 index 000000000..05708183b --- /dev/null +++ b/lib/ble/scale_presence.dart @@ -0,0 +1,129 @@ +// 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 runs every [gap]; 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 or +// the primary band's instead of racing it. 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, + this.window = const Duration(seconds: 8), + this.gap = const Duration(seconds: 20), + this.cooldown = const Duration(minutes: 3), + }) : _paired = pairedScales ?? _pairedScales, + _hear = hear ?? _scan, + _sync = sync ?? _syncSession; + + final Future> Function() _paired; + final Future Function(List, Duration) _hear; + final Future Function(String) _sync; + final Duration window, gap, cooldown; + + bool _stop = true; + Future? _loop; + Completer? _nap; + + /// Start watching; a second call while running does nothing. + void start() { + if (_loop != null) return; + _stop = false; + _loop = _run().whenComplete(() => _loop = null); + } + + /// Stop after the current cycle; completes once the loop has ended. + Future stop() async { + _stop = true; + 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) { + await _sync(scales[hit.toLowerCase()]!); + 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 _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()); + } + } + }); + try { + await FlutterBluePlus.startScan( + withRemoteIds: [for (final i in ids) i.toUpperCase()], + timeout: window, + androidScanMode: AndroidScanMode.lowPower, + ); + await FlutterBluePlus.isScanning.where((on) => !on).first; + } finally { + await sub.cancel(); + } + return hit; + }); +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2dbac2d31..1b4efca80 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -41,6 +41,7 @@ import '../ble/live_step_runs.dart'; import '../ble/ble_state.dart' show AlarmConfirmation, AlarmEffect, LiveStreamOwners; import '../ble/ios_ble_restore.dart'; +import '../ble/scale_presence.dart' show ScalePresenceWatcher; import '../ble/session_link.dart' show SessionLink; import '../cloud/companion_client.dart'; import '../compute/derivation_engine.dart'; @@ -1748,6 +1749,7 @@ 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; /// Central disposal guard. /// @@ -1766,6 +1768,7 @@ class AppState extends ChangeNotifier { @override void dispose() { + unawaited(_scaleWatcher?.stop()); _deriveCoordinator.cancelActivityReviewRetry(); if (IosShortcutSync.foregroundSync == syncForShortcut) { IosShortcutSync.foregroundSync = null; @@ -2648,6 +2651,10 @@ 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 && Platform.isAndroid) { + (_scaleWatcher ??= ScalePresenceWatcher()).start(); + } // 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..61e5e8bd5 --- /dev/null +++ b/test/ble/scale_presence_test.dart @@ -0,0 +1,80 @@ +// 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 '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), +}) => + ScalePresenceWatcher( + pairedScales: () async => paired, + hear: (ids, window) => hear(), + sync: (id) async => synced.add(id), + 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 w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async => 'AA:BB', + synced: synced); + w.start(); + await Future.delayed(const Duration(milliseconds: 200)); + 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(seconds: 5)); + w.start(); + await Future.delayed(const Duration(milliseconds: 150)); + await w.stop().timeout(const Duration(seconds: 1)); + expect(scans, 1); + }); +} From e5bb453d747bd76bc6f3fabe5680868a558e9da2 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:26:10 +0300 Subject: [PATCH 02/12] Scale watcher: skip scans while the band is reconnecting, log each cycle --- lib/ble/scale_presence.dart | 11 ++++++++++- lib/state/app_state.dart | 5 ++++- test/ble/scale_presence_test.dart | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index 05708183b..9fbe09956 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -36,6 +36,7 @@ class ScalePresenceWatcher { Future> Function()? pairedScales, Future Function(List remoteIds, Duration window)? hear, Future Function(String adapterId)? sync, + this.bandWaiting, this.window = const Duration(seconds: 8), this.gap = const Duration(seconds: 20), this.cooldown = const Duration(minutes: 3), @@ -48,6 +49,11 @@ class ScalePresenceWatcher { final Future Function(String) _sync; final Duration window, gap, cooldown; + /// True while a paired band is not connected, i.e. its reconnect scan may be + /// queued on the scan lock. The cycle is skipped then, so a scale scan can + /// never delay the band. + final bool Function()? bandWaiting; + bool _stop = true; Future? _loop; Completer? _nap; @@ -71,10 +77,13 @@ class ScalePresenceWatcher { var wait = gap; try { final scales = await _paired(); // remote id (lower case) -> adapter id - if (scales.isNotEmpty) { + if (scales.isNotEmpty && bandWaiting?.call() != true) { + debugPrint('[scale-watch] scanning for ${scales.length} scale(s)'); final hit = await _hear(scales.keys.toList(), window); + debugPrint('[scale-watch] scan done, hit=$hit'); if (hit != null && !_stop) { await _sync(scales[hit.toLowerCase()]!); + debugPrint('[scale-watch] sync done'); wait = cooldown; } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 1b4efca80..2d03cd022 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2653,7 +2653,10 @@ class AppState extends ChangeNotifier { SessionLink.onSessionDone = adoptWeighing; // Android: hear a paired scale wake up and sync it without a Devices tap. if (!_background && Platform.isAndroid) { - (_scaleWatcher ??= ScalePresenceWatcher()).start(); + (_scaleWatcher ??= ScalePresenceWatcher( + bandWaiting: () => isPaired && !engine.isConnected, + )) + .start(); } // In the background: its re-derive must not hold the shell's launch. unawaited(adoptWeighing().catchError( diff --git a/test/ble/scale_presence_test.dart b/test/ble/scale_presence_test.dart index 61e5e8bd5..f858b867c 100644 --- a/test/ble/scale_presence_test.dart +++ b/test/ble/scale_presence_test.dart @@ -10,6 +10,7 @@ ScalePresenceWatcher _w({ required Future Function() hear, required List synced, Duration cooldown = const Duration(seconds: 5), + bool Function()? bandWaiting, }) => ScalePresenceWatcher( pairedScales: () async => paired, @@ -18,6 +19,7 @@ ScalePresenceWatcher _w({ window: _ms, gap: _ms, cooldown: cooldown, + bandWaiting: bandWaiting, ); void main() { @@ -77,4 +79,20 @@ void main() { await w.stop().timeout(const Duration(seconds: 1)); expect(scans, 1); }); + + test('a band waiting to reconnect: no scale scan at all', () async { + var scans = 0; + final w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async { + scans++; + return 'aa:bb'; + }, + synced: [], + bandWaiting: () => true); + w.start(); + await Future.delayed(const Duration(milliseconds: 100)); + await w.stop(); + expect(scans, 0); + }); } From 2b39f21ba7cc6344fae8c1ae1f351f159754dfee Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:53:44 +0200 Subject: [PATCH 03/12] Scale watcher: drop the per-cycle debug logs, keep the failure log --- lib/ble/scale_presence.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index 9fbe09956..93b85c8a8 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -78,12 +78,9 @@ class ScalePresenceWatcher { try { final scales = await _paired(); // remote id (lower case) -> adapter id if (scales.isNotEmpty && bandWaiting?.call() != true) { - debugPrint('[scale-watch] scanning for ${scales.length} scale(s)'); final hit = await _hear(scales.keys.toList(), window); - debugPrint('[scale-watch] scan done, hit=$hit'); if (hit != null && !_stop) { await _sync(scales[hit.toLowerCase()]!); - debugPrint('[scale-watch] sync done'); wait = cooldown; } } From 49ea43de3793a660ab429c35c5f0966319c77dd0 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:58:22 +0200 Subject: [PATCH 04/12] Scale watcher: bound the scan-stop wait, handle a failed stopScan --- lib/ble/scale_presence.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index 93b85c8a8..60f4a8b87 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -116,7 +116,8 @@ class ScalePresenceWatcher { final id = r.device.remoteId.str.toLowerCase(); if (hit == null && want.contains(id)) { hit = id; - unawaited(FlutterBluePlus.stopScan()); + unawaited(FlutterBluePlus.stopScan().catchError( + (Object e) => debugPrint('[scale-watch] stopScan failed: $e'))); } } }); @@ -126,7 +127,12 @@ class ScalePresenceWatcher { timeout: window, androidScanMode: AndroidScanMode.lowPower, ); - await FlutterBluePlus.isScanning.where((on) => !on).first; + // Bounded: a lost scan-stop event must not hold the process-wide + // scan lock (and with it every other scan) forever. + await FlutterBluePlus.isScanning + .where((on) => !on) + .first + .timeout(window + const Duration(seconds: 5)); } finally { await sub.cancel(); } From c76760d9e27c2b1cdf3db516b6553d3973b6a590 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:00:43 +0200 Subject: [PATCH 05/12] Scale watcher: scan while the band is away, restart after a stop that is still draining --- lib/ble/scale_presence.dart | 30 +++++++++++++--------- lib/state/app_state.dart | 5 +--- test/ble/scale_presence_test.dart | 41 +++++++++++++++++++++++++------ 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index 60f4a8b87..b6e66a092 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -9,8 +9,9 @@ // `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 or -// the primary band's instead of racing it. Nothing here connects: the session +// 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 @@ -36,7 +37,6 @@ class ScalePresenceWatcher { Future> Function()? pairedScales, Future Function(List remoteIds, Duration window)? hear, Future Function(String adapterId)? sync, - this.bandWaiting, this.window = const Duration(seconds: 8), this.gap = const Duration(seconds: 20), this.cooldown = const Duration(minutes: 3), @@ -49,25 +49,33 @@ class ScalePresenceWatcher { final Future Function(String) _sync; final Duration window, gap, cooldown; - /// True while a paired band is not connected, i.e. its reconnect scan may be - /// queued on the scan lock. The cycle is skipped then, so a scale scan can - /// never delay the band. - final bool Function()? bandWaiting; - bool _stop = true; + bool _restartPending = false; Future? _loop; Completer? _nap; /// Start watching; a second call while running does nothing. void start() { - if (_loop != null) return; + 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); + _loop = _run().whenComplete(() { + _loop = null; + if (_restartPending) { + _restartPending = false; + start(); + } + }); } /// Stop after the current cycle; completes once the loop has ended. Future stop() async { _stop = true; + _restartPending = false; if (_nap case final n? when !n.isCompleted) n.complete(); await _loop; } @@ -77,7 +85,7 @@ class ScalePresenceWatcher { var wait = gap; try { final scales = await _paired(); // remote id (lower case) -> adapter id - if (scales.isNotEmpty && bandWaiting?.call() != true) { + if (scales.isNotEmpty) { final hit = await _hear(scales.keys.toList(), window); if (hit != null && !_stop) { await _sync(scales[hit.toLowerCase()]!); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index cb52ccc34..076ff78aa 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3058,10 +3058,7 @@ class AppState extends ChangeNotifier { SessionLink.onSessionDone = adoptWeighing; // Android: hear a paired scale wake up and sync it without a Devices tap. if (!_background && Platform.isAndroid) { - (_scaleWatcher ??= ScalePresenceWatcher( - bandWaiting: () => isPaired && !engine.isConnected, - )) - .start(); + (_scaleWatcher ??= ScalePresenceWatcher()).start(); } // In the background: its re-derive must not hold the shell's launch. unawaited(adoptWeighing().catchError( diff --git a/test/ble/scale_presence_test.dart b/test/ble/scale_presence_test.dart index f858b867c..a24b3c3ea 100644 --- a/test/ble/scale_presence_test.dart +++ b/test/ble/scale_presence_test.dart @@ -10,7 +10,6 @@ ScalePresenceWatcher _w({ required Future Function() hear, required List synced, Duration cooldown = const Duration(seconds: 5), - bool Function()? bandWaiting, }) => ScalePresenceWatcher( pairedScales: () async => paired, @@ -19,7 +18,6 @@ ScalePresenceWatcher _w({ window: _ms, gap: _ms, cooldown: cooldown, - bandWaiting: bandWaiting, ); void main() { @@ -80,19 +78,46 @@ void main() { expect(scans, 1); }); - test('a band waiting to reconnect: no scale scan at all', () async { + test('a start() while a stopped loop is still draining restarts it', + () async { var scans = 0; final w = _w( paired: {'aa:bb': 'miscale_bc'}, hear: () async { scans++; - return 'aa:bb'; + await Future.delayed(const Duration(milliseconds: 40)); + return null; }, - synced: [], - bandWaiting: () => true); + synced: []); + w.start(); + await Future.delayed(const Duration(milliseconds: 10)); + final stopped = w.stop(); + w.start(); // the old scan is still in flight + await stopped; + final before = scans; + await Future.delayed(const Duration(milliseconds: 150)); + expect(scans, greaterThan(before)); + await w.stop(); + }); + + test('stop() cancels a pending restart', () async { + var scans = 0; + final w = _w( + paired: {'aa:bb': 'miscale_bc'}, + hear: () async { + scans++; + await Future.delayed(const Duration(milliseconds: 40)); + return null; + }, + synced: []); + w.start(); + await Future.delayed(const Duration(milliseconds: 10)); + final stopped = w.stop(); w.start(); - await Future.delayed(const Duration(milliseconds: 100)); await w.stop(); - expect(scans, 0); + await stopped; + final after = scans; + await Future.delayed(const Duration(milliseconds: 120)); + expect(scans, after); }); } From 35e0c96d28f894a5339ae8ba35ba8340580a885b Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:02:34 +0200 Subject: [PATCH 06/12] Scale watcher: end a scan whose stop event was lost, retry at the normal gap --- lib/ble/scale_presence.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index b6e66a092..563b6add1 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -72,7 +72,9 @@ class ScalePresenceWatcher { }); } - /// Stop after the current cycle; completes once the loop has ended. + /// 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; @@ -137,10 +139,17 @@ class ScalePresenceWatcher { ); // Bounded: a lost scan-stop event must not hold the process-wide // scan lock (and with it every other scan) forever. - await FlutterBluePlus.isScanning - .where((on) => !on) - .first - .timeout(window + const Duration(seconds: 5)); + 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().catchError( + (Object e) => debugPrint('[scale-watch] stopScan failed: $e')); + } } finally { await sub.cancel(); } From 2361d5e2d6fb8b3893a8f41106ef162325138503 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:30:33 +0200 Subject: [PATCH 07/12] Allow the scale watcher in the scan-lock entry-point gate --- test/scan_lock_entry_points_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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*(///|//|\*|/\*)'); From 6243f6e824659b2cdffae73741e69220f8bcd956 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:07:02 +0200 Subject: [PATCH 08/12] Scale watcher: no start after dispose, bound the stopScan fallback and the session, tighten tests --- lib/ble/scale_presence.dart | 13 +++++++----- lib/state/app_state.dart | 2 +- test/ble/scale_presence_test.dart | 33 +++++++++++++++++++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index 563b6add1..b226315ae 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -5,7 +5,7 @@ // 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 runs every [gap]; a hit runs the normal +// 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. // @@ -40,6 +40,7 @@ class ScalePresenceWatcher { 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; @@ -47,7 +48,7 @@ class ScalePresenceWatcher { final Future> Function() _paired; final Future Function(List, Duration) _hear; final Future Function(String) _sync; - final Duration window, gap, cooldown; + final Duration window, gap, cooldown, syncTimeout; bool _stop = true; bool _restartPending = false; @@ -90,7 +91,7 @@ class ScalePresenceWatcher { if (scales.isNotEmpty) { final hit = await _hear(scales.keys.toList(), window); if (hit != null && !_stop) { - await _sync(scales[hit.toLowerCase()]!); + await _sync(scales[hit.toLowerCase()]!).timeout(syncTimeout); wait = cooldown; } } @@ -147,8 +148,10 @@ class ScalePresenceWatcher { } 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().catchError( - (Object e) => debugPrint('[scale-watch] stopScan failed: $e')); + await FlutterBluePlus.stopScan() + .timeout(const Duration(seconds: 3)) + .catchError((Object e) => + debugPrint('[scale-watch] stopScan failed: $e')); } } finally { await sub.cancel(); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 076ff78aa..51f14adfe 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3057,7 +3057,7 @@ class AppState extends ChangeNotifier { // 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 && Platform.isAndroid) { + if (!_background && Platform.isAndroid && !_disposed) { (_scaleWatcher ??= ScalePresenceWatcher()).start(); } // In the background: its re-derive must not hold the shell's launch. diff --git a/test/ble/scale_presence_test.dart b/test/ble/scale_presence_test.dart index a24b3c3ea..2a56ecd40 100644 --- a/test/ble/scale_presence_test.dart +++ b/test/ble/scale_presence_test.dart @@ -1,5 +1,7 @@ // 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'; @@ -10,11 +12,14 @@ ScalePresenceWatcher _w({ required Future Function() hear, required List synced, Duration cooldown = const Duration(seconds: 5), + Future Function(String)? syncFn, + Duration syncTimeout = const Duration(minutes: 2), }) => ScalePresenceWatcher( pairedScales: () async => paired, hear: (ids, window) => hear(), - sync: (id) async => synced.add(id), + sync: syncFn ?? (id) async => synced.add(id), + syncTimeout: syncTimeout, window: _ms, gap: _ms, cooldown: cooldown, @@ -71,11 +76,31 @@ void main() { throw StateError('bluetooth off'); }, synced: [], - cooldown: const Duration(seconds: 5)); + cooldown: const Duration(milliseconds: 60)); w.start(); - await Future.delayed(const Duration(milliseconds: 150)); + 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 abandoned, scanning resumes', () async { + var scans = 0; + 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), + cooldown: const Duration(milliseconds: 40)); + w.start(); + await Future.delayed(const Duration(milliseconds: 250)); await w.stop().timeout(const Duration(seconds: 1)); - expect(scans, 1); + expect(scans, greaterThan(1)); }); test('a start() while a stopped loop is still draining restarts it', From d789856f05203968956350b739da9c2aa683ea94 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:12:00 +0200 Subject: [PATCH 09/12] Scale watcher: start it on resume too, wait on signals instead of fixed delays in the tests --- lib/app.dart | 1 + lib/state/app_state.dart | 12 ++++++-- test/ble/scale_presence_test.dart | 48 ++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index c4bfc9b4e..0cde21fcb 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -115,6 +115,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.paused) { // Backgrounded: hand the band to the iOS restore path so it can wake-and-drain // in the background (no-op on Android, where the foreground service holds it). diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 51f14adfe..932371b45 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2008,6 +2008,14 @@ class AppState extends ChangeNotifier { 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. /// /// Setting `_disposed` and checking it at each await point only covers the @@ -3057,9 +3065,7 @@ class AppState extends ChangeNotifier { // 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 && Platform.isAndroid && !_disposed) { - (_scaleWatcher ??= ScalePresenceWatcher()).start(); - } + 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 index 2a56ecd40..1aed3f796 100644 --- a/test/ble/scale_presence_test.dart +++ b/test/ble/scale_presence_test.dart @@ -28,12 +28,19 @@ ScalePresenceWatcher _w({ 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); + synced: synced, + syncFn: (id) async { + synced.add(id); + if (!first.isCompleted) first.complete(); + }); w.start(); - await Future.delayed(const Duration(milliseconds: 200)); + 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']); }); @@ -103,40 +110,55 @@ void main() { expect(scans, greaterThan(1)); }); - test('a start() while a stopped loop is still draining restarts it', + test('a start() while a stopped loop is still draining restarts it, every time', () async { - var scans = 0; + var scans = 0, inFlight = 0, maxInFlight = 0; + Completer? entered; final w = _w( paired: {'aa:bb': 'miscale_bc'}, hear: () async { scans++; - await Future.delayed(const Duration(milliseconds: 40)); + 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(); - await Future.delayed(const Duration(milliseconds: 10)); - final stopped = w.stop(); - w.start(); // the old scan is still in flight - await stopped; - final before = scans; - await Future.delayed(const Duration(milliseconds: 150)); - expect(scans, greaterThan(before)); + 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 Future.delayed(const Duration(milliseconds: 10)); + await started.future.timeout(const Duration(seconds: 2)); final stopped = w.stop(); w.start(); await w.stop(); From fd28c060afc59818ca2fd7eb34cbeee9c1c0065d Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:25:16 +0200 Subject: [PATCH 10/12] Scale watcher: bound scan startup so a hung startScan cannot hold the scan lock --- lib/ble/scale_presence.dart | 51 +++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index b226315ae..fe7c7ed57 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -41,9 +41,9 @@ class ScalePresenceWatcher { 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; + }) : _paired = pairedScales ?? _pairedScales, + _hear = hear ?? _scan, + _sync = sync ?? _syncSession; final Future> Function() _paired; final Future Function(List, Duration) _hear; @@ -107,12 +107,12 @@ class ScalePresenceWatcher { } 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, - }; + 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(); @@ -127,17 +127,39 @@ class ScalePresenceWatcher { 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'))); + unawaited( + FlutterBluePlus.stopScan().catchError( + (Object e) => debugPrint('[scale-watch] stopScan failed: $e'), + ), + ); } } }); try { - await FlutterBluePlus.startScan( + // 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 { + // End a scan that starts late, and treat this as a quiet cycle. + unawaited( + started + .then( + (_) => FlutterBluePlus.stopScan(), + onError: (Object _) {}, + ) + .catchError( + (Object e) => + debugPrint('[scale-watch] late stopScan failed: $e'), + ), + ); + return null; + } // Bounded: a lost scan-stop event must not hold the process-wide // scan lock (and with it every other scan) forever. try { @@ -150,8 +172,9 @@ class ScalePresenceWatcher { // 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')); + .catchError( + (Object e) => debugPrint('[scale-watch] stopScan failed: $e'), + ); } } finally { await sub.cancel(); From 4fc0c81ca309a67083305f452c4cb937eb05b2d7 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:34:39 +0200 Subject: [PATCH 11/12] Scale watcher: don't stop a late-starting scan after the lock is released --- lib/ble/scale_presence.dart | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index fe7c7ed57..654610175 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -146,18 +146,10 @@ class ScalePresenceWatcher { try { await started.timeout(const Duration(seconds: 10)); } on TimeoutException { - // End a scan that starts late, and treat this as a quiet cycle. - unawaited( - started - .then( - (_) => FlutterBluePlus.stopScan(), - onError: (Object _) {}, - ) - .catchError( - (Object e) => - debugPrint('[scale-watch] late stopScan failed: $e'), - ), - ); + // 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 From de143938b4c69d5b2149549afc2cd515c339fe02 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:41:32 +0200 Subject: [PATCH 12/12] Scale watcher: stop the session when the sync timeout fires --- lib/ble/scale_presence.dart | 23 +++- test/ble/scale_presence_test.dart | 215 +++++++++++++++++------------- 2 files changed, 142 insertions(+), 96 deletions(-) diff --git a/lib/ble/scale_presence.dart b/lib/ble/scale_presence.dart index 654610175..d6cba9838 100644 --- a/lib/ble/scale_presence.dart +++ b/lib/ble/scale_presence.dart @@ -37,17 +37,20 @@ class 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; + _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; @@ -91,7 +94,19 @@ class ScalePresenceWatcher { if (scales.isNotEmpty) { final hit = await _hear(scales.keys.toList(), window); if (hit != null && !_stop) { - await _sync(scales[hit.toLowerCase()]!).timeout(syncTimeout); + 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; } } @@ -118,6 +133,10 @@ class ScalePresenceWatcher { 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; diff --git a/test/ble/scale_presence_test.dart b/test/ble/scale_presence_test.dart index 1aed3f796..171357d10 100644 --- a/test/ble/scale_presence_test.dart +++ b/test/ble/scale_presence_test.dart @@ -14,87 +14,105 @@ ScalePresenceWatcher _w({ Duration cooldown = const Duration(seconds: 5), Future Function(String)? syncFn, Duration syncTimeout = const Duration(minutes: 2), -}) => - ScalePresenceWatcher( - pairedScales: () async => paired, - hear: (ids, window) => hear(), - sync: syncFn ?? (id) async => synced.add(id), - syncTimeout: syncTimeout, - window: _ms, - gap: _ms, - cooldown: cooldown, - ); + 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( + 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']); - }); + }, + ); + 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( + 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)); + 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); - }); + 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( + 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)); - }); + 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 abandoned, scanning resumes', () async { - var scans = 0; - final w = _w( + 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++; @@ -103,18 +121,24 @@ void main() { synced: [], syncFn: (_) => Completer().future, // hangs forever syncTimeout: const Duration(milliseconds: 30), - 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)); - }); + 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( + 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++; @@ -125,38 +149,41 @@ void main() { 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; + 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)); - expect(scans, greaterThan(before), reason: 'round $round restarted'); - } - await w.stop(); - expect(maxInFlight, 1); - }); + // 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: []); + 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();