Repository navigation
BLE: cut battery drain (empty-offload backoff + reconnect-churn dwell-gate) - #228
Merged
Merged
Conversation
…churn
Two related fixes for a WHOOP band + phone draining faster than they should, sharing a
small BackfillPolicy parity port.
1. Historical-offload backoff. Android re-ran the type-47 offload every 15 min for the life
of the connection even when the strap kept banking nothing, waking the strap NAND and
holding the link on a fixed cadence. macOS already floors and backs this off via
BackfillPolicy; that gate was flagged unported on Android ("requestSync minus the
BackfillPolicy rate-limiter"). Port BackfillPolicy (Strand/BLE/BackfillPolicy.swift):
15-min periodic / 90s event floors, an empty-streak backoff that stretches the automatic
periodic and strap kicks to a 1-hour floor once offloads keep coming back empty, and a skip
of those automatic kicks while the strap clock reads future-dated (ryanbr#928/ryanbr#1012). requestSync
now takes a BackfillTrigger and gates through it; manual/connect/foreground are never
delayed; the first real record resets the streak. Covered by BackfillPolicyTest (parity
with the Swift curve).
2. Reconnect-churn dwell-gate. The direct-first reconnect makes involuntary attempts 1-2 active
DIRECT connects. On a band whose ACL is contended by the official WHOOP app it reaches
STATE_CONNECTED briefly each cycle, and the backoff reset there pinned it at attempt 1 =
active DIRECT reconnect indefinitely, keeping both radios hot. Defer the STATE_CONNECTED
backoff reset behind the existing connectGeneration guard plus an 8s survival dwell
(RECONNECT_HEALTHY_DWELL_MS): a flap bumps connectGeneration so the reset no-ops and
failedReconnectAttempts accumulates, so the involuntary reconnect escalates to low-power
PASSIVE autoConnect (>= 3) instead of hammering DIRECT. A healthy link survives the dwell
and resets, keeping the snappy 3s first retry. It does NOT trip the bond-loop re-pair guide
(the band isn't bond-looping, just contended). The survived-dwell guard is shared with the
re-pair-guide clear via runIfConnectionSurvives().
BLE timing changes, validated on a contended 5.0 (fw 50.40.1.0). Android JVM unit tests green.
ryanbr
added a commit
that referenced
this pull request
Jul 10, 2026
Polish + fixes on top of 8.6.0: - Restart strap: a confirmation-gated, non-destructive reboot on the connected band in Devices, with a Reconnecting… state (#166). iPhone, Mac, Android. - Android battery: back off empty history offloads + step down reconnect churn on a contended band (#228, tanarchytan). - Android: NOOP now appears in Health Connect's App permissions on Android 13 (#226). - Android: auto-detected workout "Save it" persists again (was cancelled mid-save) (#214). - Oura ring wear state reads "On wrist" while streaming instead of "Unknown" (#218). - iPhone/iPad: re-tapping the active tab pops to root + scrolls to top (#135). - docs/releases/v8.6.1.md (release notes) - android build.gradle.kts: versionCode 271->272, versionName 8.6.0->8.6.1 - project.yml: MARKETING_VERSION 8.6.0->8.6.1, CURRENT_PROJECT_VERSION 182->183 - in-app What's New: AppChangelog.swift + AppChangelog.kt 8.6.1 entry (parallel) Android :app:compileFullDebugKotlin passes.
2 of 4 tasks
ryanbr
pushed a commit
that referenced
this pull request
Jul 12, 2026
…rds so auto-sync can't stall (#286) dataRangeNewestUnix scanned 4-byte words aligned FROM offset 7. On WHOOP 4 the strap's newest-record u32 sits at byte offset 8, off that grid, so the scan straddled it and returned null — leaving a stale/garbage word latched in strapNewestTs. That was harmless until #228 fed strapNewestTs into BackfillPolicy: a future-reading value now makes isFutureDatedNewest skip the automatic periodic/strap offload entirely, so one bad read stalls automatic sync (observed: a 4.0 whose newest record was in fact today read ~520 days future and stopped auto-syncing; manual "Sync now" still worked). Scan every byte offset instead, and prefer the newest word at or behind the wall clock (a device cannot record the future, so a lone future-dated straddle is a decode artifact, not the frontier). Only when EVERY plausible word is future — a genuinely future-set RTC (#928) — return the future max, so isFutureDatedNewest still fires and the auto-continue / periodic guards correctly refuse the range. Family-agnostic: repairs the WHOOP 4 offset and hardens 5/MG regardless of their frame layout; cannot regress a frame the old aligned scan already read. New DataRangeScanTest pins three of David's real 4.0 GET_DATA_RANGE frames (newest = today, previously missed) plus the future-straddle and genuine-future cases. Swift twin (BLEManager.dataRangeNewestUnix) changed identically for parity; NOT compiled here (needs macOS/Xcode) — verify before merge.
ryanbr
pushed a commit
that referenced
this pull request
Jul 12, 2026
…an't stall on PASSIVE (#265) The reconnect-churn dwell-gate from #228 (RECONNECT_HEALTHY_DWELL_MS) deferred resetReconnectBackoff() behind an 8s survival window. On a band the OS holds bonded/ACL-connected — i.e. co-resident with the official WHOOP app — the link flaps through sub-8s STATE_CONNECTED episodes, so that reset never fired and failedReconnectAttempts climbed past 3, escalating the involuntary reconnect to PASSIVE autoConnect. But PASSIVE autoConnect STALLS on exactly that kind of band: it waits for an advertisement / connection-complete the OS never re-emits while it already holds the ACL (the case handleDisconnect documents as "passive mode stalls", which is why the direct path exists). The link then never re-established, so the live keep-alive stopped — freezing the WHOOP 4.0 battery poll — and no historical offload ran, so automatic data sync stopped on both 4.0 and 5.0, with app-open unable to recover it. The dwell-gate's premise (escalate to PASSIVE to save radio power on a flapping band) does not hold for a co-resident band, which can only be recovered by the fast DIRECT reconnect. Restore the immediate reset (pre-#228 behaviour); the reconnect-churn battery cost belongs to the separate hybrid link-lifecycle work, not to a reconnect mode that can't reconnect this band. Removes RECONNECT_HEALTHY_DWELL_MS and the deferred reset; keeps runIfConnectionSurvives (still used by the #711 re-pair-guide clear) and the empty-offload BackfillPolicy. Compile-only; needs on-strap validation on a band held concurrently by the WHOOP app.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…churn (ryanbr#228) Two related fixes for a WHOOP band + phone draining faster than they should, sharing a small BackfillPolicy parity port. 1. Historical-offload backoff. Android re-ran the type-47 offload every 15 min for the life of the connection even when the strap kept banking nothing, waking the strap NAND and holding the link on a fixed cadence. macOS already floors and backs this off via BackfillPolicy; that gate was flagged unported on Android ("requestSync minus the BackfillPolicy rate-limiter"). Port BackfillPolicy (Strand/BLE/BackfillPolicy.swift): 15-min periodic / 90s event floors, an empty-streak backoff that stretches the automatic periodic and strap kicks to a 1-hour floor once offloads keep coming back empty, and a skip of those automatic kicks while the strap clock reads future-dated (ryanbr#928/ryanbr#1012). requestSync now takes a BackfillTrigger and gates through it; manual/connect/foreground are never delayed; the first real record resets the streak. Covered by BackfillPolicyTest (parity with the Swift curve). 2. Reconnect-churn dwell-gate. The direct-first reconnect makes involuntary attempts 1-2 active DIRECT connects. On a band whose ACL is contended by the official WHOOP app it reaches STATE_CONNECTED briefly each cycle, and the backoff reset there pinned it at attempt 1 = active DIRECT reconnect indefinitely, keeping both radios hot. Defer the STATE_CONNECTED backoff reset behind the existing connectGeneration guard plus an 8s survival dwell (RECONNECT_HEALTHY_DWELL_MS): a flap bumps connectGeneration so the reset no-ops and failedReconnectAttempts accumulates, so the involuntary reconnect escalates to low-power PASSIVE autoConnect (>= 3) instead of hammering DIRECT. A healthy link survives the dwell and resets, keeping the snappy 3s first retry. It does NOT trip the bond-loop re-pair guide (the band isn't bond-looping, just contended). The survived-dwell guard is shared with the re-pair-guide clear via runIfConnectionSurvives(). BLE timing changes, validated on a contended 5.0 (fw 50.40.1.0). Android JVM unit tests green.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
Polish + fixes on top of 8.6.0: - Restart strap: a confirmation-gated, non-destructive reboot on the connected band in Devices, with a Reconnecting… state (ryanbr#166). iPhone, Mac, Android. - Android battery: back off empty history offloads + step down reconnect churn on a contended band (ryanbr#228, tanarchytan). - Android: NOOP now appears in Health Connect's App permissions on Android 13 (ryanbr#226). - Android: auto-detected workout "Save it" persists again (was cancelled mid-save) (ryanbr#214). - Oura ring wear state reads "On wrist" while streaming instead of "Unknown" (ryanbr#218). - iPhone/iPad: re-tapping the active tab pops to root + scrolls to top (ryanbr#135). - docs/releases/v8.6.1.md (release notes) - android build.gradle.kts: versionCode 271->272, versionName 8.6.0->8.6.1 - project.yml: MARKETING_VERSION 8.6.0->8.6.1, CURRENT_PROJECT_VERSION 182->183 - in-app What's New: AppChangelog.swift + AppChangelog.kt 8.6.1 entry (parallel) Android :app:compileFullDebugKotlin passes.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…rds so auto-sync can't stall (ryanbr#286) dataRangeNewestUnix scanned 4-byte words aligned FROM offset 7. On WHOOP 4 the strap's newest-record u32 sits at byte offset 8, off that grid, so the scan straddled it and returned null — leaving a stale/garbage word latched in strapNewestTs. That was harmless until ryanbr#228 fed strapNewestTs into BackfillPolicy: a future-reading value now makes isFutureDatedNewest skip the automatic periodic/strap offload entirely, so one bad read stalls automatic sync (observed: a 4.0 whose newest record was in fact today read ~520 days future and stopped auto-syncing; manual "Sync now" still worked). Scan every byte offset instead, and prefer the newest word at or behind the wall clock (a device cannot record the future, so a lone future-dated straddle is a decode artifact, not the frontier). Only when EVERY plausible word is future — a genuinely future-set RTC (ryanbr#928) — return the future max, so isFutureDatedNewest still fires and the auto-continue / periodic guards correctly refuse the range. Family-agnostic: repairs the WHOOP 4 offset and hardens 5/MG regardless of their frame layout; cannot regress a frame the old aligned scan already read. New DataRangeScanTest pins three of David's real 4.0 GET_DATA_RANGE frames (newest = today, previously missed) plus the future-straddle and genuine-future cases. Swift twin (BLEManager.dataRangeNewestUnix) changed identically for parity; NOT compiled here (needs macOS/Xcode) — verify before merge.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…an't stall on PASSIVE (ryanbr#265) The reconnect-churn dwell-gate from ryanbr#228 (RECONNECT_HEALTHY_DWELL_MS) deferred resetReconnectBackoff() behind an 8s survival window. On a band the OS holds bonded/ACL-connected — i.e. co-resident with the official WHOOP app — the link flaps through sub-8s STATE_CONNECTED episodes, so that reset never fired and failedReconnectAttempts climbed past 3, escalating the involuntary reconnect to PASSIVE autoConnect. But PASSIVE autoConnect STALLS on exactly that kind of band: it waits for an advertisement / connection-complete the OS never re-emits while it already holds the ACL (the case handleDisconnect documents as "passive mode stalls", which is why the direct path exists). The link then never re-established, so the live keep-alive stopped — freezing the WHOOP 4.0 battery poll — and no historical offload ran, so automatic data sync stopped on both 4.0 and 5.0, with app-open unable to recover it. The dwell-gate's premise (escalate to PASSIVE to save radio power on a flapping band) does not hold for a co-resident band, which can only be recovered by the fast DIRECT reconnect. Restore the immediate reset (pre-ryanbr#228 behaviour); the reconnect-churn battery cost belongs to the separate hybrid link-lifecycle work, not to a reconnect mode that can't reconnect this band. Removes RECONNECT_HEALTHY_DWELL_MS and the deferred reset; keeps runIfConnectionSurvives (still used by the ryanbr#711 re-pair-guide clear) and the empty-offload BackfillPolicy. Compile-only; needs on-strap validation on a band held concurrently by the WHOOP app.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…churn (ryanbr#228) Two related fixes for a WHOOP band + phone draining faster than they should, sharing a small BackfillPolicy parity port. 1. Historical-offload backoff. Android re-ran the type-47 offload every 15 min for the life of the connection even when the strap kept banking nothing, waking the strap NAND and holding the link on a fixed cadence. macOS already floors and backs this off via BackfillPolicy; that gate was flagged unported on Android ("requestSync minus the BackfillPolicy rate-limiter"). Port BackfillPolicy (Strand/BLE/BackfillPolicy.swift): 15-min periodic / 90s event floors, an empty-streak backoff that stretches the automatic periodic and strap kicks to a 1-hour floor once offloads keep coming back empty, and a skip of those automatic kicks while the strap clock reads future-dated (ryanbr#928/ryanbr#1012). requestSync now takes a BackfillTrigger and gates through it; manual/connect/foreground are never delayed; the first real record resets the streak. Covered by BackfillPolicyTest (parity with the Swift curve). 2. Reconnect-churn dwell-gate. The direct-first reconnect makes involuntary attempts 1-2 active DIRECT connects. On a band whose ACL is contended by the official WHOOP app it reaches STATE_CONNECTED briefly each cycle, and the backoff reset there pinned it at attempt 1 = active DIRECT reconnect indefinitely, keeping both radios hot. Defer the STATE_CONNECTED backoff reset behind the existing connectGeneration guard plus an 8s survival dwell (RECONNECT_HEALTHY_DWELL_MS): a flap bumps connectGeneration so the reset no-ops and failedReconnectAttempts accumulates, so the involuntary reconnect escalates to low-power PASSIVE autoConnect (>= 3) instead of hammering DIRECT. A healthy link survives the dwell and resets, keeping the snappy 3s first retry. It does NOT trip the bond-loop re-pair guide (the band isn't bond-looping, just contended). The survived-dwell guard is shared with the re-pair-guide clear via runIfConnectionSurvives(). BLE timing changes, validated on a contended 5.0 (fw 50.40.1.0). Android JVM unit tests green.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
Polish + fixes on top of 8.6.0: - Restart strap: a confirmation-gated, non-destructive reboot on the connected band in Devices, with a Reconnecting… state (ryanbr#166). iPhone, Mac, Android. - Android battery: back off empty history offloads + step down reconnect churn on a contended band (ryanbr#228, tanarchytan). - Android: NOOP now appears in Health Connect's App permissions on Android 13 (ryanbr#226). - Android: auto-detected workout "Save it" persists again (was cancelled mid-save) (ryanbr#214). - Oura ring wear state reads "On wrist" while streaming instead of "Unknown" (ryanbr#218). - iPhone/iPad: re-tapping the active tab pops to root + scrolls to top (ryanbr#135). - docs/releases/v8.6.1.md (release notes) - android build.gradle.kts: versionCode 271->272, versionName 8.6.0->8.6.1 - project.yml: MARKETING_VERSION 8.6.0->8.6.1, CURRENT_PROJECT_VERSION 182->183 - in-app What's New: AppChangelog.swift + AppChangelog.kt 8.6.1 entry (parallel) Android :app:compileFullDebugKotlin passes.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…rds so auto-sync can't stall (ryanbr#286) dataRangeNewestUnix scanned 4-byte words aligned FROM offset 7. On WHOOP 4 the strap's newest-record u32 sits at byte offset 8, off that grid, so the scan straddled it and returned null — leaving a stale/garbage word latched in strapNewestTs. That was harmless until ryanbr#228 fed strapNewestTs into BackfillPolicy: a future-reading value now makes isFutureDatedNewest skip the automatic periodic/strap offload entirely, so one bad read stalls automatic sync (observed: a 4.0 whose newest record was in fact today read ~520 days future and stopped auto-syncing; manual "Sync now" still worked). Scan every byte offset instead, and prefer the newest word at or behind the wall clock (a device cannot record the future, so a lone future-dated straddle is a decode artifact, not the frontier). Only when EVERY plausible word is future — a genuinely future-set RTC (ryanbr#928) — return the future max, so isFutureDatedNewest still fires and the auto-continue / periodic guards correctly refuse the range. Family-agnostic: repairs the WHOOP 4 offset and hardens 5/MG regardless of their frame layout; cannot regress a frame the old aligned scan already read. New DataRangeScanTest pins three of David's real 4.0 GET_DATA_RANGE frames (newest = today, previously missed) plus the future-straddle and genuine-future cases. Swift twin (BLEManager.dataRangeNewestUnix) changed identically for parity; NOT compiled here (needs macOS/Xcode) — verify before merge.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…an't stall on PASSIVE (ryanbr#265) The reconnect-churn dwell-gate from ryanbr#228 (RECONNECT_HEALTHY_DWELL_MS) deferred resetReconnectBackoff() behind an 8s survival window. On a band the OS holds bonded/ACL-connected — i.e. co-resident with the official WHOOP app — the link flaps through sub-8s STATE_CONNECTED episodes, so that reset never fired and failedReconnectAttempts climbed past 3, escalating the involuntary reconnect to PASSIVE autoConnect. But PASSIVE autoConnect STALLS on exactly that kind of band: it waits for an advertisement / connection-complete the OS never re-emits while it already holds the ACL (the case handleDisconnect documents as "passive mode stalls", which is why the direct path exists). The link then never re-established, so the live keep-alive stopped — freezing the WHOOP 4.0 battery poll — and no historical offload ran, so automatic data sync stopped on both 4.0 and 5.0, with app-open unable to recover it. The dwell-gate's premise (escalate to PASSIVE to save radio power on a flapping band) does not hold for a co-resident band, which can only be recovered by the fast DIRECT reconnect. Restore the immediate reset (pre-ryanbr#228 behaviour); the reconnect-churn battery cost belongs to the separate hybrid link-lifecycle work, not to a reconnect mode that can't reconnect this band. Removes RECONNECT_HEALTHY_DWELL_MS and the deferred reset; keeps runIfConnectionSurvives (still used by the ryanbr#711 re-pair-guide clear) and the empty-offload BackfillPolicy. Compile-only; needs on-strap validation on a band held concurrently by the WHOOP app.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related fixes for a WHOOP band + phone draining faster than they should, sharing a small BackfillPolicy parity port. Validated on a contended WHOOP 5.0 (fw 50.40.1.0).
1. Historical-offload backoff
Android re-ran the type-47 offload every 15 min for the life of the connection even when the strap kept banking nothing, waking the strap NAND and holding the link on a fixed cadence. macOS already floors + backs this off via
BackfillPolicy; that gate was flagged unported on Android (requestSync"minus the BackfillPolicy rate-limiter").Ports
BackfillPolicy(Strand/BLE/BackfillPolicy.swift) tocom.noop.ble.BackfillPolicy:requestSyncnow takes aBackfillTriggerand gates through it; manual/connect/foreground are never delayed, and the first real record resets the streak. Covered byBackfillPolicyTest(parity with the Swift backoff curve).2. Reconnect-churn dwell-gate
The direct-first reconnect makes involuntary attempts 1‑2 active DIRECT connects. On a band whose ACL is contended by the official WHOOP app it reaches
STATE_CONNECTEDbriefly each cycle, and the backoff reset there pinned it at attempt 1 = active DIRECT reconnect indefinitely, keeping both radios hot.Defers the
STATE_CONNECTEDbackoff reset behind the existingconnectGenerationguard plus an 8s survival dwell (RECONNECT_HEALTHY_DWELL_MS): a flap bumpsconnectGenerationso the reset no-ops andfailedReconnectAttemptsaccumulates, so the involuntary reconnect escalates to low-power PASSIVEautoConnect(>= 3) instead of hammering DIRECT. A healthy link survives the dwell and resets, keeping the snappy 3s first retry. It does not trip the bond-loop re-pair guide (the band isn't bond-looping, just contended). The survived-dwell guard is shared with the re-pair-guide clear viarunIfConnectionSurvives().Test plan
./gradlew :app:testFullDebugUnitTest --tests "com.noop.ble.*"green (BackfillPolicyTest + the rest).BackfillPolicyport mirrors the existing SwiftBackfillPolicy(no Swift change needed — it already has it).