Skip to content

BLE (Android): reliable 5/MG reconnect, attach without a scan, live battery + manual controls - #173

Merged
ryanbr merged 3 commits into
ryanbr:mainfrom
tanarchytan:fix/reconnect-battery
Jul 10, 2026
Merged

ryanbr merged 3 commits into
ryanbr:mainfrom
tanarchytan:fix/reconnect-battery

Conversation

@tanarchytan

Copy link
Copy Markdown

Summary

Makes WHOOP 5.0/MG reconnect, manual connect and the add-device search actually reach a band, adds a live battery refresh for 5/MG, and gives a one-tap manual recovery in the device menu. Three focused commits; WHOOP 4 behaviour is unchanged.

Problem

1. Scan-only could not reach a band another app holds. The BLE client reached a 5/MG only by scanning for its advertisement. Once the official WHOOP app holds the band it stops advertising, so a manual connect, the add-device search and the automatic reconnect all found nothing and looped on Scanning for WHOOP... forever.

2. Reconnect after a drop used the passive path and hung. Reconnect called connectToDevice(autoConnect = true), the mode that waits for a fresh advertisement or connection-complete event. A 5/MG the OS still holds bonded and ACL-connected never re-emits that event, so the reconnect stalled, while an app-start connect returns instantly through the autoConnect = false fast path.

3. 5/MG battery only refreshed on a manual sync. Battery on 5/MG comes only from a 0x2A19 read (refreshBattery), which was invoked only from the Live screen, and the keep-alive battery poll was WHOOP4-only. The battery ring refreshed only on a manual Body > Health > Sync.

Fix

  • Attach without a scan (root cause). Android multiplexes one ACL across GATT clients, so a band held by another app is still returned by getConnectedDevices even when it is not advertising, and a client can attach to it directly. connect() and the add-device search now try getConnectedDevices first, then a bonded-but-not-connected direct connect, then the scan. Both attach helpers match only a 5/MG strap by name (a WHOOP 4 falls through to the scan) and pin selectedModel to 5/MG on a hit, so a fresh install still defaulting to WHOOP 4.0 attaches to a connected 5/MG instead of scanning for the wrong family; the family is resolved from the discovered GATT services.
  • Direct-first reconnect. Use the fast autoConnect = false path for the first two reconnect attempts, then fall back to autoConnect = true from the third attempt for a strap that is genuinely out of range (keeps the return-to-range behaviour).
  • Live battery on 5/MG. Poll 0x2A19 in the keep-alive for 5/MG too (~60s, advancing the keep-alive tick for both families) and read it once after the connect handshake so it populates on connect.
  • Manual Reconnect/Disconnect. Add Reconnect and Disconnect actions to the WHOOP device's overflow menu (the same menu as Remove) for one-tap recovery when the automatic reconnect is stuck. Reconnect uses the same active direct path onboarding uses. WHOOP only; shown first; Disconnect replaces it while connected.

Notes

  • No schema, permission or dependency changes.
  • WHOOP 4 connect/reconnect/battery paths are untouched (5/MG-only name filters, family-gated branches).
  • Verified on-device against a WHOOP 5.0/MG: reconnect after drop, manual Reconnect, add-device search and the battery ring all resolve while the band is held by the official app.

Commits

  • fix(ble): reliable 5/MG reconnect + live battery push
  • feat(ui): manual Reconnect/Disconnect in the device menu
  • fix(ble): attach to an OS-connected WHOOP without a scan (getConnectedDevices)

Reconnect after an involuntary drop used connectToDevice(autoConnect=true), the
passive mode that waits for a fresh advertisement or connection-complete event. A
5/MG the OS still holds bonded and ACL-connected never re-emits that event, so the
reconnect hangs, while app-start connects instantly through the autoConnect=false
fast path. Use the fast direct path for the first two reconnect attempts, then fall
back to autoConnect=true from the third attempt for a strap that is genuinely out of
range (keeps the ryanbr#61 return-to-range behaviour).

Battery on 5/MG comes only from a 0x2A19 read (refreshBattery), which was invoked
only from the Live screen, and the keep-alive battery poll was WHOOP4-only, so the
ring refreshed only on a manual Body > Health > Sync. Poll 0x2A19 in the keep-alive
for 5/MG too (every ~60s, advancing keepAliveTick for both families) and read it once
after the connect handshake so it populates on connect.
Add Reconnect and Disconnect actions to the WHOOP device's overflow menu (the same
menu as Remove), so there is a one-tap manual recovery when the automatic reconnect
is stuck. Reconnect calls AppViewModel.connect(), the same active, direct
(autoConnect=false) path onboarding uses, which is why re-running setup always
reconnects while a passive auto-reconnect can hang. WHOOP only; shown first in the
menu; Disconnect replaces it while connected.
…dDevices)

The BLE client was scan-only: it could reach a WHOOP 5/MG only by scanning for its
advertisement. Once another app (the official WHOOP app) holds the band it stops
advertising, so a manual connect, the add-device search and the automatic reconnect
all found nothing and looped on "Scanning for WHOOP...". Android multiplexes one ACL
across GATT clients, so the band is still returned by getConnectedDevices even when
it is not advertising, and a client can attach to it directly.

connect() and the add-device search now try getConnectedDevices first, then a
bonded-but-not-connected direct connect, then the scan. Both attach helpers match
only a 5/MG strap by name (a WHOOP 4 falls through to the scan) and pin selectedModel
to 5/MG on a hit, so a fresh install that still defaults to WHOOP 4.0 attaches to a
connected 5/MG instead of scanning for the wrong family; the family is resolved from
the discovered GATT services. Also polished the reconnect and battery comments to be
implementation-neutral.
@ryanbr
ryanbr merged commit 44e3a47 into ryanbr:main Jul 10, 2026
@tanarchytan
tanarchytan deleted the fix/reconnect-battery branch July 11, 2026 15:22
rntcruz23 pushed a commit to rntcruz23/noop that referenced this pull request Jul 12, 2026
Conflict resolutions:
- android/app/build.gradle.kts + project.yml: version 8.7.0 (upstream's, the
  feature set this merge brings in) with the fork's HIGHER build counters
  (versionCode 278, iOS build 189) so installed fork builds keep updating.
- WhoopBleClient.kt: kept the fork's status-147 establishTimeout reset AND
  upstream's ryanbr#173 regression fix (immediate resetReconnectBackoff on connect,
  replacing the dwell-gated reset).
- SleepScreen.kt: union - fork's Sleep-cycles staircase (SleepCyclesChart +
  displaySmoothedWeights) alongside upstream's ryanbr#988 stage-timeline family
  (ClockLabelRow, StageTimeline, StageTimelineRow, StageRowTrack, StageInsight).
- RebootProbeVariantTest.kt: upstream's doc-comment wording (content-identical).
- docs/WHOOP5_DEEP_DATA.md: union - upstream's ground-truth correlation section
  followed by the fork's "What your strap has proven" section.
- altstore-source.json: kept the fork's manifest wholesale (upstream entries
  point at upstream's release assets; the fork's release workflow prepends its
  own next entry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pwRSEnFWdsYMC7CLXWNEW
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…attery + manual controls (ryanbr#173)

* fix(ble): reliable 5/MG reconnect + live battery push

Reconnect after an involuntary drop used connectToDevice(autoConnect=true), the
passive mode that waits for a fresh advertisement or connection-complete event. A
5/MG the OS still holds bonded and ACL-connected never re-emits that event, so the
reconnect hangs, while app-start connects instantly through the autoConnect=false
fast path. Use the fast direct path for the first two reconnect attempts, then fall
back to autoConnect=true from the third attempt for a strap that is genuinely out of
range (keeps the ryanbr#61 return-to-range behaviour).

Battery on 5/MG comes only from a 0x2A19 read (refreshBattery), which was invoked
only from the Live screen, and the keep-alive battery poll was WHOOP4-only, so the
ring refreshed only on a manual Body > Health > Sync. Poll 0x2A19 in the keep-alive
for 5/MG too (every ~60s, advancing keepAliveTick for both families) and read it once
after the connect handshake so it populates on connect.

* feat(ui): manual Reconnect/Disconnect in the device menu

Add Reconnect and Disconnect actions to the WHOOP device's overflow menu (the same
menu as Remove), so there is a one-tap manual recovery when the automatic reconnect
is stuck. Reconnect calls AppViewModel.connect(), the same active, direct
(autoConnect=false) path onboarding uses, which is why re-running setup always
reconnects while a passive auto-reconnect can hang. WHOOP only; shown first in the
menu; Disconnect replaces it while connected.

* fix(ble): attach to an OS-connected WHOOP without a scan (getConnectedDevices)

The BLE client was scan-only: it could reach a WHOOP 5/MG only by scanning for its
advertisement. Once another app (the official WHOOP app) holds the band it stops
advertising, so a manual connect, the add-device search and the automatic reconnect
all found nothing and looped on "Scanning for WHOOP...". Android multiplexes one ACL
across GATT clients, so the band is still returned by getConnectedDevices even when
it is not advertising, and a client can attach to it directly.

connect() and the add-device search now try getConnectedDevices first, then a
bonded-but-not-connected direct connect, then the scan. Both attach helpers match
only a 5/MG strap by name (a WHOOP 4 falls through to the scan) and pin selectedModel
to 5/MG on a hit, so a fresh install that still defaults to WHOOP 4.0 attaches to a
connected 5/MG instead of scanning for the wrong family; the family is resolved from
the discovered GATT services. Also polished the reconnect and battery comments to be
implementation-neutral.
ryanbr added a commit that referenced this pull request Jul 27, 2026
…ttery policies every second (#886)

From a user report of NOOP at 51.75% of battery over 6h31m, confirmed by a second user. Two safe
changes, plus the release-hygiene fix that was blocking every Android PR from going green.

1. LOG HOW LONG A LINK WAS HELD. Reaching STATE_CONNECTED zeroes failedReconnectAttempts, and that
counter is the ONLY input to scanModeForReconnectAttempts. So a strap that connects and drops
repeatedly — edge of range, phone in another room — never accumulates a streak, and every rescan
stays on SCAN_MODE_LOW_LATENCY, which is exactly what the PR #588 backoff exists to escape. The #982
guard covers the never-bonded variant of that loop; a bonded-but-short-lived link is not covered.

That is a hypothesis, not a measurement, so this changes no behaviour — it makes the pattern readable
in an ordinary strap log:

    Disconnected (status=19); rescanning in 3s (attempt 1, held 24s)

A run of drops that all say `attempt 1` with a short `held` IS the bug, with no instrumentation.
Attempts climbing normally rules it out and points elsewhere. Snapshotted and cleared at the top of
handleDisconnect — a stale linkUpSinceMs surviving into a drop that never reached STATE_CONNECTED
would report a hold time for a link that was never up, the diagnostic inventing the evidence it
exists to find. handleDisconnect is the single teardown funnel (its own comment: it "publishes
connected=false and closes + nulls gatt"), so the clear cannot leak.

2. STOP RE-ASKING THE BATTERY POLICIES EVERY SECOND. The foreground-service collector ticks at
live-HR cadence (~1/s while connected) and ran onBatteryUpdate AND onCriticalBattery on every tick.
Both early-exit on their own persisted once-per-crossing gates — but not before each does a
SharedPreferences read, an areNotificationsEnabled() binder call and an ensureChannel(). Roughly two
binder calls and half a dozen prefs reads every second, to re-answer a question the strap only
changes every ~8 minutes. Now gated on (SoC, charging) changing, the same shape as the
lastRuntimeEvalPct gate two lines below.

Gated on the PAIR, not the percentage: onBatteryUpdate owns the charge-complete and re-arm
transitions, which key off `charging`. One behavioural delta, stated rather than papered over:
enabling the battery-alerts pref while already past a threshold now waits for the next
(pct, charging) change instead of firing within a second — up to one battery-report cycle.

3. LOCALIZE THE 9.2.0 WHAT'S NEW TITLE. The 9.2.0 release left main FAILING i18n-coverage.
appchangelog-gen writes the title as a raw Kotlin literal; extraction into a string resource is a
separate step that normally happens in the PR flow where the gate catches it, and fork-release.yml
commits straight to main. That also means the 9.2.0 title shipped UNTRANSLATED on Android — the only
one of 240+ release entries to do so.

Resource added under the existing convention and translated in all five locale files. The convention
was DERIVED, not guessed: first six slug words plus sha1(unescaped value)[:8], which reproduces 267
of 268 existing keys — the single exception being the key I had first written by eyeballing a few
examples and assuming a ~40-character truncation. values-zh closed rather than its allowance raised,
because Tools/i18n_extra_locale_baseline.txt says in its own header to ratchet DOWN, never up.

This will recur on every release until the extraction runs inside fork-release.yml.

RULED OUT while looking, all already defended: the FGS notification dedupes on a
connected|backfilling|recovery|battery key (live HR is not in it); the Glance widget push has a cheap
non-suspending PushGate; the offload chunk counter republishes every ~10 chunks; connection priority
does not escalate for live HR by default; reconnect backoff is capped 3s->60s; IllnessWatch.evaluate
is bounded to <=31 days of means; uiString is ~1us per call.

NOT DONE, deliberately: making the reconnect streak survive a short-lived connection. That reset
exists to stop a flapping co-resident band from freezing the link (#173 regression, where a climbing
counter escalated to PASSIVE autoConnect and stalled). Changing it blind, on an unverified
hypothesis, on the path that keeps the strap connected at all, trades a battery complaint for a strap
that does not come back. The hold-time log is what tells us whether it is worth doing.

Register verified rather than assumed: all three Android locales are decisively informal (es 35:1,
de 118:6, pt 241:0 on unambiguous markers), matching the strings added here. No CI job compiles
Kotlin, so every symbol was checked against its declaration by hand.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…attery + manual controls (ryanbr#173)

* fix(ble): reliable 5/MG reconnect + live battery push

Reconnect after an involuntary drop used connectToDevice(autoConnect=true), the
passive mode that waits for a fresh advertisement or connection-complete event. A
5/MG the OS still holds bonded and ACL-connected never re-emits that event, so the
reconnect hangs, while app-start connects instantly through the autoConnect=false
fast path. Use the fast direct path for the first two reconnect attempts, then fall
back to autoConnect=true from the third attempt for a strap that is genuinely out of
range (keeps the ryanbr#61 return-to-range behaviour).

Battery on 5/MG comes only from a 0x2A19 read (refreshBattery), which was invoked
only from the Live screen, and the keep-alive battery poll was WHOOP4-only, so the
ring refreshed only on a manual Body > Health > Sync. Poll 0x2A19 in the keep-alive
for 5/MG too (every ~60s, advancing keepAliveTick for both families) and read it once
after the connect handshake so it populates on connect.

* feat(ui): manual Reconnect/Disconnect in the device menu

Add Reconnect and Disconnect actions to the WHOOP device's overflow menu (the same
menu as Remove), so there is a one-tap manual recovery when the automatic reconnect
is stuck. Reconnect calls AppViewModel.connect(), the same active, direct
(autoConnect=false) path onboarding uses, which is why re-running setup always
reconnects while a passive auto-reconnect can hang. WHOOP only; shown first in the
menu; Disconnect replaces it while connected.

* fix(ble): attach to an OS-connected WHOOP without a scan (getConnectedDevices)

The BLE client was scan-only: it could reach a WHOOP 5/MG only by scanning for its
advertisement. Once another app (the official WHOOP app) holds the band it stops
advertising, so a manual connect, the add-device search and the automatic reconnect
all found nothing and looped on "Scanning for WHOOP...". Android multiplexes one ACL
across GATT clients, so the band is still returned by getConnectedDevices even when
it is not advertising, and a client can attach to it directly.

connect() and the add-device search now try getConnectedDevices first, then a
bonded-but-not-connected direct connect, then the scan. Both attach helpers match
only a 5/MG strap by name (a WHOOP 4 falls through to the scan) and pin selectedModel
to 5/MG on a hit, so a fresh install that still defaults to WHOOP 4.0 attaches to a
connected 5/MG instead of scanning for the wrong family; the family is resolved from
the discovered GATT services. Also polished the reconnect and battery comments to be
implementation-neutral.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ttery policies every second (ryanbr#886)

From a user report of NOOP at 51.75% of battery over 6h31m, confirmed by a second user. Two safe
changes, plus the release-hygiene fix that was blocking every Android PR from going green.

1. LOG HOW LONG A LINK WAS HELD. Reaching STATE_CONNECTED zeroes failedReconnectAttempts, and that
counter is the ONLY input to scanModeForReconnectAttempts. So a strap that connects and drops
repeatedly — edge of range, phone in another room — never accumulates a streak, and every rescan
stays on SCAN_MODE_LOW_LATENCY, which is exactly what the PR ryanbr#588 backoff exists to escape. The ryanbr#982
guard covers the never-bonded variant of that loop; a bonded-but-short-lived link is not covered.

That is a hypothesis, not a measurement, so this changes no behaviour — it makes the pattern readable
in an ordinary strap log:

    Disconnected (status=19); rescanning in 3s (attempt 1, held 24s)

A run of drops that all say `attempt 1` with a short `held` IS the bug, with no instrumentation.
Attempts climbing normally rules it out and points elsewhere. Snapshotted and cleared at the top of
handleDisconnect — a stale linkUpSinceMs surviving into a drop that never reached STATE_CONNECTED
would report a hold time for a link that was never up, the diagnostic inventing the evidence it
exists to find. handleDisconnect is the single teardown funnel (its own comment: it "publishes
connected=false and closes + nulls gatt"), so the clear cannot leak.

2. STOP RE-ASKING THE BATTERY POLICIES EVERY SECOND. The foreground-service collector ticks at
live-HR cadence (~1/s while connected) and ran onBatteryUpdate AND onCriticalBattery on every tick.
Both early-exit on their own persisted once-per-crossing gates — but not before each does a
SharedPreferences read, an areNotificationsEnabled() binder call and an ensureChannel(). Roughly two
binder calls and half a dozen prefs reads every second, to re-answer a question the strap only
changes every ~8 minutes. Now gated on (SoC, charging) changing, the same shape as the
lastRuntimeEvalPct gate two lines below.

Gated on the PAIR, not the percentage: onBatteryUpdate owns the charge-complete and re-arm
transitions, which key off `charging`. One behavioural delta, stated rather than papered over:
enabling the battery-alerts pref while already past a threshold now waits for the next
(pct, charging) change instead of firing within a second — up to one battery-report cycle.

3. LOCALIZE THE 9.2.0 WHAT'S NEW TITLE. The 9.2.0 release left main FAILING i18n-coverage.
appchangelog-gen writes the title as a raw Kotlin literal; extraction into a string resource is a
separate step that normally happens in the PR flow where the gate catches it, and fork-release.yml
commits straight to main. That also means the 9.2.0 title shipped UNTRANSLATED on Android — the only
one of 240+ release entries to do so.

Resource added under the existing convention and translated in all five locale files. The convention
was DERIVED, not guessed: first six slug words plus sha1(unescaped value)[:8], which reproduces 267
of 268 existing keys — the single exception being the key I had first written by eyeballing a few
examples and assuming a ~40-character truncation. values-zh closed rather than its allowance raised,
because Tools/i18n_extra_locale_baseline.txt says in its own header to ratchet DOWN, never up.

This will recur on every release until the extraction runs inside fork-release.yml.

RULED OUT while looking, all already defended: the FGS notification dedupes on a
connected|backfilling|recovery|battery key (live HR is not in it); the Glance widget push has a cheap
non-suspending PushGate; the offload chunk counter republishes every ~10 chunks; connection priority
does not escalate for live HR by default; reconnect backoff is capped 3s->60s; IllnessWatch.evaluate
is bounded to <=31 days of means; uiString is ~1us per call.

NOT DONE, deliberately: making the reconnect streak survive a short-lived connection. That reset
exists to stop a flapping co-resident band from freezing the link (ryanbr#173 regression, where a climbing
counter escalated to PASSIVE autoConnect and stalled). Changing it blind, on an unverified
hypothesis, on the path that keeps the strap connected at all, trades a battery complaint for a strap
that does not come back. The hold-time log is what tells us whether it is worth doing.

Register verified rather than assumed: all three Android locales are decisively informal (es 35:1,
de 118:6, pt 241:0 on unambiguous markers), matching the strings added here. No CI job compiles
Kotlin, so every symbol was checked against its declaration by hand.
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