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..6850c930 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,20 @@ 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) — 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; 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,