Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
46 changes: 38 additions & 8 deletions Strand/BLE/OuraLiveSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -1633,6 +1641,7 @@ public final class OuraLiveSource: NSObject, ObservableObject {
chainedDrainTimer?.invalidate()
chainedDrainTimer = nil
reachedStreaming = false
liveHRArmedThisSession = false
pendingInstallKey = nil
adoptPhase = .idle
batteryPct = nil
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 }
}

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)")
}
Expand Down
31 changes: 27 additions & 4 deletions android/app/src/main/java/com/noop/oura/OuraDriver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions android/app/src/test/java/com/noop/oura/OuraDriverTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading