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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation

/// The hours of the local day in which a wearable's daytime-HR mode should stand down so the device can
/// run its own night suite — derived from the sleep schedule NOOP already learns, never from a fixed clock.
///
/// WHY THIS EXISTS. An Oura ring produces daytime heart rate ONLY while a client holds it in daytime-HR
/// mode (`DHR_mode:3`); there is no banked daytime HR family it emits on its own. NOOP's screen-off suspend
/// (#1526) stops holding the ring so its sleep suite can run — the right call at night, r = −0.93 between
/// the overnight hold and the ring producing SpO2 / a hypnogram / `0x6A` — but a screen-off gate is also
/// true for most of a working day, so from the night that build shipped the daytime 5-min HR bins went from
/// 123–144/144 to a median of ~16, and windowed rMSSD by day emptied with them (the daytime beats are the
/// same `0x80` records). This band is what lets the stand-down key on the NIGHT instead of on the screen.
///
/// Bedtime is `habitualMidsleepSec − typicalSleepHours / 2`, wake is `+ typicalSleepHours / 2`, exactly the
/// derivation `BatteryEstimator.bedtimeAlert` uses, so the two policies can never disagree about when the
/// user's night is; the band then opens `leadSeconds` before that bedtime (an early night must still stand
/// down) and closes `tailSeconds` after that wake (a lie-in must not re-arm the hold). Cold start — fewer
/// nights than the learner needs — yields nil, and the caller falls back to the screen rule: inventing a
/// 23:00 band would hold or release the ring at the wrong hour for exactly the shift/late sleepers the
/// learner exists for. A nap outside the band is not stood down for; that is a known limit of this first cut.
///
/// Pure and clock-free so it is `swift test`-able like `BatteryEstimator.bedtimeAlert`.
public enum NightStandDown {

/// A circular local-time band `[startSec, endSec)` in seconds-of-day; `endSec` may be numerically
/// smaller than `startSec` when the band crosses midnight, which the usual night does.
public struct Band: Equatable, Sendable {
public let startSec: Int
public let endSec: Int
public init(startSec: Int, endSec: Int) { self.startSec = startSec; self.endSec = endSec }
}

/// How long before the learned bedtime the stand-down opens. One hour covers an early night without
/// giving the evening away: the screen-off grace still applies inside the band, so an evening spent on
/// the phone keeps live HR until it is pocketed.
public static let leadSeconds = 3_600
/// How long after the learned wake the stand-down stays closed. One hour covers a lie-in; after it the
/// ring is held again even with the screen still dark, which is what puts the morning back on the chart.
public static let tailSeconds = 3_600

static let secondsPerDay = 86_400

/// The night band for a learned schedule, or nil at cold start (no learned midsleep / no typical night).
public static func band(habitualMidsleepSec: Int?, typicalSleepHours: Double?,
leadSeconds: Int = leadSeconds, tailSeconds: Int = tailSeconds) -> Band? {
guard let midsleep = habitualMidsleepSec, let hours = typicalSleepHours, hours > 0,
(0..<secondsPerDay).contains(midsleep) else { return nil }
let half = Int((hours * 1_800).rounded())
// A schedule whose padded night would cover the whole day has nothing left to call "day"; treat
// it as unlearned rather than hold the ring never.
guard 2 * half + leadSeconds + tailSeconds < secondsPerDay else { return nil }
return Band(startSec: floorMod(midsleep - half - leadSeconds, secondsPerDay),
endSec: floorMod(midsleep + half + tailSeconds, secondsPerDay))
}

/// Whether a local second-of-day falls inside the band, circularly.
public static func contains(_ band: Band, secOfDay: Int) -> Bool {
let s = floorMod(secOfDay, secondsPerDay)
if band.startSec <= band.endSec {
return s >= band.startSec && s < band.endSec
}
return s >= band.startSec || s < band.endSec
}

/// `HH:MM–HH:MM` for a log line.
public static func describe(_ band: Band) -> String {
func hm(_ s: Int) -> String { String(format: "%02d:%02d", s / 3_600, (s % 3_600) / 60) }
return "\(hm(band.startSec))–\(hm(band.endSec))"
}

static func floorMod(_ a: Int, _ n: Int) -> Int {
let r = a % n
return r < 0 ? r + n : r
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import XCTest
@testable import StrandAnalytics

/// `NightStandDown` — the learned night band an Oura ring's daytime-HR hold stands down for when the
/// all-day HR toggle is on. Pure; the live source's `shouldSuspendLiveHR` composes it with the screen rule.
final class NightStandDownTests: XCTestCase {

private func sec(_ h: Int, _ m: Int = 0) -> Int { h * 3_600 + m * 60 }

/// Midsleep 02:30 on an 8 h night: bedtime 22:30, wake 06:30 → band 21:30 → 07:30 across midnight.
func testBandIsBedtimeMinusLeadToWakePlusTail() {
let band = NightStandDown.band(habitualMidsleepSec: sec(2, 30), typicalSleepHours: 8)
XCTAssertEqual(band, NightStandDown.Band(startSec: sec(21, 30), endSec: sec(7, 30)))
XCTAssertEqual(NightStandDown.describe(band!), "21:30–07:30")
}

func testContainsIsCircularAcrossMidnight() {
let band = NightStandDown.Band(startSec: sec(21, 30), endSec: sec(7, 30))
XCTAssertTrue(NightStandDown.contains(band, secOfDay: sec(21, 30))) // start inclusive
XCTAssertTrue(NightStandDown.contains(band, secOfDay: sec(23, 59)))
XCTAssertTrue(NightStandDown.contains(band, secOfDay: 0))
XCTAssertTrue(NightStandDown.contains(band, secOfDay: sec(7, 29)))
XCTAssertFalse(NightStandDown.contains(band, secOfDay: sec(7, 30))) // end exclusive
XCTAssertFalse(NightStandDown.contains(band, secOfDay: sec(12)))
XCTAssertFalse(NightStandDown.contains(band, secOfDay: sec(21, 29)))
// A second-of-day past the day wraps instead of falling out.
XCTAssertTrue(NightStandDown.contains(band, secOfDay: 86_400 + sec(1)))
}

/// A shift worker sleeping 09:00 → 16:00 (midsleep 12:30, 7 h): band 08:00 → 17:00, no midnight crossing.
func testDaytimeSleeperBandDoesNotCrossMidnight() {
let band = NightStandDown.band(habitualMidsleepSec: sec(12, 30), typicalSleepHours: 7)!
XCTAssertEqual(band, NightStandDown.Band(startSec: sec(8), endSec: sec(17)))
XCTAssertTrue(NightStandDown.contains(band, secOfDay: sec(12)))
XCTAssertFalse(NightStandDown.contains(band, secOfDay: sec(2)))
XCTAssertFalse(NightStandDown.contains(band, secOfDay: sec(22)))
}

/// Cold start (no learned schedule) is nil — the caller keeps the screen rule rather than a made-up clock.
func testColdStartIsNil() {
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: nil, typicalSleepHours: 8))
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: sec(2), typicalSleepHours: nil))
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: sec(2), typicalSleepHours: 0))
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: -1, typicalSleepHours: 8))
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: 86_400, typicalSleepHours: 8))
}

/// A padded night that would swallow the whole day is unlearned, not "never hold the ring".
func testWholeDayNightIsNil() {
XCTAssertNil(NightStandDown.band(habitualMidsleepSec: sec(2), typicalSleepHours: 22.5))
XCTAssertNotNil(NightStandDown.band(habitualMidsleepSec: sec(2), typicalSleepHours: 21.9))
}

/// Same bedtime derivation as the battery night-guard: midsleep − half the night, circular.
func testBedtimeMatchesTheBatteryNightGuardDerivation() {
// 00:30 midsleep, 7.5 h → bedtime 20:45 → band opens 19:45; wake 04:15 → band closes 05:15.
let band = NightStandDown.band(habitualMidsleepSec: sec(0, 30), typicalSleepHours: 7.5)!
XCTAssertEqual(band.startSec, sec(19, 45))
XCTAssertEqual(band.endSec, sec(5, 15))
}
}
25 changes: 24 additions & 1 deletion Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,8 @@ final class AppModel: ObservableObject {
// path. Timestamp matches BLEManager.log()'s "HH:mm:ss" so the lines read consistently.
straplog: { [weak self] line in
self?.live.append(log: "[\(AppModel.logTimeFormatter.string(from: Date()))] \(line)")
})
},
ouraNightBand: { [weak self] in self?.ouraNightBand() }) // item 27
coordinator.start()
self.deviceRegistry = registry
// #1303: adoption re-points the strap onto its stable `whoop-<serial>` id inside BLEManager (which
Expand Down Expand Up @@ -2085,6 +2086,28 @@ final class AppModel: ObservableObject {
set { UserDefaults.standard.set(newValue, forKey: Self.ouraNotifyMaskFullKey) }
}

/// Item 27 (EXPERIMENTAL, default OFF): keep the Oura ring in its daytime-HR mode while the phone's screen
/// is off during the DAY, standing it down only for the learned night band (`NightStandDown`), instead
/// of on every screen-off. The ring emits daytime heart rate — and the beats behind windowed rMSSD —
/// only while a client holds that mode, so with the screen-keyed suspend a pocketed phone empties the
/// day. ON costs ring battery (its own daytime PPG); OFF is today's behaviour, and the night is
/// unchanged either way. No effect without an Oura ring; cold start (no learned schedule) keeps OFF's rule.
static let ouraAllDayLiveHRKey = "noopOuraAllDayLiveHR"
var ouraAllDayLiveHR: Bool {
get { UserDefaults.standard.bool(forKey: Self.ouraAllDayLiveHRKey) }
set { UserDefaults.standard.set(newValue, forKey: Self.ouraAllDayLiveHRKey) }
}

/// Item 27: the learned night band for the all-day HR stand-down — the SAME midsleep + typical-night
/// inputs the battery night-guard reads (`refreshHabitualMidsleep`, hourly), so the two policies share
/// one notion of the user's night. nil at cold start.
func ouraNightBand() -> NightStandDown.Band? {
NightStandDown.band(
habitualMidsleepSec: habitualMidsleepCache,
typicalSleepHours: BatteryEstimator.typicalSleepHours(
nightlyHours: repo.days.compactMap { $0.totalSleepMin.map { $0 / 60.0 } }))
}

/// Recompute the v5 skin-temp suite snapshots (cycle phase + body clock) from the current history.
/// Called from the analytics pass and when the cycle opt-in flips. Honest-nil throughout: cycle is
/// nil unless opted in; circadian is nil unless a usable activity profile exists.
Expand Down
Loading
Loading