Skip to content
Open
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
76 changes: 76 additions & 0 deletions Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,82 @@ public struct OuraSpO2: Equatable, Sendable, Codable {
}
}

/// Which physical quantity an `OuraSpO2.unit` tag describes.
///
/// The ring sends TWO things down the same `.spo2` event and they are three orders of magnitude apart:
/// 0x6F/0x7B carry a firmware-computed PERCENTAGE, and 0x77 carries a raw DC perfusion magnitude
/// (-1,016 … 11,709,098 in one overnight capture). Only the unit tag survives decode to tell them
/// apart, so anything that reports a value to a human has to ask this before it names it.
///
/// WHY THIS TYPE EXISTS RATHER THAN A `unit == "raw"` CHECK AT EACH SITE. The strap log's
/// "first SpO2 decoded" line printed whichever sample the drain happened to serve first, with no
/// channel in the text — so on one reconnect it read `value 93 (raw)` and on the next
/// `value 101144 (dc_raw)`, from the same ring, minutes apart. A reporter read the five-digit one as a
/// percentage and opened a defect against SpO2 that was never wrong. A log line may only assert what
/// it can attribute; this makes the attribution a value rather than a string comparison.
///
/// Both known tags are matched EXACTLY; anything else is `.unknown`, which names its tag and never
/// claims a percentage. Treating "not perfusion" as a percentage would print a case variant or a future
/// tag's magnitude with a `%` on it, which is the defect this type exists to stop. `OuraStreamMapping`
/// (both platforms) keeps its own `unit == "raw"` allow-list for the same reason from the other side:
/// it is a persistence gate, so an unrecognised unit falls on the "do not store" side there and on the
/// "do not call it a percentage" side here. Kotlin twin: `OuraSpO2Channel`.
public enum OuraSpO2Channel: String, Equatable, Sendable, Codable {
/// 0x6F / 0x7B — a firmware-computed SpO2 percentage. (The unit tag is the legacy string `"raw"`,
/// which names the CHANNEL, not the quantity; see `decodeSpO2Event`.)
case percentage
/// 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one.
case perfusion
/// A unit tag no decoder stamps today (a case variant, or a future tag). Named, never a percentage.
case unknown

/// The unit tag 0x6F and 0x7B stamp on their samples (`OuraSpO2`'s default).
public static let percentageUnit = "raw"
/// The unit tag 0x77 stamps on its samples.
public static let perfusionUnit = "dc_raw"

/// Resolve a sample's channel from its unit tag. Both known tags match exactly (case-sensitive, like
/// the tag); anything else is `.unknown` rather than a guess.
/// Kotlin twin: `OuraSpO2Channel.forUnit`.
public static func forUnit(_ unit: String) -> OuraSpO2Channel {
switch unit {
case percentageUnit: return .percentage
case perfusionUnit: return .perfusion
default: return .unknown
}
}

/// How this channel is named in the strap log. Spelled out rather than printed as the unit tag:
/// `"raw"` names the CHANNEL, not the quantity, and reads as "unprocessed" to everyone who has not
/// read `decodeSpO2PerSample`.
public var logLabel: String {
switch self {
case .percentage: return "SpO2 percentage"
case .perfusion: return "SpO2 raw DC perfusion (NOT a percentage)"
case .unknown: return "SpO2 sample on an unrecognised channel (NOT known to be a percentage)"
}
}

/// The strap-log body for "the first sample of this channel arrived this session", WITHOUT either
/// platform's `Oura: ` prefix (each source adds its own, as it does for every other line).
///
/// It lives here, not at the two call sites, for the reason the whole type exists: the two platforms
/// must not be able to disagree about what they call these numbers. The unit tag is still printed,
/// so a log can be matched back to the decoder, and the `%` is appended ONLY on the percentage
/// channel — a perfusion magnitude with a `%` on it is the original bug in a new costume.
/// Kotlin twin: `OuraSpO2Channel.firstDecodedLogLine`, oracle-tested against this function's output.
public static func firstDecodedLogLine(value: Int, unit: String) -> String {
let c = forUnit(unit)
return "first \(c.logLabel) decoded (last night) - \(value)\(c == .percentage ? " %" : "")"
+ " (channel \"\(unit)\")"
}
}

public extension OuraSpO2 {
/// What this sample's number actually is. See `OuraSpO2Channel`.
var channel: OuraSpO2Channel { OuraSpO2Channel.forUnit(unit) }
}

/// One decoded skin-temperature sample in hundredths of a degree C scaled to C (value already / 100).
public struct OuraTemp: Equatable, Sendable, Codable {
public let ringTimestamp: UInt32
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import XCTest
@testable import OuraProtocol

/// `OuraSpO2Channel` is the one resolver that says what an SpO2 sample's number IS. It exists because
/// the strap log used to print a 0x77 perfusion magnitude and a 0x6F percentage under the same words,
/// on the same ring, minutes apart. These tests pin the two things that made that possible: the unit
/// tags the decoders actually stamp, and the resolver's disposition for each.
final class OuraSpO2ChannelTests: XCTestCase {

// MARK: - The resolver itself

func testPerfusionUnitResolvesToPerfusion() {
XCTAssertEqual(OuraSpO2Channel.forUnit("dc_raw"), .perfusion)
XCTAssertEqual(OuraSpO2Channel.perfusionUnit, "dc_raw")
}

func testPercentageUnitResolvesToPercentage() {
XCTAssertEqual(OuraSpO2Channel.forUnit("raw"), .percentage)
}

/// Both known tags match EXACTLY; anything else is `.unknown`. Treating "not perfusion" as a
/// percentage would print a case variant or a future tag's magnitude with a `%` on it — the defect
/// this type exists to stop, in a new costume.
func testUnknownUnitIsUnknownNotAPercentage() {
XCTAssertEqual(OuraSpO2Channel.forUnit("raw_adc"), .unknown)
XCTAssertEqual(OuraSpO2Channel.forUnit(""), .unknown)
XCTAssertEqual(OuraSpO2Channel.forUnit("DC_RAW"), .unknown) // case-sensitive, like the tag
XCTAssertEqual(OuraSpO2Channel.forUnit("RAW"), .unknown)
XCTAssertEqual(OuraSpO2Channel.percentageUnit, "raw")
}

/// An unknown channel names its tag and never carries a `%`.
func testUnknownChannelLineHasNoPercentSign() {
let line = OuraSpO2Channel.firstDecodedLogLine(value: 3, unit: "DC_RAW")
XCTAssertEqual(line,
#"first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 3 (channel "DC_RAW")"#)
XCTAssertFalse(line.contains("%"), line)
}

func testSampleAccessorMatchesTheResolver() {
let pct = OuraSpO2(ringTimestamp: 1, value: 93)
let dc = OuraSpO2(ringTimestamp: 1, value: 101_144, unit: "dc_raw")
XCTAssertEqual(pct.channel, .percentage)
XCTAssertEqual(dc.channel, .perfusion)
}

// MARK: - The log line

/// The exact strings a reader will see. Pinned here so a rename on THIS side is a failing test
/// rather than a silent divergence from `OuraSpO2ChannelOracleTest`, whose literals are this
/// function's own stdout. The two directions together are what stop either platform drifting.
func testFirstDecodedLogLineText() {
XCTAssertEqual(OuraSpO2Channel.firstDecodedLogLine(value: 93, unit: "raw"),
#"first SpO2 percentage decoded (last night) - 93 % (channel "raw")"#)
XCTAssertEqual(OuraSpO2Channel.firstDecodedLogLine(value: 101_144, unit: "dc_raw"),
#"first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw")"#)
}

/// A negative perfusion magnitude is real (the 0x77 accumulator goes below zero) and must not pick
/// up a `%`. This is the original defect in its most misleading form: `-288 %`.
func testNegativePerfusionNeverGetsAPercentSign() {
let line = OuraSpO2Channel.firstDecodedLogLine(value: -288, unit: "dc_raw")
XCTAssertFalse(line.contains("%"), line)
XCTAssertTrue(line.contains("NOT a percentage"), line)
}

/// The percentage channel keeps its unit tag in the text, so a log still ties back to the decoder
/// even though the words no longer repeat the tag.
func testPercentageLineStillNamesItsUnitTag() {
XCTAssertTrue(OuraSpO2Channel.firstDecodedLogLine(value: 95, unit: "raw").contains(#"channel "raw""#))
}

// MARK: - The decoders really do stamp those tags

/// Without this the resolver could be right about strings nothing produces. 0x6F yields the
/// percentage channel; 0x77 yields perfusion. Both are fed real-shaped bodies, not hand-set units.
func testDecodedSpO2PerSampleIsThePercentageChannel() throws {
let rec = OuraRecord(type: 0x6F, ringTimestamp: 100, payload: [0x00, 95, 96, 97])
let out = try XCTUnwrap(OuraDecoders.decodeSpO2PerSample(rec))
XCTAssertFalse(out.isEmpty)
XCTAssertTrue(out.allSatisfy { $0.channel == .percentage }, "0x6F must read as a percentage")
}

func testDecodedSpO2DCIsThePerfusionChannel() throws {
// hasBase (bit 6) + a 24-bit LE base, then one sign-magnitude delta.
let rec = OuraRecord(type: 0x77, ringTimestamp: 100,
payload: [0x40, 0x2C, 0xA0, 0x00, 0x05])
let out = try XCTUnwrap(OuraDecoders.decodeSpO2DC(rec))
XCTAssertFalse(out.isEmpty)
XCTAssertTrue(out.allSatisfy { $0.channel == .perfusion }, "0x77 must read as perfusion")
// And the magnitudes really are the three-orders-of-magnitude-apart kind that started this.
XCTAssertGreaterThan(out[0].value, 100, "a perfusion base is not a percentage")
}

/// 0x7B carries a single BIG-endian value and takes the default unit, so it is a percentage too.
func testDecodedSpO2StableIsThePercentageChannel() throws {
let rec = OuraRecord(type: 0x7B, ringTimestamp: 100, payload: [0x00, 0x60])
let s = try XCTUnwrap(OuraDecoders.decodeSpO2Stable(rec))
XCTAssertEqual(s.channel, .percentage)
}
}
21 changes: 13 additions & 8 deletions Strand/BLE/OuraLiveSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,14 @@ public final class OuraLiveSource: NSObject, ObservableObject {
/// stop/disconnect. These are last-night values from the history fetch, not live pushes, but we still
/// only want one log line, not one per sample. Twin of `loggedFirstHR`.
private var loggedFirstTemp = false
/// Logs the FIRST SpO2 sample decoded this session only. Twin of `loggedFirstTemp`.
private var loggedFirstSpo2 = false
/// Logs the FIRST SpO2 sample decoded this session, PER CHANNEL. Twin of `loggedFirstTemp`, except
/// that `.spo2` carries two quantities three orders of magnitude apart (`OuraSpO2Channel`), and one
/// latch across both reported whichever the drain served first: the same ring printed `value 93
/// (raw)` on one reconnect and `value 101144 (dc_raw)` on the next. A reporter read the second as a
/// percentage and filed a defect against SpO2 that was never wrong. One latch per channel, so each
/// line names one quantity and a session that only ever saw perfusion says so instead of implying a
/// percentage arrived.
private var loggedFirstSpo2: Set<OuraSpO2Channel> = []
/// The 0x13 SyncTime reply parked because nothing yet available could disambiguate its unit (ticks vs
/// seconds x10): the resume cursor was 0 (fresh pair / post-reboot full pull) or so stale the ring's
/// clock had run past the window. Retried against the drain's `maxSeenRingTime` as the first batch
Expand Down Expand Up @@ -1604,7 +1610,7 @@ public final class OuraLiveSource: NSObject, ObservableObject {
loggedFirstHR = false
droppedFirstLiveHR = false
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedFirstSpo2.removeAll()
loggedAnchor = false
pendingSyncTime = nil
loggedTierBKinds.removeAll()
Expand Down Expand Up @@ -2107,9 +2113,8 @@ public final class OuraLiveSource: NSObject, ObservableObject {
}

case .spo2(let s):
if !loggedFirstSpo2 {
loggedFirstSpo2 = true
log("Oura: first SpO2 decoded (last night) - value \(s.value) (\(s.unit))")
if loggedFirstSpo2.insert(s.channel).inserted {
log("Oura: " + OuraSpO2Channel.firstDecodedLogLine(value: s.value, unit: s.unit))
}
if let ts = driver.unixSeconds(forRingTimestamp: s.ringTimestamp) {
enqueue([e], ts: ts)
Expand Down Expand Up @@ -2779,7 +2784,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate {
loggedFirstHR = false
droppedFirstLiveHR = false
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedFirstSpo2.removeAll()
loggedAnchor = false
pendingSyncTime = nil
loggedTierBKinds.removeAll()
Expand Down Expand Up @@ -2875,7 +2880,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate {
loggedFirstHR = false
droppedFirstLiveHR = false
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedFirstSpo2.removeAll()
loggedAnchor = false
pendingSyncTime = nil
loggedTierBKinds.removeAll()
Expand Down
26 changes: 18 additions & 8 deletions android/app/src/main/java/com/noop/ble/OuraLiveSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import com.noop.oura.OuraOuterFrame
import com.noop.oura.OuraReassembler
import com.noop.oura.OuraRingGen
import com.noop.oura.OuraSleepSession
import com.noop.oura.OuraSpO2Channel
import com.noop.oura.channel
import com.noop.oura.OuraSleepSessionMapping
import com.noop.oura.OuraTransition
import com.noop.oura.OuraWearState
Expand Down Expand Up @@ -328,8 +330,14 @@ class OuraLiveSource(
* stop/disconnect. These are last-night values from the history fetch, not live pushes, but we still
* only want one log line, not one per sample. Twin of [loggedFirstHr]. */
private var loggedFirstTemp = false
/** Logs the FIRST SpO2 sample decoded this session only. Twin of [loggedFirstTemp]. */
private var loggedFirstSpo2 = false
/** Logs the FIRST SpO2 sample decoded this session, PER CHANNEL. Twin of [loggedFirstTemp], except
* that `.spo2` carries two quantities three orders of magnitude apart ([OuraSpO2Channel]), and one
* latch across both reported whichever the drain served first: the same ring printed `value 93
* (raw)` on one reconnect and `value 101144 (dc_raw)` on the next. A reporter read the second as a
* percentage and filed a defect against SpO2 that was never wrong. One latch per channel, so each
* line names one quantity and a session that only ever saw perfusion says so instead of implying a
* percentage arrived. Twin of Swift's `loggedFirstSpo2` set. */
private val loggedFirstSpo2 = mutableSetOf<OuraSpO2Channel>()
/** The 0x13 SyncTime reply parked because nothing yet available could disambiguate its unit (ticks vs
* seconds x10): the resume cursor was 0 (fresh pair / post-reboot full pull) or so stale the ring's
* clock had run past the window. Retried against the drain's maxSeenRingTime as the first batch lands
Expand Down Expand Up @@ -1200,7 +1208,7 @@ class OuraLiveSource(
authEscalations = 0
authToggleCccdPending = 0
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedFirstSpo2.clear()
loggedAnchor = false
pendingSyncTime = null
loggedTierBKinds.clear()
Expand Down Expand Up @@ -1302,7 +1310,7 @@ class OuraLiveSource(
reassembler.reset()
loggedFirstHr = false // a later reconnect should log its first sample again
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedFirstSpo2.clear()
loggedAnchor = false
pendingSyncTime = null
loggedTierBKinds.clear()
Expand Down Expand Up @@ -1454,7 +1462,7 @@ class OuraLiveSource(
dropUnanchoredHypnogramBursts()
reassembler.reset()
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedFirstSpo2.clear()
loggedAnchor = false
pendingSyncTime = null
loggedTierBKinds.clear()
Expand Down Expand Up @@ -2060,9 +2068,11 @@ class OuraLiveSource(
}
}
is OuraEvent.Spo2 -> {
if (!loggedFirstSpo2) {
loggedFirstSpo2 = true
log("Oura: first SpO2 decoded (last night) - value ${e.value.value} (${e.value.unit})")
if (loggedFirstSpo2.add(e.value.channel)) {
log(
"Oura: " +
OuraSpO2Channel.firstDecodedLogLine(e.value.value, e.value.unit),
)
}
enqueueAnchoredOrPark(e, e.value.ringTimestamp, d)
}
Expand Down
Loading
Loading