Skip to content
Draft
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
10 changes: 9 additions & 1 deletion Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 10 additions & 3 deletions Strand/BLE/BackfillPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}
80 changes: 80 additions & 0 deletions StrandTests/BackfillPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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))
Expand Down
9 changes: 7 additions & 2 deletions android/app/src/main/java/com/noop/ble/BackfillPolicy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,18 @@ 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,
nowSeconds: Double,
lastBackfillAtSeconds: Double?,
emptyStreak: Int = 0,
clockUntrusted: Boolean = false,
automaticFloorSeconds: Double = 0.0,
): Boolean {
val last = lastBackfillAtSeconds ?: return true
val elapsed = nowSeconds - last
Expand All @@ -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)
}
}
}
8 changes: 8 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
77 changes: 77 additions & 0 deletions android/app/src/test/java/com/noop/ble/BackfillPolicyTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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<Int>()
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() {
Expand Down
Loading