Skip to content
Closed
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
35 changes: 30 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 21 additions & 2 deletions app/src/main/java/com/pulseloop/ring/CRPCoordinator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 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,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<RingDecodedEvent> {
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,
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
15 changes: 15 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,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
Expand Down
28 changes: 22 additions & 6 deletions app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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
Expand All @@ -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 -> {
Expand Down
Loading
Loading