Skip to content
Merged
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
8 changes: 8 additions & 0 deletions Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,14 @@ public final class OuraDriver {
return seconds * 1000 // safe: bounded input, cannot overflow
}

/// True when `seconds` falls inside the anchor plausibility window (2020-01-01 .. 2035-01-01), i.e. the
/// `.timeSync` / `.rtcBeacon` ingest would accept it. A record whose epoch is outside this is silently
/// ignored so a garbage value can't anchor history to ~1970. Exposed READ-ONLY so OuraLiveSource can log
/// WHY an anchor was rejected (#91) without duplicating the bounds or reaching into anchor state. Pure.
public static func isPlausibleAnchorEpoch(_ seconds: Int64) -> Bool {
plausibleAnchorMs(fromEpochSeconds: seconds) != nil
}

// MARK: - Record ingest (decode)

/// Decode one parsed TLV inner record into zero or more events. A malformed/short record (or an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ final class OuraDriverTests: XCTestCase {
XCTAssertEqual(d.phase, .needsKeyInstall)
}

func testIsPlausibleAnchorEpochBounds() {
// The anchor plausibility window is [2020-01-01, 2035-01-01] UTC. OuraLiveSource reads this same
// predicate to log WHY an anchor was rejected (#91), so the boundaries are pinned here.
XCTAssertTrue(OuraDriver.isPlausibleAnchorEpoch(1_577_836_800)) // 2020-01-01, inclusive min
XCTAssertTrue(OuraDriver.isPlausibleAnchorEpoch(2_051_222_400)) // 2035-01-01, inclusive max
XCTAssertTrue(OuraDriver.isPlausibleAnchorEpoch(1_700_000_000)) // ~2023, mid-window
XCTAssertFalse(OuraDriver.isPlausibleAnchorEpoch(1_577_836_799)) // one second before min
XCTAssertFalse(OuraDriver.isPlausibleAnchorEpoch(2_051_222_401)) // one second past max
XCTAssertFalse(OuraDriver.isPlausibleAnchorEpoch(0)) // epoch 0 — the ~1970 anchor #91 must avoid
}

func testFactoryResetStatusDrivesNeedsKeyInstall() {
let d = OuraDriver(ringGen: .gen3, authKey: key)
_ = d.nextStep(after: .ready)
Expand Down
27 changes: 22 additions & 5 deletions Strand/BLE/OuraLiveSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -657,15 +657,32 @@ public final class OuraLiveSource: NSObject, ObservableObject {
pendingAnchorEvents.append((e, v.ringTimestamp))
}

case .timeSync:
if !loggedAnchor {
loggedAnchor = true
log("Oura: UTC time anchor acquired - history-fetched samples now get their real time")
case .timeSync(let ts):
// #91: a 0x42 whose epoch is outside the 2020–2035 plausibility window is silently ignored,
// so history samples stay unanchored (no sleep/daily). Log the rejection with the offending
// epoch; only announce "acquired" when the sync ACTUALLY anchored (the old unconditional
// "acquired" line fired even on a rejected sync). `epochMs` holds the raw wire value, which
// is unix SECONDS despite the name (s6.11).
if OuraDriver.isPlausibleAnchorEpoch(ts.epochMs) {
if !loggedAnchor {
loggedAnchor = true
log("Oura: UTC time anchor acquired - history-fetched samples now get their real time")
}
} else {
log("Oura: 0x42 time-sync REJECTED - implausible epoch \(ts.epochMs)s (outside the 2020–2035 anchor window); history samples stay unanchored (#91)")
}
// The 0x42 time-sync can arrive ANYWHERE in a history-fetch stream, not necessarily first.
// Anything parked while unanchored gets its real time retroactively the moment an anchor lands.
drainPendingAnchorEvents()

case .rtcBeacon(let r):
// #91: the 0x85 beacon is the SECONDARY anchor (fills the gap only until a 0x42 arrives). A
// beacon ignored because a primary anchor already exists is NORMAL and not logged; only an
// IMPLAUSIBLE-epoch beacon is a real failure (it can never anchor), so log just that.
if !OuraDriver.isPlausibleAnchorEpoch(Int64(r.unixSeconds)) {
log("Oura: 0x85 RTC beacon REJECTED - implausible epoch \(r.unixSeconds)s (outside the 2020–2035 anchor window) (#91)")
}

case .tierB(let summary):
// INVESTIGATION ONLY (real_steps / activity-summary / sleep-summary / smoothed-SpO2,
// OURA_PROTOCOL.md s7.3 Tier B; PR #960). Logged ONCE PER KIND with the raw bytes so we
Expand All @@ -688,7 +705,7 @@ public final class OuraLiveSource: NSObject, ObservableObject {
log("Oura: activity (Tier-B) state=\(info.state) met=\(info.met)")

default:
break // motion / state / rtcBeacon / debugText: not a durable Streams row (see OuraStreamMapping)
break // motion / state / debugText: not a durable Streams row (see OuraStreamMapping)
}
}
}
Expand Down
25 changes: 22 additions & 3 deletions android/app/src/main/java/com/noop/ble/OuraLiveSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1081,14 +1081,33 @@ class OuraLiveSource(
is OuraEvent.Hrv -> enqueueAnchoredOrPark(e, e.value.ringTimestamp, d)
is OuraEvent.SleepPhaseEvent -> enqueueAnchoredOrPark(e, e.value.ringTimestamp, d)
is OuraEvent.TimeSyncEvent -> {
if (!loggedAnchor) {
loggedAnchor = true
log("Oura: UTC time anchor acquired - history-fetched samples now get their real time")
// #91: a 0x42 whose epoch is outside the 2020–2035 plausibility window is silently ignored,
// so history samples stay unanchored (no sleep/daily). Log the rejection with the offending
// epoch; only announce "acquired" when the sync ACTUALLY anchored (the old unconditional
// "acquired" line fired even on a rejected sync). `epochMs` holds the raw wire value, which
// is unix SECONDS despite the name (s6.11).
if (d.isPlausibleAnchorEpoch(e.value.epochMs)) {
if (!loggedAnchor) {
loggedAnchor = true
log("Oura: UTC time anchor acquired - history-fetched samples now get their real time")
}
} else {
log("Oura: 0x42 time-sync REJECTED - implausible epoch ${e.value.epochMs}s (outside the " +
"2020–2035 anchor window); history samples stay unanchored (#91)")
}
// The 0x42 time-sync can arrive ANYWHERE in a history-fetch stream, not necessarily first.
// Anything parked while unanchored gets its real time retroactively the moment it lands.
drainPendingAnchorEvents()
}
is OuraEvent.RtcBeaconEvent -> {
// #91: the 0x85 beacon is the SECONDARY anchor (fills the gap only until a 0x42 arrives). A
// beacon ignored because a primary anchor already exists is NORMAL and not logged; only an
// IMPLAUSIBLE-epoch beacon is a real failure (it can never anchor), so log just that.
if (!d.isPlausibleAnchorEpoch(e.value.unixSeconds)) {
log("Oura: 0x85 RTC beacon REJECTED - implausible epoch ${e.value.unixSeconds}s (outside " +
"the 2020–2035 anchor window) (#91)")
}
}
is OuraEvent.TierB -> {
// INVESTIGATION ONLY (real_steps / activity-summary / sleep-summary / smoothed-SpO2,
// OURA_PROTOCOL.md s7.3 Tier B; PR #960). Logged ONCE PER KIND with the raw bytes so we
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/java/com/noop/oura/OuraDriver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,14 @@ class OuraDriver(
return epochSeconds * 1000 // safe: bounded input, cannot overflow
}

/**
* True when [epochSeconds] falls inside the anchor plausibility window (2020-01-01 .. 2035-01-01), i.e.
* `setAnchorIfPlausible` would accept it. A 0x42/0x85 whose epoch is outside this is silently ignored so
* a garbage value can't anchor history to ~1970. Exposed READ-ONLY so OuraLiveSource can log WHY an
* anchor was rejected (#91) without duplicating the bounds or reaching into anchor state. Pure.
*/
fun isPlausibleAnchorEpoch(epochSeconds: Long): Boolean = plausibleAnchorMs(epochSeconds) != null

// MARK: - Record ingest (decode)

/**
Expand Down
15 changes: 15 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 @@ -2,6 +2,7 @@ package com.noop.oura

import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
Expand All @@ -22,6 +23,20 @@ class OuraDriverTest {

private fun bytes(s: String) = OuraTestHex.bytes(s)

@Test
fun testIsPlausibleAnchorEpochBounds() {
// The anchor plausibility window is [2020-01-01, 2035-01-01] UTC. OuraLiveSource reads this same
// predicate to log WHY an anchor was rejected (#91), so the boundaries are pinned here. Twin of the
// Swift OuraDriverTests.testIsPlausibleAnchorEpochBounds.
val d = OuraDriver(ringGen = OuraRingGen.GEN3, authKey = key)
assertTrue(d.isPlausibleAnchorEpoch(1_577_836_800L)) // 2020-01-01, inclusive min
assertTrue(d.isPlausibleAnchorEpoch(2_051_222_400L)) // 2035-01-01, inclusive max
assertTrue(d.isPlausibleAnchorEpoch(1_700_000_000L)) // ~2023, mid-window
assertFalse(d.isPlausibleAnchorEpoch(1_577_836_799L)) // one second before min
assertFalse(d.isPlausibleAnchorEpoch(2_051_222_401L)) // one second past max
assertFalse(d.isPlausibleAnchorEpoch(0L)) // epoch 0 — the ~1970 anchor #91 must avoid
}

// MARK: - Full happy-path step sequence (auth -> enable triplet -> streaming)

@Test
Expand Down