Oura: stable device identity from the ring's serial, and generation from its hardware id (#771, #772) - #776
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch:
oura-serial-identity→mainCloses: #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:
oura-<CoreBluetooth-UUID>. iOS can't read aring's MAC (CoreBluetooth only exposes an opaque
identifierthat rotates on re-pair), so everyre-pair minted a new id and stranded the ring's prior samples under the old one. Observed on device: one
physical Gen3 ring produced four
oura-<UUID>rows, the live data stranded under an archived one, andthe dashboard reading blank because the active row had no history.
recognise(advertisedName:), which read any digit in theBLE name as the generation. A factory-reset ring advertises its serial in the name
(
Oura 2H3B2405003655), whose5was read as Gen5 on a real Gen3.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-authget_serial(18 03 08 00 10) andget_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
0x19with the layoutbyte0 = status, then NUL-terminated ASCII(validated on a Gen3:serial
2H3B2405003655, hardwareBLB_03). Told apart by content: the hardware id has an_NNgenerationmarker, the serial does not.
2. Generation from the hardware id (#772)
OuraDecoders.productInfoString+OuraRingGen.from(hardwareId:)mapBLB_03→ Gen3 (_NNsuffix;gen4/5 codes unconfirmed →
nil, never a guess).recognise(advertisedName:)hardened to infer a generation only from an explicitgen/ringtoken,so a serial-bearing name yields
nil(→ safe default) instead of a bogus generation.onModel→registry.setModelcorrects the mis-stamped row on connect — no re-pair needed (the same WHOOPsetModel-on-connect pattern as Skin Temp, Wrong Strap Model #716).3. Stable identity from the serial (#771)
oura-<CBUUID/addr>at pairing; on reading the serial page itfires
onSerial(guarded byisPlausibleSerial: alphanumeric, 8..24, so a misframed reply can't mint abogus id).
SourceCoordinator.adoptOuraSerialre-points the active row ontooura-<serial>(prefix from thebrand catalog, never a literal) via
DeviceRegistryStore.adoptSerialIdentity(from:to:): it folds onlythe active row's data + registry into the serial id (a rename when new, an
UPDATE OR IGNOREmerge —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-pointedsession couldn't re-authenticate), then
setActive(serialId)moves the read/write spine over.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, explicitoperation (not in this PR).
Cross-platform parity
Byte-identical Swift/Kotlin twins throughout: the decoders (
productInfoString,from(hardwareId:)), theonModel/onSerialcallbacks, and the reconcile (adoptSerialIdentity). Android needs per-table Roomqueries (no dynamic table names), so it carries 24
reKey<Table>UPDATE OR IGNOREqueries over the samedevice-scoped table set as
deleteDeviceData. On AndroidsetActiveis followed byonActiveDeviceChangedto re-point (macOS gets that free from the
@MainActoractiveDeviceIdobserver).Verification
swift test— OuraProtocol 127/0 (incl. newRingGenProductInfoTests, 10) and WhoopStore 273/0(incl. 4 new
adoptSerialIdentitytests: rename-when-new, merge-when-exists, leaves-other-pairings-untouched, no-op).
compileFullDebugKotlinOK (Room KSP validates the newqueries). App-target Swift is touched, which default CI does not compile — built locally.
adoptSerialIdentityunit test is deferred: the Android test module doesn't compile untilAndroid: unblock testFullDebugUnitTest — implement DeviceRegistryDao.setModel in the three fake DAOs #725 adds the three fake-DAO
setModeloverrides (pre-existing, unrelated); the Swift twin's 4 testspin 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 theCoreBluetooth 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).adopted stable serial id oura-2H3B2405003655 … re-pointing); the re-pointed sessionauthenticated with no key re-install (the key migration worked); the DB active row became
oura-2H3B2405003655with the session's HR under it, the four old archived rows untouched. A subsequentre-pair converges onto the same id.