Skip to content

ble: experimental LE 2M PHY preference for the offload burst (#533) - #537

Merged
ryanbr merged 1 commit into
mainfrom
feat/le-2m-phy-533
Jul 17, 2026
Merged

ble: experimental LE 2M PHY preference for the offload burst (#533)#537
ryanbr merged 1 commit into
mainfrom
feat/le-2m-phy-533

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 17, 2026

Copy link
Copy Markdown
Owner

The finding

NOOP has never called setPreferredPhy — grep finds no PHY handling anywhere in the tree. So every historical offload has run on the 1M PHY, and BLE 5's LE 2M has simply never been asked for. 2M doubles the symbol rate: the same bytes spend half the air-time.

minSdk = 26, which is exactly when setPreferredPhy landed — it's been available the whole time.

Why this is the better-behaved of the two levers

#536 (connection interval) this (2M PHY)
Mechanism ~2.7× more radio wakeups half the air-time per packet
Battery ambiguous — depends on link- vs firmware-limited should cost less energy per byte
Stacks with the other yes yes

They're orthogonal, so they're deliberately separate toggles: bundling them would make a field report un-attributable, and they have opposite battery profiles — a cost from one could mask a gain from the other.

Design decisions worth calling out

Requested at offload START, not on connect. The connect handshake is fragile: the code explicitly warns that an extra GATT op before requestMtu can make it return false → MTU skipped → the offload capped (#85), plus the OnePlus double-MTU bug (#50). Touching that path to speed up sync could cap the very thing it's speeding up. The burst is where throughput matters anyway, and PHY persists once negotiated.

The mask always includes 1M — never 2M alone. It's a preference; keeping 1M lets the controller fall back rather than cling to a 2M link gone marginal (2M trades range for speed). The strap may decline it outright.

onPhyUpdate logs what was actually negotiated. This is the only way to learn whether WHOOP supports 2M at all, and it lands in the strap log right next to the offload's existing session started/ended + persisted N rows — so one before/after log yields records/sec and the PHY together. Log-only; nothing branches on it.

Battery

Unlike #536, the mechanism here points the right way: same bytes, half the air-time, radio on for less of it. But it's still unproven — the strap may decline 2M (no-op), or a marginal link could retransmit and eat the gain. That's what the toggle and the onPhyUpdate line are for.

Experimental, default OFF

Settings → Strap → "Faster Bluetooth link (experimental)". Off is byte-for-byte today's behaviour: applyPreferredPhy early-returns and issues zero BLE ops.

Not validated on hardware — I have no strap, and BLE behaviour can't be CI- or Linux-tested. Default-off makes it safe to merge; proving it needs an on-strap before/after.

Tests

  • PreferredPhyTest4/4. Pins that off = plain 1M, that on always keeps 1M in the mask for fallback, and that phyLabel reads the PHY_LE_* value (1/2/3) rather than the *_MASK constants — the two numbering schemes overlap, so a label comparing against masks would misreport the link.
  • Full com.noop.ble suite green — including GattCrashSafetyTest 7/7 after the GattOps interface addition.
  • compileFullDebugKotlin + i18n audit (all focus locales) green.

How to measure

Flip it, sync a real day-or-two backlog, compare the strap log before/after:

  • PHY negotiated: tx=2M rx=2M → the strap supports it and it took.
  • still 1M → WHOOP declined; that alone is worth knowing.
  • records/sec from session started/ended + persisted N rows.

If neither this nor #536 moves the needle, that's strong evidence the offload really is firmware-paced — which genuinely earns the HCI capture #533 is asking for.

Cross-platform: no Swift twin (deliberate)

Per the parity contract this needs an explicit "why not": CoreBluetooth exposes no app-side API for this. The peripheral proposes the GAP connection parameters and Apple's stack negotiates them (and the PHY) itself — apps get no say. #477 already records the same divergence in-code: "Android-only by necessity … so there is no Swift twin — a deliberate platform divergence, not a parity gap."

Useful corollary: because iOS/macOS have their link parameters chosen for them, they are effectively "Android with these levers permanently on and untunable" — which makes them a control group. If a Mac drains a comparable backlog at a much higher rows/sec than Android (compare Backfill: session started / session ended / persisted N rows, which both platforms log identically), the strap is provably not firmware-paced, since the strap is the constant across the test. If they match, these levers likely will not help and #533's HCI capture is the better investment.

The one lever here that does have a Swift twin is the scheduler (MAX_AUTO_CONTINUES / the 15-min BACKFILL_INTERVAL_MS, mirrored by BLEManager.maxAutoContinues / BackfillPolicy.periodicFloorSeconds) — deliberately untouched in this PR, and it would need both platforms if changed.

NOOP has never called setPreferredPhy — grep finds no PHY handling at all — so
every historical offload has run on the 1M PHY. LE 2M doubles the symbol rate:
the same bytes spend HALF the air-time.

That makes this the better-behaved of the two sync-speed levers. #536's
connection-interval lever buys speed with ~2.7x more radio wakeups, so its
battery effect is ambiguous and hinges on whether the offload is link- or
firmware-limited. Halving air-time for the same bytes should cost LESS radio
energy per byte, not more. The two are orthogonal and stack, and are deliberately
SEPARATE toggles: they have opposite battery profiles, so bundling them would
make a field report un-attributable.

Requested at offload START, not on connect, on purpose: the connect handshake is
fragile — an extra GATT op before requestMtu can make it return false, which
skips the MTU bump and caps the very offload this is meant to speed up (#85/#50).

The mask ALWAYS includes 1M, never 2M alone: it is a preference, and keeping 1M
lets the controller fall back rather than cling to a 2M link gone marginal (2M
trades range for speed). Turning the toggle off releases an already-2M link back
to 1M — a PHY persists once negotiated, and the toggle's own copy tells the user
to switch it off if syncing goes flaky at range, which is exactly when 2M is the
suspect. Reuses #536's releasesConnectionPriority edge rule, so the default
launch path issues zero BLE ops.

onPhyUpdate logs the PHY actually negotiated — the only way to learn whether
WHOOP supports 2M at all, and it lands next to the offload's existing session
timestamps + 'persisted N rows' for a before/after records/sec.

Android-only by necessity: CoreBluetooth exposes no app-side PHY API, so there is
no Swift twin — documented in-code, mirroring #477's precedent.

EXPERIMENTAL, default OFF. Off is byte-for-byte today. NOT validated on hardware.

i18n audit OK; compileFullDebugKotlin + the full com.noop.ble suite green
(PreferredPhyTest 6, ConnectionPriorityTest 11, GattCrashSafetyTest 7).
@ryanbr
ryanbr force-pushed the feat/le-2m-phy-533 branch from 4c0446f to 8debcf4 Compare July 17, 2026 01:16
@ryanbr
ryanbr merged commit 7397254 into main Jul 17, 2026
1 check passed
@ryanbr
ryanbr deleted the feat/le-2m-phy-533 branch July 17, 2026 01:17
ryanbr added a commit that referenced this pull request Jul 17, 2026
Two follow-ups on #536/#537, found re-reviewing them after merge.

A. The PHY was never released at offload END, unlike its twin lever.
   refreshConnectionPriority() runs at BOTH enterBackfilling and exitBackfilling,
   so the priority escalation is bounded to the burst. applyPreferredPhy() ran only
   at enterBackfilling - and a PHY PERSISTS once negotiated, so a link that went 2M
   for a sync stayed 2M for the WHOLE connection, including the overnight window.
   That is not what the toggle's copy promises (it says "while your strap hands over
   its stored history"), and it left 2M's range trade-off in force long after the
   transfer it was for.

   Release to 1M at exitBackfilling so the preference is bounded to the burst,
   symmetric with the priority lever, and the shipped copy becomes accurate rather
   than aspirational.

   Battery was NOT the reason: 2M costs LESS energy per byte, so leaving it on was
   arguably fine. The reason is that RANGE is 2M's unvalidated trade-off, and an
   experimental lever should confine its unknown to the window the user was told
   about. If field data later shows 2M is good everywhere, widen it deliberately.

   The guard lives at the call site, not inside releasePreferredPhy: that method
   cannot check the flag, because setFastLinkPhy's on->off edge calls it AFTER the
   flag is already false. Guarding at the call site keeps the default path at ZERO
   BLE ops.

B. releasesConnectionPriority was a misnomer the moment #537 reused it for the PHY:
   one predicate, two levers, a name that claimed one. Renamed to releasesOnDisable
   and its doc now says it serves both. My own mess from the reuse.

compileFullDebugKotlin + the full com.noop.ble suite green (PreferredPhyTest 6,
ConnectionPriorityTest 11, GattCrashSafetyTest 7); i18n audit OK. Still not
validated on hardware - both toggles remain default-off.
ryanbr added a commit that referenced this pull request Jul 17, 2026
…le (#538)

Two follow-ups on #536/#537, found re-reviewing them after merge.

A. The PHY was never released at offload END, unlike its twin lever.
   refreshConnectionPriority() runs at BOTH enterBackfilling and exitBackfilling,
   so the priority escalation is bounded to the burst. applyPreferredPhy() ran only
   at enterBackfilling - and a PHY PERSISTS once negotiated, so a link that went 2M
   for a sync stayed 2M for the WHOLE connection, including the overnight window.
   That is not what the toggle's copy promises (it says "while your strap hands over
   its stored history"), and it left 2M's range trade-off in force long after the
   transfer it was for.

   Release to 1M at exitBackfilling so the preference is bounded to the burst,
   symmetric with the priority lever, and the shipped copy becomes accurate rather
   than aspirational.

   Battery was NOT the reason: 2M costs LESS energy per byte, so leaving it on was
   arguably fine. The reason is that RANGE is 2M's unvalidated trade-off, and an
   experimental lever should confine its unknown to the window the user was told
   about. If field data later shows 2M is good everywhere, widen it deliberately.

   The guard lives at the call site, not inside releasePreferredPhy: that method
   cannot check the flag, because setFastLinkPhy's on->off edge calls it AFTER the
   flag is already false. Guarding at the call site keeps the default path at ZERO
   BLE ops.

B. releasesConnectionPriority was a misnomer the moment #537 reused it for the PHY:
   one predicate, two levers, a name that claimed one. Renamed to releasesOnDisable
   and its doc now says it serves both. My own mess from the reuse.

compileFullDebugKotlin + the full com.noop.ble suite green (PreferredPhyTest 6,
ConnectionPriorityTest 11, GattCrashSafetyTest 7); i18n audit OK. Still not
validated on hardware - both toggles remain default-off.
ryanbr added a commit that referenced this pull request Jul 17, 2026
Numbers are main's current 292/203 +1, matching the previous staging commit's
pattern, so the .staging app upgrades cleanly over the last testing build (290)
and stays above main's released high-water.

Carries everything merged since the 9.0.1 release:
 - Workouts read HR under the recording strap, not a hardcoded id (#512 Kotlin,
   #513 Swift twin)
 - Oura 0x60/0x80 IBI decoders corrected to the ring's real byte layout (#511)
 - Whoop5Config enable_sig12 corrected to ASCII '1' (#522)
 - Sleep time edits save as one validated window (#525)
 - "Recompute this night" for deleted sleep windows (#526)
 - Stress marker values stay readable (#507)
 - Faster history sync + faster Bluetooth link, both EXPERIMENTAL and default-off
   (#536/#537/#538) - the whole point of this build is field data on those
 - i18n changelog-title fix + main-push audit (#514/#535)

versionName stays 9.0.2 here for the staging identity only; the real 9.0.2
release still bumps main separately (and must be raised past 293 first).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
) (ryanbr#537)

NOOP has never called setPreferredPhy — grep finds no PHY handling at all — so
every historical offload has run on the 1M PHY. LE 2M doubles the symbol rate:
the same bytes spend HALF the air-time.

That makes this the better-behaved of the two sync-speed levers. ryanbr#536's
connection-interval lever buys speed with ~2.7x more radio wakeups, so its
battery effect is ambiguous and hinges on whether the offload is link- or
firmware-limited. Halving air-time for the same bytes should cost LESS radio
energy per byte, not more. The two are orthogonal and stack, and are deliberately
SEPARATE toggles: they have opposite battery profiles, so bundling them would
make a field report un-attributable.

Requested at offload START, not on connect, on purpose: the connect handshake is
fragile — an extra GATT op before requestMtu can make it return false, which
skips the MTU bump and caps the very offload this is meant to speed up (ryanbr#85/ryanbr#50).

The mask ALWAYS includes 1M, never 2M alone: it is a preference, and keeping 1M
lets the controller fall back rather than cling to a 2M link gone marginal (2M
trades range for speed). Turning the toggle off releases an already-2M link back
to 1M — a PHY persists once negotiated, and the toggle's own copy tells the user
to switch it off if syncing goes flaky at range, which is exactly when 2M is the
suspect. Reuses ryanbr#536's releasesConnectionPriority edge rule, so the default
launch path issues zero BLE ops.

onPhyUpdate logs the PHY actually negotiated — the only way to learn whether
WHOOP supports 2M at all, and it lands next to the offload's existing session
timestamps + 'persisted N rows' for a before/after records/sec.

Android-only by necessity: CoreBluetooth exposes no app-side PHY API, so there is
no Swift twin — documented in-code, mirroring ryanbr#477's precedent.

EXPERIMENTAL, default OFF. Off is byte-for-byte today. NOT validated on hardware.

i18n audit OK; compileFullDebugKotlin + the full com.noop.ble suite green
(PreferredPhyTest 6, ConnectionPriorityTest 11, GattCrashSafetyTest 7).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…le (ryanbr#538)

Two follow-ups on ryanbr#536/ryanbr#537, found re-reviewing them after merge.

A. The PHY was never released at offload END, unlike its twin lever.
   refreshConnectionPriority() runs at BOTH enterBackfilling and exitBackfilling,
   so the priority escalation is bounded to the burst. applyPreferredPhy() ran only
   at enterBackfilling - and a PHY PERSISTS once negotiated, so a link that went 2M
   for a sync stayed 2M for the WHOLE connection, including the overnight window.
   That is not what the toggle's copy promises (it says "while your strap hands over
   its stored history"), and it left 2M's range trade-off in force long after the
   transfer it was for.

   Release to 1M at exitBackfilling so the preference is bounded to the burst,
   symmetric with the priority lever, and the shipped copy becomes accurate rather
   than aspirational.

   Battery was NOT the reason: 2M costs LESS energy per byte, so leaving it on was
   arguably fine. The reason is that RANGE is 2M's unvalidated trade-off, and an
   experimental lever should confine its unknown to the window the user was told
   about. If field data later shows 2M is good everywhere, widen it deliberately.

   The guard lives at the call site, not inside releasePreferredPhy: that method
   cannot check the flag, because setFastLinkPhy's on->off edge calls it AFTER the
   flag is already false. Guarding at the call site keeps the default path at ZERO
   BLE ops.

B. releasesConnectionPriority was a misnomer the moment ryanbr#537 reused it for the PHY:
   one predicate, two levers, a name that claimed one. Renamed to releasesOnDisable
   and its doc now says it serves both. My own mess from the reuse.

compileFullDebugKotlin + the full com.noop.ble suite green (PreferredPhyTest 6,
ConnectionPriorityTest 11, GattCrashSafetyTest 7); i18n audit OK. Still not
validated on hardware - both toggles remain default-off.
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.

1 participant