Skip to content

feat(ble): adopt a stable whoop-<serial> identity for 5.0/MG (#1303 phase 1) - #1455

Open
ryanbr wants to merge 4 commits into
mainfrom
feat/whoop5-serial-identity
Open

feat(ble): adopt a stable whoop-<serial> identity for 5.0/MG (#1303 phase 1)#1455
ryanbr wants to merge 4 commits into
mainfrom
feat/whoop5-serial-identity

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 19, 2026

Copy link
Copy Markdown
Owner

DO NOT MERGE until hardware-validated — see below. Opening it now so the design can be reviewed
while a 5.0/MG night is arranged.

Phase 1 of #1303, the multi-strap keystone.

Why this is smaller than it looks

WHOOP identity is a transient CoreBluetooth UUID / Bluetooth address, so a re-pair or factory reset
mints a fresh one and the same physical strap forks into a second registry row, orphaning its history
(#1193).

The scary part — re-keying every stored row — is already written, shipped and proven:
adoptSerialIdentity migrates every deviceScopedTables row, is PK-clash-safe, is unit-tested, and has
been running on both platforms for the ring since #771. This PR does not add a migration. It calls the
proven one from the WHOOP path.

Why 5.0/MG only

Their serial is already read at connect from DIS 0x2A25 for MG-vs-5.0 discrimination — so this adds
no BLE traffic whatsoever, it consumes a value already in hand.

A 4.0 is untouched: the DIS read is explicitly gated != .whoop4, and the 4.0 serial's source on the wire
is not yet identified (REPORT_VERSION_INFO's 35 bytes are fully accounted for as firmware). Splitting
the two is precisely what lets this half ship now rather than waiting on a 4.0 protocol capture — that is
Phase 0/2, and it needs a capture, not speculative code.

The refusals are the feature

Adoption migrates every device-scoped row onto whatever id is returned, so WhoopSerialIdentity refuses a
blank, truncated, or descriptive-prose DIS read and returns nil, leaving the strap on its existing id.
Moving a history onto a garbage key is worse than not adopting. Serials are upper-cased so one strap
read in two cases cannot become two ids, and isAlreadyAdopted is the reconnect early-out — after the
first adoption it is one string compare and no database work.

Design notes worth reviewing

  • Both platforms defer off the BLE callback, mirroring adoptOuraSerial: adoption re-points the ACTIVE
    device, and the observers that react tear down the very connection the callback is running inside.
  • On Android it lives in AppViewModel, not SourceCoordinator — that file is documented as inert on
    the WHOOP path and never touches WhoopBleClient internals. AppViewModel already owns the registry
    handle and a scope. (I wired it into SourceCoordinator first and reverted; the file's own header says why.)
  • The serial is a device identifier: only its 3-char prefix is ever logged, the rule
    noteWhoop5VariantFromDIS already applies. logSafe enforces it and a test pins that the full serial
    cannot appear in a shareable strap log.

Verification

  • 8 mirrored test vectors per platform on the pure core (composition, case-folding, trim, and five refusal
    cases)
  • Android 4,091 tests / 0 failures; Kotlin compile, i18n, doc-comment gates clean
  • app-build dispatched for the app-target Swift

What must happen before merge

This is the BLE path — compiling proves nothing. A 5.0/MG night must confirm:

  1. the strap adopts once, and the strap log shows the prefix-only line beside the DIS line
  2. a reconnect is a no-op (no repeated adoption, no database churn)
  3. a re-pair re-anchors onto the existing serial row rather than forking a new one — the actual Duplicate device entries for one physical strap — removal doesn't stick (iOS 4.0 + Android 5.0) #1193
    scenario

ryanbr added 3 commits August 19, 2026 21:21
…hase 1)

WHOOP strap identity is a TRANSIENT CoreBluetooth UUID / Bluetooth address, so a re-pair or
factory reset mints a fresh one and the same physical strap forks into a second registry row,
orphaning its history (#1193). The ring solved this already: adoptSerialIdentity re-points a
provisional id onto a serial id and migrates every device-scoped row, and it has been shipping
on both platforms since #771. This is the WHOOP half of the same idea - no new migration, the
proven one is simply called from the WHOOP path.

Scoped to 5.0/MG deliberately. Their serial is ALREADY read at connect from DIS 0x2A25 for
MG-vs-5.0 discrimination, so this adds no BLE traffic at all - it consumes a value already in
hand. A 4.0 is untouched: the DIS read is gated away from it, and the 4.0 serial's source on the
wire is not yet identified. Splitting the two is what lets this half ship now instead of waiting
on a 4.0 protocol capture.

WhoopSerialIdentity is the pure, shared core, and its REFUSALS are the point: adoption migrates
every device-scoped row onto the id it returns, so a blank, truncated, or descriptive-prose DIS
read must yield nil and leave the strap on its existing id. Moving a history onto a garbage key
is worse than not adopting. Serials are upper-cased so one strap read in two cases cannot become
two ids, and isAlreadyAdopted is the reconnect early-out: after the first adoption the check is
one string compare and touches no database.

Both platforms defer the adoption off the BLE callback, mirroring adoptOuraSerial - it re-points
the ACTIVE device, and the observers that react tear down the very connection the callback is
running inside. On Android it lives in AppViewModel rather than SourceCoordinator, which is
documented as inert on the WHOOP path and never touches WhoopBleClient internals; AppViewModel
already owns both the registry handle and a scope.

The serial is a device identifier, so only its 3-character prefix is ever logged - the rule
noteWhoop5VariantFromDIS already applies to the variant line. logSafe enforces it and a test pins
that the full serial cannot appear.

NOT YET HARDWARE-VALIDATED. This is the BLE path: compiling proves nothing about the serial read,
the adoption, or the reconnect. Must not merge until a 5.0/MG night confirms the strap adopts
once, that a reconnect is a no-op, and that a re-pair re-anchors onto the existing serial row
rather than forking a new one.

8 mirrored test vectors per platform on the pure core. Android 4,091 tests / 0 failures; Kotlin
compile, i18n and doc-comment gates clean.
Re-review of my own PR. Adoption fires twice per connect on both platforms - the DIS serial and
the hardware-revision characteristic arrive as separate callbacks and each re-runs the variant
note - which is safe, because the deferred re-check sees the active id already equals the serial
id and returns. That is exactly why the Oura pattern re-checks after deferring, and both twins
mirror it.

The asymmetry is elsewhere and worth writing down rather than discovering from a capture. Swift
adopts INLINE in BLEManager and uses its closure only to notify, so it adopts whether or not
anything is listening. Kotlin emits the serial and the adoption lives in the observer, because
AppViewModel is what owns the registry handle and a scope while SourceCoordinator is inert on the
WHOOP path by design. A connect completing before any observer is wired therefore emits into a
null callback on Android and does not adopt.

Deferred, not lost - DIS is read on every connect, so the next one with an observer alive adopts.
But it means a capture can legitimately show the adoption line on iOS and not on Android for the
same strap, and someone comparing the two should know that is timing rather than a bug.
Re-review of my own PR caught a severe defect in it: adoption would have re-pointed the legacy
"my-whoop" seed, which is the active id for every existing single-WHOOP install.

The consequence is worse than a rename. adoptSerialIdentity migrates every device-scoped row onto
whoop-<serial>, but WhoopBleClient.deviceId is documented as never reassigned on the single-WHOOP
path - "with one WHOOP it stays my-whoop throughout, byte-for-byte today's behaviour" - so new
samples would keep being written under my-whoop while the entire history sat under the serial id.
A split history, presenting as data that vanished and then began re-accumulating from zero. The
~47 code paths that still read the "my-whoop" literal directly would all read the empty side of
the split.

mayAdopt now restricts adoption to a provisional whoop-<uuid> id. That is exactly the multi-strap
case this ships for - a strap added through the wizard - and it leaves every legacy install
untouched, so this phase is a genuine no-op for the single-WHOOP path it must not disturb. The
legacy seed joins the adoption path in #1304, once the literals no longer assume it.

That ordering matters and I had it half-stated: my plan said the literal sweep must come after
adoption so it is not re-pointed at an unstable identity. The dependency runs both ways - adopting
the seed before the sweep breaks the installs the literals serve.

Mirrored vectors pin the refusal, that a provisional id is still adoptable, and that another
brand's id is never touched. Android 4,092 tests / 0 failures; doc-comment gate clean.
@ryanbr
ryanbr force-pushed the feat/whoop5-serial-identity branch from 101dcc8 to a0dc842 Compare August 19, 2026 09:30
… id (#1303 phase 1)

Second instance of the same split-history defect, found re-reviewing the fix for the first one.
Excluding the legacy seed protected existing installs, but the provisional path had the identical
problem on Swift.

adoptSerialIdentity moves every device-scoped row onto whoop-<serial> and DELETES the old
registry row. BLEManager's own deviceId - and the copies the Collector and Backfiller stamp rows
with at write time - were left on the old provisional id, so the next live sample and the next
offload chunk would have been written under an id that no longer exists. Rows orphaned, history
split in two, which is exactly what this phase exists to prevent.

setActiveDeviceId now runs in the same turn as the adoption, which is the call Kotlin already
reaches through SourceCoordinator.pointWhoop (it re-points any non-legacy id, so the adopted
serial id qualifies). That asymmetry is why only Swift was exposed.

The notification closure was also declared and never consumed - dead API dressed as a seam. It
now has a real job: BLEManager holds only the non-observable store, so the store write does not
publish, and AppModel mirrors the adoption onto the observable DeviceRegistry. Without it the
Devices screen and the source coordinator would keep watching an id that no longer exists until
the next relaunch.

Android is unaffected: pointWhoop already re-points, and no Kotlin file changes here.
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