diff --git a/AGENTS.md b/AGENTS.md index 826769a..2eec6dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,9 +68,41 @@ supporting evidence as the cause. `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. +- **SpO2 works on the R11 — do not "fix" it by removing the capability.** zaggash's 2026-07-23 + capture (build 26) contains a real reading: `group 1 / cmd 11` payload `0x61` = **97 %**. It is + slow and contact-sensitive — the successful measure took **48 s** of silence before answering, and + only 1 of 3 attempts in that session succeeded. A later session where every attempt failed is a + contact problem, not absent hardware. COLMI's product page lists only a "Vcare VC30F heart rate" + sensor; that is a marketing page, not a bill of materials, and reading it as proof of no SpO2 + hardware already produced one PR that had to be closed (#40). +- **HR success does not prove contact is good enough for SpO2.** In that same capture HR returned a + reading **3 seconds before all three** SpO2 attempts — the two that failed and the one that + succeeded. HR reads fine at contact quality SpO2 cannot use, so never gate SpO2 messaging on recent + HR. "Put the ring on snugly" is the *correct* advice for an SpO2 failure even when HR just worked. +- **`group 3 / cmd 7 [00]` predicts measurement failure.** It appeared ~4 s into every failed spot + measure across both captures and never before the successful one, landing ~2 ms before the + `group 1 / cmd 11 [FF]` no-reading sentinel. Keeping the fast-fail is right: it turns a 60 s dead + wait into a ~4.5 s failure. Note the ring never emits `[01]` in either capture, so treat it as a + failure signal rather than a literal wear flag — but its user-facing advice (improve contact) is + correct either way. +- **Read-backs exist — ask the ring instead of guessing.** `querySupportSpO2Type` (`2/37`) answers + NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN, and the monitor-state queries `2/6` HR, `2/7` HRV, + `2/8` SpO2, `2/45` stress, `2/21` temp each 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 (`7/1`), all 23-sent/0-answered. Send them + **once per connection**, not per poll pass: `runStartup` is also the ~30-minute background sync. +- **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. 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. + it from a capture where the channel is actually saturated during a failed measure. It is still the + reason to keep per-pass traffic lean (see the read-backs above). - **All-day "timing" vital history is DECODED (build 27, rc3), confirmed against zaggash's rc2 capture.** Layout (vendor `e1/{f,d,g,l}.java`, group 2): a query `[day, frameIndex]` returns `[day][frameIndex][slots…]`, one **5-minute** slot per sample (`w0.b.a()/5`), `0` = no reading. diff --git a/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt b/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt index efe7701..f451c4b 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,40 @@ 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`, which defines exactly three values: + * **0 = NOT_SUPPORT, 1 = SLEEP_OXYGEN, 2 = TIMING_OXYGEN** — `getInstance` returns null for + * anything else, so only 1 and 2 count as a claim of support. + * + * Treating "any non-zero" as support would be a real hazard on this ring: it uses `0xFF` as a + * no-reading sentinel elsewhere (every failed spot SpO2 answers `group 1 / cmd 11 [FF]`), and a + * `0xFF` here would otherwise read as a capability claim. + * + * **This is currently diagnostic, not capability-driving.** SpO2 is in [CRPCoordinator]'s + * unconditional capabilities because it is hardware-confirmed: zaggash's 2026-07-23 capture has a + * real reading (`group 1 / cmd 11` payload `0x61` = 97 %). Refinement via + * `RingBLEClient.refineActiveCapabilities` is additive-only, so a NOT_SUPPORT answer cannot take + * SpO2 away — decoding it simply puts the ring's own answer in the raw-packet feed, where the + * next capture can confirm or challenge what we assume. Acting on a NOT_SUPPORT would need a + * subtractive mechanism that does not exist yet, and should not be invented without a ring that + * actually reports one. + */ + private fun decodeSpO2Support(payload: ByteArray): List { + val type = payload.firstOrNull()?.toInt()?.and(0xFF) ?: return listOf(supportAck()) + // Only the two documented "supported" values; 0 = NOT_SUPPORT and anything else is unknown. + // Report an empty set rather than nothing, so the feed records that the ring was asked. + val granted = if (type == 1 || type == 2) + setOf(WearableCapability.SPO2, WearableCapability.MANUAL_SPO2) + else emptySet() + 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..78d2a96 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt @@ -32,8 +32,11 @@ 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)) } + sendConnectionReadBacks() // 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 @@ -52,6 +55,44 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { queryAllHistory() } + /** Whether this connection's read-backs have been sent. A fresh [CRPSyncEngine] is built per + * connection (`RingBLEClient` calls `driver.makeSyncEngine()` on connect), so instance state + * gives "once per connection" for free. */ + private var readBacksSent = false + + /** + * Ask the ring to describe itself, once per connection. + * + * `querySupportSpO2Type` answers NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN; the timing-state + * queries report each all-day monitor's configured interval (0 = off). Together they are the + * evidence base for whether a silent history query means "the monitor is off" or "this ring + * lacks the sensor" — 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 apart next capture. + * + * Deliberately **not** part of the poll pass. [runStartup] doubles as the ~30-minute background + * re-sync and is also reached from `refresh()`/`querySleep()`, but what a ring supports cannot + * change between syncs. Re-asking would add six writes to every pass on a ring that funnels the + * handshake, timing config, history pull *and* on-demand measures through the single `fdd2` + * channel — and a spot SpO2 needs ~48 s of that channel to return a reading. + * + * **Call order matters: this must run BEFORE [applyTimingSettings].** The state queries report + * each monitor's *current* interval, and `applyTimingSettings` force-enables everything moments + * later. Ask afterwards and every reply describes the state we just imposed, which answers + * nothing — the whole point is to learn whether stress and temperature were silent because their + * monitor was off. `CRPSyncEngineTest` pins the ordering; if that assertion ever fails, fix the + * call site rather than the expectation. + */ + private fun sendConnectionReadBacks() { + if (readBacksSent) return + readBacksSent = true + send(CRPProtocol.querySupportSpO2Type()) + send(CRPProtocol.queryTimingHeartRateState()) + send(CRPProtocol.queryTimingHrvState()) + send(CRPProtocol.queryTimingSpO2State()) + send(CRPProtocol.queryTimingStressState()) + send(CRPProtocol.queryTimingTempState()) + } + /** Frame follow-ups already requested this poll pass, keyed `cmd * 100 + frameIndex`, so a ring * that re-sends the same frame can't trigger a request storm. Cleared at the start of every * [queryAllHistory] pass so each sync re-pulls the full timeline. */ diff --git a/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt index 099d5a0..5efadd9 100644 --- a/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt @@ -317,6 +317,63 @@ 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 the two values that mean "I have the sensor". + * + * NOTE: this asserts the *decode* only. Nothing is granted today — `CRPCoordinator` declares no + * `bitmapGatedCapabilities`, so `RingBLEClient.refineActiveCapabilities` intersects with the + * empty set and returns early. SpO2 is already an unconditional CRP capability (hardware- + * confirmed), so the read-back is diagnostic: it puts the ring's own answer in the packet feed. + * Refinement is additive-only and could not remove a capability even if the ring said + * NOT_SUPPORT. */ + @Test + fun `a real SpO2 type is decoded as a claim of support`() { + 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, + ) + } + } + + /** `0xFF` is this ring's no-reading sentinel (every failed spot SpO2 answers `1/11 [FF]`), and + * `CRPBloodOxygenType` has no value for it. It must not read as a capability claim. */ + @Test + fun `an unknown support type is not treated as a claim of support`() { + for (type in listOf(0xFF.toByte(), 3, 9)) { + 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 + assertTrue("type $type must not grant", event.capabilities.isEmpty()) + } + } + + /** SpO2 is hardware-confirmed on the R11 — zaggash's 2026-07-23 capture has a real 97 % reading — + * so it stays an unconditional capability and the Vitals card and Measure button stay visible. + * The read-back is diagnostic; refinement is additive-only and cannot take it away. */ + @Test + fun `CRP keeps SpO2 as an unconditional capability`() { + assertTrue(WearableCapability.SPO2 in CRPCoordinator.capabilities) + assertTrue(WearableCapability.MANUAL_SPO2 in CRPCoordinator.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..8ae302d 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,18 +34,50 @@ 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. + // + // The read-backs MUST precede the enables: they report each monitor's current interval, and + // the enables force everything on moments later. Asking afterwards would only describe the + // state we just imposed. If this assertion fails, move the call site back — don't reorder the + // expectation. See CRPSyncEngine.sendConnectionReadBacks. + 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. + // A second pass on the same connection re-sends the poll work but NOT the read-backs — + // what the ring supports cannot change between syncs. assertEquals(listOf(1 to 1, 7 to 1, 1 to 0) + timingEnables + historyQueries, w.opcodes()) } + /** + * `runStartup` doubles as the ~30-minute background poll and is also reached from + * `refresh()`/`querySleep()`. Re-asking what the ring supports on every one of those would add + * six writes per pass to the single `fdd2` channel a spot SpO2 needs for ~48 s. A fresh engine is + * built per connection, so the next connection asks again. + */ + @Test + fun `read-backs are sent once per connection, not once per poll pass`() { + val w = FakeWriter() + val engine = CRPSyncEngine(w) + engine.runStartup() + assertTrue(w.opcodes().containsAll(readBackQueries)) + + w.sent.clear() + engine.runStartup() + engine.runStartup() + for (q in readBackQueries) { + assertTrue("read-back $q must not repeat within a connection", q !in w.opcodes()) + } + + // A new connection builds a new engine, which asks again. + val reconnected = FakeWriter() + CRPSyncEngine(reconnected).runStartup() + assertTrue(reconnected.opcodes().containsAll(readBackQueries)) + } + @Test fun `runStartup honours a saved config, disabling the vitals the user turned off`() { val w = FakeWriter()