Skip to content

Feature Colmi R11 CRP Driver#93

Draft
foureight84 wants to merge 7 commits into
saksham2001:mainfrom
foureight84:feat/colmi-r11-crp-driver
Draft

Feature Colmi R11 CRP Driver#93
foureight84 wants to merge 7 commits into
saksham2001:mainfrom
foureight84:feat/colmi-r11-crp-driver

Conversation

@foureight84

@foureight84 foureight84 commented Jul 21, 2026

Copy link
Copy Markdown

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 ring
driven 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 layouts
were 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:

  • FramingFD DA 10 <len> <group> <cmd> <payload>, 9th length bit on byte[2]. fdd2
    write, fdd3 framed replies reassembled across notifications, fdd1 current-steps push,
    battery via standard 180f/2a19.
  • Connect handshake — clock (vendor GMT+8 quirk), user info, firmware-version query.
  • Spot measures — HR and SpO2, decoded from group-1 replies (cmd 9/10/11/14/32 per the
    vendor dispatcher in g1/a.java), with plausibility guards.
  • All-day timeline history — group-2 cmd 15 (HR) / 16 (HRV) / 17 (SpO2) / 47 (stress), one
    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.
  • Sleep — group-2 cmd 14, vendor e1/j.b layout: [dayIndex] then 3-byte
    [state, hour, minute] records, each state running until the next. Splits into separate
    timelines on a long awake run so a nap doesn't merge into the night.
  • Wear state — group 3 / cmd 7 (onWearStateChange). A spot measure taken with the ring off
    the 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 is
    hardware-confirmed.
  • Force-enable all-day monitoring on connect — a fresh R11 ships with every monitor off and
    records nothing, so without this every history query comes back empty.

Wiring

New .crp RingDeviceType (+ displayName, .limited support level) and a colmiR11CRP catalog
card ("Colmi R11 (Da Rings app)").

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 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

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 📟 New / improved wearable support (BLE driver layer)
  • 🤖 Coach / LLM change (tools, prompts, orchestration)
  • 🎨 UI / DesignSystem change
  • 🧹 Refactor / chore (no behavior change)
  • 📝 Docs only
  • ⚠️ Breaking change (existing data, settings, or APIs change)

How was this tested?

  • Added / updated unit tests (PulseLoopTests) — CRPProtocolTests, CRPDecoderTests,
    CRPSyncEngineTests, plus CRP coverage in PairingMatchingTests/EventBridgeTests.
    Decoder oracles are real bytes from an R11 owner's captures, ported from the Android tests.
  • Ran the test suite locally (780 tests, 0 failures).
  • Tested on a physical device with a real ring — model: Colmi R11 (CRP firmware)
  • Tested with demo data (-seedDemo YES, no hardware)
  • N/A (docs / non-code change)

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: RingEventBridge now maps .wearingStatus to a PulseEvent, which it previously
dropped. The fan-out is unconditional, but RingSyncCoordinator gates 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

  • This change does not send health data off-device without explicit user action.
  • No secrets, API keys, or personal data are committed.
  • N/A

Checklist

  • My code follows the project's style. (SwiftLint wasn't run locally — checked by hand
    against .swiftlint.yml's error thresholds: no line >200 chars, no file >1000 lines, no
    function >150 lines. CI's SwiftLint job is the real check.)
  • I ran the tests and they pass (780 tests, 0 failures, iPhone 17 Pro simulator).
  • I updated docs / README where relevant.
  • I read the Contributing guide.

@foureight84
foureight84 requested a review from saksham2001 as a code owner July 21, 2026 13:14
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
foureight84 force-pushed the feat/colmi-r11-crp-driver branch from a4acdbc to 9d3b133 Compare July 21, 2026 13:16
@foureight84 foureight84 changed the title Feat/colmi r11 crp driver Feature Colmi R11 CRP Driver Jul 21, 2026
… 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
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.
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