Skip to content

Colmi R10 support: pairing fix, on-demand vitals, self-update & PHI-safe diagnostics#6

Open
foureight84 wants to merge 6 commits into
mainfrom
Colmi_R10_BLE
Open

Colmi R10 support: pairing fix, on-demand vitals, self-update & PHI-safe diagnostics#6
foureight84 wants to merge 6 commits into
mainfrom
Colmi_R10_BLE

Conversation

@foureight84

Copy link
Copy Markdown
Owner

Builds out Colmi R10 support and the surrounding shippability work, verified on a Pixel 8 with a real R10.

Connectivity

  • Fix the R10 pairing hang. Android allows one outstanding GATT op at a time; onServicesDiscovered issued a firmware read (on the 0x180A DIS service the R10 exposes but the R02 doesn't) before the CCCD write that gates CONNECTED, so the descriptor write was silently dropped. Replaced the write-only queue with a unified, serialized GATT op queue (notify-enable first). Also fixed the cosmetic pairing badge ("COLMI R10_1203" → "Colmi R10").

Vitals

  • On-demand HR + live SpO₂ for Colmi via the real-time command family (0x69), reverse-engineered and verified against the ring. Surfaced as a Vitals "Measure" button (Colmi-only spot flow alongside the Jring combined flow).
  • Activity decode fix: the live-activity (0x73 0x12) frame packs steps/cal/distance big-endian; we read them little-endian, inflating steps to millions and locking a garbage daily total via the max-merge. Now decoded big-endian + plausibility guard + self-heal of a stuck total.
  • Dropped autoMeasureOnConnect — it pinned the optical sensor on every (re)connect. Now matches iOS: connect runs the history sync only; vitals come from the ring's periodic monitoring + the manual Measure button.

Self-update (release-only)

  • In-app updater polls the GitHub latest release, compares versionCode from the v<name>+<code> tag, downloads + installs the universal APK. On-launch (throttled) + a Settings "Check for updates" button.
  • Tag-driven GitHub Actions workflow builds, signs, and attaches the release universal APK.
  • Coexisting .debug build (applicationIdSuffix) so a debug install never wipes the release app's data.

Diagnostics + privacy

  • Hardened the in-app export: own-PID logcat (incl. in-process BluetoothGatt callbacks) + a crash handler, plus accurate build info.
  • PHI scrubbing on by default — health values, ring serial, and MAC addresses removed; models/opcodes/control frames/errors kept — with an opt-out toggle for full unmasked BLE frames. Verified masked vs full on-device.

Ring removal (restores the iOS two-action model)

  • "Forget" is now non-destructive for both rings (unbind + disconnect; no power-off/factory-reset wiping a Colmi's history on a normal remove).
  • New Colmi-only "Factory Reset" syncs latest history first, then resets, behind a confirmation dialog.

Misc

  • Persist + prettify the connected ring's name (was always showing the default "SMART_RING").

Test notes

Verified on-device: R10 reaches CONNECTED and syncs; auto/manual measure produced live HR + SpO₂ (96–98%); masked vs full diagnostics export confirmed (vital values + serial + MACs removed when masked); steps/cal/distance corrected (9.5M → 249); .debug coexists with the release app without data loss.

The R10 connected and discovered services but never finished pairing,
sitting on "Connecting…" forever. Root cause: Android's BLE stack allows
only one outstanding GATT operation at a time, and onServicesDiscovered
issued a firmware readCharacteristic (on the 0x180A DIS service the R10
exposes but the R02 does not) before the CCCD descriptor write that gates
the CONNECTED transition — so the descriptor write was silently dropped.

- Replace the write-only queue with a unified FIFO GATT operation queue
  (CommandWrite / Read / DescriptorWrite). Every read, write, and CCCD
  descriptor write now runs strictly one-at-a-time, each retired by its
  completion callback. Notify-CCCD writes are enqueued first so the ring
  reaches CONNECTED before informational battery/firmware reads.
- Check the boolean each GATT call returns; log and skip on rejection
  instead of stranding the queue. The completion-timeout guard now covers
  every op type, not just writes.
- Fix the cosmetic pairing badge: derive the real model from the advertised
  name ("COLMI R10_1203" → "Colmi R10") instead of the family displayName,
  which mislabeled every Colmi as "Colmi R02".

Verified on a Pixel 8: the R10 now reaches CONNECTED and syncs live data.
Colmi rings never showed the Vitals "Measure" button: it was gated on
BLOOD_PRESSURE/BLOOD_SUGAR (the Jring combined 0x23 packet), which Colmi
hardware doesn't have. Colmi did already support on-demand HR (0x69) but
only exposed it via the coach tool, and had no live SpO2 path at all.

Add live SpO2 via the real-time command family, then surface a spot
measurement (HR + SpO2) both as a Vitals button and automatically on
connect.

- ColmiEncoder/Protocol: live SpO2 start ([0x69, reading_type=3, START])
  and stop ([0x6A, 3, 0, 0]), per the colmi_r02_client real-time protocol.
- ColmiDecoder: branch the 0x69 real-time response on reading_type so
  type 3 decodes to Spo2Result (value at v[3]); the HR path is unchanged.
- ColmiSyncEngine: startSpO2/stopSpO2 now drive the live spot command
  (historical big-data SpO2 sync is untouched). Add MANUAL_SPO2 capability.
- RingSyncCoordinator: measureSpot() runs HR then SpO2, capability-gated;
  autoMeasureOnConnect() fires it ~2s after connect. Wired into onConnected.
- Vitals screen: show the Measure button for rings with manual HR/SpO2
  (spot mode) alongside the existing combined flow, with its own countdown
  and "measuring heart rate & SpO2" copy.

Verified on a Pixel 8 with an R10: auto-measure on connect populated HR
and SpO2 (96%), the value confirmed against the ring's raw 0x69/3 frame,
and the manual Measure button runs the spot flow.
Builds on the R10 pairing fix + measurement feature with the support,
observability, and correctness work needed to ship it.

Self-update (release-only):
- In-app updater polls the GitHub latest release, compares versionCode from
  the `v<name>+<code>` tag, downloads + installs the universal APK. On-launch
  (throttled) + a Settings "Check for updates" button.
- Tag-driven GitHub Actions workflow builds, signs, and attaches the APKs.
- Coexisting `.debug` build (applicationIdSuffix) so a debug install never
  wipes the release app's data; enable buildConfig; env-overridable signing.

Diagnostics + privacy:
- Harden the export: own-PID logcat capture + a crash handler (new Application)
  persisting stack traces, plus accurate build/version info.
- PHI scrubbing on by default (health values, ring serial, MAC addresses
  removed; models/opcodes/control frames/errors kept) with an opt-out toggle
  for full unmasked BLE frames. Verified masked vs full on-device.

Ring removal (restores the iOS two-action model):
- "Forget" is now non-destructive (unbind + disconnect only) for both rings —
  no more power-off/factory-reset wiping a Colmi's history on a normal remove.
- New Colmi-only "Factory Reset" syncs latest history first, then resets,
  behind a confirmation dialog.

Fixes:
- Persist + prettify the connected ring's name (was always showing the default
  "SMART_RING"; now "Colmi R10"); shared ringModelLabel.
- Live-activity (0x73 0x12) decode read big-endian fields as little-endian,
  inflating steps to millions and locking a garbage daily total via max-merge.
  Decode big-endian + plausibility guard + self-heal of a stuck total.
- Drop autoMeasureOnConnect: connecting no longer pins the optical sensor on.
  Matches iOS — connect runs the history sync only; vitals come from the ring's
  periodic monitoring + the manual Measure button.
End users get diagnostics from the in-app export, so there's no need to
distribute a debug APK. The debug variant stays for local dev/repro
(adb, run-as, system-Bluetooth logs); it's just no longer a release asset.
Two reasons the Colmi green LED kept pulsing after a measurement:

- The HR stop frame was wrong: manualHeartRate(false) sent [0x69, 0x02],
  but 0x69 is CMD_START_REAL_TIME — so the 'stop' actually started another
  real-time reading and the sensor never switched off (the ring only timed
  out on its own). Send CMD_STOP_REAL_TIME (0x6A) instead, mirroring SpO2.

- measureHR/measureSpO2/measureCombined sent the stop after the wait loop,
  not in a finally. The Measure button runs in the screen's coroutine scope,
  so navigating away mid-measurement cancelled it before the stop ran. Wrap
  each in try/finally so the sensor is always switched off.
@robisaks

Copy link
Copy Markdown
Contributor

I did test this one too. I have a JRing, not a Colmi, so I wasnt able to test that side of it but everything else worked great.

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