Skip to content

BLE: cut battery drain (empty-offload backoff + reconnect-churn dwell-gate) - #228

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
tanarchytan:feat/ble-battery-optimization
Jul 10, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
tanarchytan:feat/ble-battery-optimization

Conversation

@tanarchytan

Copy link
Copy Markdown

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) to com.noop.ble.BackfillPolicy:

requestSync now takes a BackfillTrigger and gates through it; manual/connect/foreground are never delayed, and the first real record resets the streak. Covered by BackfillPolicyTest (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_CONNECTED briefly each cycle, and the backoff reset there pinned it at attempt 1 = active DIRECT reconnect indefinitely, keeping both radios hot.

Defers 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().

Test plan

  • ./gradlew :app:testFullDebugUnitTest --tests "com.noop.ble.*" green (BackfillPolicyTest + the rest).
  • BLE timing behaviour validated on a contended 5.0 (fw 50.40.1.0).
  • Android-only reimplementation; the BackfillPolicy port mirrors the existing Swift BackfillPolicy (no Swift change needed — it already has it).

…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
ryanbr merged commit 0c552aa into ryanbr:main Jul 10, 2026
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.
@tanarchytan
tanarchytan deleted the feat/ble-battery-optimization branch July 11, 2026 15:23
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants