Skip to content

fix(crp): stop claiming SpO2 the R11 has no sensor for, and ask the ring instead - #40

Closed
foureight84 wants to merge 1 commit into
mainfrom
fix/crp-spo2-capability-and-wear-evidence
Closed

fix(crp): stop claiming SpO2 the R11 has no sensor for, and ask the ring instead#40
foureight84 wants to merge 1 commit into
mainfrom
fix/crp-spo2-capability-and-wear-evidence

Conversation

@foureight84

Copy link
Copy Markdown
Owner

Diagnosed from zaggash's 2026-07-25 diagnostics export — the first capture from a shipped build (v1.0.0+30) on his COLMI R11.

The R11 has no SpO2 hardware

COLMI's own spec sheet lists exactly two sensors: an STK8321 accelerometer and a Vcare VC30F — heart rate. Pulse oximetry needs a second wavelength the VC30F hasn't got. It advertises HR, sleep, activity and stress, and never mentions SpO2.

The capture agrees:

Result
Spot SpO2 0xFF no-reading sentinel, every attempt
All-day SpO2 history all-zero, both frames, every sync

We were showing a Measure button that could never succeed.

Ask the ring instead of hardcoding

SPO2/MANUAL_SPO2 leave CRPCoordinator.capabilities (the unconditional floor) and become bitmapGatedCapabilities entries. The vendor exposes exactly the read-back needed — 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, through the same additive SupportFunctions path YCBT's 02 01 bitmap already uses.

A unit that genuinely has the sensor earns SpO2 back. One that doesn't never gets it. Neither answer is hardcoded.

The monitor-state read-backs come along too — 2/6 HR, 2/7 HRV, 2/8 SpO2, 2/45 stress, 2/21 temp, each reporting the configured interval (0 = off). Stress, temperature and firmware all went 23-sent / 0-answered on his ring; these replies are what will distinguish "the monitor is switched off" 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). Real temperature history is i0.b(day, frameIndex) = q.c(2,22,[day,idx]), the same shape as the other timing histories. That is why 2/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.

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 arriving seconds after a good HR reading. It lands ~2 ms before the 0xFF sentinel.

  • As an abort it is reliable, and kept: 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 was wrong, blaming how the user wears the 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.

Also validated by this capture

The multi-frame follow-up, which was the open hardware question from the rc3 work: 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 local-midnight anchoring is correct with no UTC drift.

Testing

TZ=UTC ./gradlew testDebugUnitTest780 tests, 0 failures. New coverage: WearEvidenceTest (6 tests for the HR-as-wear-witness rule, incl. expiry and a clock-jump guard) and three decoder/coordinator tests asserting NOT_SUPPORT grants nothing, types 1/2 grant SpO2 back, and SpO2 is genuinely absent from the floor while HR stays unconditional.

Reviewer notes

  • User-visible: this removes the SpO2 card and Measure button for existing R11 users. That is the intent, but worth a release note.
  • AGENTS.md is corrected — its previous note read g3c7 [00] as "ring not worn".
  • Not yet re-validated on hardware; the next capture from zaggash should show the 2/37 and state replies, and confirm whether stress/temp are off or absent.

…ing instead

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.
@foureight84

Copy link
Copy Markdown
Owner Author

Closing — the central premise is falsified by an earlier capture.

zaggash sent a 2026-07-23 diagnostics export (build 26) from the same COLMI R11, and it contains a real SpO2 reading: group 1 / cmd 11, payload 0x61 = 97%. The ring has SpO2 hardware. I built this PR on COLMI's spec sheet listing only a "Vcare VC30F heart rate" sensor and inferred pulse oximetry was physically impossible — that was reading a marketing page as a hardware bill of materials.

What actually happens: SpO2 is slow and contact-sensitive. Of three attempts in that capture, the one that succeeded took 48 seconds of silence before answering; the two that failed both drew a group 3 / cmd 7 [00] push at ~4s. The build-30 session I based this PR on simply had poor contact throughout.

The WearEvidence change here is also wrong. It downgraded the "put the ring on snugly" copy whenever HR had recently succeeded. But HR succeeded 3 seconds before all three SpO2 attempts — including both failures. HR reads fine at contact quality where SpO2 cannot, so it proves nothing about SpO2 contact, and the change would suppress genuinely correct advice in exactly the cases that need it.

Still valid, moving to a clean branch:

  • Temperature history opcode 2/482/22 (q.b(2,48) is the vendor's querySleepState)
  • querySupportSpO2Type (2/37) and the monitor-state read-backs — more useful now, to settle capability questions from the ring rather than a spec sheet
  • Fix for the 0xFF-grants-SpO2 bug found in adversarial review

The wear-state fast-fail itself stays as it shipped: the [00] push preceded every failed measure in both captures and never the success, so it is a sound failure predictor and its contact advice is correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant