Skip to content

Feature Colmi R11 CRP Driver - #93

Merged
saksham2001 merged 11 commits into
saksham2001:mainfrom
foureight84:feat/colmi-r11-crp-driver
Aug 10, 2026
Merged

Feature Colmi R11 CRP Driver#93
saksham2001 merged 11 commits into
saksham2001:mainfrom
foureight84:feat/colmi-r11-crp-driver

Conversation

@foureight84

@foureight84 foureight84 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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.
Brings the iOS CRP driver up to the Android app's finished R11 state. Every
opcode below was re-resolved through its `d1/b.java` caller in
decompiled-moyoung-official/, not by position in the builder class — jadx
alphabetises method names, and pairing them positionally is what produced the
wrong constants in the first place.

- Group 7 is the vendor's **Gomore** module, not device info. Every `b1/r`
  builder resolves to a Gomore call, so `queryFirmwareVersion` was really
  `querySavedGomoreKey` — which is why zaggash's R11 answered none of the 23
  sends (issue saksham2001#29). Firmware is group 3 / cmd 3 (`b1/l.k`), a bare UTF-8
  string, now decoded to `.firmware(version:)`. `queryDeviceInfo`/`queryDeviceSN`
  are gone; the Gomore constants stay only so a capture is still identifiable.
- Temperature history is `2/22` (`b1/i0.b`, `[day, frameIndex]`), not `2/48` —
  `q.b(2,48)` is the vendor's `querySleepState`.
- Both all-day temp toggles ride cmd 13 with an enable byte. The disable used
  cmd 32, which is `b1/i0.d`, the *spot* toggle — so disabling all-day
  temperature was starting a one-shot measurement instead.
- Add the read-backs that let the ring describe itself: `querySupportSpO2Type`
  (2/37) plus the monitor-state queries (2/6, 2/7, 2/8, 2/45, 2/21). Sent once
  per connection and **before** the timing config, so the replies report the
  ring's own state rather than the one we just imposed. SpO2 support is decoded
  for the raw-packet feed only — the hardware is capture-confirmed and
  `refinedCapabilities` is additive-only, so it stays unconditional.
- Backfill the prior six nights of sleep once per connection. The poll pass only
  ever asks for `daysAgo = 0`, so stored history could otherwise only grow one
  night at a time. Safe to send blind: each reply carries its own day index.
- Add the missing HRV/stress/temp spot-measure builders.

Also fixes an iOS-only bug: `setMeasurementSettings` took a `MeasurementSettings?`,
a signature that satisfied no `RingSyncEngine` requirement. The protocol's no-op
default extension supplied conformance instead, so every `RingSyncCoordinator`
call landed there and the user's saved all-day config was silently discarded in
favour of `.allOnDefault`.
…driver

# Conflicts:
#	PulseLoop/RingProtocol/RingBLEClient.swift
#	PulseLoop/Views/Settings/DeviceHeroCard.swift
#	PulseLoop/Wearables/WearableCoordinator.swift
#	PulseLoop/Wearables/WearableModel.swift
#	PulseLoopTests/PairingMatchingTests.swift
…n fdd3

Findings from an adversarial review of this branch. The first two are real
bugs; the rest are hardening and corrected reasoning.

- **The frame assembler survived a dropped link.** `CRPDriver`'s doc claimed a
  fresh driver is built per connect, but only `beginConnect`/`adoptRememberedIdentity`
  call `installDriver` — auto-reconnect re-dials with a bare `central.connect` and
  keeps the instance. A frame left half-assembled when the old link dropped was
  completed with bytes from the new one and decoded as genuine; because the
  group-2 history frames are long and multi-notification, the spliced result is a
  fabricated vital sample or sleep record, not a parse failure. Adds
  `CRPFrameAssembler.reset()` and calls it from both lifecycle hooks, matching
  `LuckRingDriver`/`YCBTDriver`.
- **`.connected` no longer fires before the reply channel is live.** With no
  `requiredSubscriptionsBeforeConnected`, the connection counted as up on the
  first notify characteristic to report `isNotifying` — for CRP that is `fdd1`
  (steps), never `fdd3`, which carries every command reply. `.connected` runs
  `runStartup`, so the handshake could write ~26 frames into a channel we weren't
  listening to yet, and a lost reply looks exactly like a slow one — the same
  signature as the opcode bug this branch just fixed.
- Fold the firmware query into the once-per-connection block. It was sent on
  every pass while the six read-backs beside it were gated, on the argument that
  the single `fdd2` channel is scarce — a firmware string is exactly as immutable
  as a sensor roster.
- Key the timing-history follow-up guard on `day` as well as `cmd`. Every timing
  query is day 0 today, but this engine already issues multi-day sleep requests,
  and the old key would silently swallow day 1's frame-1 follow-up the moment the
  vitals got the same backfill.
- Validate the firmware string instead of coercing it. `String(decoding:as:)`
  cannot fail — it substitutes U+FFFD — so a binary payload rendered as
  replacement characters presented as a firmware version. Now strict UTF-8, then
  padding trimmed, then any remaining control byte rejects the frame to an ack.
  Trimming is deliberately narrower than the vendor's `trim { it <= ' ' }`, which
  would strip a binary payload's leading junk and pass whatever followed (`01 02
  03 41` → "A").
- Correct the "once per connection" comments: that state is per driver install,
  not per GATT link. Surviving a reconnect is the behaviour we want, so the note
  says so rather than inviting a lifecycle-hook "fix".
- Make the sleep-backfill loop half-open; `1...0` would trap if the documented
  tuning knob were turned down to today-only.
@foureight84
foureight84 marked this pull request as ready for review August 7, 2026 23:25
@saksham2001
saksham2001 merged commit 439ca81 into saksham2001:main Aug 10, 2026
2 checks passed
@foureight84
foureight84 deleted the feat/colmi-r11-crp-driver branch August 10, 2026 08:27
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.

2 participants