From d51a25287370e39e9cbaff15dddbbfb50d2ac3cb Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 25 Jul 2026 16:32:22 -0700 Subject: [PATCH] fix(crp): stop claiming SpO2 the R11 has no sensor for, and ask the ring instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zaggash's 2026-07-25 capture (build 30) is the first from a shipped R11, and it settles three things we had been guessing at. 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 answered with the 0xFF no-reading sentinel, every all-day SpO2 frame came back all-zero. We were showing a Measure button that could never succeed. So SpO2 leaves CRPCoordinator.capabilities (the unconditional floor) and becomes a bitmapGatedCapabilities entry, granted only when the ring itself claims it. The vendor exposes exactly the read-back for that — querySupportSpO2Type (2/37) answers NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN — so CRPSyncEngine asks on connect and CRPDecoder.decodeSpO2Support turns a real type into the grant, via the same additive SupportFunctions path YCBT's 02 01 bitmap already uses. A unit that genuinely has the sensor earns SpO2 back without us hardcoding either way. The monitor-state read-backs come along too (2/6 HR, 2/7 HRV, 2/8 SpO2, 2/45 stress, 2/21 temp; each reports the configured interval, 0 = off). Stress, temperature and firmware all went 23-sent/0-answered on his ring, and these replies are what will tell "the monitor is switched off" apart from "this ring lacks the sensor" in the next capture. Temperature history was on the wrong opcode: q.b(2,48) is the vendor's querySleepState (d1/b.java:650), while real temp history is i0.b(day, frameIndex) = q.c(2,22,[day,idx]). That is why 48 never answered. Fixed to 2/22; its sample layout is still unconfirmed by a non-empty capture, so the reply stays an ack rather than inventing values. Finally, group 3 / cmd 7 is a measurement-failed signal, not a wear signal. 32 pushes in the capture, every one [00], never once [01], several seconds after a good HR reading — it lands ~2 ms before the 0xFF sentinel. As an abort it is reliable (every measure that saw one produced no reading; every measure that didn't produced one) and worth keeping: it turns a 60 s SpO2 dead-wait into a ~4.5 s failure. As "put the ring on" copy it was wrong, blaming how the user wears a ring for a vital it cannot measure. WearEvidence now 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. AGENTS.md is corrected to match; its previous note read [00] as "ring not worn". 780 tests pass. --- AGENTS.md | 35 ++++++++++-- .../java/com/pulseloop/ring/CRPCoordinator.kt | 23 +++++++- .../java/com/pulseloop/ring/CRPDecoder.kt | 33 +++++++++++ .../java/com/pulseloop/ring/CRPProtocol.kt | 48 +++++++++++++++- .../java/com/pulseloop/ring/CRPSyncEngine.kt | 15 +++++ .../pulseloop/service/RingSyncCoordinator.kt | 28 +++++++-- .../com/pulseloop/service/WearEvidence.kt | 46 +++++++++++++++ .../java/com/pulseloop/ring/CRPDecoderTest.kt | 44 ++++++++++++++ .../com/pulseloop/ring/CRPSyncEngineTest.kt | 18 ++++-- .../com/pulseloop/service/WearEvidenceTest.kt | 57 +++++++++++++++++++ 10 files changed, 326 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/service/WearEvidence.kt create mode 100644 app/src/test/java/com/pulseloop/service/WearEvidenceTest.kt 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)) + } +}