From bd1c2ce6a932cf7573b16315f59d59e87f5eac89 Mon Sep 17 00:00:00 2001 From: prasad Date: Fri, 23 Oct 2026 22:55:56 -0400 Subject: [PATCH] fix(power): enforce saving cadence for automatic sync triggers --- Strand/BLE/BLEManager.swift | 10 ++- Strand/BLE/BackfillPolicy.swift | 13 ++- StrandTests/BackfillPolicyTests.swift | 80 +++++++++++++++++++ .../main/java/com/noop/ble/BackfillPolicy.kt | 9 ++- .../main/java/com/noop/ble/WhoopBleClient.kt | 8 ++ .../java/com/noop/ble/BackfillPolicyTest.kt | 77 ++++++++++++++++++ 6 files changed, 191 insertions(+), 6 deletions(-) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 690e394194..c1866bc144 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -4951,12 +4951,20 @@ public final class BLEManager: NSObject, ObservableObject { // live HR lapses. Feed the already-tracked future-dated signal into BackfillPolicy, which SKIPS // the .strap/.periodic triggers entirely for such a strap (the .connect pass still re-checks it). let clockUntrusted = BackfillContinuation.isFutureDatedNewest(strapNewestTs, wallNowUnix: Int(now)) + // Power saving must gate pushed events too: a slower timer alone still permits an offload + // every 90 s. Reuse the timer's cadence only while a user-elected saving lever is active; + // ordinary event syncing retains its existing floor, including above the battery threshold. + let (pct, charging) = batteryPctAndCharging() + let powerSavingActive = lowRefreshMode || BLEManager.lowPowerThrottleActive( + batteryPct: pct, charging: charging, thresholdPct: lowBatteryOffloadPct) + let automaticFloor = powerSavingActive ? TimeInterval(nextBackfillInterval()) : 0 guard BackfillPolicy.shouldRun(trigger: trigger, now: now, lastBackfillAt: last, // #battery: back off on EITHER a console-only streak (clock-lost) OR a // plain empty-offload streak incl. idle-timeout stalls — the latter is // what stops the 15-min poll spinning on a caught-up strap. emptyStreak: max(emptySyncTracker.consecutiveEmptySyncs, consecutiveEmptyOffloads), - clockUntrusted: clockUntrusted) else { + clockUntrusted: clockUntrusted, + automaticFloorSeconds: automaticFloor) else { log("Backfill: \(trigger) skipped (rate-limited; last \(last.map { Int(now - $0) } ?? -1)s ago)") return } diff --git a/Strand/BLE/BackfillPolicy.swift b/Strand/BLE/BackfillPolicy.swift index 2dca7386b0..58235c6069 100644 --- a/Strand/BLE/BackfillPolicy.swift +++ b/Strand/BLE/BackfillPolicy.swift @@ -40,9 +40,14 @@ enum BackfillPolicy { /// SKIPPED ENTIRELY while the clock is untrusted — the per-connection `.connect` pass still runs, so a /// clock that later self-corrects is picked up on the next connection (or a manual sync). As with /// `emptyStreak`, `.connect`/`.foreground`/`.manual`/`.autoContinue` are never affected. + /// `automaticFloorSeconds` carries the active Power saving cadence into BOTH automatic triggers. + /// Stretching only the periodic timer lets pushed strap events bypass Low refresh / low-battery + /// throttling through the 90 s event floor. Zero preserves normal event-driven syncing. Compose + /// with the empty backoff using max, not multiplication; user and connection triggers keep their floors. static func shouldRun(trigger: BackfillTrigger, now: TimeInterval, lastBackfillAt: TimeInterval?, emptyStreak: Int = 0, - clockUntrusted: Bool = false) -> Bool { + clockUntrusted: Bool = false, + automaticFloorSeconds: TimeInterval = 0) -> Bool { guard let last = lastBackfillAt else { return true } let elapsed = now - last let backoff: Double = emptyStreak >= emptyBackoffThreshold @@ -56,8 +61,10 @@ enum BackfillPolicy { // #160: a future-dated-clock strap's recurring automatic offloads are near-useless (#1012 won't // trust the range) but each holds the link ~60s and starves the WHOOP4 realtime-HR re-arm, so skip // them entirely — not just stretch the floor. The .connect pass above still re-checks the clock. - case .strap: return !clockUntrusted && elapsed >= eventFloorSeconds * backoff - case .periodic: return !clockUntrusted && elapsed >= periodicFloorSeconds * backoff + case .strap: + return !clockUntrusted && elapsed >= max(eventFloorSeconds * backoff, automaticFloorSeconds) + case .periodic: + return !clockUntrusted && elapsed >= max(periodicFloorSeconds * backoff, automaticFloorSeconds) } } } diff --git a/StrandTests/BackfillPolicyTests.swift b/StrandTests/BackfillPolicyTests.swift index 5c711e17ec..c20daef763 100644 --- a/StrandTests/BackfillPolicyTests.swift +++ b/StrandTests/BackfillPolicyTests.swift @@ -1,4 +1,5 @@ import XCTest +import CryptoKit @testable import Strand /// `BackfillPolicy` rate-limiter, incl. the empty-streak backoff that stops an off-wrist / not-banking @@ -7,6 +8,85 @@ final class BackfillPolicyTests: XCTestCase { private let fe = BackfillPolicy.eventFloorSeconds // 90 private let fp = BackfillPolicy.periodicFloorSeconds // 900 + // Compiled from the production Swift helper with swiftc; Kotlin pins the same 5,760 outcomes. + func testPowerSavingDecisionOracle() { + let triggers: [BackfillTrigger] = [.periodic, .connect, .foreground, .manual, .strap, .autoContinue] + let times: [Double] = [-1, 0, 89, 90, 479, 480, 899, 900, 1799, 1800, 2699, 2700, 3599, 3600, 7199, 7200] + var bits = Data() + for trigger in triggers { + for last: Double? in [nil, 0] { + for now in times { + for streak in [0, 2, 3, 4, 99] { + for untrusted in [false, true] { + for floor in [0.0, 2700.0, 3600.0] { + let run = BackfillPolicy.shouldRun(trigger: trigger, now: now, + lastBackfillAt: last, emptyStreak: streak, clockUntrusted: untrusted, + automaticFloorSeconds: floor) + bits.append(run ? 49 : 48) + } + } + } + } + } + } + XCTAssertEqual(bits.count, 5760) + XCTAssertEqual(SHA256.hash(data: bits).map { String(format: "%02x", $0) }.joined(), + "66d75a86f19bc42e51993cc47554f4cc514267e071953b79670a3b771c3766f9") + } + + func testLowRefreshCannotBeBypassedByStrapEvents() { + var last = 0.0 + var starts: [Int] = [] + for now in stride(from: 90, through: 7200, by: 90) { + if BackfillPolicy.shouldRun(trigger: .strap, now: Double(now), lastBackfillAt: last, + automaticFloorSeconds: 3600) { + starts.append(now) + last = Double(now) + } + } + XCTAssertEqual(starts, [3600, 7200]) + } + + func testPowerSavingFloorsBothAutomaticTriggersAtTheBoundary() { + for trigger in [BackfillTrigger.strap, .periodic] { + for floor in [2700.0, 3600.0] { + XCTAssertFalse(BackfillPolicy.shouldRun(trigger: trigger, now: floor - 1, + lastBackfillAt: 0, automaticFloorSeconds: floor)) + XCTAssertTrue(BackfillPolicy.shouldRun(trigger: trigger, now: floor, + lastBackfillAt: 0, automaticFloorSeconds: floor)) + } + } + // A manual sync becomes the new reference for subsequent automatic attempts too. + XCTAssertTrue(BackfillPolicy.shouldRun(trigger: .manual, now: 600, + lastBackfillAt: 0, automaticFloorSeconds: 3600)) + XCTAssertFalse(BackfillPolicy.shouldRun(trigger: .strap, now: 3600, + lastBackfillAt: 600, automaticFloorSeconds: 3600)) + XCTAssertTrue(BackfillPolicy.shouldRun(trigger: .strap, now: 4200, + lastBackfillAt: 600, automaticFloorSeconds: 3600)) + } + + func testPowerSavingNeverDelaysRecoveryOrAnActiveDrain() { + for trigger in [BackfillTrigger.connect, .foreground, .manual, .autoContinue] { + XCTAssertTrue(BackfillPolicy.shouldRun(trigger: trigger, now: 90, + lastBackfillAt: 0, emptyStreak: 99, clockUntrusted: true, automaticFloorSeconds: 3600)) + } + XCTAssertTrue(BackfillPolicy.shouldRun(trigger: .strap, now: 1, + lastBackfillAt: nil, automaticFloorSeconds: 3600)) + } + + func testPowerSavingComposesWithEmptyBackoffAndUntrustedClock() { + // Existing periodic backoff (3600 s) wins over low-battery saving (2700 s). + XCTAssertFalse(BackfillPolicy.shouldRun(trigger: .periodic, now: 2700, + lastBackfillAt: 0, emptyStreak: 5, automaticFloorSeconds: 2700)) + XCTAssertTrue(BackfillPolicy.shouldRun(trigger: .periodic, now: 3600, + lastBackfillAt: 0, emptyStreak: 5, automaticFloorSeconds: 2700)) + // Hourly saving wins over the event backoff, without multiplying it to four hours. + XCTAssertTrue(BackfillPolicy.shouldRun(trigger: .strap, now: 3600, + lastBackfillAt: 0, emptyStreak: 99, automaticFloorSeconds: 3600)) + XCTAssertFalse(BackfillPolicy.shouldRun(trigger: .strap, now: 7200, + lastBackfillAt: 0, clockUntrusted: true, automaticFloorSeconds: 3600)) + } + func testFirstSyncAlwaysRuns() { XCTAssertTrue(BackfillPolicy.shouldRun(trigger: .periodic, now: 1000, lastBackfillAt: nil)) XCTAssertTrue(BackfillPolicy.shouldRun(trigger: .strap, now: 1000, lastBackfillAt: nil)) diff --git a/android/app/src/main/java/com/noop/ble/BackfillPolicy.kt b/android/app/src/main/java/com/noop/ble/BackfillPolicy.kt index eb65e5afb1..9d61dfcc7a 100644 --- a/android/app/src/main/java/com/noop/ble/BackfillPolicy.kt +++ b/android/app/src/main/java/com/noop/ble/BackfillPolicy.kt @@ -59,6 +59,10 @@ object BackfillPolicy { * near-useless (#1012) while each ~60s offload holds the link — so PERIODIC/STRAP are SKIPPED ENTIRELY * while the clock is untrusted. The per-connection CONNECT pass still runs, so a clock that later * self-corrects is picked up on the next connection (or a manual sync). + * + * [automaticFloorSeconds] applies the active Power saving cadence to BOTH automatic triggers. + * Otherwise strap events bypass the slower periodic timer through the 90 s event floor. Zero + * preserves normal event-driven syncing; max composes it with empty backoff without multiplying it. */ fun shouldRun( trigger: BackfillTrigger, @@ -66,6 +70,7 @@ object BackfillPolicy { lastBackfillAtSeconds: Double?, emptyStreak: Int = 0, clockUntrusted: Boolean = false, + automaticFloorSeconds: Double = 0.0, ): Boolean { val last = lastBackfillAtSeconds ?: return true val elapsed = nowSeconds - last @@ -79,8 +84,8 @@ object BackfillPolicy { // (bounded by the caller's consecutive-cap, not here). BackfillTrigger.MANUAL, BackfillTrigger.AUTO_CONTINUE -> true BackfillTrigger.CONNECT, BackfillTrigger.FOREGROUND -> elapsed >= EVENT_FLOOR_SECONDS - BackfillTrigger.STRAP -> !clockUntrusted && elapsed >= EVENT_FLOOR_SECONDS * backoff - BackfillTrigger.PERIODIC -> !clockUntrusted && elapsed >= PERIODIC_FLOOR_SECONDS * backoff + BackfillTrigger.STRAP -> !clockUntrusted && elapsed >= maxOf(EVENT_FLOOR_SECONDS * backoff, automaticFloorSeconds) + BackfillTrigger.PERIODIC -> !clockUntrusted && elapsed >= maxOf(PERIODIC_FLOOR_SECONDS * backoff, automaticFloorSeconds) } } } 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 8fd7e63730..1124fec26b 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -10984,6 +10984,13 @@ class WhoopBleClient( val s = _state.value if (!canRequestSync(s.connected, s.bonded, backfilling)) return val clockUntrusted = isFutureDatedNewest(strapNewestTs, System.currentTimeMillis() / 1000L) + // A slower timer alone still lets pushed events offload every 90 s. Apply the timer's cadence + // to the shared automatic gate only while a user-elected Power saving lever is active. + val (batteryPct, charging) = batteryPctAndCharging() + val powerSavingActive = lowRefreshMode || idleThrottleActive( + batteryPct, charging, lowBatteryOffloadPct, + ) + val automaticFloor = if (powerSavingActive) nextBackfillDelayMs() / 1000.0 else 0.0 if (!BackfillPolicy.shouldRun( trigger = trigger, nowSeconds = System.currentTimeMillis() / 1000.0, @@ -10993,6 +11000,7 @@ class WhoopBleClient( // latter is what stops the 15-min radio poll spinning on a caught-up strap. emptyStreak = maxOf(emptySyncTracker.consecutiveEmptySyncs, consecutiveEmptyOffloads), clockUntrusted = clockUntrusted, + automaticFloorSeconds = automaticFloor, ) ) { // #1683: the empty streak only stretches the floor for the AUTOMATIC triggers (PERIODIC and diff --git a/android/app/src/test/java/com/noop/ble/BackfillPolicyTest.kt b/android/app/src/test/java/com/noop/ble/BackfillPolicyTest.kt index 1ae05b4602..121138cf6b 100644 --- a/android/app/src/test/java/com/noop/ble/BackfillPolicyTest.kt +++ b/android/app/src/test/java/com/noop/ble/BackfillPolicyTest.kt @@ -2,7 +2,9 @@ package com.noop.ble import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals import org.junit.Test +import java.security.MessageDigest /** * Parity tests for [BackfillPolicy] — the port of the Swift `BackfillPolicy` @@ -13,6 +15,81 @@ import org.junit.Test */ class BackfillPolicyTest { + /** Verbatim SHA-256 from the compiled production Swift helper over these 5,760 decisions. */ + @Test + fun powerSavingDecisionOracle() { + val times = listOf(-1.0, 0.0, 89.0, 90.0, 479.0, 480.0, 899.0, 900.0, + 1799.0, 1800.0, 2699.0, 2700.0, 3599.0, 3600.0, 7199.0, 7200.0) + val bits = StringBuilder() + for (trigger in BackfillTrigger.values()) { + for (last in listOf(null, 0.0)) { + for (now in times) { + for (streak in listOf(0, 2, 3, 4, 99)) { + for (untrusted in listOf(false, true)) { + for (floor in listOf(0.0, 2700.0, 3600.0)) { + val run = BackfillPolicy.shouldRun(trigger, now, last, streak, untrusted, floor) + bits.append(if (run) '1' else '0') + } + } + } + } + } + } + assertEquals(5760, bits.length) + val digest = MessageDigest.getInstance("SHA-256").digest(bits.toString().toByteArray(Charsets.US_ASCII)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + assertEquals("66d75a86f19bc42e51993cc47554f4cc514267e071953b79670a3b771c3766f9", digest) + } + + @Test + fun lowRefreshCannotBeBypassedByStrapEvents() { + var last = 0.0 + val starts = mutableListOf() + for (now in 90..7200 step 90) { + if (BackfillPolicy.shouldRun(BackfillTrigger.STRAP, now.toDouble(), last, + automaticFloorSeconds = 3600.0)) { + starts.add(now) + last = now.toDouble() + } + } + assertEquals(listOf(3600, 7200), starts) + } + + @Test + fun powerSavingFloorsBothAutomaticTriggersAtTheBoundary() { + for (trigger in listOf(BackfillTrigger.STRAP, BackfillTrigger.PERIODIC)) { + for (floor in listOf(2700.0, 3600.0)) { + assertFalse(BackfillPolicy.shouldRun(trigger, floor - 1, 0.0, automaticFloorSeconds = floor)) + assertTrue(BackfillPolicy.shouldRun(trigger, floor, 0.0, automaticFloorSeconds = floor)) + } + } + assertTrue(BackfillPolicy.shouldRun(BackfillTrigger.MANUAL, 600.0, 0.0, automaticFloorSeconds = 3600.0)) + assertFalse(BackfillPolicy.shouldRun(BackfillTrigger.STRAP, 3600.0, 600.0, automaticFloorSeconds = 3600.0)) + assertTrue(BackfillPolicy.shouldRun(BackfillTrigger.STRAP, 4200.0, 600.0, automaticFloorSeconds = 3600.0)) + } + + @Test + fun powerSavingNeverDelaysRecoveryOrAnActiveDrain() { + for (trigger in listOf(BackfillTrigger.CONNECT, BackfillTrigger.FOREGROUND, + BackfillTrigger.MANUAL, BackfillTrigger.AUTO_CONTINUE)) { + assertTrue(BackfillPolicy.shouldRun(trigger, 90.0, 0.0, emptyStreak = 99, + clockUntrusted = true, automaticFloorSeconds = 3600.0)) + } + assertTrue(BackfillPolicy.shouldRun(BackfillTrigger.STRAP, 1.0, null, automaticFloorSeconds = 3600.0)) + } + + @Test + fun powerSavingComposesWithEmptyBackoffAndUntrustedClock() { + assertFalse(BackfillPolicy.shouldRun(BackfillTrigger.PERIODIC, 2700.0, 0.0, + emptyStreak = 5, automaticFloorSeconds = 2700.0)) + assertTrue(BackfillPolicy.shouldRun(BackfillTrigger.PERIODIC, 3600.0, 0.0, + emptyStreak = 5, automaticFloorSeconds = 2700.0)) + assertTrue(BackfillPolicy.shouldRun(BackfillTrigger.STRAP, 3600.0, 0.0, + emptyStreak = 99, automaticFloorSeconds = 3600.0)) + assertFalse(BackfillPolicy.shouldRun(BackfillTrigger.STRAP, 7200.0, 0.0, + clockUntrusted = true, automaticFloorSeconds = 3600.0)) + } + // The first-ever call (no prior backfill) always runs, whatever the trigger. @Test fun nullLastAlwaysRuns() {