From 80c51f3a77843e975e5069cb2ea32081e9041dc4 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 18 Aug 2026 22:16:35 +1200 Subject: [PATCH] feat(power): "Low refresh" hourly sync, a sub-option of Power saving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a user-elected hourly background-offload cadence under Settings -> Power saving, beside the existing battery threshold and "Pause HRV capture" levers. Unlike the battery lever it is not charge-gated: once chosen, 60 min is the BASE the other levers stretch from, at any strap charge. Fewer periodic offloads means fewer reconnect bursts, which is the measured WHOOP 4.0 drain mechanism (#1120/#1144) - a strap log from a 10.1.1 staging build showed 52 periodic offloads in a session, 26 of them idle-timeout stalls, each holding the link and re-connecting for little or no new data. Composition: every lever now stretches from the low-refresh base with `max`, so a lever can only make the cadence quieter, never restore a faster one the user asked to slow down. The 5/MG empty-history stretch (45 min) and the low-battery stretch (45 min) are both shorter than 60 min, so they become no-ops rather than speed-ups. Default off; with the toggle off every existing path is byte-identical. DELIBERATELY CADENCE-ONLY - the idea comes from @cbedsole03, whose version also stretched the keep-alive 30s -> 300s and released continuous HRV capture. Both are dropped here because they cost data rather than delaying it: - The keep-alive tick is not just a ping: it re-arms the WHOOP 4 realtime burst (R10/R11) every cycle so streaming cannot lapse, and it evaluates the stall fuse, which is 120s on a 4.0. A 300s tick under-samples that fuse, so a stalled 4.0 link would sit stalled up to 5 min - prolonging exactly the reconnect churn this feature exists to reduce. `startKeepAlive` already documents that the battery gain there "rounds to nothing on a 30 s timer". - Releasing continuous HRV capture drops dense beat-to-beat R-R while it is on, INCLUDING overnight, which is where that stream exists to be dense. Density not captured cannot be backfilled later, and it would silently override the user`s own Continuous-HRV-capture preference. "Pause HRV capture" (#477) and the #927 overnight window are the levers for that, and they stay in charge. So the shipped feature delays sync into larger batches and loses nothing: the strap banks to flash and only trims on our ack. PARITY: the Android twin lands in the same PR — NoopPrefs.lowRefresh, the WhoopBleClient LOW_REFRESH_BACKFILL_INTERVAL_MS/baseBackfillIntervalMs pair, the same max() composition inside nextBackfillDelayMs, and the same sub-toggle inside the Settings if(powerSaving) block. Both platforms apply on the NEXT re-arm (matching each side's existing battery lever), so a sync in flight is never cut. New copy localized de/es/fr/pl/pt-PT/zh on Android and de/es/fr/pl/pt-PT in the iOS catalog. Tests: LowRefreshCadenceTests (Swift) + LowRefreshCadenceTest (Kotlin) pin the same five cases — base on/off, and the three composition cases (low-battery lever, 5/MG empty-history stretch, unchanged default path). Validation: full Android suite 4042 tests / 490 classes green (5 new); compileFullDebugKotlin + processFullDebugResources + i18n --ci green. The Swift half is app-target, so it is validated by app-build; note StrandTests do not run in default CI, so the Swift twin of the cadence test is compile-checked there rather than executed. --- Strand/App/AppModel.swift | 2 + Strand/BLE/BLEManager.swift | 40 +++++++-- Strand/BLE/PuffinExperiment.swift | 6 ++ Strand/Resources/Localizable.xcstrings | 68 +++++++++++++++ Strand/Screens/SettingsView.swift | 16 ++++ StrandTests/LowRefreshCadenceTests.swift | 69 +++++++++++++++ Tools/i18n_extra_locale_baseline.txt | 8 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 42 ++++++++-- .../src/main/java/com/noop/ui/AppViewModel.kt | 9 ++ .../src/main/java/com/noop/ui/MainActivity.kt | 9 ++ .../main/java/com/noop/ui/SettingsScreen.kt | 31 +++++++ .../app/src/main/res/values-de/strings.xml | 2 + .../app/src/main/res/values-es/strings.xml | 2 + .../app/src/main/res/values-fr/strings.xml | 2 + .../app/src/main/res/values-pl/strings.xml | 2 + .../src/main/res/values-pt-rPT/strings.xml | 2 + .../app/src/main/res/values-zh/strings.xml | 2 + android/app/src/main/res/values/strings.xml | 2 + .../com/noop/ble/LowRefreshCadenceTest.kt | 84 +++++++++++++++++++ 19 files changed, 382 insertions(+), 16 deletions(-) create mode 100644 StrandTests/LowRefreshCadenceTests.swift create mode 100644 android/app/src/test/java/com/noop/ble/LowRefreshCadenceTest.kt diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index d6dd454e27..5de1ff530c 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -462,6 +462,8 @@ final class AppModel: ObservableObject { /// The riskier connection-priority idle throttle is intentionally not wired (Android-only, and dormant). func applyPowerSaving() { let on = PuffinExperiment.powerSavingEnabled + // Sub-option: only in effect while the Power-saving master is on, like the HRV-pause lever below. + ble.setLowRefreshMode(on && PuffinExperiment.lowRefreshEnabled) ble.setLowBatteryOffloadThrottle(on ? PuffinExperiment.powerSavingBatteryPct : 0) // HRV pause is battery-%-aware like the offload lever — pass the same threshold. ble.setPauseCaptureOnPowerSave(on && PuffinExperiment.pauseHrvOnPowerSaveEnabled, diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index b5de98578e..9c4c2ca9b4 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -537,6 +537,8 @@ public final class BLEManager: NSObject, ObservableObject { /// high-freq-sync), so each periodic tick just routes through requestSync(.periodic) → beginBackfill /// (SEND_HISTORICAL_DATA + watchdog), subject to the BackfillPolicy floor. private var backfillTimer: DispatchSourceTimer? + /// User-elected hourly background-sync cadence (Settings → Power saving → "Low refresh"). Default off. + private var lowRefreshMode = false // The timer fires this often, but BackfillPolicy.periodicFloorSeconds is the real floor (a recent // event-triggered sync defers the next periodic tick). 900s = 15 min, matching WHOOP. static let backfillIntervalSeconds = 900 @@ -548,6 +550,15 @@ public final class BLEManager: NSObject, ObservableObject { /// so this only delays sync (larger batches), never loses data. Mirrors Android /// `LOW_BATTERY_BACKFILL_INTERVAL_MS`. static let lowBatteryBackfillIntervalSeconds = 2700 + /// Low-refresh cadence (60 min): the user-elected sub-option of Power saving. Unlike the battery + /// lever above it is NOT battery-gated — once chosen it is the baseline the other levers stretch + /// FROM, so a quiet strap stays quiet at any charge. Same no-loss property: the strap banks to flash + /// and only trims on our ack, so this delays sync into larger batches, it never drops history. + /// Deliberately cadence-ONLY: it does not touch the keep-alive (that tick re-arms the WHOOP 4 + /// realtime burst every cycle and evaluates the 120 s stall fuse — see `startKeepAlive`) and it does + /// not touch continuous HRV capture (that is what "Pause HRV capture" and #927's overnight window + /// are for). Fewer periodic offloads = fewer reconnect bursts, which is the measured 4.0 drain. + static let lowRefreshBackfillIntervalSeconds = 3600 /// Pure battery-adaptive gate (#477), the twin of Android `WhoopBleClient.idleThrottleActive`. Keyed /// on the STRAP's battery: armed by `thresholdPct` > 0, engages while the strap is discharging at/below @@ -563,6 +574,13 @@ public final class BLEManager: NSObject, ObservableObject { ? max(baseSeconds, lowSeconds) : baseSeconds } + /// Pure baseline-cadence decision: low refresh replaces the 15-min BASE with the hourly one, and every + /// other lever composes on top with `max`, so a lever can only ever make the cadence QUIETER, never + /// restore a faster one the user asked to slow down. Unit-testable without a CoreBluetooth seam. + static func baseBackfillInterval(lowRefresh: Bool) -> Int { + lowRefresh ? lowRefreshBackfillIntervalSeconds : backfillIntervalSeconds + } + /// #battery: pure 5/MG battery-read throttle decision, unit-testable without a CoreBluetooth seam. /// Returns true when no prior read exists (the first read of a connection, or post-disconnect re-seed) /// OR when at least `whoop5BatteryReadMinIntervalSeconds` has elapsed since the last read. Stops the @@ -2502,6 +2520,13 @@ public final class BLEManager: NSObject, ObservableObject { lowBatteryOffloadPct = thresholdPct } + /// Settings sub-option of Power saving: the user-elected hourly background cadence. Applies on the + /// NEXT re-arm exactly like the battery lever beside it, so a sync already in flight is never + /// interrupted (the cadence is re-read at each re-arm). Twin of Android `setLowRefreshMode`. + public func setLowRefreshMode(_ enabled: Bool) { + lowRefreshMode = enabled + } + /// #477 (Settings): pause the background continuous-HRV stream when the strap is low. Keyed on the /// STRAP's battery like the offload lever — pass the same threshold; engages at/below it (0 = off). /// Reconciles now. @@ -2530,19 +2555,22 @@ public final class BLEManager: NSObject, ObservableObject { /// tracker's quietThreshold is 2) and at a fixed 45-min floor that stacks with it. Twin of Android /// `nextBackfillDelayMs`. Resets with `whoop5EmptyOffload` on disconnect (a fresh connect re-probes). private func nextBackfillInterval() -> Int { + // Low refresh moves the BASE the other levers stretch from; each one composes with `max`, so the + // cadence can only get quieter, never faster than the user asked for. + let base = BLEManager.baseBackfillInterval(lowRefresh: lowRefreshMode) // #battery: known-empty-history 5/MG → stretch to the 45-min floor before any battery lever. if selectedModel.deviceFamily == .whoop5 { let stretched = BLEManager.whoop5EmptyHistoryBackfillInterval( - baseSeconds: BLEManager.backfillIntervalSeconds, - lowSeconds: BLEManager.lowBatteryBackfillIntervalSeconds, + baseSeconds: base, + lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds), historyEmpty: whoop5EmptyOffload.historyEmpty) - if stretched != BLEManager.backfillIntervalSeconds { return stretched } + if stretched != base { return stretched } } - guard lowBatteryOffloadPct > 0 else { return BLEManager.backfillIntervalSeconds } + guard lowBatteryOffloadPct > 0 else { return base } let (pct, charging) = batteryPctAndCharging() return BLEManager.offloadInterval( - baseSeconds: BLEManager.backfillIntervalSeconds, - lowSeconds: BLEManager.lowBatteryBackfillIntervalSeconds, + baseSeconds: base, + lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds), batteryPct: pct, charging: charging, thresholdPct: lowBatteryOffloadPct) } diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index db52147b74..b6865ba874 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -158,6 +158,12 @@ enum PuffinExperiment { static let powerSavingKey = "noopPowerSaving" static var powerSavingEnabled: Bool { UserDefaults.standard.bool(forKey: powerSavingKey) } + /// "Low refresh": a sub-option of Power saving (only meaningful while that master is on). Stretches + /// the periodic history offload to hourly at ANY strap charge, instead of only while the battery is + /// low. Default off. Cadence only — no data is lost (the strap banks to flash and trims on our ack). + static let lowRefreshKey = "noopLowRefresh" + static var lowRefreshEnabled: Bool { UserDefaults.standard.bool(forKey: lowRefreshKey) } + /// Battery-% threshold for power saving (10–30). Default 20 (0 in the store means "unset" → 20). static let powerSavingBatteryPctKey = "noopPowerSavingBatteryPct" static var powerSavingBatteryPct: Int { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 9dc702f252..19598ca68e 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -207150,6 +207150,74 @@ } } } + }, + "Low refresh": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Seltener aktualisieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualización lenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rafraîchissement réduit" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Atualização reduzida" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rzadsze odświeżanie" + } + } + } + }, + "Sync in the background every hour instead of every 15 minutes, whatever the strap's charge — fewer reconnections is the biggest saving on a WHOOP 4.0. Nothing is lost: the strap banks everything and hands it over in larger batches. Pull to sync still runs straight away, and live heart rate is untouched.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Synchronisiert im Hintergrund stündlich statt alle 15 Minuten, unabhängig vom Ladestand — weniger Verbindungsaufbauten sind die größte Ersparnis bei einer WHOOP 4.0. Es geht nichts verloren: Das Band speichert alles und übergibt es in größeren Paketen. Manuelles Synchronisieren läuft weiterhin sofort, die Live-Herzfrequenz bleibt unberührt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincroniza en segundo plano cada hora en vez de cada 15 minutos, sea cual sea la carga de la banda: menos reconexiones es el mayor ahorro en una WHOOP 4.0. No se pierde nada: la banda lo guarda todo y lo entrega en lotes más grandes. Sincronizar manualmente sigue siendo inmediato y la frecuencia cardíaca en vivo no cambia." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronise en arrière-plan toutes les heures au lieu de toutes les 15 minutes, quelle que soit la charge du bracelet : moins de reconnexions, c'est la plus grosse économie sur une WHOOP 4.0. Rien n'est perdu : le bracelet enregistre tout et le transmet par lots plus importants. La synchro manuelle reste immédiate et la fréquence cardiaque en direct n'est pas affectée." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sincroniza em segundo plano a cada hora em vez de a cada 15 minutos, seja qual for a carga da pulseira — menos religações é a maior poupança numa WHOOP 4.0. Nada se perde: a pulseira guarda tudo e entrega em lotes maiores. A sincronização manual continua imediata e a frequência cardíaca em direto não é afetada." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Synchronizuje w tle co godzinę zamiast co 15 minut, niezależnie od poziomu naładowania opaski — mniej ponownych połączeń to największa oszczędność w WHOOP 4.0. Nic nie ginie: opaska zapisuje wszystko i przekazuje w większych paczkach. Ręczna synchronizacja nadal działa od razu, a tętno na żywo pozostaje bez zmian." + } + } + } } }, "version": "1.0" diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 6af95ee82b..f6d76e3e1a 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -147,6 +147,7 @@ struct SettingsView: View { // #477 Power saving (parity with Android). Battery-adaptive sync cadence + an HRV-pause sub-option. @AppStorage(PuffinExperiment.powerSavingKey) private var powerSavingEnabled = false + @AppStorage(PuffinExperiment.lowRefreshKey) private var lowRefreshEnabled = false @AppStorage(PuffinExperiment.powerSavingBatteryPctKey) private var powerSavingPct = 20 /// Stored INVERTED so the default (absent = false) reads as "HRV pause on". The toggle shows `!this`. @AppStorage(PuffinExperiment.pauseHrvDisabledKey) private var pauseHrvDisabled = false @@ -1446,6 +1447,21 @@ struct SettingsView: View { .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + + rowDivider + // Low refresh: a sub-option that applies at ANY charge, not just below the threshold. + Toggle(isOn: $lowRefreshEnabled) { + Text("Low refresh") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .onChangeCompat(of: lowRefreshEnabled) { _ in model.applyPowerSaving() } + Text("Sync in the background every hour instead of every 15 minutes, whatever the strap's charge — fewer reconnections is the biggest saving on a WHOOP 4.0. Nothing is lost: the strap banks everything and hands it over in larger batches. Pull to sync still runs straight away, and live heart rate is untouched.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/StrandTests/LowRefreshCadenceTests.swift b/StrandTests/LowRefreshCadenceTests.swift new file mode 100644 index 0000000000..feac2decfb --- /dev/null +++ b/StrandTests/LowRefreshCadenceTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import Strand + +/// "Low refresh" (Settings → Power saving sub-option) moves the BASE periodic-offload cadence from 15 min +/// to 60 min. The invariant that matters is COMPOSITION: every other lever stretches FROM that base with +/// `max`, so no lever can ever hand back a faster cadence than the user asked for, and a lever that would +/// have stretched a 15-min base is a no-op once the base is already longer. +/// +/// Cadence only, by design: low refresh deliberately does NOT touch the keep-alive (that tick re-arms the +/// WHOOP 4 realtime burst each cycle and evaluates the 120 s stall fuse) and does NOT release continuous +/// HRV capture (that is the separate "Pause HRV capture" lever / #927's overnight window). Those are the +/// two places a quieter radio would cost real data rather than merely delaying it. +// +// BLEManager is @MainActor, so its static helpers are main-actor-isolated; the test methods must run on +// the main actor to call them (matches Whoop5BatteryBackfillThrottleTests etc.). Class-level @MainActor +// covers all. +@MainActor +final class LowRefreshCadenceTests: XCTestCase { + + func testBaseIsFifteenMinutesWhenOff() { + XCTAssertEqual(BLEManager.baseBackfillInterval(lowRefresh: false), BLEManager.backfillIntervalSeconds) + XCTAssertEqual(BLEManager.backfillIntervalSeconds, 900) + } + + func testBaseIsHourlyWhenOn() { + XCTAssertEqual(BLEManager.baseBackfillInterval(lowRefresh: true), BLEManager.lowRefreshBackfillIntervalSeconds) + XCTAssertEqual(BLEManager.lowRefreshBackfillIntervalSeconds, 3600) + } + + /// The low-battery lever (45 min) is SHORTER than low refresh (60 min): composed with `max` it must + /// leave the hourly cadence alone rather than speeding it back up. + func testLowBatteryLeverNeverShortensLowRefresh() { + let base = BLEManager.baseBackfillInterval(lowRefresh: true) + let composed = BLEManager.offloadInterval( + baseSeconds: base, + lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds), + batteryPct: 10, charging: false, thresholdPct: 20) // lever fully engaged + XCTAssertEqual(composed, BLEManager.lowRefreshBackfillIntervalSeconds) + } + + /// Without low refresh the same engaged lever still stretches 15 → 45 min, i.e. this change is inert + /// for everyone who does not turn it on. + func testDefaultBehaviourIsUnchangedWhenOff() { + let base = BLEManager.baseBackfillInterval(lowRefresh: false) + XCTAssertEqual( + BLEManager.offloadInterval(baseSeconds: base, + lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds), + batteryPct: 10, charging: false, thresholdPct: 20), + BLEManager.lowBatteryBackfillIntervalSeconds) + // …and an idle/charged strap keeps the plain 15-min cadence. + XCTAssertEqual( + BLEManager.offloadInterval(baseSeconds: base, + lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds), + batteryPct: 90, charging: false, thresholdPct: 20), + BLEManager.backfillIntervalSeconds) + } + + /// The 5/MG empty-history stretch (45 min) composes the same way: it may quieten a 15-min base, but + /// must not pull an already-hourly cadence back down. + func testWhoop5EmptyHistoryStretchNeverShortensLowRefresh() { + let base = BLEManager.baseBackfillInterval(lowRefresh: true) + XCTAssertEqual( + BLEManager.whoop5EmptyHistoryBackfillInterval( + baseSeconds: base, + lowSeconds: max(base, BLEManager.lowBatteryBackfillIntervalSeconds), + historyEmpty: true), + BLEManager.lowRefreshBackfillIntervalSeconds) + } +} diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt index 02784c01c1..cbd4d3066f 100644 --- a/Tools/i18n_extra_locale_baseline.txt +++ b/Tools/i18n_extra_locale_baseline.txt @@ -14,8 +14,8 @@ NOOPWatch/Localizable.xcstrings:zh-Hant 0 NOOPWatchComplications/Localizable.xcstrings:it 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hans 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hant 25 -Strand/Resources/Localizable.xcstrings:it 177 -Strand/Resources/Localizable.xcstrings:ru 154 -Strand/Resources/Localizable.xcstrings:zh-Hans 127 -Strand/Resources/Localizable.xcstrings:zh-Hant 177 +Strand/Resources/Localizable.xcstrings:it 179 +Strand/Resources/Localizable.xcstrings:ru 156 +Strand/Resources/Localizable.xcstrings:zh-Hans 129 +Strand/Resources/Localizable.xcstrings:zh-Hant 179 values-zh/strings.xml 50 diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 3029d977c2..a19c112804 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -717,6 +717,21 @@ class WhoopBleClient( * meanwhile, so this only delays sync (larger batches), never loses data. Gated on the discharging * battery-% threshold; 0 = disabled → always [BACKFILL_INTERVAL_MS]. */ private const val LOW_BATTERY_BACKFILL_INTERVAL_MS = 2_700_000L + + /** Low-refresh cadence (60 min): the user-elected sub-option of Power saving. NOT battery-gated — + * once chosen it is the BASE the other levers stretch from, at any strap charge. Same no-loss + * property as the lever above: the strap banks to flash and only trims on our ack, so this delays + * sync into larger batches, it never drops history. Cadence ONLY — deliberately does not touch the + * keep-alive (that tick re-arms realtime and evaluates the stall fuse) or continuous HRV capture + * (that is [setPauseCaptureOnPowerSave] / the overnight window). Twin of Swift + * `BLEManager.lowRefreshBackfillIntervalSeconds`. */ + internal const val LOW_REFRESH_BACKFILL_INTERVAL_MS = 3_600_000L + + /** Pure baseline-cadence decision: low refresh swaps the 15-min BASE for the hourly one; every other + * lever composes on top with `max`, so a lever can only make the cadence quieter, never restore a + * faster one the user asked to slow down. Twin of Swift `BLEManager.baseBackfillInterval`. */ + internal fun baseBackfillIntervalMs(lowRefresh: Boolean): Long = + if (lowRefresh) LOW_REFRESH_BACKFILL_INTERVAL_MS else BACKFILL_INTERVAL_MS /** How far back the inactivity check reads gravity on each offload completion (4 h comfortably * spans the threshold + re-nudge cadence and a separating Active break for bout continuity). */ private const val INACTIVITY_LOOKBACK_S = 4 * 3600L @@ -1882,12 +1897,24 @@ class WhoopBleClient( * OFF, so this ships dormant. The Settings picker offers 10/15/20/25/30. */ @Volatile private var lowBatteryOffloadPct: Int = 0 + /** User-elected hourly background-sync cadence (Settings -> Power saving -> "Low refresh"). Default + * off. Like [lowBatteryOffloadPct] it takes effect on the NEXT re-arm. */ + @Volatile private var lowRefreshMode: Boolean = false + /** Opt into the low-battery offload-cadence stretch (#477). Applies on the NEXT re-arm; a live sync * in flight is never interrupted. */ fun setLowBatteryOffloadThrottle(thresholdPct: Int) { lowBatteryOffloadPct = thresholdPct } + /** Settings sub-option of Power saving: the user-elected hourly background cadence. Applies on the + * next re-arm like the battery lever above; a sync already in flight is never interrupted. Twin of + * Swift `BLEManager.setLowRefreshMode`. */ + fun setLowRefreshMode(enabled: Boolean) { + if (lowRefreshMode == enabled) return + lowRefreshMode = enabled + } + /** #477: pause BACKGROUND continuous-HRV capture while the STRAP's battery is low (own toggle, * DEFAULT OFF). A visible Live screen is unaffected — only the held-open background stream is * released. Gated through [continuousCaptureWantsNow]. */ @@ -1911,20 +1938,23 @@ class WhoopBleClient( * quietThreshold = 2) at a fixed 45-min floor that stacks with it. Twin of iOS * `BLEManager.nextBackfillInterval`. Resets with [whoop5EmptyOffload] on disconnect. */ private fun nextBackfillDelayMs(): Long { + // Low refresh moves the BASE the other levers stretch from; each composes with `max`, so the + // cadence can only get quieter, never faster than the user asked for. + val base = baseBackfillIntervalMs(lowRefreshMode) // #battery: known-empty-history 5/MG → stretch to the 45-min floor before any battery lever. if (connectedFamily == DeviceFamily.WHOOP5) { val stretched = whoop5EmptyHistoryBackfillIntervalMs( - baseMs = BACKFILL_INTERVAL_MS, - lowBatteryMs = LOW_BATTERY_BACKFILL_INTERVAL_MS, + baseMs = base, + lowBatteryMs = maxOf(base, LOW_BATTERY_BACKFILL_INTERVAL_MS), historyEmpty = whoop5EmptyOffload.historyEmpty, ) - if (stretched != BACKFILL_INTERVAL_MS) return stretched + if (stretched != base) return stretched } - if (lowBatteryOffloadPct <= 0) return BACKFILL_INTERVAL_MS // dormant: no battery read, unchanged cadence + if (lowBatteryOffloadPct <= 0) return base // dormant: no battery read, unchanged cadence val (batteryPct, charging) = batteryPctAndCharging() return offloadIntervalMsFor( - baseMs = BACKFILL_INTERVAL_MS, - lowBatteryMs = LOW_BATTERY_BACKFILL_INTERVAL_MS, + baseMs = base, + lowBatteryMs = maxOf(base, LOW_BATTERY_BACKFILL_INTERVAL_MS), batteryPct = batteryPct, charging = charging, thresholdPct = lowBatteryOffloadPct, diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index a1f55f2b87..0c1625ef6a 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1154,6 +1154,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // independent of the Power-saving master, and its own toggle so a field report can tell the two // apart (they have opposite battery profiles). No-op unless on. ble.setFastLinkPhy(NoopPrefs.fastLinkPhy(appContext)) + // Low refresh is a sub-option too: only in effect while the master is on. Unlike the levers + // around it, it is NOT battery-gated once chosen — it moves the BASE cadence to hourly. + ble.setLowRefreshMode(on && NoopPrefs.lowRefresh(appContext)) ble.setLowBatteryOffloadThrottle(if (on) NoopPrefs.powerSavingBatteryPct(appContext) else 0) // HRV pause is a sub-option: only effective while the master is on (defaults on when it is), and // now battery-%-aware like the offload lever — pass the same threshold. @@ -1175,6 +1178,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { applyPowerSaving() } + /** Flip "Low refresh" (Settings). Persists + applies immediately. */ + fun setLowRefresh(enabled: Boolean) { + NoopPrefs.setLowRefresh(appContext, enabled) + applyPowerSaving() + } + /** Flip "Pause HRV capture in Battery Saver" (Settings). Persists + applies immediately. */ fun setPauseHrvOnPowerSave(enabled: Boolean) { NoopPrefs.setPauseHrvOnPowerSave(appContext, enabled) diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index fb629776c5..28919e5b76 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -250,6 +250,7 @@ object NoopPrefs { * Benign — the strap banks to flash meanwhile, so sync just batches; no data loss, no link risk. * Default OFF. Drives [com.noop.ble.WhoopBleClient.setLowBatteryOffloadThrottle] via [AppViewModel]. */ const val KEY_POWER_SAVING = "noop.powerSaving" + const val KEY_LOW_REFRESH = "low_refresh" /** Battery-% threshold for [KEY_POWER_SAVING] (10/15/20/25/30). Default 20. */ const val KEY_POWER_SAVING_BATTERY_PCT = "noop.powerSavingBatteryPct" /** "Pause HRV capture when the strap is low" (#477): when on, NOOP releases the held-open background @@ -281,6 +282,14 @@ object NoopPrefs { of(context).edit().putBoolean(KEY_POWER_SAVING, enabled).apply() } + /** "Low refresh": sub-option of Power saving. Hourly background sync at ANY strap charge. Default off. */ + fun lowRefresh(context: Context): Boolean = + of(context).getBoolean(KEY_LOW_REFRESH, false) + + fun setLowRefresh(context: Context, enabled: Boolean) { + of(context).edit().putBoolean(KEY_LOW_REFRESH, enabled).apply() + } + /** Battery-% threshold for power saving (default 20). */ fun powerSavingBatteryPct(context: Context): Int = of(context).getInt(KEY_POWER_SAVING_BATTERY_PCT, 20) diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 953e5fe300..82381d25ce 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -612,6 +612,7 @@ fun SettingsScreen( // #477 Power saving: battery-adaptive strap-sync cadence + optional HRV-capture pause. Local mirrors. var powerSaving by remember { mutableStateOf(NoopPrefs.powerSaving(context)) } var powerSavingBatteryPct by remember { mutableStateOf(NoopPrefs.powerSavingBatteryPct(context)) } + var lowRefresh by remember { mutableStateOf(NoopPrefs.lowRefresh(context)) } var pauseHrvOnPowerSave by remember { mutableStateOf(NoopPrefs.pauseHrvOnPowerSave(context)) } // --- v5 Health & wellness toggle group. All SharedPreferences-backed (not reactive), so each Switch @@ -2230,6 +2231,36 @@ fun SettingsScreen( ), ) } + SettingsRowDivider() + // Low refresh: a sub-option that applies at ANY charge, not just below the threshold. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.power_saving_low_refresh), style = NoopType.subhead, color = Palette.textPrimary) + Text( + stringResource(R.string.power_saving_low_refresh_desc), + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } + Switch( + checked = lowRefresh, + onCheckedChange = { + lowRefresh = it + vm.setLowRefresh(it) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textSecondary, + uncheckedTrackColor = Palette.surfaceInset, + uncheckedBorderColor = Palette.hairline, + ), + ) + } } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 2423d3c9ae..1f45bb1d05 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2143,4 +2143,6 @@ Entfernt diesen aufgezeichneten Schlaf und berechnet den Tag ohne ihn neu. NOOP erkennt in diesem Zeitfenster keinen Schlaf mehr. Du kannst dies für ein paar Sekunden rückgängig machen. Optional · VO₂max entsteht aus ~4 Nächten Herzfrequenz; ein Taillenumfang macht ihn genauer. Das Fitness-Alter selbst braucht ihn nicht. Miss um deine Körpermitte, auf Höhe des Bauchnabels. Dein VO₂max nutzt deinen Taillenumfang für eine genauere Schätzung + Seltener aktualisieren + Synchronisiert im Hintergrund stündlich statt alle 15 Minuten, unabhängig vom Ladestand — weniger Verbindungsaufbauten sind die größte Ersparnis bei einer WHOOP 4.0. Es geht nichts verloren: Das Band speichert alles und übergibt es in größeren Paketen. Manuelles Synchronisieren läuft weiterhin sofort, die Live-Herzfrequenz bleibt unberührt. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 68ed704c58..7fe0410561 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2129,4 +2129,6 @@ Elimina este sueño registrado y recalcula el día sin él. NOOP no volverá a detectar sueño en esta ventana. Puedes deshacerlo durante unos segundos. Opcional · El VO₂max se construye a partir de ~4 noches de frecuencia cardíaca; la cintura lo hace más preciso. La Edad de forma física no la necesita. Mide alrededor de tu cintura, a la altura del ombligo. Tu VO₂max usa tu cintura para una estimación más precisa + Actualización lenta + Sincroniza en segundo plano cada hora en vez de cada 15 minutos, sea cual sea la carga de la banda: menos reconexiones es el mayor ahorro en una WHOOP 4.0. No se pierde nada: la banda lo guarda todo y lo entrega en lotes más grandes. Sincronizar manualmente sigue siendo inmediato y la frecuencia cardíaca en vivo no cambia. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index b86157b57f..ed71ebd767 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2128,4 +2128,6 @@ Supprime ce sommeil enregistré et recalcule la journée sans lui. NOOP ne redétectera pas de sommeil dans cette fenêtre. Vous pouvez annuler pendant quelques secondes. Facultatif · La VO₂max se construit à partir d’environ 4 nuits de fréquence cardiaque ; le tour de taille la rend plus précise. L’Âge de forme n’en a pas besoin. Mesurez autour de votre taille, au niveau du nombril. Votre VO₂max utilise votre tour de taille pour une estimation plus précise + Rafraîchissement réduit + Synchronise en arrière-plan toutes les heures au lieu de toutes les 15 minutes, quelle que soit la charge du bracelet : moins de reconnexions, c\'est la plus grosse économie sur une WHOOP 4.0. Rien n\'est perdu : le bracelet enregistre tout et le transmet par lots plus importants. La synchro manuelle reste immédiate et la fréquence cardiaque en direct n\'est pas affectée. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index b55dfc1ee2..49e676ec29 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2122,4 +2122,6 @@ Usuwa ten zarejestrowany sen i przelicza dzień bez niego. NOOP nie wykryje ponownie snu w tym oknie. Możesz cofnąć przez kilka sekund. Opcjonalnie · VO₂max powstaje z ~4 nocy pomiaru tętna; obwód talii zwiększa dokładność. Wiek sprawnościowy go nie potrzebuje. Zmierz w pasie, na wysokości pępka. Twój VO₂max korzysta z obwodu talii, aby oszacowanie było dokładniejsze + Rzadsze odświeżanie + Synchronizuje w tle co godzinę zamiast co 15 minut, niezależnie od poziomu naładowania opaski — mniej ponownych połączeń to największa oszczędność w WHOOP 4.0. Nic nie ginie: opaska zapisuje wszystko i przekazuje w większych paczkach. Ręczna synchronizacja nadal działa od razu, a tętno na żywo pozostaje bez zmian. diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 0a6d7d6e02..85048e13dc 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2122,4 +2122,6 @@ Remove este sono registado e recalcula o dia sem ele. O NOOP não vai voltar a detetar sono nesta janela. Podes anular durante alguns segundos. Opcional · O VO₂max é construído a partir de ~4 noites de frequência cardíaca; a cintura torna-o mais preciso. A Idade de forma física não precisa dela. Mede à volta da tua cintura, à altura do umbigo. O teu VO₂max usa a tua cintura para uma estimativa mais precisa + Atualização reduzida + Sincroniza em segundo plano a cada hora em vez de a cada 15 minutos, seja qual for a carga da pulseira — menos religações é a maior poupança numa WHOOP 4.0. Nada se perde: a pulseira guarda tudo e entrega em lotes maiores. A sincronização manual continua imediata e a frequência cardíaca em direto não é afetada. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 64ba40772c..4d00a9a9dc 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2048,4 +2048,6 @@ 移除此次已记录的睡眠并重新计算当天数据。NOOP 不会在此时间段内重新检测睡眠。之后几秒内可以撤销。 可选 · VO₂max 由约 4 晚的心率数据构建;腰围可让它更准确。健康年龄本身并不需要它。请在肚脐高度绕腰一周测量。 你的 VO₂max 使用腰围来获得更准确的估算 + 低频刷新 + 无论手环电量如何,后台同步改为每小时一次而非每 15 分钟一次——减少重新连接是 WHOOP 4.0 上最大的省电项。不会丢失任何数据:手环会记录全部内容,并以更大的批次交付。手动同步仍会立即运行,实时心率不受影响。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index cbb9a1b880..afef7a999f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2155,4 +2155,6 @@ Removes this recorded sleep and recomputes the day without it. NOOP won\'t re-detect sleep in this window. You can undo for a few seconds after. Optional · VO₂max builds from ~4 nights of heart rate; a waist makes it more accurate. The Fitness Age itself doesn’t need it. Measure around your middle, at the navel. Your VO₂max uses your waist for a more accurate estimate + Low refresh + Sync in the background every hour instead of every 15 minutes, whatever the strap\'s charge — fewer reconnections is the biggest saving on a WHOOP 4.0. Nothing is lost: the strap banks everything and hands it over in larger batches. Pull to sync still runs straight away, and live heart rate is untouched. diff --git a/android/app/src/test/java/com/noop/ble/LowRefreshCadenceTest.kt b/android/app/src/test/java/com/noop/ble/LowRefreshCadenceTest.kt new file mode 100644 index 0000000000..58c1697c52 --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/LowRefreshCadenceTest.kt @@ -0,0 +1,84 @@ +package com.noop.ble + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Kotlin twin of `LowRefreshCadenceTests.swift`. "Low refresh" (Settings -> Power saving sub-option) + * moves the BASE periodic-offload cadence from 15 min to 60 min. The invariant that matters is + * COMPOSITION: every other lever stretches FROM that base with `max`, so no lever can hand back a faster + * cadence than the user asked for, and a lever that would have stretched a 15-min base becomes a no-op + * once the base is already longer. + * + * Cadence only, by design: low refresh does NOT touch the keep-alive (that tick re-arms realtime and + * evaluates the stall fuse) and does NOT release continuous HRV capture (that is the separate + * `setPauseCaptureOnPowerSave` lever). Those are the two places a quieter radio would cost real data + * rather than merely delaying it. + */ +class LowRefreshCadenceTest { + + @Test + fun baseIsFifteenMinutesWhenOff() { + assertEquals(900_000L, WhoopBleClient.baseBackfillIntervalMs(lowRefresh = false)) + } + + @Test + fun baseIsHourlyWhenOn() { + assertEquals( + WhoopBleClient.LOW_REFRESH_BACKFILL_INTERVAL_MS, + WhoopBleClient.baseBackfillIntervalMs(lowRefresh = true), + ) + assertEquals(3_600_000L, WhoopBleClient.LOW_REFRESH_BACKFILL_INTERVAL_MS) + } + + /** The low-battery lever (45 min) is SHORTER than low refresh (60 min): composed with `max` it must + * leave the hourly cadence alone rather than speeding it back up. */ + @Test + fun lowBatteryLeverNeverShortensLowRefresh() { + val base = WhoopBleClient.baseBackfillIntervalMs(lowRefresh = true) + val composed = WhoopBleClient.offloadIntervalMsFor( + baseMs = base, + lowBatteryMs = maxOf(base, 2_700_000L), + batteryPct = 10, + charging = false, + thresholdPct = 20, // lever fully engaged + ) + assertEquals(WhoopBleClient.LOW_REFRESH_BACKFILL_INTERVAL_MS, composed) + } + + /** With low refresh off the same engaged lever still stretches 15 -> 45 min: inert for everyone who + * does not turn it on. */ + @Test + fun defaultBehaviourIsUnchangedWhenOff() { + val base = WhoopBleClient.baseBackfillIntervalMs(lowRefresh = false) + assertEquals( + 2_700_000L, + WhoopBleClient.offloadIntervalMsFor( + baseMs = base, lowBatteryMs = maxOf(base, 2_700_000L), + batteryPct = 10, charging = false, thresholdPct = 20, + ), + ) + // …and an idle/charged strap keeps the plain 15-min cadence. + assertEquals( + 900_000L, + WhoopBleClient.offloadIntervalMsFor( + baseMs = base, lowBatteryMs = maxOf(base, 2_700_000L), + batteryPct = 90, charging = false, thresholdPct = 20, + ), + ) + } + + /** The 5/MG empty-history stretch (45 min) composes the same way. */ + @Test + fun whoop5EmptyHistoryStretchNeverShortensLowRefresh() { + val base = WhoopBleClient.baseBackfillIntervalMs(lowRefresh = true) + assertEquals( + WhoopBleClient.LOW_REFRESH_BACKFILL_INTERVAL_MS, + WhoopBleClient.whoop5EmptyHistoryBackfillIntervalMs( + baseMs = base, + lowBatteryMs = maxOf(base, 2_700_000L), + historyEmpty = true, + ), + ) + } +}