Skip to content

fix(ring): restore the Android manufacturer-data match; fall back off 0x1E on RT-series firmware - #57

Merged
foureight84 merged 2 commits into
mainfrom
fix/issues-55-56-ring-recognition-and-live-hr
Aug 30, 2026
Merged

fix(ring): restore the Android manufacturer-data match; fall back off 0x1E on RT-series firmware#57
foureight84 merged 2 commits into
mainfrom
fix/issues-55-56-ring-recognition-and-live-hr

Conversation

@foureight84

Copy link
Copy Markdown
Owner

Fixes #56. Partially fixes #55 (live workout HR; see "Still open" below).

Both reports are a ring the app couldn't drive, for unrelated reasons.

#56 — YCBT ring not detected

The reporter is right on both counts, and the two causes are independent — either one alone would have hidden the ring.

The manufacturer-data fallback was dead code on Android. ScanRecord.getManufacturerSpecificData() returns a SparseArray keyed by company ID with the ID stripped from the value. CoreBluetooth hands iOS the raw block, ID included, and every coordinator was ported from Swift — so all of them match a little-endian company-ID prefix:

Coordinator prefix
TK5Coordinator 10786501
ColmiSmartHealthCoordinator 1078
LuckRingCoordinator 64ff
RWfitProtocol.MANUFACTURER_HEX_PREFIXES d605… / d606…

matchDeviceType passed valueAt(0) straight through, so those prefixes could never appear. No YCBT, LuckRing or RWfit ring could ever match on manufacturer data on this platform — only JringCoordinator survived, because it uses contains() on a needle that sits inside the payload. It also read only entry 0, despite a comment saying it iterated all of them.

New AdvertisementMatcher (WearableDriver.kt) rebuilds the on-air layout and offers every entry to each coordinator. Registry order stays the outer loop, so a coordinator listed earlier still wins over a later one matching a different block of the same device.

The name path failed too, one layer above where the report placed it. ColmiSmartHealthCoordinator.namePattern only runs once a catalog card has already claimed the name — and WearableModel.COLMI_SMARTHEALTH carried a second copy of the same literal, which is what actually rejected the hyphen in Ale-Hop2211 E1C7. Collapsed to one constant, WearableModel.SMARTHEALTH_NAME_PATTERN, widened to allow -. A QRing-Colmi has no space before its hex suffix, so the space-vs-underscore split that separates the families is untouched.

The ring lands on ColmiSmartHealthCoordinator rather than TK5Coordinator — its 1078d408… lacks TK5's 6501 second word — which is also the better fit: that coordinator bitmap-gates HRV/temperature/BP/stress/blood-sugar, matching the all-zero capability bitmap the reporter measured.

#55 — Colmi R09, no bpm during a workout

The diagnostics export shows the ring recognised, bonded-eligible and syncing normally. The workout failure is one specific command.

Every 0x1E frame in the capture — start (1e 01), stop (1e 02) and the 20 s continue (1e 03) alike — came back 9e ee about 300 ms later. Eleven for eleven, no HR frame ever streamed. In the same session 69 03 25 (spot SpO₂) streamed warm-up frames for ~25 s and then returned real readings, so the link, the sensor and the command family were all fine.

0x1E is not a QRing command. No BaseReqCmd subclass in the decompile is constructed with opcode 30 — grep super((byte) across req/ and 30 is absent. The SDK only receives it: BeanFactory case 30 → RealTimeHeartRateRsp, whose acceptData reads a bare heart = bArr[0], an unsolicited push. The request form is GadgetBridge's (YawellRingDeviceSupport.onEnableRealtimeHeartRateMeasurement), which is where PulseLoop took it from, and RT-series firmware rejects it. GadgetBridge has no 0x9E handler either, so it fails the same way on this ring.

Every live reading the vendor app takes is StartHeartRateReq.getSimpleReq(TYPE_HEARTRATE = 1)69 01 00, streaming [0x69, type, errCode, value] until StopHeartRateReq.stopHeartRate sends 6a 01 <lastBpm> 00. StartHeartRateReq does define a TYPE_REALTIMEHEARTRATE = 6, but nothing in the app calls it — dead code, so it would have been a guess rather than a reference.

ColmiSyncEngine still probes with 0x1E (one frame; rings that answer it keep the cheaper stream). A 0x9E reply flips a sticky realtimeRejected, cancels the 1e 03 keepalive and restarts the session on 0x69 01; later workouts on the same connection skip the probe. The keepalive becomes idle-gated — re-issue the start only after 30 s of silence — because the ring streams on its own and a mid-measurement restart would throw away a reading in progress. ColmiDecoder already mapped [0x69, 1, err, bpm] to HeartRateSample, so nothing downstream changed.

Still open on #55

The reporter's other symptom — "only connects right after coming off the charger" — is not fixed here. Their logcat ends mid-attempt: discoverServices() issued at 17:59:33.112, no onServicesDiscovered in the remaining 11 s of capture. That is consistent with the connect-then-hang the 30 s watchdog recycles, but the export was taken 15 s into the attempt, so it isn't proof, and per AGENTS.md I'd rather not change connect code on a maybe. Needs: whether R09_9D07 appears in Android's paired-devices list, and a logcat filtered to RingBLEClient|BluetoothGatt across a failing attempt.

Worth noting their SpO₂ complaint is partly contradicted by their own capture — two real SpO₂ results landed at 15:48:55 after ~25 s of warm-up. That may be a "took too long, left the screen" problem rather than a missing reading.

Not hardware-validated

That the R09 streams 0x69 01 continuously rather than stopping after one reading. Both outcomes beat the current zero readings; if a capture shows the stream ending early, the idle re-arm window is the knob to shorten.

Tests

1232 unit tests, 0 failures. New: AdvertisementMatcherTest (13) covers the company-ID restoration, multi-entry scanning, the reported ring by both paths, registry precedence, and the previously-dead TK5/LuckRing/RWfit branches; ColmiRealtimeHeartRateFallbackTest (8) covers the fallback, its stickiness, the 0x6A stop frame, idempotent restarts, and that a ring which does answer 0x1E keeps the old path.

Docs: qring-ble-adoption.md §3 gains the 0x69/0x6A/0x1E/0x9E rows and a new §4a; AGENTS.md gains the Android-vs-iOS manufacturer-data rule.

…off 0x1E

Two user-reported issues, both a ring the app couldn't drive.

#56 — a YCBT ring (`Ale-Hop2211 E1C7`, company 0x7810) wasn't detected at all.
`ScanRecord.getManufacturerSpecificData()` keys its SparseArray by company ID and
strips the ID from the value; the coordinators were ported from Swift, where
CoreBluetooth leaves it in, so all of them match a little-endian company-ID prefix.
`matchDeviceType` passed `valueAt(0)` through untouched, so no coordinator's
manufacturer branch could ever fire — dead code for the whole YCBT, LuckRing and
RWfit families — and only entry 0 was read despite a comment claiming otherwise.
`AdvertisementMatcher` now restores the company ID and offers every entry to each
coordinator, registry order still outermost.

The same ring also failed the name path: the SmartHealth convention regex existed in
two copies (catalog card + coordinator) and both rejected the hyphen in a reseller
badge. Collapsed to `WearableModel.SMARTHEALTH_NAME_PATTERN`, which now allows `-`.
No space before the hex still keeps every QRing-Colmi on ColmiCoordinator.

#55 — an R09 (`RT09_3.10.22_260420`) showed no bpm during a workout. Its capture has
eleven `0x1E` frames, all answered `9e ee`. `0x1E` is not a QRing command: no
`BaseReqCmd` in the decompile uses opcode 30, the SDK only receives it as an
unsolicited bpm push, and the request form came from GadgetBridge. Every live reading
the vendor app takes is `0x69 01 00`, stopped with `0x6A 01 <bpm> 00` — the family
this ring answered fine for SpO2 in the same session. The engine still probes with
`0x1E`, but a `0x9E` reply now moves the session onto `0x69` and is remembered for
the connection. The keepalive is idle-gated so a re-arm can't discard a reading the
ring is mid-way through.

Not hardware-validated: that the R09 streams `0x69 01` continuously rather than
stopping after one reading.
- stopSpO2() clears manualHRActive so a spot SpO2 reading no longer
  blocks restartWorkoutHeartRateIfActive() on the 0x69 fallback stream
- mark realtimeHRActive/manualHRActive @volatile (written on Main, read
  on the GATT notify thread)
- compile advertised-name patterns once instead of per lookup, now that
  AdvertisementMatcher walks the registry per manufacturer block
- AdvertisementMatcherTest uses full 128-bit service UUIDs, matching
  what production reports
@foureight84
foureight84 merged commit fe1682b into main Aug 30, 2026
1 check passed
@foureight84
foureight84 deleted the fix/issues-55-56-ring-recognition-and-live-hr branch August 30, 2026 20:38
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.

YCBT ring not detected, and the manufacturer-data fallback looks broken on Android Colmi R09 called R09_9D07 can't connect

1 participant