diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift index 4a10f3f9dc..9636c2b71e 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift @@ -69,6 +69,19 @@ public final class OuraDriver { public private(set) var phase: OuraDriverPhase = .idle /// Tracks how many of the live-HR enable triplet ACKs have been seen. private var liveHREnableStep = 0 + /// Whether auth success should arm the live-HR enable triplet (`dhr_read` / `dhr_enable` / + /// `dhr_subscribe`). Default true — the historical behaviour. The app clears it for a connect it + /// makes while its live-HR stream is suspended (screen off overnight): before this flag every + /// reconnect ran the triplet unconditionally, the app's suspend guard undid it one second later, + /// and the ring logged `DHR_mode:3` → `DHR_mode:0` on each visit. On a Ring 5 overnight capture + /// (issue #2075's reporter, 2026-09-16) four of the five interruptions of the ring's own SpO2 + /// session started on exactly the second of such a connect (3–49 min each, ≈ 2 h of a 9 h night). + /// With the flag false the driver goes straight to `.streaming` — authenticated and idle — so the + /// history drain, SyncTime and status reads run as before and no daytime-HR write is made at all. + /// The ring's own night suite is the thing being left alone; the Oura app never runs live mode + /// during a sync either (OURA_PROTOCOL.md s5.6). Read once, at the auth-success step; changing it + /// later has no effect on a session already past that step. + public var liveHRWanted = true /// The most recent ring time seen on any record, used to stamp live-HR pushes (which are not TLV /// records and carry no timestamp of their own). private var lastRingTimestamp: UInt32 = 0 @@ -137,6 +150,13 @@ public final class OuraDriver { case .authCompleted(let status): switch status { case .success: + // A connect the app does not want live HR for (suspended night) skips the triplet + // entirely: `.streaming` here means "authenticated, idle", which is all the history + // fetch / SyncTime / status reads need. Nothing is written to the daytime-HR feature. + guard liveHRWanted else { + phase = .streaming + return [] + } phase = .enablingLiveHR liveHREnableStep = 0 // Begin the live-HR enable triplet (gen-appropriate; gen3 verified, gen4/5 same path). diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraDriverTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraDriverTests.swift index edf98d8bc0..5f4628e12e 100644 --- a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraDriverTests.swift +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraDriverTests.swift @@ -59,6 +59,46 @@ final class OuraDriverTests: XCTestCase { XCTAssertEqual(d.phase, .streaming) } + // MARK: - Suspended connect: auth success without the live-HR triplet + + /// With `liveHRWanted` cleared (the app's screen-off suspend is in force), auth success goes straight + /// to `.streaming` and writes NOTHING to the daytime-HR feature: no `dhr_read`, no `dhr_enable`, no + /// `dhr_subscribe`. The history path still works from that phase, and a stray enable ACK (the ring + /// answering something else on the shared sub-op) cannot restart the triplet. + func testAuthSuccessSkipsLiveHRTripletWhenNotWanted() { + let d = OuraDriver(ringGen: .gen3, authKey: key) + d.liveHRWanted = false + _ = d.nextStep(after: .ready) + _ = d.nextStep(after: .nonceReceived(bytes("0102030405060708090a0b0c0d0e0f"))) + + let onAuth = d.nextStep(after: .authCompleted(.success)) + XCTAssertTrue(onAuth.isEmpty, "a suspended connect must not arm daytime HR") + XCTAssertEqual(d.phase, .streaming) + + // A stray enable ACK outside `.enablingLiveHR` is inert — the triplet does not start late. + XCTAssertTrue(d.nextStep(after: .enableAckReceived).isEmpty) + XCTAssertEqual(d.phase, .streaming) + + // The drain runs from `.streaming` exactly as after a full triplet. + let fetch = d.nextStep(after: .startHistoryFetch(cursor: 0)) + XCTAssertEqual(fetch.map { $0.label }, ["flush_buffer", "get_events"]) + XCTAssertEqual(d.phase, .fetchingHistory) + _ = d.nextStep(after: .historyCursorAdvanced(cursor: 0, moreData: false)) + XCTAssertEqual(d.phase, .streaming) + } + + /// The default is the historical behaviour: `liveHRWanted` is true and auth success starts the + /// triplet with `dhr_read` (byte-for-byte the sequence `testFullEnableSequence` pins). + func testLiveHRWantedDefaultsToArmingTheTriplet() { + let d = OuraDriver(ringGen: .gen3, authKey: key) + XCTAssertTrue(d.liveHRWanted) + _ = d.nextStep(after: .ready) + _ = d.nextStep(after: .nonceReceived(bytes("0102030405060708090a0b0c0d0e0f"))) + let onAuth = d.nextStep(after: .authCompleted(.success)) + XCTAssertEqual(onAuth.map { $0.label }, ["dhr_read"]) + XCTAssertEqual(d.phase, .enablingLiveHR) + } + // MARK: - Honest pairing path when no key func testNoKeyDrivesNeedsKeyInstall() { diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index c089011816..c9a5db7808 100644 --- a/Strand/BLE/OuraLiveSource.swift +++ b/Strand/BLE/OuraLiveSource.swift @@ -459,6 +459,14 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// True once the live-HR stream has been requested, so the disconnect handler can tell "we never got /// authenticated/streaming" (-> honest note) from "the link just dropped". private var reachedStreaming = false + /// True while THIS session has actually put the ring into daytime-HR mode: the driver's enable + /// triplet completed, `reengageLiveHR()` wrote an enable, or a live push proved the ring is streaming + /// regardless of what we asked. `disableLiveHR()` keys off it so a connect made while suspended — + /// which now skips the triplet (`OuraDriver.liveHRWanted`) — does not follow up with a `mode 0x00` + /// write for a mode it never set: that write is itself a state change on the ring, and the whole + /// point of the suspended connect is to leave the ring's own night suite untouched. Cleared with + /// `reachedStreaming` and after a disable is sent. + private var liveHRArmedThisSession = false /// The freshly-generated 16-byte key written to the ring during an adopt key install. Held in memory /// ONLY between writing the `0x24` install and receiving the `0x25` ack: it is persisted to the keystore /// ONLY on an OK ack (so a failed/absent ack never leaves a key the next session would wrongly trust). @@ -1633,6 +1641,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { chainedDrainTimer?.invalidate() chainedDrainTimer = nil reachedStreaming = false + liveHRArmedThisSession = false pendingInstallKey = nil adoptPhase = .idle batteryPct = nil @@ -1764,12 +1773,13 @@ public final class OuraLiveSource: NSObject, ObservableObject { linkPhase = .authenticated adoptPhase = .streaming // re-auth after an install (or a normal auth) reached the stream: adoption complete pendingInstallKey = nil // an OK ack already persisted the key; nothing left in flight - // The driver's own auth-success path (OuraDriver.nextStep, .authCompleted(.success)) has no - // suspend awareness - it unconditionally re-runs the live-HR enable triplet on EVERY connect, - // including a reconnect during a suspended night (08-17/18: 25 reconnects, green never hit - // zero any hour). Only claim the stream is wanted, and only start the keep-alive, when the - // screen is actually on; startReengageTimer's own suspended guard sends the explicit disable - // that undoes what the triplet above just armed. + // The driver ran its live-HR enable triplet only if `liveHRWanted` was set at auth + // (`handleSecure`, `.authStatus`); a suspended connect skipped it and arrives here having + // written nothing to the daytime-HR feature. (Before that flag the triplet ran on EVERY + // connect — 08-17/18: 25 reconnects, green never hit zero any hour — and + // `startReengageTimer()`'s suspended guard sent the disable that undid it.) Only claim the + // stream is wanted, and only start the keep-alive, when the screen is actually on. + if driver.liveHRWanted { liveHRArmedThisSession = true } if !liveHRSuspended { if feedsLive { live.streamingLiveHR = true } // drive the green menu-bar STREAMING pill (no WHOOP bond) log("Oura: live-HR enabled - streaming HR / IBI") @@ -2038,6 +2048,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { log("Oura: live-HR push arrived while SUSPENDED (\(hr.bpm) bpm) - dhr_disable did not " + "stop the stream, self-healing now") } + liveHRArmedThisSession = true // the push is the proof; let the disable go out startReengageTimer() } // Drop the first (settling) live-HR sample of the session — it is frequently an artifact. @@ -2574,9 +2585,15 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// intentional teardown must hand the ring back out of daytime mode for the same reason a suspend /// must. The `.streaming` guard covers both callers -- there is nothing to disable if the session /// never got that far. + /// Additionally gated on `liveHRArmedThisSession`: a connect made while suspended never armed + /// daytime HR (the driver skipped its triplet), so there is nothing to hand back and the `mode 0x00` + /// write would be a gratuitous state change on a ring we are trying to leave alone. A live push while + /// suspended marks the session armed first (the ring is evidently streaming), so the self-heal path + /// still sends the disable. private func disableLiveHR() { - guard let driver, driver.phase == .streaming else { return } + guard let driver, driver.phase == .streaming, liveHRArmedThisSession else { return } write([OuraCommands.liveHRDisable(), OuraCommands.liveHRUnsubscribe()]) + liveHRArmedThisSession = false if feedsLive { live.streamingLiveHR = false } } @@ -2599,6 +2616,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { } guard let driver, reachedStreaming, driver.phase != .fetchingHistory else { return } write(driver.reengageLiveHRCommands()) + liveHRArmedThisSession = true // Live-HR watchdog: if the stream has gone silent past the grace window while we were WORN, the // ring came off the finger (no "removed" event exists) -> NOT WORN. Only meaningful once we have // seen at least one live beat this session. @@ -2776,6 +2794,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { clearAuthWatchdog() // a fresh session starts with a clean escalation count authEscalations = 0 authToggleInFlight = false + liveHRArmedThisSession = false loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false @@ -2894,6 +2913,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { lastPhaseUtc = nil lastPhaseCodeCount = 0 reachedStreaming = false + liveHRArmedThisSession = false pendingInstallKey = nil // A disconnect MID-install is an honest failure (no ack came); a disconnect after streaming leaves // the completed `.streaming` outcome intact so the wizard's success transition isn't undone. @@ -3187,7 +3207,17 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { advance(.nonceReceived(nonce)) case .authStatus(let status): if status.isSuccess { - log("Oura: auth OK - enabling live HR") + // Decide HERE, before the driver's next step, whether this connect arms daytime HR at all. + // A reconnect during a suspended night used to run the enable triplet regardless and have + // `startReengageTimer()` undo it one second later — `DHR_mode:3` → `DHR_mode:0` on the + // ring for every overnight visit. On a Ring 5 overnight capture (#2075's reporter, + // 2026-09-16) four of the five interruptions of the ring's own SpO2 session began on the + // exact second of such a visit (3–49 min each, ≈ 2 h of a 9 h night). Suspended ⇒ the + // driver goes straight to `.streaming` with no daytime-HR write; the log says which. + let wanted = !liveHRSuspended + driver?.liveHRWanted = wanted + log(wanted ? "Oura: auth OK - enabling live HR" + : "Oura: auth OK - live HR suspended (screen off), daytime HR left untouched") } else { log("Oura: WARNING auth status \(status.rawValue)") } diff --git a/android/app/src/main/java/com/noop/oura/OuraDriver.kt b/android/app/src/main/java/com/noop/oura/OuraDriver.kt index 7adbdbf968..1d53c3c04b 100644 --- a/android/app/src/main/java/com/noop/oura/OuraDriver.kt +++ b/android/app/src/main/java/com/noop/oura/OuraDriver.kt @@ -106,6 +106,21 @@ class OuraDriver( /** Tracks how many of the live-HR enable triplet ACKs have been seen. */ private var liveHREnableStep = 0 + /** + * Whether auth success should arm the live-HR enable triplet (`dhr_read` / `dhr_enable` / + * `dhr_subscribe`). Default true — the historical behaviour. The app clears it for a connect it + * makes while its live-HR stream is suspended (screen off overnight): before this flag every + * reconnect ran the triplet unconditionally, the app's suspend guard undid it one second later, + * and the ring logged `DHR_mode:3` → `DHR_mode:0` on each visit. On a Ring 5 overnight capture + * (issue #2075's reporter, 2026-09-16) four of the five interruptions of the ring's own SpO2 + * session started on exactly the second of such a connect (3–49 min each, ≈ 2 h of a 9 h night). + * With the flag false the driver goes straight to `Streaming` — authenticated and idle — so the + * history drain, SyncTime and status reads run as before and no daytime-HR write is made at all. + * Read once, at the auth-success step. Twin of the Swift `OuraDriver.liveHRWanted`; Android's + * `OuraLiveSource` has no screen-off suspend yet (#1546), so nothing clears it there today. + */ + var liveHRWanted: Boolean = true + /** * The most recent ring time seen on any record, used to stamp live-HR pushes (which are not TLV * records and carry no timestamp of their own). @@ -182,10 +197,18 @@ class OuraDriver( is OuraTransition.AuthCompleted -> when (after.status) { OuraAuthStatus.SUCCESS -> { - phase = OuraDriverPhase.EnablingLiveHR - liveHREnableStep = 0 - // Begin the live-HR enable triplet (gen-appropriate; gen3 verified, gen4/5 same path). - listOf(OuraCommands.liveHREnableSequence()[0]) + // A connect the app does not want live HR for (suspended night) skips the triplet + // entirely: `Streaming` here means "authenticated, idle", which is all the history + // fetch / SyncTime / status reads need. Nothing is written to the daytime-HR feature. + if (!liveHRWanted) { + phase = OuraDriverPhase.Streaming + emptyList() + } else { + phase = OuraDriverPhase.EnablingLiveHR + liveHREnableStep = 0 + // Begin the live-HR enable triplet (gen-appropriate; gen3 verified, gen4/5 same path). + listOf(OuraCommands.liveHREnableSequence()[0]) + } } OuraAuthStatus.IN_FACTORY_RESET -> { // Ring needs a key install first; this is an explicit, named provisioning step the app diff --git a/android/app/src/test/java/com/noop/oura/OuraDriverTest.kt b/android/app/src/test/java/com/noop/oura/OuraDriverTest.kt index a94f104938..75046f889d 100644 --- a/android/app/src/test/java/com/noop/oura/OuraDriverTest.kt +++ b/android/app/src/test/java/com/noop/oura/OuraDriverTest.kt @@ -81,6 +81,49 @@ class OuraDriverTest { // MARK: - Honest pairing path when no key + // MARK: - Suspended connect: auth success without the live-HR triplet + + /** + * With `liveHRWanted` cleared (the app's screen-off suspend is in force), auth success goes straight + * to `Streaming` and writes NOTHING to the daytime-HR feature: no `dhr_read`, no `dhr_enable`, no + * `dhr_subscribe`. The history path still works from that phase, and a stray enable ACK cannot + * restart the triplet. Twin of the Swift `testAuthSuccessSkipsLiveHRTripletWhenNotWanted`. + */ + @Test + fun testAuthSuccessSkipsLiveHRTripletWhenNotWanted() { + val d = OuraDriver(ringGen = OuraRingGen.GEN3, authKey = key) + d.liveHRWanted = false + d.nextStep(OuraTransition.Ready) + d.nextStep(OuraTransition.NonceReceived(bytes("0102030405060708090a0b0c0d0e0f"))) + + val onAuth = d.nextStep(OuraTransition.AuthCompleted(OuraAuthStatus.SUCCESS)) + assertTrue("a suspended connect must not arm daytime HR", onAuth.isEmpty()) + assertEquals(OuraDriverPhase.Streaming, d.phase) + + // A stray enable ACK outside EnablingLiveHR is inert — the triplet does not start late. + assertTrue(d.nextStep(OuraTransition.EnableAckReceived).isEmpty()) + assertEquals(OuraDriverPhase.Streaming, d.phase) + + // The drain runs from Streaming exactly as after a full triplet. + val fetch = d.nextStep(OuraTransition.StartHistoryFetch(cursor = 0L)) + assertEquals(listOf("flush_buffer", "get_events"), fetch.map { it.label }) + assertEquals(OuraDriverPhase.FetchingHistory, d.phase) + d.nextStep(OuraTransition.HistoryCursorAdvanced(cursor = 0L, moreData = false)) + assertEquals(OuraDriverPhase.Streaming, d.phase) + } + + /** The default is the historical behaviour: auth success starts the triplet with `dhr_read`. */ + @Test + fun testLiveHRWantedDefaultsToArmingTheTriplet() { + val d = OuraDriver(ringGen = OuraRingGen.GEN3, authKey = key) + assertTrue(d.liveHRWanted) + d.nextStep(OuraTransition.Ready) + d.nextStep(OuraTransition.NonceReceived(bytes("0102030405060708090a0b0c0d0e0f"))) + val onAuth = d.nextStep(OuraTransition.AuthCompleted(OuraAuthStatus.SUCCESS)) + assertEquals(listOf("dhr_read"), onAuth.map { it.label }) + assertEquals(OuraDriverPhase.EnablingLiveHR, d.phase) + } + @Test fun testNoKeyDrivesNeedsKeyInstall() { val d = OuraDriver(ringGen = OuraRingGen.GEN3, authKey = null)