Feature Colmi R11 CRP Driver#93
Draft
foureight84 wants to merge 7 commits into
Draft
Conversation
Port the CRP ("crrepa"/CRPsmart) `fdda`-profile ring family from the Android
app (PR saksham2001#36, foureight84/PulseLoopAndroid) to iOS. This is the second firmware
sold as "R11 / SMART_RING": it speaks a proprietary `fdda` profile — not the
Colmi/QRing Nordic-UART one `ColmiDriver` speaks — so a CRP ring driven by the
Colmi/jring driver finds none of its characteristics and hangs the connect
(issue saksham2001#29). Official app is Moyoung "Da Rings" (com.moyoung.ring); framing/
command layouts are faithful to `decompiled-moyoung-official/`.
New driver family (CRPProtocol/CRPDecoder/CRPDriver/CRPCoordinator/CRPSyncEngine):
- `FD DA 10 <len> <group> <cmd> <payload>` framing, 9th length bit on byte[2].
- fdd1 current-steps push, 2a37 HR stream (0x0400-marker gated), fdd3 framed
replies reassembled across notifications; battery via standard 180f/2a19.
- Connect handshake sets clock (vendor GMT+8 quirk) + user info; live/manual HR,
find-device. Sleep/SpO2/HRV/stress/temperature/history deferred until their
reply layouts are confirmed against hardware, so the UI hides them.
Wiring: new `.crp` RingDeviceType (+ displayName, .limited support level),
`colmiR11CRP` catalog card ("Colmi R11 (Da Rings app)", reusing the yawell-r11
art), CRPCoordinator registered.
Reverse-port adaptation: Android re-routes the driver post-connect once the
`fdda` service is discovered. iOS has no post-connect driver swap and instead
resolves ambiguous SMART_RING/Colmi firmware by the user's carousel pick at
pairing (exactly as it separates QRing vs SmartHealth Colmi), so the CRP driver
is reached by picking the "Colmi R11 (Da Rings app)" card (preferredFamily
= .crp), not by an auto-reroute.
Tests: CRPProtocol/Decoder/SyncEngine oracles ported from the Android unit
tests; PairingMatchingTests gains CRP coverage. Full suite green (71 tests).
foureight84
force-pushed
the
feat/colmi-r11-crp-driver
branch
from
July 21, 2026 13:16
a4acdbc to
9d3b133
Compare
… mappings Ported from Android feat/crp-vitals branch. Same root cause and fix: Root cause — CRPDecoder.decodeFramedReply collapsed every group-1 reply to CommandAck, discarding real-time vital results (HR 74, etc.) that the ring sends back on fdd3 as group-1/cmd replies. The vendor's dispatcher (g1/a.java lines 664–712) routes them by cmd: 9=HR, 10=HRV, 11=SpO2, 14=stress, 32=temp. Fixes: - CRPDecoder.decodeFramedReply now decodes group-1 vital results with payload[0] value parsing and plausibility guards (HR 40-200, SpO2 70-100, stress 0-100, HRV 20-200). - Removed dead 2a37 HR characteristic path — CRP rings never use it. - CRPCoordinator now advertises .spo2, .stress, .hrv, .temperature. Command mapping corrections (verified against decompiled b1 package): - enableTimingHR: cmd 6 (was 7), enableTimingHRV: cmd 7 (was 9 — collided with MEASURE_HR), enableTimingSpO2: cmd 8 (was 11 — collided with MEASURE_SPO2), enableTimingStress: cmd 39 (was 13), enableTimingTemp: cmd 13 (was 15). - Disable: HR/HRV/SpO2/Stress use enable with interval=0; Temp uses cmd 32 with [false]. - History queries: group 7 (was group 2) — e0.a/b/e/f use q.b(7,…) and q.c(7,…). Only sleep (cmd 14) and temp (cmd 48) remain on group 2. - Added queryFirmwareVersion() to startup handshake (fixes "Firmware: reading" in UI). - CRPSyncEngine accepts MeasurementSettings, uses hrIntervalMinutes for vital intervals, re-sends enable/disable on live config changes. Unit tests updated: removed 2a37 tests (dead code), added vital result decode tests for HR/HRV/SpO2/stress/temp with plausibility guards.
foureight84
marked this pull request as draft
July 22, 2026 05:28
…from Android
Brings the iOS CRP driver up to the Android implementation that shipped in
v1.0.0+30, so the R11 is no longer a connect-and-spot-measure-only device here.
Protocol — the history opcodes were wrong. HR/SpO2/HRV/stress/sleep were being
queried on group 7 (the device-info group); the ring answers every one of those
empty. They live on group 2: sleep 14, HR 15, HRV 16, SpO2 17, stress 47, temp
48, each taking [day, frameIndex]. Same fix as Android's ea9855c.
Decoder:
- decodeTimingHistory: the all-day timeline. One 5-minute slot per sample,
zero = no reading. HR/SpO2/stress are one byte per slot (144 slots/frame,
terminal frame 1); HRV is little-endian two-byte (72 slots/frame, terminal
frame 3). Slots anchor on LOCAL midnight of (today - day), so a Calendar is
now threaded through decode(). Emits one .historyMeasurement per valid slot
plus a .timingHistoryFrame cursor.
- decodeSleep: vendor e1/j.b. [dayIndex] then 3-byte [state, hour, minute]
records, each state running until the next record. Splits into separate
timelines on an awake run >= SleepSegmentation.sessionGapMinutes so a nap
doesn't merge into the night; short mid-night wakes stay inside their bout.
- wear state (group 3 / cmd 7): onWearStateChange(payload[0] > 0). This is the
signal that explained the R11 "measure broken" report on Android — an optical
sensor with no skin contact cannot read.
Also fixes group-1 vital results, which were switched on the enable-timing
opcodes (7/8/39/13) rather than the result opcodes (10/11/14/32) the vendor
dispatcher uses. HRV/SpO2/stress/temperature results were therefore never
decoded, while an all-day config ack could be mistaken for a reading. The
existing tests encoded the same mistake -- each one's comment named the right
opcode while its code passed the wrong constant -- so they passed against the
buggy decoder. Temperature also now uses the real two-byte layout
((p[1]<<8|p[0])/10) instead of treating a raw byte as celsius.
Sync engine: force all-day monitoring on when no config is saved (a fresh ring
ships with every monitor off and records nothing), pull the stored timelines on
each startup pass, and walk each vital's frames to its terminal index with a
duplicate-request guard.
763 tests pass, up from 738 with 2 failing: the startup test had never been
updated for the firmware query added for zaggash's "Firmware: reading" report.
Not yet hardware-validated on iOS -- the layouts are confirmed against
zaggash's R11 captures via the Android implementation, not an iOS device.
The capability was withheld with the note "result parsing deferred, so capability isn't advertised". That premise no longer holds: group-1 cmd 11 decodes into .spo2Result, and startSpO2/stopSpO2 already send the confirmed b1/h.d start/stop commands. .manualSpo2 is what surfaces the SpO2 "Measure now" button in Vitals, so without it the R11 could take a spot SpO2 reading but the user had no way to ask for one. Android's CRPCoordinator has claimed MANUAL_SPO2 all along. Also drops the stale "history sync and sleep are still deferred" note — both are decoded now; they just aren't capability-gated.
…ll window
Wear state was decoded but consumed by nothing, so a measure taken with the ring
off the finger spun its entire window and then blamed the user's stillness. An
optical sensor with no skin contact cannot read at all — stillness is the wrong
thing to fix, and 30 seconds is a long time to wait to be told the wrong thing.
- PulseEvent.wearState(worn:) and the bridge mapping for .wearingStatus. The
bridge fans out unconditionally; RingSyncCoordinator gates on .crp, because
only CRP's polarity is hardware-confirmed. That moves the guard on YCBT's
unverified polarity from the bridge to the coordinator rather than dropping
it — a wrong guess still cannot reach the UI.
- RingSyncCoordinator.measureNotWorn, set when the not-worn push arrives while
a measure is in flight AND before any reading has landed, so a wear-state
drop right after a good reading can't turn a success into a failure. HR
reuses the existing hrNoReadingReported abort; SpO2 gets spo2NotWornReported,
since SpO2 has no "complete with no reading" reply to key off.
- The measurement sheet swaps its steadiness copy for "The ring isn't detecting
your finger. Put it on snugly, then try again." One message for every kind:
the fix doesn't vary by vital, and naming the vital would bury the
instruction that matters.
Ports Android's behaviour from the R11 wear-state work, matching its gating and
its "only before a reading" rule.
765 tests pass. One existing YCBT assertion changed on purpose: it asserted wear
state stays out of the fan-out because "nothing in the app gates on wear state
yet", which is no longer true.
One conflict, in RingSyncCoordinator.handle(_:). Upstream (saksham2001#94/saksham2001#95/saksham2001#73) moved the "store and done" live-value cases out into a mirrorLiveValue(_:) helper reached via `default:`, while this branch had added a .wearState case to the same switch. Resolved by taking upstream's refactor and keeping only .wearState in handle(): it isn't a store-and-done mirror — it gates on family and mutates measurement flags — so it belongs in the switch proper, ahead of the default that delegates. 780 tests pass.
…have
`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` makes every class in this project
main-actor isolated, so a plain `deinit` hops back to the main actor to run —
the pattern that double-frees and SIGABRTs the test runner on the iOS 26.0–26.2
simulator runtimes (see the note in .github/workflows/ci.yml). Both sibling
assemblers, `YCBTFrameAssembler` and `LuckRingFrameAssembler`, carry
`nonisolated deinit {}` for exactly this reason; CRPFrameAssembler was the one
that didn't.
It is the riskiest of the three to leave out: a fresh `CRPDriver` — and with it
a fresh assembler — is built on every connect, so the deallocation happens on
each disconnect/reconnect cycle rather than once at teardown.
780 tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the CRP (
crrepa/CRPsmart,fdda-profile) ring driver — the second firmware sold as"R11 / SMART_RING", whose official app is Moyoung "Da Rings" (
com.moyoung.ring). A CRP ringdriven by the Colmi/jring Nordic-UART driver finds none of its characteristics and hangs the
connect (issue #29); this gives it a driver of its own.
Ported from the Android implementation that shipped in
v1.0.0+30, where the protocol layoutswere confirmed against a real R11 owner's packet captures. Framing and command layouts are
faithful to
decompiled-moyoung-official/.Still a draft — pending data validation from an R11 owner. Please don't merge yet.
What the driver does
New family
CRPProtocol/CRPDecoder/CRPDriver/CRPCoordinator/CRPSyncEngine:FD DA 10 <len> <group> <cmd> <payload>, 9th length bit onbyte[2].fdd2write,
fdd3framed replies reassembled across notifications,fdd1current-steps push,battery via standard
180f/2a19.vendor dispatcher in
g1/a.java), with plausibility guards.5-minute slot per sample anchored on local midnight. HR/SpO2/stress are one byte per slot
(144/frame, terminal frame 1); HRV is little-endian two-byte (72/frame, terminal 3). The sync
engine walks each vital's frames to its terminal index with a duplicate-request guard.
e1/j.blayout:[dayIndex]then 3-byte[state, hour, minute]records, each state running until the next. Splits into separatetimelines on a long awake run so a nap doesn't merge into the night.
onWearStateChange). A spot measure taken with the ring offthe finger now fails in ~2 s with "put it on snugly" instead of spinning its full window and
blaming the user's stillness. Gated to
.crp, because only CRP's polarity ishardware-confirmed.
records nothing, so without this every history query comes back empty.
Wiring
New
.crpRingDeviceType(+ displayName,.limitedsupport level) and acolmiR11CRPcatalogcard ("Colmi R11 (Da Rings app)").
Reverse-port adaptation: Android re-routes the driver post-connect once the
fddaservice isdiscovered. iOS has no post-connect driver swap and instead resolves ambiguous
SMART_RING/Colmi firmware by the user's carousel pick at pairing — exactly as it already
separates QRing vs SmartHealth Colmi — so the CRP driver is reached by picking the "Colmi R11
(Da Rings app)" card (
preferredFamily = .crp), not by an auto-reroute.Related issues
Part of #29 (SMART_RING connect-then-hang).
Type of change
How was this tested?
PulseLoopTests) —CRPProtocolTests,CRPDecoderTests,CRPSyncEngineTests, plus CRP coverage inPairingMatchingTests/EventBridgeTests.Decoder oracles are real bytes from an R11 owner's captures, ported from the Android tests.
-seedDemo YES, no hardware)This is the open item. The protocol layouts are confirmed against real R11 captures via the
Android implementation, not against an iOS device. Nothing here has run against physical
hardware on iOS.
Existing drivers
No behavior change to Colmi, jring, YCBT or LuckRing paths. One deliberate change outside the CRP
family:
RingEventBridgenow maps.wearingStatusto aPulseEvent, which it previouslydropped. The fan-out is unconditional, but
RingSyncCoordinatorgates acting on it to.crp—so YCBT's unverified wear polarity still can't reach the UI. That moved the guard rather than
removing it, and one YCBT test assertion changed on purpose (it asserted wear state stays out of
the fan-out "because nothing gates on it yet", which is no longer true).
Privacy & data
Checklist
against
.swiftlint.yml's error thresholds: no line >200 chars, no file >1000 lines, nofunction >150 lines. CI's SwiftLint job is the real check.)