From 2127948ac3b6840c68e6b477caa2a2b417ee2496 Mon Sep 17 00:00:00 2001 From: Danny McClelland Date: Sun, 19 Jul 2026 20:03:11 +0100 Subject: [PATCH 1/2] fix(ble): sticky standard-HR fallback silently zeroed step calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the marginal-radio or frame-corruption detector tripped, the standard-HR fallback flag was never reset and enableLiveStreams honoured it silently — so every later calibration walk and workout counted 0 steps for the rest of the process lifetime (the R10/IMU toggles were never sent). Android trips the detectors far more readily than iOS, which is why the 0-step calibration reports were Android-only. - BleEngine.retryFullLiveStreams(): clears the fallback + resets both detectors, then re-arms the full live set. If the radio genuinely can't sustain the flood the detectors re-trip within seconds. - startStepCalibration/startWorkout call it: an explicit foreground user action whose feature needs the 100 Hz stream is exactly the moment to retry (spot check + breathing only need 0x28, which the fallback still delivers, so they're unchanged). - Calibration screen: if the fallback re-trips mid-walk, show a 'Bluetooth can't keep up' card with a retry action instead of 'Keep walking…' over a count that will never move. --- lib/ble/ble_engine.dart | 20 ++++++++++++++++++++ lib/state/app_state.dart | 19 ++++++++++++++++--- lib/ui/today/step_calibration_screen.dart | 19 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index c828a3b2..5c3cfd67 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2538,6 +2538,26 @@ class BleEngine { _log('Live streams enabled (optical: wrist-gated).'); } + /// Clear the sticky standard-HR fallback and give the full live set another + /// chance. The fallback protects a struggling radio from the high-rate + /// flood, but it never resets and [enableLiveStreams] honours it silently — + /// so once tripped, every later step calibration / workout counted 0 steps + /// for the rest of the process lifetime (the IMU toggles were never sent). + /// Call this from EXPLICIT foreground user actions whose feature needs the + /// 100 Hz stream: there the flood is the point, and if the radio genuinely + /// can't sustain it the detectors re-trip (and re-downgrade) within seconds. + Future retryFullLiveStreams() async { + if (state.standardHrFallback) { + _log('Radio fallback: cleared by explicit user action — retrying the ' + 'full live set.'); + state.standardHrFallback = false; + _marginalRadio.reset(); + _frameCorruption.reset(); + onState(state); + } + await enableLiveStreams(); + } + /// Background live downgrade: keep ONLY the compact realtime-HR stream (0x28) /// armed and turn the high-rate R10/R11 + IMU + optical flood OFF. Used while /// backgrounded with no live consumer, so the radio isn't saturated by a raw diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 8fdc7800..03d63fd7 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2813,11 +2813,17 @@ class AppState extends ChangeNotifier { // open session still expects on. If the background downgrade left live in // HR-only, upgrade to full (the walk needs the 100 Hz IMU stream) without // taking ownership. + // + // retryFullLiveStreams (not enableLiveStreams): the walk NEEDS the 100 Hz + // IMU stream, and the sticky standard-HR fallback silently vetoes it — + // every calibration after a fallback trip counted 0 steps forever. An + // explicit user-initiated walk is exactly the moment to give the full + // flood another chance; the detectors re-trip if the radio can't cope. if (!engine.liveEnabled) { - await engine.enableLiveStreams(); + await engine.retryFullLiveStreams(); _stepCalEnabledStreams = true; - } else if (engine.liveHrOnly) { - await engine.enableLiveStreams(); + } else if (engine.liveHrOnly || device.standardHrFallback) { + await engine.retryFullLiveStreams(); } _resetLivePedometer(); // count this walk from 0 notifyListeners(); @@ -2917,6 +2923,13 @@ class AppState extends ChangeNotifier { if (activeWorkout != null) return; final start = DateTime.now(); final id = workoutId ?? 'w${start.millisecondsSinceEpoch}'; + // The workout screen's live step count rides the 100 Hz IMU stream, which + // the sticky standard-HR fallback silently suppresses (same starvation as + // the calibration walk). A deliberate workout start is an explicit user + // action — retry the full live set; detectors re-trip if it can't hold. + if (isConnected && device.standardHrFallback) { + unawaited(engine.retryFullLiveStreams()); + } _workoutRawBase = _liveRaw; activeWorkout = LiveWorkoutState( startTime: start, diff --git a/lib/ui/today/step_calibration_screen.dart b/lib/ui/today/step_calibration_screen.dart index bea607ac..38bdd078 100644 --- a/lib/ui/today/step_calibration_screen.dart +++ b/lib/ui/today/step_calibration_screen.dart @@ -81,6 +81,12 @@ class _StepCalibrationScreenState extends State { @override Widget build(BuildContext context) { final steps = context.select((a) => a.liveSteps); + // The standard-HR radio fallback suppresses the 100 Hz IMU stream this + // walk counts on. startStepCalibration clears it and retries; if it + // TRIPS AGAIN mid-walk the radio genuinely can't sustain the stream — + // say so instead of showing "Keep walking…" over a count of 0 forever. + final radioDegraded = + context.select((a) => a.device.standardHrFallback); final done = _learnedCadence != null; final t = (_target > 0 ? steps / _target : 0.0).clamp(0.0, 1.0).toDouble(); final ready = steps >= _target; @@ -144,6 +150,19 @@ class _StepCalibrationScreenState extends State { : Text(_started ? 'Keep walking…' : 'Starting…', style: AppText.label.copyWith(color: AppColors.inkSoft)), ), + if (radioDegraded && _started && !ready) ...[ + const SizedBox(height: Sp.x4), + StateCard( + icon: OsIcon.bluetooth, + title: "Bluetooth can't keep up", + message: + 'The connection to your strap is struggling to carry the ' + 'high-rate motion stream, so steps aren\'t coming through. ' + 'Bring your phone closer to the strap and retry.', + actionLabel: 'Retry stream', + onAction: _start, + ), + ], const SizedBox(height: Sp.x6), SurfaceCard( entranceIndex: 1, From 52445722bc6871fa13e2605a232752aed33bb315 Mon Sep 17 00:00:00 2001 From: Danny McClelland Date: Sun, 19 Jul 2026 20:09:20 +0100 Subject: [PATCH 2/2] fix: arm full live streams for every connected workout start, not only after fallback --- lib/state/app_state.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 03d63fd7..6850c930 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2925,9 +2925,16 @@ class AppState extends ChangeNotifier { final id = workoutId ?? 'w${start.millisecondsSinceEpoch}'; // The workout screen's live step count rides the 100 Hz IMU stream, which // the sticky standard-HR fallback silently suppresses (same starvation as - // the calibration walk). A deliberate workout start is an explicit user - // action — retry the full live set; detectors re-trip if it can't hold. - if (isConnected && device.standardHrFallback) { + // the calibration walk) — and which may simply be off (spot-check cleanup + // restores streams to OFF when they were off before) or still in the + // background HR-only downgrade. A deliberate workout start is an explicit + // user action — retry the full live set; detectors re-trip if it can't + // hold. No ownership flag: the background downgrade / session close + // manage the stream lifecycle exactly as for openSession's arming. + if (isConnected && + (!engine.liveEnabled || + engine.liveHrOnly || + device.standardHrFallback)) { unawaited(engine.retryFullLiveStreams()); } _workoutRawBase = _liveRaw;