diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift index 4c5022c931..c98ea5c0f8 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift @@ -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 diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift new file mode 100644 index 0000000000..3b3b7a096b --- /dev/null +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift @@ -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) + } +} diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index c089011816..5415d10c2c 100644 --- a/Strand/BLE/OuraLiveSource.swift +++ b/Strand/BLE/OuraLiveSource.swift @@ -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 = [] /// 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 @@ -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() @@ -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) @@ -2779,7 +2784,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.removeAll() loggedAnchor = false pendingSyncTime = nil loggedTierBKinds.removeAll() @@ -2875,7 +2880,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.removeAll() loggedAnchor = false pendingSyncTime = nil loggedTierBKinds.removeAll() diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index 12f739782a..0982b3fa8a 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -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 @@ -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() /** 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 @@ -1200,7 +1208,7 @@ class OuraLiveSource( authEscalations = 0 authToggleCccdPending = 0 loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.clear() loggedAnchor = false pendingSyncTime = null loggedTierBKinds.clear() @@ -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() @@ -1454,7 +1462,7 @@ class OuraLiveSource( dropUnanchoredHypnogramBursts() reassembler.reset() loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.clear() loggedAnchor = false pendingSyncTime = null loggedTierBKinds.clear() @@ -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) } diff --git a/android/app/src/main/java/com/noop/oura/OuraEvents.kt b/android/app/src/main/java/com/noop/oura/OuraEvents.kt index bdf94fb0c8..73b1fd4276 100644 --- a/android/app/src/main/java/com/noop/oura/OuraEvents.kt +++ b/android/app/src/main/java/com/noop/oura/OuraEvents.kt @@ -128,6 +128,93 @@ data class OuraSpO2( val count: Int = 1, ) +/** + * 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. Twin of Swift `OuraSpO2Channel`. + */ +enum class OuraSpO2Channel { + /** + * 0x6F / 0x7B — a firmware-computed SpO2 percentage. (The unit tag is the legacy string `"raw"`, + * which names the CHANNEL, not the quantity; see `decodeSpO2Event`.) + */ + PERCENTAGE, + + /** 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. */ + PERFUSION, + + /** A unit tag no decoder stamps today (a case variant, or a future tag). Named, never a percentage. */ + 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`. Twin of Swift `OuraSpO2Channel.logLabel`. + */ + val logLabel: String get() = when (this) { + PERCENTAGE -> "SpO2 percentage" + PERFUSION -> "SpO2 raw DC perfusion (NOT a percentage)" + UNKNOWN -> "SpO2 sample on an unrecognised channel (NOT known to be a percentage)" + } + + companion object { + /** The unit tag 0x6F and 0x7B stamp on their samples (`OuraSpO2`'s default). */ + const val PERCENTAGE_UNIT = "raw" + + /** The unit tag 0x77 stamps on its samples. */ + const val PERFUSION_UNIT = "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. Twin of Swift + * `OuraSpO2Channel.forUnit`. + */ + fun forUnit(unit: String): OuraSpO2Channel = when (unit) { + PERCENTAGE_UNIT -> PERCENTAGE + PERFUSION_UNIT -> PERFUSION + else -> UNKNOWN + } + + /** + * 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. Twin of Swift `OuraSpO2Channel.firstDecodedLogLine`; `OuraSpO2ChannelOracleTest` + * asserts this against that function's own stdout. + */ + fun firstDecodedLogLine(value: Int, unit: String): String { + val c = forUnit(unit) + val pct = if (c == PERCENTAGE) " %" else "" + return "first ${c.logLabel} decoded (last night) - $value$pct (channel \"$unit\")" + } + } +} + +/** What this sample's number actually is. See [OuraSpO2Channel]. */ +val OuraSpO2.channel: OuraSpO2Channel get() = OuraSpO2Channel.forUnit(unit) + /** One decoded skin-temperature sample in degrees C (value already / 100). */ data class OuraTemp(val ringTimestamp: Long, val celsius: Double) diff --git a/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt b/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt new file mode 100644 index 0000000000..33de0c7baa --- /dev/null +++ b/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt @@ -0,0 +1,106 @@ +package com.noop.oura + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Oracle test for the SpO2 channel resolver and its strap-log line. + * + * WHY AN ORACLE AND NOT A HAND-WRITTEN EXPECTATION. The defect this whole type fixes was two numbers + * three orders of magnitude apart being printed under the same words. Two platforms hand-writing + * "what we call this channel" is how that comes back — one side says "perfusion", the other "DC", and + * nobody notices until a reporter pastes a log and is told the wrong thing. So the literals below are + * NOT written by hand: they are the verbatim stdout of the SHIPPED Swift function, captured with + * + * swift build # in Packages/OuraProtocol + * swiftc -O -I /Products/Debug -L /Products/Debug -lOuraProtocol main.swift -o oracle + * ./oracle + * + * where main.swift prints `value|unit|channel.rawValue|OuraSpO2Channel.firstDecodedLogLine(value:unit:)` + * for each row. The last five rows are unit tags no decoder stamps: they resolve to `unknown` and carry + * no `%`. Re-capture and re-paste if the Swift side ever changes; do not edit these by eye. + * + * NOTE ON DIRECTION: this guards Kotlin against the Swift of the day. `OuraSpO2ChannelTests` on the + * Swift side pins the same dispositions, which is what stops Swift drifting silently. + */ +class OuraSpO2ChannelOracleTest { + + /** `value|unit|channel|line` — verbatim Swift stdout. */ + private val oracle = listOf( + """93|raw|percentage|first SpO2 percentage decoded (last night) - 93 % (channel "raw")""", + """94|raw|percentage|first SpO2 percentage decoded (last night) - 94 % (channel "raw")""", + """98|raw|percentage|first SpO2 percentage decoded (last night) - 98 % (channel "raw")""", + """0|raw|percentage|first SpO2 percentage decoded (last night) - 0 % (channel "raw")""", + """100|raw|percentage|first SpO2 percentage decoded (last night) - 100 % (channel "raw")""", + """101144|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw")""", + """-288|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - -288 (channel "dc_raw")""", + """41132|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 41132 (channel "dc_raw")""", + """65815|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 65815 (channel "dc_raw")""", + """208|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 208 (channel "dc_raw")""", + """1|raw_adc|unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 1 (channel "raw_adc")""", + """2||unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 2 (channel "")""", + """3|DC_RAW|unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 3 (channel "DC_RAW")""", + """4|RAW|unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 4 (channel "RAW")""", + """-5|dc_raw2|unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - -5 (channel "dc_raw2")""", + ) + + @Test + fun kotlinMatchesTheSwiftOracleRowForRow() { + for (row in oracle) { + val parts = row.split("|", limit = 4) + val value = parts[0].toInt() + val unit = parts[1] + val expectedChannel = parts[2] + val expectedLine = parts[3] + + assertEquals( + "channel for unit \"$unit\"", + expectedChannel, + OuraSpO2Channel.forUnit(unit).name.lowercase(), + ) + assertEquals( + "log line for $value ($unit)", + expectedLine, + OuraSpO2Channel.firstDecodedLogLine(value, unit), + ) + } + } + + /** The sample accessor and the resolver must not be able to disagree. */ + @Test + fun sampleAccessorMatchesTheResolver() { + assertEquals(OuraSpO2Channel.PERCENTAGE, OuraSpO2(ringTimestamp = 1L, value = 93).channel) + assertEquals( + OuraSpO2Channel.PERFUSION, + OuraSpO2(ringTimestamp = 1L, value = 101144, unit = "dc_raw").channel, + ) + } + + /** + * The decoders really do stamp those tags — without this the resolver could be right about strings + * nothing produces. Mirrors the Swift `testDecodedSpO2*` cases. + */ + @Test + fun decodersProduceTheChannelsTheResolverNames() { + val perSample = OuraDecoders.decodeSpO2PerSample( + OuraRecord(type = 0x6F, ringTimestamp = 100L, payload = intArrayOf(0x00, 95, 96, 97)), + ) + assertEquals(true, perSample!!.isNotEmpty()) + assertEquals(true, perSample.all { it.channel == OuraSpO2Channel.PERCENTAGE }) + + val dc = OuraDecoders.decodeSpO2DC( + OuraRecord( + type = 0x77, + ringTimestamp = 100L, + payload = intArrayOf(0x40, 0x2C, 0xA0, 0x00, 0x05), + ), + ) + assertEquals(true, dc!!.isNotEmpty()) + assertEquals(true, dc.all { it.channel == OuraSpO2Channel.PERFUSION }) + + val stable = OuraDecoders.decodeSpO2Stable( + OuraRecord(type = 0x7B, ringTimestamp = 100L, payload = intArrayOf(0x00, 0x60)), + ) + assertEquals(OuraSpO2Channel.PERCENTAGE, stable!!.channel) + } +}