Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 34 additions & 6 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand Down
6 changes: 6 additions & 0 deletions Strand/BLE/PuffinExperiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
69 changes: 69 additions & 0 deletions StrandTests/LowRefreshCadenceTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 4 additions & 4 deletions Tools/i18n_extra_locale_baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading