diff --git a/AGENTS.md b/AGENTS.md index 826769a..b1718eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,11 +63,36 @@ supporting evidence as the cause. `group/cmd` frames against the vendor `g1/a.java` response dispatch before attributing a symptom to a recent commit. A known-good "measure button" capture (worn, HR returns ~19 s after `g1/cmd9 [01]`) is the baseline to diff against. -- **Wear state = `group 3 / cmd 7`** (`onWearStateChange`, `payload[0] > 0`). Decoded as of the - wear-state fix: `CRPDecoder` → `RingDecodedEvent.WearingStatus` → `PulseEvent.WearState`, and - `RingSyncCoordinator` fast-fails an in-flight CRP spot measure (with a "put the ring on" message) - when it reports not-worn *before* any reading. Gated to CRP — YCBT's wear polarity is unverified. - A not-worn measure now fails in ~2 s with guidance instead of spinning the full window silently. +- **`group 3 / cmd 7` is a measurement-failed signal, NOT a wear signal.** The vendor calls it + `onWearStateChange(payload[0] > 0)`, and an earlier note here read `[00]` as "ring not worn". The + 2026-07-25 capture (build 30, zaggash) contradicts that: **32 pushes, every one `[00]`, never once + `[01]`** — several arriving seconds *after* a good HR reading. It is emitted when a spot measure is + about to come back empty, landing ~2 ms before the `0xFF` no-reading sentinel. + - **As an abort it is reliable** and worth keeping: every measure that saw one produced no reading, + every measure that didn't produced one. It turns a 60 s SpO2 dead-wait into a ~4.5 s failure. + - **As "put the ring on" copy it needs corroboration**, or it blames the user's wearing for a vital + the ring cannot measure. `WearEvidence` holds the rule: a real bpm can only be read off skin, so a + recent HR sample vouches for contact and downgrades the message to the generic failure. Gated to + CRP — YCBT's polarity is still unverified. +- **The R11 has no SpO2 hardware.** COLMI's own spec sheet lists two sensors — an STK8321 + accelerometer and a **Vcare VC30F heart-rate** unit — and pulse oximetry needs a second wavelength + the VC30F hasn't got. The capture agrees: every spot SpO2 answers `0xFF`, every all-day SpO2 frame + is all-zero. So `CRPCoordinator` keeps SpO2 out of its unconditional `capabilities` and offers it + via `bitmapGatedCapabilities`, granted only when the ring's own `querySupportSpO2Type` (`2/37`) + answers SLEEP_OXYGEN or TIMING_OXYGEN. **Ask the ring; don't hardcode either answer.** +- **Read-backs exist — use them instead of guessing.** `querySupportSpO2Type` (`2/37`) and the + monitor-state queries `2/6` HR, `2/7` HRV, `2/8` SpO2, `2/45` stress, `2/21` temp all report the + configured interval (`0` = off). These are how you tell "the monitor is switched off" apart from + "this ring lacks the sensor" — the open question for stress (`2/47`), temperature and firmware, + which went 23-sent/0-answered on zaggash's ring. +- **Temperature history is `2/22`, not `2/48`.** `q.b(2,48)` is the vendor's `querySleepState` + (`d1/b.java` line 650); real temp history is `i0.b(day, frameIndex)` = `q.c(2,22,[day,idx])`, the + same shape as the other timing histories. We queried 48 for months and never got a reply. Its + sample layout is still unconfirmed — no non-empty capture yet — so the reply stays an ack. +- **The multi-frame follow-up is hardware-validated** (was open on rc3): HR asked frames (0,0)+(0,1) + and got both; HRV asked (0,0)…(0,3) and got all four. HR history decoded 27 readings at 00:10–11:35 + local (46–104 bpm), HRV 11 readings (30–56 ms), sleep 12 records across light/deep/REM — so the + local-midnight anchoring is right and there is no UTC drift. - The single-channel contention theory is **plausible but unproven** — no capture has shown a spot measure starved by an active history dump. Don't treat it as established; if you suspect it, prove it from a capture where the channel is actually saturated during a failed measure. diff --git a/app/src/main/java/com/pulseloop/ring/CRPCoordinator.kt b/app/src/main/java/com/pulseloop/ring/CRPCoordinator.kt index 61e8030..cedf4bd 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPCoordinator.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPCoordinator.kt @@ -37,13 +37,32 @@ object CRPCoordinator : WearableCoordinator { override val capabilities = setOf( WearableCapability.STEPS, WearableCapability.REALTIME_STEPS, WearableCapability.HEART_RATE, WearableCapability.REALTIME_HEART_RATE, - WearableCapability.MANUAL_HEART_RATE, WearableCapability.MANUAL_SPO2, - WearableCapability.SPO2, WearableCapability.STRESS, WearableCapability.HRV, + WearableCapability.MANUAL_HEART_RATE, + WearableCapability.STRESS, WearableCapability.HRV, WearableCapability.TEMPERATURE, WearableCapability.BATTERY, WearableCapability.FIND_DEVICE, WearableCapability.FACTORY_RESET, ) + /** + * SpO2 is **not** part of the floor above, because on the one CRP unit we have captures from it + * does not exist in hardware: COLMI's R11 spec sheet lists two sensors — an STK8321 accelerometer + * and a Vcare VC30F *heart rate* unit — and pulse oximetry needs a second wavelength the VC30F + * hasn't got. zaggash's 2026-07-25 capture matches: every spot SpO2 answered with the `0xFF` + * no-reading sentinel, and every all-day SpO2 frame came back all-zero. Advertising it gave the + * user a Measure button that could never succeed, and made the ring's generic + * measurement-failed push look like "you're not wearing it". + * + * It stays gate-able rather than deleted because the CRP family is wider than one SKU, and the + * vendor exposes a direct read-back: `querySupportSpO2Type` (`2/37`) answers NOT_SUPPORT / + * SLEEP_OXYGEN / TIMING_OXYGEN. `CRPSyncEngine` asks on connect and `CRPDecoder.decodeSpO2Support` + * turns a real type into the grant, so a unit that genuinely has the sensor gets SpO2 back + * without us guessing on its behalf. + */ + override val bitmapGatedCapabilities = setOf( + WearableCapability.SPO2, WearableCapability.MANUAL_SPO2, + ) + override val iconSystemName = "circle.circle.fill" override fun makeDriver(writer: RingCommandWriter): WearableDriver = CRPDriver(writer) diff --git a/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt b/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt index efe7701..1f07d0d 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt @@ -113,6 +113,9 @@ object CRPDecoder { if (cmd == CRPCommands.CMD_QUERY_HISTORY_SLEEP) { return decodeSleep(payload, now, zone) } + if (cmd == CRPCommands.CMD_QUERY_SUPPORT_SPO2_TYPE) { + return decodeSpO2Support(payload) + } decodeTimingHistory(cmd, payload, now, zone)?.let { return it } return listOf(RingDecodedEvent.CommandAck(commandId = ((group shl 4) or (cmd and 0x0F)).toUByte())) } @@ -180,6 +183,36 @@ object CRPDecoder { return listOf(RingDecodedEvent.CommandAck(commandId = ((CRPCommands.GROUP_DEVICE_INFO shl 4) or (cmd and 0x0F)).toUByte())) } + /** + * The ring's own answer to "do you have SpO2 hardware?" (`group 2 / cmd 37`). Vendor `g1/a.V0` + * hands `payload[0]` to `CRPBloodOxygenType`: **0 = NOT_SUPPORT**, 1 = SLEEP_OXYGEN, + * 2 = TIMING_OXYGEN. + * + * This is the read-back that lets the app stop guessing. [CRPCoordinator] leaves SpO2 out of its + * unconditional capabilities — COLMI's R11 spec lists a single optical sensor (Vcare VC30F, heart + * rate) and the ring answers every SpO2 measure with the `0xFF` no-reading sentinel — and offers + * it as a `bitmapGatedCapabilities` entry instead. A unit that reports a real type earns SpO2 + * back here; a unit that says NOT_SUPPORT (or never answers) simply never gets it, so we no + * longer show a Measure button that cannot succeed. + * + * Emitted as [RingDecodedEvent.SupportFunctions], the same additive refinement path YCBT's + * `02 01` bitmap uses — `RingBLEClient.refineActiveCapabilities` intersects it with the + * coordinator's gate-able set and unions the result in. + */ + private fun decodeSpO2Support(payload: ByteArray): List { + val type = payload.firstOrNull()?.toInt()?.and(0xFF) ?: return listOf(supportAck()) + // 0 = NOT_SUPPORT. Report an empty set rather than nothing, so the diagnostics feed records + // that the ring was asked and said no. + val granted = if (type == 0) emptySet() + else setOf(WearableCapability.SPO2, WearableCapability.MANUAL_SPO2) + return listOf(RingDecodedEvent.SupportFunctions(granted)) + } + + private fun supportAck() = RingDecodedEvent.CommandAck( + commandId = ((CRPCommands.GROUP_HISTORY shl 4) or + (CRPCommands.CMD_QUERY_SUPPORT_SPO2_TYPE and 0x0F)).toUByte() + ) + /** All-day timeline frames carry 144 sample-slots at a fixed 5-minute cadence (`w0.b.a() / 5` * in the vendor). Two slot widths: HR/SpO2/stress store one byte per slot (144 slots/frame, * terminal frame index 1); HRV stores a little-endian 2-byte value per slot (72 slots/frame, diff --git a/app/src/main/java/com/pulseloop/ring/CRPProtocol.kt b/app/src/main/java/com/pulseloop/ring/CRPProtocol.kt index ff1a883..6877f36 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPProtocol.kt @@ -98,9 +98,29 @@ object CRPCommands { const val CMD_QUERY_TIMING_HRV = 16 // b1/u.b: q.c(2,16, [day, 0]) const val CMD_QUERY_TIMING_SPO2 = 17 // b1/h.b: q.c(2,17, [day, 0]) const val CMD_QUERY_TIMING_STRESS = 47 // b1/h0.b: q.c(2,47, [day, 0]) - const val CMD_QUERY_HISTORY_TEMP = 48 // b1/e0.d: q.b(2,48) + /** Temperature history. **Not 48** — `q.b(2,48)` is the vendor's `querySleepState` (`d1/b.java` + * line 650); the real temperature history is `i0.b(day, frameIndex)` = `q.c(2,22, [day, idx])`, + * the same `[day, frameIndex]` shape as the other timing histories. We queried 48 for months and + * the ring never answered — see zaggash's 2026-07-25 capture, 23 sends and 0 replies. Its sample + * layout is still unconfirmed by a non-empty capture, so the reply stays an ack for now. */ + const val CMD_QUERY_HISTORY_TEMP = 22 // b1/i0.b: q.c(2,22, [day, frameIndex]) const val HISTORY_DAY_TODAY = 0 // CRPHistoryDay.TODAY; YESTERDAY = 1 + // Group 2 — read-back queries. The ring can be *asked* what it supports and what is currently + // enabled, so the app doesn't have to guess (vendor `d1/b.java` querySupport*/queryTiming*State). + /** `b1/h.e`: q.b(2,37). Reply payload[0] is a `CRPBloodOxygenType`: 0 = NOT_SUPPORT, + * 1 = SLEEP_OXYGEN, 2 = TIMING_OXYGEN (`g1/a.V0` → `onSupportBloodOxygenType`). The R11 has no + * SpO2 hardware at all — COLMI's spec lists one optical sensor, a Vcare VC30F heart-rate unit — + * so this is how a ring that *does* have it earns the capability back. */ + const val CMD_QUERY_SUPPORT_SPO2_TYPE = 37 + /** The all-day monitor state queries. Each reply carries the configured interval in minutes + * (`g1/a.{p1,r1,n1,t1}` → `onTimingInterval`); 0 means the monitor is off. */ + const val CMD_QUERY_TIMING_HR_STATE = 6 // b1/t.e: q.b(2,6) + const val CMD_QUERY_TIMING_HRV_STATE = 7 // b1/u.e: q.b(2,7) + const val CMD_QUERY_TIMING_SPO2_STATE = 8 // b1/h.f: q.b(2,8) + const val CMD_QUERY_TIMING_TEMP_STATE = 21 // b1/i0.a: q.b(2,21) → onTimingState(type, state) + const val CMD_QUERY_TIMING_STRESS_STATE = 45 // b1/h0.e: q.b(2,45) + // Group 3 — power control + device-state pushes. const val GROUP_POWER = 3 const val CMD_FACTORY_RESET = 0 // b1/l.v: q.b(3,0) @@ -264,8 +284,30 @@ object CRPProtocol { fun queryHistorySleep(daysAgo: Int = 0): ByteArray = frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_HISTORY_SLEEP, byteArrayOf(daysAgo.toByte())) - fun queryHistoryTemp(): ByteArray = - frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_HISTORY_TEMP) + fun queryHistoryTemp(day: Int = CRPCommands.HISTORY_DAY_TODAY, frameIndex: Int = 0): ByteArray = + frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_HISTORY_TEMP, + byteArrayOf(day.toByte(), frameIndex.toByte())) + + // ---- Read-back queries: let the ring tell us what it supports and what is enabled ---- + + /** Ask whether this unit has SpO2 hardware at all. See [CRPCommands.CMD_QUERY_SUPPORT_SPO2_TYPE]. */ + fun querySupportSpO2Type(): ByteArray = + frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_SUPPORT_SPO2_TYPE) + + fun queryTimingHeartRateState(): ByteArray = + frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_TIMING_HR_STATE) + + fun queryTimingHrvState(): ByteArray = + frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_TIMING_HRV_STATE) + + fun queryTimingSpO2State(): ByteArray = + frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_TIMING_SPO2_STATE) + + fun queryTimingStressState(): ByteArray = + frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_TIMING_STRESS_STATE) + + fun queryTimingTempState(): ByteArray = + frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_TIMING_TEMP_STATE) // ---- Device info queries (group 7) ---- diff --git a/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt index d9e923e..e135c59 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt @@ -32,8 +32,23 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { // the ring's step/calorie algorithm has real inputs. send(CRPProtocol.setTime()) // Query firmware version so the UI doesn't show "Firmware: reading" (zaggash's report). + // NOTE: still unanswered on his R11 — 23 sends, 0 replies in the 2026-07-25 capture — so the + // panel keeps showing "?". The group-7 opcode is the vendor's, but this ring ignores it. send(CRPProtocol.queryFirmwareVersion()) profile?.let { send(userInfoFrame(it)) } + // Ask the ring what it actually is before assuming. `querySupportSpO2Type` is the vendor's own + // read-back (NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN) and is what grants the SpO2 capability + // the coordinator deliberately withholds — see [CRPCoordinator.bitmapGatedCapabilities]. The + // timing-state queries report each all-day monitor's configured interval (0 = off), which is + // the evidence base for whether a silent history query means "off" or "unsupported": stress + // (2/47), temperature (2/22) and firmware (7/1) all went unanswered on zaggash's ring, and + // these replies are how we tell those two cases apart in the next capture. + send(CRPProtocol.querySupportSpO2Type()) + send(CRPProtocol.queryTimingHeartRateState()) + send(CRPProtocol.queryTimingHrvState()) + send(CRPProtocol.queryTimingSpO2State()) + send(CRPProtocol.queryTimingStressState()) + send(CRPProtocol.queryTimingTempState()) // Enable all-day vital monitoring. A fresh ring has these OFF, so without this the ring // stores no HR/SpO2/HRV/stress/temperature history and every history query below returns an // empty reply (issue #29, zaggash's full-day capture). When the user has saved a config we diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index 3156fb7..bebf85c 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -78,6 +78,8 @@ class RingSyncCoordinator( * reading (seen on real hardware, issue #29) can't turn a success into "not worn". */ var measureNotWorn: Boolean = false private set + /** Proof the ring is actually on a finger — see [WearEvidence], which owns the rule. */ + private var wearEvidence = WearEvidence() /** The samples of the HR measurement in flight, and the rule for whether they settled — see * [HRSampleWindow], which owns the warm-up echo and the consistency gate (iOS #66). */ private val hrWindow = HRSampleWindow() @@ -574,6 +576,9 @@ class RingSyncCoordinator( when (event) { is PulseEvent.HeartRateSample -> { latestHRValue = event.bpm + // A real bpm can only come off skin, so this doubles as the wear witness — see the + // WearState branch below and [WearEvidence]. + wearEvidence = wearEvidence.withHeartRateSample(System.currentTimeMillis()) if (hrState == MeasureState.MEASURING) hrWindow.collect(event.bpm) } is PulseEvent.HeartRateComplete -> { @@ -607,11 +612,22 @@ class RingSyncCoordinator( } } - // The CRP ring pushes wear state; `worn == false` means no skin contact, so an optical - // spot measure can't read (issue #29). Fast-fail the in-flight measure instead of idling - // out the full window, and flag *why* — but only if no reading landed first (a wear-state - // drop right after a good reading must not turn a success into a failure). Gated to CRP: - // other families' wear polarity is unverified (RingDecodedEvent.WearingStatus). + // The CRP ring pushes `group 3 / cmd 7 [00]` when a spot measure is about to come back + // empty — in zaggash's 2026-07-25 capture it lands 2 ms before the `0xFF` no-reading + // sentinel, and every measure that saw one produced no reading while every measure that + // didn't produced one. So it is a reliable *abort* signal: fast-fail instead of idling out + // the full window (SpO2's is 60 s). Gated to CRP; other families' polarity is unverified. + // + // It is NOT a reliable *wear* signal. That ring never once reports `[01]` — 32 pushes in + // the capture, all `[00]`, several of them seconds after a good HR reading. Blaming the + // user's wearing for every one of them is wrong: per COLMI's own spec the R11 carries a + // single optical sensor (Vcare VC30F, heart rate) and no SpO2 hardware at all, so its SpO2 + // measure *always* fails no matter how the ring is worn. Telling someone to put on a ring + // they are already wearing sends them to fix the one thing that isn't broken. + // + // So the "put the ring on" copy needs corroboration, and HR is the honest witness: it is + // the ring's one working optical metric, so a recent HR sample is proof of skin contact. + // Without that proof we still say "not worn"; with it we fall back to the generic failure. is PulseEvent.WearState -> { if (!event.worn && client.state.value.activeDeviceType == RingDeviceType.CRP) { var flagged = false @@ -621,7 +637,7 @@ class RingSyncCoordinator( if (spo2State == MeasureState.MEASURING && latestSpO2Value == null) { spo2NoReadingReported = true; flagged = true } - if (flagged) measureNotWorn = true + if (flagged) measureNotWorn = !wearEvidence.provesWorn(System.currentTimeMillis()) } } is PulseEvent.DeviceStateChanged -> { diff --git a/app/src/main/java/com/pulseloop/service/WearEvidence.kt b/app/src/main/java/com/pulseloop/service/WearEvidence.kt new file mode 100644 index 0000000..aacde7b --- /dev/null +++ b/app/src/main/java/com/pulseloop/service/WearEvidence.kt @@ -0,0 +1,46 @@ +package com.pulseloop.service + +/** + * Whether the app has recent proof the ring is actually on a finger. + * + * The CRP ring pushes `group 3 / cmd 7 [00]` when a spot measure is about to come back empty. As an + * *abort* signal that is reliable — in zaggash's 2026-07-25 capture it arrives 2 ms before the `0xFF` + * no-reading sentinel, and every measure that saw one produced no reading while every measure that + * didn't produced one. As a *wear* signal it is not: that ring never once reports `[01]` (32 pushes, + * all `[00]`), several of them seconds after a good heart-rate reading. + * + * The reason is hardware, not firmware. Per COLMI's own R11 spec the ring carries two sensors — an + * STK8321 accelerometer and a Vcare VC30F *heart rate* sensor — and no SpO2 hardware, so its SpO2 + * measure fails no matter how well the ring is worn. Telling someone to put on a ring they are + * already wearing sends them to fix the one thing that isn't broken. + * + * So the "put the ring on" copy needs corroboration, and heart rate is the honest witness: a real + * bpm can only be read off skin, making it the ring's one trustworthy proof of contact. This holds + * the rule as a value type so it can be tested without a live BLE link — the same reason + * [SpotMeasurementGate] and `HRSampleWindow` are separate types. + */ +data class WearEvidence( + /** When the ring last returned a real bpm, or `null` if it never has this connection. */ + val lastHeartRateSampleAt: Long? = null, +) { + /** Record a bpm arriving at [at]. */ + fun withHeartRateSample(at: Long): WearEvidence = copy(lastHeartRateSampleAt = at) + + /** Forget what we knew — a new connection has to earn its own proof. */ + fun cleared(): WearEvidence = WearEvidence() + + /** + * True when a real bpm arrived recently enough to vouch for skin contact, so a failed measure + * should NOT be blamed on how the ring is worn. + * + * The window only has to span one measurement plus the gap to the next: in the capture a good HR + * reading and the SpO2 failure that followed it were 8 seconds apart. Beyond it we stop vouching, + * so a ring genuinely taken off is reported as not-worn again within a couple of minutes. + */ + fun provesWorn(now: Long): Boolean = + lastHeartRateSampleAt?.let { now - it in 0..WEAR_PROOF_WINDOW_MS } == true + + companion object { + const val WEAR_PROOF_WINDOW_MS = 120_000L + } +} diff --git a/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt index 099d5a0..b25f585 100644 --- a/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt @@ -317,6 +317,50 @@ class CRPDecoderTest { assertTrue(RingEventBridge.eventsFor(RingDecodedEvent.TimingHistoryFrame(CRPCommands.CMD_QUERY_TIMING_HR, 0, 0)).isEmpty()) } + // ---- SpO2 support read-back (group 2 / cmd 37) ---- + + /** `CRPBloodOxygenType.NOT_SUPPORT`. The R11 has no SpO2 hardware, so the ring must be able to + * say so and keep the capability from ever being granted. */ + @Test + fun `a NOT_SUPPORT reply grants no SpO2 capability`() { + val frame = CRPProtocol.frame( + CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_SUPPORT_SPO2_TYPE, byteArrayOf(0), + ) + val event = CRPDecoder.decode(frame, fdd3).single() + assertTrue(event is RingDecodedEvent.SupportFunctions) + assertTrue((event as RingDecodedEvent.SupportFunctions).capabilities.isEmpty()) + } + + /** SLEEP_OXYGEN (1) and TIMING_OXYGEN (2) are both real sensors — a unit reporting either earns + * SpO2 back through the additive `bitmapGatedCapabilities` path. */ + @Test + fun `a real SpO2 type grants the SpO2 capabilities back`() { + for (type in listOf(1, 2)) { + val frame = CRPProtocol.frame( + CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_SUPPORT_SPO2_TYPE, byteArrayOf(type), + ) + val event = CRPDecoder.decode(frame, fdd3).single() as RingDecodedEvent.SupportFunctions + assertEquals( + setOf(WearableCapability.SPO2, WearableCapability.MANUAL_SPO2), event.capabilities, + ) + } + } + + /** Only the coordinator's gate-able set can ever be granted, and SpO2 must not be in the floor — + * otherwise the read-back is decorative and the Measure button comes back regardless. */ + @Test + fun `CRP withholds SpO2 until the ring claims it`() { + val coordinator = CRPCoordinator + assertTrue(WearableCapability.SPO2 !in coordinator.capabilities) + assertTrue(WearableCapability.MANUAL_SPO2 !in coordinator.capabilities) + assertEquals( + setOf(WearableCapability.SPO2, WearableCapability.MANUAL_SPO2), + coordinator.bitmapGatedCapabilities, + ) + // HR is hardware the ring definitely has, so it stays unconditional. + assertTrue(WearableCapability.MANUAL_HEART_RATE in coordinator.capabilities) + } + private fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { ((hex[it * 2].digitToInt(16) shl 4) or hex[it * 2 + 1].digitToInt(16)).toByte() } } diff --git a/app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt b/app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt index a1e4762..46259a7 100644 --- a/app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt +++ b/app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt @@ -18,7 +18,12 @@ class CRPSyncEngineTest { /** The all-day history pull appended to every runStartup (the poll pass). All group 2: the * "timing" vital timelines HR/SpO2/HRV/stress (cmd 15/17/16/47), then temp (48) + sleep (14) — * the opcodes the ring actually answers (issue #29). See CRPProtocol.queryTiming/queryHistory. */ - private val historyQueries = listOf(2 to 15, 2 to 17, 2 to 16, 2 to 47, 2 to 48, 2 to 14) + /** Temperature history is 2/22, not 2/48 — `q.b(2,48)` is the vendor's `querySleepState`. */ + private val historyQueries = listOf(2 to 15, 2 to 17, 2 to 16, 2 to 47, 2 to 22, 2 to 14) + + /** The read-backs that let the ring describe itself instead of us guessing: SpO2 support type, + * then each all-day monitor's configured interval. See CRPSyncEngine.runStartup. */ + private val readBackQueries = listOf(2 to 37, 2 to 6, 2 to 7, 2 to 8, 2 to 45, 2 to 21) /** The all-day monitor enables sent on connect (default ALL_ON): HR, HRV, stress, SpO2, temp — * see CRPSyncEngine.applyTimingSettings. Without these a fresh R11 records no history. */ @@ -29,16 +34,19 @@ class CRPSyncEngineTest { val w = FakeWriter() val engine = CRPSyncEngine(w) engine.runStartup() - // set-time, firmware query, default-on monitor enables, then the history pull. - assertEquals(listOf(1 to 1, 7 to 1) + timingEnables + historyQueries, w.opcodes()) + // set-time, firmware query, read-backs, default-on monitor enables, then the history pull. + assertEquals(listOf(1 to 1, 7 to 1) + readBackQueries + timingEnables + historyQueries, w.opcodes()) w.sent.clear() engine.setUserProfile( UserProfileValues(metric = true, gender = 1u, age = 30u, heightCm = 180u, weightKg = 75u), ) engine.runStartup() - // set-time, firmware query, set-user-info, monitor enables, then the history pull. - assertEquals(listOf(1 to 1, 7 to 1, 1 to 0) + timingEnables + historyQueries, w.opcodes()) + // set-time, firmware query, set-user-info, read-backs, monitor enables, then the history pull. + assertEquals( + listOf(1 to 1, 7 to 1, 1 to 0) + readBackQueries + timingEnables + historyQueries, + w.opcodes(), + ) } @Test diff --git a/app/src/test/java/com/pulseloop/service/WearEvidenceTest.kt b/app/src/test/java/com/pulseloop/service/WearEvidenceTest.kt new file mode 100644 index 0000000..22ac990 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/WearEvidenceTest.kt @@ -0,0 +1,57 @@ +package com.pulseloop.service + +import com.pulseloop.service.WearEvidence.Companion.WEAR_PROOF_WINDOW_MS +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rule that keeps a vital the ring cannot measure from being reported as "you're not wearing it". + * See [WearEvidence] for the capture evidence behind it. + */ +class WearEvidenceTest { + + private val t0 = 1_700_000_000_000L + + @Test + fun `without a heart rate reading nothing vouches for wear`() { + // A fresh connection has earned no proof, so a not-worn push is taken at face value — + // which is the behaviour that makes the "put the ring on" hint useful in the first place. + assertFalse(WearEvidence().provesWorn(t0)) + } + + @Test + fun `a recent bpm proves the ring is on a finger`() { + val e = WearEvidence().withHeartRateSample(t0) + assertTrue(e.provesWorn(t0)) + // The real case from zaggash's capture: a good HR reading, then an SpO2 failure 8s later. + assertTrue(e.provesWorn(t0 + 8_000)) + } + + @Test + fun `proof expires so a ring genuinely taken off is reported again`() { + val e = WearEvidence().withHeartRateSample(t0) + assertTrue(e.provesWorn(t0 + WEAR_PROOF_WINDOW_MS)) + assertFalse(e.provesWorn(t0 + WEAR_PROOF_WINDOW_MS + 1)) + } + + @Test + fun `a bpm stamped in the future never vouches`() { + // A clock jump must not hand out indefinite proof. + val e = WearEvidence().withHeartRateSample(t0 + 60_000) + assertFalse(e.provesWorn(t0)) + } + + @Test + fun `a newer bpm extends the proof`() { + val e = WearEvidence().withHeartRateSample(t0).withHeartRateSample(t0 + 100_000) + assertFalse(WearEvidence().withHeartRateSample(t0).provesWorn(t0 + 200_000)) + assertTrue(e.provesWorn(t0 + 200_000)) + } + + @Test + fun `clearing forgets the proof so a new connection must earn its own`() { + val e = WearEvidence().withHeartRateSample(t0) + assertFalse(e.cleared().provesWorn(t0)) + } +}