Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/com/pulseloop/ring/CRPDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
Expand Down Expand Up @@ -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<RingDecodedEvent> {
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,
Expand Down
48 changes: 45 additions & 3 deletions app/src/main/java/com/pulseloop/ring/CRPProtocol.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) ----

Expand Down
41 changes: 41 additions & 0 deletions app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down
57 changes: 57 additions & 0 deletions app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Byte>(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<Byte>(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() }
}
Loading
Loading