Skip to content

Oura: stable device identity from the ring's serial, and generation from its hardware id (#771, #772) - #776

Merged
ryanbr merged 6 commits into
ryanbr:mainfrom
pipiche38:oura-serial-identity
Jul 24, 2026
Merged

Oura: stable device identity from the ring's serial, and generation from its hardware id (#771, #772)#776
ryanbr merged 6 commits into
ryanbr:mainfrom
pipiche38:oura-serial-identity

Conversation

@pipiche38

Copy link
Copy Markdown

Branch: oura-serial-identitymain
Closes: #771 (stable ring identity), #772 (generation detection)

Why

Two linked defects, both because NOOP inferred an Oura ring's identity and generation from the wrong source:

The fix for both is the same read: the ring's own GetProductInfo pages, already defined in the command
set but never called. The serial is the identity that survives a re-pair; the hardware id is the
authoritative generation.

What

Three tightly-linked pieces (one product-info read feeds all of them):

1. Read the product-info pages on connect (capture-first)

At .streaming, send the pre-auth get_serial (18 03 08 00 10) and get_hardware (18 03 18 00 10) —
read-only, same class as the existing SpO2 / real-steps status reads — and decode each reply. Both answer
under outer op 0x19 with the layout byte0 = status, then NUL-terminated ASCII (validated on a Gen3:
serial 2H3B2405003655, hardware BLB_03). Told apart by content: the hardware id has an _NN generation
marker, the serial does not.

2. Generation from the hardware id (#772)

  • OuraDecoders.productInfoString + OuraRingGen.from(hardwareId:) map BLB_03 → Gen3 (_NN suffix;
    gen4/5 codes unconfirmed → nil, never a guess).
  • recognise(advertisedName:) hardened to infer a generation only from an explicit gen/ring token,
    so a serial-bearing name yields nil (→ safe default) instead of a bogus generation.
  • On connect, if the hardware id resolves a generation different from the session's, onModel
    registry.setModel corrects the mis-stamped row on connect — no re-pair needed (the same WHOOP
    setModel-on-connect pattern as Skin Temp, Wrong Strap Model #716).

3. Stable identity from the serial (#771)

  • The live source still mints the provisional oura-<CBUUID/addr> at pairing; on reading the serial page it
    fires onSerial (guarded by isPlausibleSerial: alphanumeric, 8..24, so a misframed reply can't mint a
    bogus id).
  • SourceCoordinator.adoptOuraSerial re-points the active row onto oura-<serial> (prefix from the
    brand catalog, never a literal) via DeviceRegistryStore.adoptSerialIdentity(from:to:): it folds only
    the active row's
    data + registry into the serial id (a rename when new, an UPDATE OR IGNORE merge —
    canonical wins a PK clash — when a prior pairing established it) and deliberately leaves other past
    oura-* rows untouched
    . The Keychain install key is migrated onto the serial id (or the re-pointed
    session couldn't re-authenticate), then setActive(serialId) moves the read/write spine over.
  • The provisional row is deleted (not archived), so future re-pairs converge onto the serial id and add
    no new "Removed" clutter.

Honest-data / scope

Read-only; no new writes to the ring. Nothing is persisted beyond a corrected model label and the re-keyed
identity. Only the active row is folded — consolidating older orphaned oura-* rows is a separate, explicit
operation (not in this PR).

Cross-platform parity

Byte-identical Swift/Kotlin twins throughout: the decoders (productInfoString, from(hardwareId:)), the
onModel/onSerial callbacks, and the reconcile (adoptSerialIdentity). Android needs per-table Room
queries (no dynamic table names), so it carries 24 reKey<Table> UPDATE OR IGNORE queries over the same
device-scoped table set as deleteDeviceData. On Android setActive is followed by onActiveDeviceChanged
to re-point (macOS gets that free from the @MainActor activeDeviceId observer).

Verification

  • swift testOuraProtocol 127/0 (incl. new RingGenProductInfoTests, 10) and WhoopStore 273/0
    (incl. 4 new adoptSerialIdentity tests: rename-when-new, merge-when-exists, leaves-other-pairings-
    untouched, no-op).
  • macOS Strand BUILD SUCCEEDED; Android compileFullDebugKotlin OK (Room KSP validates the new
    queries). App-target Swift is touched, which default CI does not compile — built locally.
  • The Android adoptSerialIdentity unit test is deferred: the Android test module doesn't compile until
    Android: unblock testFullDebugUnitTest — implement DeviceRegistryDao.setModel in the three fake DAOs #725 adds the three fake-DAO setModel overrides (pre-existing, unrelated); the Swift twin's 4 tests
    pin the logic.

On-device (macOS, Gen3, 2026-07-24) — the BLE path, validated on hardware

  • get_serial = 2H3B2405003655, byte-identical across a factory-reset-and-adopt that rotated the
    CoreBluetooth UUID (99B6BA9D…4DD70E24…) — i.e. the serial is stable where the UUID is not.
  • get_hardware = BLB_03 → the ring registers as Oura Ring 3 (was Gen5 before the fix).
  • The re-point fired (adopted stable serial id oura-2H3B2405003655 … re-pointing); the re-pointed session
    authenticated with no key re-install (the key migration worked); the DB active row became
    oura-2H3B2405003655 with the session's HR under it, the four old archived rows untouched. A subsequent
    re-pair converges onto the same id.

pipiche38 and others added 6 commits July 24, 2026 13:42
…ork, ryanbr#771/ryanbr#772)

Read-only capture instrumentation, both platforms. On reaching the stream, ASK the ring its already-
defined, pre-auth-readable GetProductInfo serial (`18 03 08 00 10`) + hardware (`18 03 18 00 10`) pages
and LOG the raw replies once per op per session (hex + ASCII, since both are strings, e.g. "BLB_03").
Nothing is decoded, minted into a device id, or persisted — this is the capture-first step so a real
fixture backs the decode before it drives:
  - ryanbr#771 a STABLE `oura-<serial>` identity (iOS can't read the MAC; the CoreBluetooth UUID rotates on
    re-pair and orphans the ring's history), and
  - ryanbr#772 generation from the hardware id instead of stray digits in the advertised name.

- OuraLiveSource (Swift + Kotlin twin): send the two reads alongside the existing SpO2 / real-steps
  status reads in the `.streaming` first-reached block (same read-only class, never a set/enable write);
  peek every notification's outer frames and log any product-info reply. The reply op is captured as
  BOTH 0x18 and 0x19 (request→response +1 convention, cf. GetBattery 0x0C→0x0D), so the fixture lands
  whatever the firmware uses. Neither op is an event tag (>= 0x41), so the peek never disturbs the TLV
  decode. `loggedProductInfoOps` gates once-per-op and resets on stop/disconnect.

Verification: macOS Strand BUILD SUCCEEDED; Android compileFullDebugKotlin OK. The reply layout +
whether the serial is stable across a factory-reset-and-adopt are confirmed on the next on-device
capture (this instrumentation is how that fixture is collected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
…'t swallowed (ryanbr#771/ryanbr#772)

On-device the first capture logged only ONE product-info reply (the serial, op 0x19) even though both
get_serial AND get_hardware were sent: the guard printed once per OP, and both pages answer under op 0x19,
so the get_hardware reply — the page that carries the hardware id for ryanbr#772 generation detection — was
suppressed. Key the once-per-session guard by op+body instead of op, so each DISTINCT reply logs once and
only identical re-serves are collapsed. Both platforms.

Field-confirmed by the capture this fixes: get_serial reply op=0x19 `00 <ascii serial> 00 00` =
"2H3B2405003655", byte-identical before and after a factory-reset-and-adopt (which rotated the
CoreBluetooth UUID from 99B6BA9D… to 4DD70E24…) — i.e. the serial is a STABLE ring identity where the
CBUUID is not. Still capture-only; nothing decoded/minted yet.

Verification: macOS Strand BUILD SUCCEEDED; Android compileFullDebugKotlin OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
…name digits (ryanbr#772)

On-device a Gen3 ring registered as "Oura Ring 5": a factory-reset ring advertises its SERIAL in the BLE
name ("Oura 2H3B2405003655"), and `recognise(advertisedName:)` read the "5" in that serial as gen5. The
authoritative generation is in the ring's GetProductInfo hardware id, which the capture just confirmed:
`get_hardware` reply (op 0x19) = "BLB_03" → Gen3.

- Pure (OuraProtocol, both platforms): `OuraDecoders.productInfoString` decodes a GetProductInfo reply
  (byte0 status + NUL-terminated ASCII → serial "2H3B2405003655" / hardware "BLB_03");
  `OuraRingGen.from(hardwareId:)` maps the trailing "_NN" → gen (BLB_03→gen3 validated; gen4/5 codes
  unconfirmed → nil, never a guess). Harden `recognise(advertisedName:)` to infer a gen ONLY from an
  explicit "gen"/"ring" token, so a serial-bearing name yields nil (→ safe default) instead of a bogus gen.
- App (OuraLiveSource, both platforms): on a product-info reply, if the hardware id resolves a gen that
  differs from the session's, fire a new `onModel` callback with the true "Oura Ring N" label; the
  SourceCoordinator wires it to `registry.setModel`, correcting a mis-stamped row ON CONNECT — no re-pair
  needed (the same WHOOP `setModel`-on-connect pattern, ryanbr#716). The serial reply has no "_NN" marker, so it
  never triggers a correction even though both pages answer under op 0x19.

Tier discipline: read-only; no new writes to the ring; nothing persisted beyond the corrected model label.
Verification: swift test OuraProtocol 127/0 (incl. new RingGenProductInfoTests, 10); macOS Strand BUILD
SUCCEEDED; Android compileFullDebugKotlin OK. The Kotlin twin test mirrors the Swift one but its RUN is
gated by the pre-existing ryanbr#725 fake-`setModel` gap (three test fakes on main don't implement the DAO
method) — unrelated to this change; the main module compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
…ting off the CBUUID (ryanbr#771)

An Oura re-pair mints a fresh CoreBluetooth UUID, so `oura-<CBUUID>` changes every pairing and orphans the
ring's history under the old id (on-device: one ring produced FOUR oura-<UUID> rows, the live data stranded
under an archived one). The ring's SERIAL (read via GetProductInfo on connect) is the one identity that
survives a re-pair, so key the device on `oura-<serial>` instead.

Approach (option 1, agreed with the user — Apple side; Android twin deferred, see below):
- The live source keeps minting the provisional `oura-<CBUUID>` at pairing; once it reads the serial page at
  `.streaming`, it fires a new `onSerial` callback (guarded by `isPlausibleSerial`: alphanumeric, 8..24, so a
  misframed reply can't mint a bogus id). The serial page is told apart from the hardware page (both op 0x19)
  by content — the hardware id has an "_NN" gen marker, the serial does not.
- `SourceCoordinator.adoptOuraSerial` re-points the ACTIVE row onto `oura-<serial>` (prefix from the brand
  catalog, never a literal): `DeviceRegistryStore.adoptSerialIdentity(from:to:)` folds ONLY the active
  CB-UUID row's data + registry into the serial id (a rename when new, an UPDATE-OR-IGNORE merge when a prior
  pairing already established it) and DELIBERATELY leaves other past `oura-*` rows untouched — the agreed
  scope. The install key (Keychain-keyed by deviceId) is migrated onto the serial id or the re-pointed
  session couldn't authenticate. Then `setActive(serialId)` moves the read/write spine over (the coordinator
  rebuilds the source under the serial id via the activeDeviceId observer; deferred one main-loop turn so the
  current BLE callback returns before its own source is torn down).

Result: after the first connect on the new build the active id becomes `oura-<serial>`, and every future
re-pair converges onto it — no more orphaning. Idempotent (serialId == deviceId ⇒ no-op).

Verification: swift test WhoopStore 273/0 (+4 adoptSerialIdentity tests: rename-when-new, merge-when-exists,
leaves-other-pairings-untouched, no-op); macOS Strand BUILD SUCCEEDED. The re-point + key migration + adopt-
flow interaction need on-device validation (a mid-session identity change is novel), so the Android twin
(a sizeable per-table Room reconcile + wiring) is deliberately deferred until this is proven on hardware —
porting first risks redoing it. The pure decode (productInfoString / fromHardwareId) is already twinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
)

Mirrors the Apple ryanbr#771 serial→id re-point on Android now that it's hardware-validated on macOS.

- DeviceRegistryDao: 24 `reKey<Table>(from,to)` `UPDATE OR IGNORE` queries (the SAME device-scoped table
  set as deleteDeviceData; Room has no dynamic table names), plus `pairedDevice(id)` and
  `deletePairedDeviceRow`/`deleteDeviceRow`.
- DeviceRegistry.adoptSerialIdentity(activeId, serialId): one-transaction twin of the Swift store method —
  clone the active (provisional) row under the serial id when new, or carry this pairing's peripheralId/model
  onto an existing serial row (keep active, preserve its addedAt); re-key each table then clear leftovers;
  drop the provisional CB-UUID pairedDevice + device rows. ONLY the active row is folded — other past oura-*
  rows are left untouched (agreed scope).
- OuraLiveSource: `onSerial` callback fired from the product-info peek when the serial page decodes (guarded
  by `isPlausibleSerial`); the hardware page still routes to `onModel` (ryanbr#772). Byte-identical to Swift.
- SourceCoordinator.adoptOuraSerial: build `oura-<serial>` from the brand catalog (never a literal), migrate
  the install key via OuraInstallKeyStore (else the re-pointed session can't auth), `setActive` +
  `onActiveDeviceChanged` re-point the source onto the serial id.

Verification: Android compileFullDebugKotlin OK (Room KSP validates the new queries). The adoptSerialIdentity
unit test is deferred like the other Oura Kotlin tests — the test module doesn't compile until ryanbr#725 adds the
three fake-DAO `setModel` overrides; the Swift twin's 4 tests already pin the logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
…ock conflict

ryanbr#773 (SleepNet hypnogram) and this PR both added state-clears to the same
resetState blocks. Kept BOTH: loggedProductInfo (this PR) + greenIbiAmp* /
recentSleepWindows049 (ryanbr#773). Independent resets, union resolution. All other
Oura files auto-merged. App-target Swift - not compiled here; needs a local
xcodebuild before merge.
@ryanbr
ryanbr merged commit e128b3e into ryanbr:main Jul 24, 2026
9 checks passed
@pipiche38
pipiche38 deleted the oura-serial-identity branch July 24, 2026 18:47
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