fix(ring): restore the Android manufacturer-data match; fall back off 0x1E on RT-series firmware - #57
Merged
Conversation
…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
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.
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 aSparseArraykeyed 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:TK5Coordinator10786501ColmiSmartHealthCoordinator1078LuckRingCoordinator64ffRWfitProtocol.MANUFACTURER_HEX_PREFIXESd605…/d606…matchDeviceTypepassedvalueAt(0)straight through, so those prefixes could never appear. No YCBT, LuckRing or RWfit ring could ever match on manufacturer data on this platform — onlyJringCoordinatorsurvived, because it usescontains()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.namePatternonly runs once a catalog card has already claimed the name — andWearableModel.COLMI_SMARTHEALTHcarried a second copy of the same literal, which is what actually rejected the hyphen inAle-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
ColmiSmartHealthCoordinatorrather thanTK5Coordinator— its1078d408…lacks TK5's6501second 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
0x1Eframe in the capture — start (1e 01), stop (1e 02) and the 20 s continue (1e 03) alike — came back9e eeabout 300 ms later. Eleven for eleven, no HR frame ever streamed. In the same session69 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.0x1Eis not a QRing command. NoBaseReqCmdsubclass in the decompile is constructed with opcode 30 — grepsuper((byte)acrossreq/and 30 is absent. The SDK only receives it:BeanFactorycase 30 →RealTimeHeartRateRsp, whoseacceptDatareads a bareheart = 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 no0x9Ehandler 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]untilStopHeartRateReq.stopHeartRatesends6a 01 <lastBpm> 00.StartHeartRateReqdoes define aTYPE_REALTIMEHEARTRATE = 6, but nothing in the app calls it — dead code, so it would have been a guess rather than a reference.ColmiSyncEnginestill probes with0x1E(one frame; rings that answer it keep the cheaper stream). A0x9Ereply flips a stickyrealtimeRejected, cancels the1e 03keepalive and restarts the session on0x69 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.ColmiDecoderalready mapped[0x69, 1, err, bpm]toHeartRateSample, 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, noonServicesDiscoveredin 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 perAGENTS.mdI'd rather not change connect code on a maybe. Needs: whetherR09_9D07appears in Android's paired-devices list, and a logcat filtered toRingBLEClient|BluetoothGattacross 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 01continuously 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, the0x6Astop frame, idempotent restarts, and that a ring which does answer0x1Ekeeps the old path.Docs:
qring-ble-adoption.md§3 gains the0x69/0x6A/0x1E/0x9Erows and a new §4a;AGENTS.mdgains the Android-vs-iOS manufacturer-data rule.