From 0411f84e5ed98e9b859295aabfc899279b2fc94d Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 30 Aug 2026 12:54:50 -0700 Subject: [PATCH 1/2] fix(ring): restore the manufacturer-data match on Android, fall back off 0x1E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- AGENTS.md | 22 +++ .../com/pulseloop/ring/ColmiSyncEngine.kt | 101 +++++++++++ .../java/com/pulseloop/ring/RingBLEClient.kt | 15 +- .../java/com/pulseloop/ring/WearableDriver.kt | 63 +++++++ .../com/pulseloop/ring/YCBTCoordinators.kt | 21 ++- .../com/pulseloop/wearables/WearableModel.kt | 19 +- .../ring/AdvertisementMatcherTest.kt | 170 ++++++++++++++++++ .../ColmiRealtimeHeartRateFallbackTest.kt | 155 ++++++++++++++++ docs/qring-ble-adoption.md | 45 +++++ 9 files changed, 600 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/pulseloop/ring/AdvertisementMatcherTest.kt create mode 100644 app/src/test/java/com/pulseloop/ring/ColmiRealtimeHeartRateFallbackTest.kt diff --git a/AGENTS.md b/AGENTS.md index 517f6b43..37d0db50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,28 @@ root) and match its actual behavior. Do not port iOS's CoreBluetooth sequencing Bluetooth stack behaves differently (pairing/bonding flow, MTU negotiation, background restrictions), and a straight iOS port has caused real pairing/data-collection bugs before. +## Manufacturer data: Android splits the field, iOS doesn't — put the company ID back + +`ScanRecord.getManufacturerSpecificData()` returns a `SparseArray` **keyed by company ID, with the +ID removed from the value**. CoreBluetooth hands iOS the raw block, company ID and all. Every +coordinator was ported from Swift and therefore matches a little-endian company-ID *prefix* +(`TK5Coordinator` `10786501`, `ColmiSmartHealthCoordinator` `1078`, `LuckRingCoordinator` `64ff`, +`RWfitProtocol.MANUFACTURER_HEX_PREFIXES` `d605…`/`d606…`). `RingBLEClient` used to pass +`valueAt(0)` through untouched, so those prefixes could never appear and **every manufacturer-data +fallback in the registry was dead code on Android** — a ring whose name the catalog didn't +recognise matched nothing at all (issue #56, an `Ale-Hop2211 E1C7` YCBT ring). It also read only +entry 0 despite a comment claiming otherwise. + +Build `AdvertisementInfo` through `AdvertisementMatcher` (`WearableDriver.kt`), which restores the +company ID and offers **every** entry to each coordinator, registry order still outermost. If you +add a coordinator that matches manufacturer bytes, write the prefix in on-air layout (company ID +first) and cover it in `AdvertisementMatcherTest`. + +Same lesson, different field: a name pattern is one convention, so keep it in one constant. +`WearableModel.SMARTHEALTH_NAME_PATTERN` gates two decisions in series — whether +`modelForAdvertisedName` returns a card at all, and whether `ColmiSmartHealthCoordinator` accepts +the name — and the two copies that used to exist drifted into the same bug. + ## Colmi/Yawell OS-bonding is a hand-curated allowlist — not "match QRing exactly" **This is the one rule in this file most likely to get silently reverted by a future "generalize diff --git a/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt index af3a6957..653dc6dc 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt @@ -85,6 +85,20 @@ class ColmiSyncEngine( private var manualHRActive = false private var manualSpO2Active = false + /** + * This ring answered the `0x1E` realtime-HR request with a `0x9E` error frame, so the session + * runs on the `0x69` spot-measure stream instead (see [onRealtimeHeartRateRejected]). + * + * Sticky for the life of the engine: once a ring has refused `0x1E` it refuses every one of + * them, so later workouts skip the probe and start on `0x69` directly. A reconnect builds a + * new driver and a new engine, so this never outlives the firmware it was measured against. + */ + @Volatile private var realtimeRejected = false + + /** Wall-clock of the last `0x69` frame seen while the fallback stream runs — the fallback + * keepalive re-issues the start only when the ring has actually gone quiet. */ + @Volatile private var lastFallbackFrameAt = 0L + /** Last bpm seen while a manual spot measurement runs: QRing reports it in the 0x6A stop * frame so the ring's own measurement log records the reading (0 = cancelled). */ private var lastManualBpm = 0 @@ -98,6 +112,11 @@ class ColmiSyncEngine( /** 20 s wall-clock re-arm, matching QRing's HeartActivity timer. */ private const val REALTIME_KEEPALIVE_MS = 20_000L + + /** How long the `0x69` fallback stream may stay silent before the start is re-issued. + * Generous on purpose: the R09's SpO2 twin streamed warm-up frames for ~25 s before its + * first reading, so a shorter window would restart a measurement that was working. */ + private const val FALLBACK_STREAM_IDLE_MS = 30_000L } override fun runStartup() { @@ -176,6 +195,21 @@ class ColmiSyncEngine( * the ring's reported interval. */ override fun handleRawNotify(data: ByteArray) { + // Realtime-HR rejection / fallback bookkeeping runs ahead of the seeding guard: both are + // live-measurement concerns, unrelated to whether a config seed is in progress. + val frame = ColmiPacket.validating(data)?.bytes + if (frame?.get(0)?.toUByte() == ColmiCommandID.REALTIME_HEART_RATE_ERROR) { + onRealtimeHeartRateRejected() + return + } + // Any 0x69 *heart-rate* frame is proof the fallback stream is alive. Reading type matters: + // a concurrent 0x69/3 SpO2 measure shares the opcode and must not stand in for HR traffic. + if (frame?.get(0)?.toUByte() == ColmiCommandID.MANUAL_HEART_RATE && + frame[1].toUByte() == ColmiCommandID.RT_HEART_RATE + ) { + lastFallbackFrameAt = System.currentTimeMillis() + } + // Device-support reply is independent of config seeding: remember the temperature-path // capability and, if the ring wants a bond, ask the client to create one. Return early — // a 0x3C frame carries nothing else we consume. @@ -537,6 +571,12 @@ class ColmiSyncEngine( // MARK: Measurement actions override fun startHeartRate() { + // A ring that has already refused 0x1E on this connection never gets asked again — go + // straight to the 0x69 stream the vendor app itself uses (see onRealtimeHeartRateRejected). + if (realtimeRejected) { + startFallbackHeartRateStream() + return + } realtimeHRActive = true writer?.enqueue(encoder.realtimeHeartRate(enable = true)) // Re-arm the stream on a wall-clock timer like QRing (20 s), not per received frame: @@ -551,6 +591,67 @@ class ColmiSyncEngine( } } + /** + * The ring answered `0x1E` with a `0x9E` error frame — it does not implement the continuous + * realtime-HR request. Move the live session onto the `0x69` stream instead. + * + * **Why `0x69` is the right fallback, not a guess.** No `BaseReqCmd` in the decompiled QRing + * app is built with opcode 30 — the SDK only ever *receives* `0x1E` (`BeanFactory` case 30 → + * `RealTimeHeartRateRsp`, a bare bpm push). `0x1E` as a *request* comes from GadgetBridge + * (`YawellRingDeviceSupport.onEnableRealtimeHeartRateMeasurement`), which is where PulseLoop + * took it from, and RT-series firmware rejects it. Every live reading QRing itself takes goes + * through `StartHeartRateReq.getSimpleReq(TYPE_HEARTRATE=1)` = `0x69 01 00`, whose reply + * (`StartHeartRateRsp`: `[type, errCode, value]`) the decoder already maps to + * `HeartRateSample`, and which the ring keeps streaming until a `0x6A` stops it. + * + * Issue #55, `R09_9D07` / firmware `RT09_3.10.22_260420`: every one of the eleven `0x1E` + * frames in the user's capture — start, stop and continue alike — came back `9e ee`, so the + * workout screen never showed a bpm, while `0x69 03` (SpO2, same command family) streamed + * warm-up frames for ~25 s and then returned real readings. + */ + private fun onRealtimeHeartRateRejected() { + if (!realtimeHRActive && !realtimeRejected) { + // Unsolicited 0x9E with no session running (the ring volunteering a failed reading): + // remember the refusal so the next startHeartRate skips the probe, but start nothing. + realtimeRejected = true + return + } + realtimeRejected = true + if (!realtimeHRActive) return + realtimeHRActive = false + realtimeKeepaliveJob?.cancel(); realtimeKeepaliveJob = null + startFallbackHeartRateStream() + } + + /** + * Run the live-HR session on `0x69` and keep it alive. + * + * The re-arm is idle-gated rather than unconditional: the ring streams on its own once + * started, and re-issuing `0x69 01` mid-measurement restarts the reading. It fires only after + * [FALLBACK_STREAM_IDLE_MS] of silence, which is what covers a ring-side measurement window + * expiring partway through a workout. Repeat starts are safe — the capture in issue #55 shows + * the ring accepting three `0x69 03` starts in ninety seconds. + */ + private fun startFallbackHeartRateStream() { + if (manualHRActive) return // already streaming on 0x69 + manualHRActive = true + lastManualBpm = 0 + lastFallbackFrameAt = System.currentTimeMillis() + writer?.enqueue(encoder.manualHeartRate(enable = true)) + realtimeKeepaliveJob?.cancel() + realtimeKeepaliveJob = scope.launch { + while (isActive) { + delay(REALTIME_KEEPALIVE_MS) + if (!manualHRActive) continue + val silentFor = System.currentTimeMillis() - lastFallbackFrameAt + if (silentFor >= FALLBACK_STREAM_IDLE_MS) { + lastFallbackFrameAt = System.currentTimeMillis() + writer?.enqueue(encoder.manualHeartRate(enable = true)) + } + } + } + } + override fun stopHeartRate() { realtimeKeepaliveJob?.cancel(); realtimeKeepaliveJob = null if (manualHRActive) { diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 5d72eba7..bae70186 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1159,13 +1159,18 @@ class RingBLEClient( private fun matchDeviceType(name: String?, scanRecord: ScanRecord?): RingDeviceType? { val serviceUUIDs = scanRecord?.serviceUuids?.map { it.uuid.toString() } ?: emptyList() - // Iterate all manufacturer-specific data entries to find a match - var mfg: ByteArray? = null + // Flatten the SparseArray to (companyId, value) pairs — every entry, not just index 0. + // [AdvertisementMatcher] puts the company ID back in front of each value: Android strips + // it into the key, the coordinators match it as a prefix, and without it every + // manufacturer-data fallback in the registry was unreachable (issue #56). + val manufacturerEntries = mutableListOf>() scanRecord?.manufacturerSpecificData?.let { data -> - if (data.size() > 0) mfg = data.valueAt(0) + for (i in 0 until data.size()) { + val value = data.valueAt(i) ?: continue + manufacturerEntries.add(data.keyAt(i) to value) + } } - val info = AdvertisementInfo(serviceUUIDs, mfg) - return coordinators.firstOrNull { it.matches(name, info) }?.deviceType + return AdvertisementMatcher.match(coordinators, name, serviceUUIDs, manufacturerEntries) } private inline fun updateState(crossinline update: BLEState.() -> BLEState) { diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index 91d05019..6bd9f86c 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -206,6 +206,12 @@ interface RingSyncEngine { /** * Ported from [AdvertisementInfo] in WearableCoordinator.swift. + * + * [manufacturerData] is one manufacturer-specific data block in its **on-air layout**: the + * little-endian company ID first, then the vendor bytes. That is what CoreBluetooth hands iOS, + * and it is what every coordinator's prefix matches (`1078…`, `64ff…`, `d605…`). Android's + * `ScanRecord` splits the same field the other way, so build this through + * [AdvertisementMatcher.manufacturerBlocks] rather than from `valueAt()` directly. */ data class AdvertisementInfo( val serviceUUIDs: List, @@ -222,6 +228,63 @@ data class AdvertisementInfo( 31 * serviceUUIDs.hashCode() + manufacturerData.contentHashCode() } +/** + * Turns Android's parsed advertisement fields into the [AdvertisementInfo]s the coordinators + * expect, and walks the registry against them. + * + * Exists because of a platform mismatch that made every manufacturer-data fallback dead code on + * Android (issue #56). `ScanRecord.getManufacturerSpecificData()` returns a `SparseArray` **keyed + * by company ID with the ID stripped from the value**; the coordinators were ported from Swift, + * where CoreBluetooth includes the company ID in the bytes, so they all match a little-endian + * company-ID prefix (`TK5Coordinator` `10786501`, `ColmiSmartHealthCoordinator` `1078`, + * `LuckRingCoordinator` `64ff`, `RWfitProtocol.MANUFACTURER_HEX_PREFIXES` `d605…`/`d606…`). Those + * prefixes can never appear in a `valueAt()` payload, so the fallback never fired for any YCBT, + * LuckRing or RWfit ring — a ring whose name the catalog doesn't recognise was simply invisible. + */ +object AdvertisementMatcher { + + /** + * Re-attach the company ID to each entry, little-endian, restoring the on-air layout. + * + * Returns one block per entry — **all** of them, not just index 0: a device may advertise + * several company blocks and nothing guarantees the family marker is the first. A device with + * no manufacturer data yields a single `null` block, so the service/name matches still run. + */ + fun manufacturerBlocks(entries: List>): List { + if (entries.isEmpty()) return listOf(null) + return entries.map { (companyId, value) -> + byteArrayOf( + (companyId and 0xFF).toByte(), + ((companyId shr 8) and 0xFF).toByte(), + ) + value + } + } + + /** + * First coordinator in registry order that claims the advertisement, or `null`. + * + * Registry order stays the outer loop — it is load-bearing (see `RingBLEClient.coordinators`) — + * so a coordinator listed earlier still wins even when a later one matches a different + * manufacturer block of the same device. + */ + fun match( + coordinators: List, + name: String?, + serviceUUIDs: List, + manufacturerEntries: List>, + ): RingDeviceType? { + val blocks = manufacturerBlocks(manufacturerEntries) + for (coordinator in coordinators) { + for (block in blocks) { + if (coordinator.matches(name, AdvertisementInfo(serviceUUIDs, block))) { + return coordinator.deviceType + } + } + } + return null + } +} + /** * Ported from [WearableCoordinator] in WearableCoordinator.swift. * Capability + metadata descriptor for a wearable family. diff --git a/app/src/main/java/com/pulseloop/ring/YCBTCoordinators.kt b/app/src/main/java/com/pulseloop/ring/YCBTCoordinators.kt index 6d4bd3a7..01f6931f 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTCoordinators.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTCoordinators.kt @@ -94,19 +94,30 @@ object TK5Coordinator : WearableCoordinator { * * Both confirmed YCBT rings (`TK5 24AA`, `R99 54DC`) name themselves `<4 hex>`, while * every QRing-Colmi in the catalog uses an underscore (`R02_A1B2`, `COLMI R10_9C3F`). That - * space-versus-underscore split is the primary signal — not the manufacturer data, which is - * unconfirmed for this family (the only capture in it, the TK5, isn't even this coordinator). + * space-versus-underscore split is the primary signal; the manufacturer marker is the fallback for + * a unit the catalog doesn't name, and issue #56 confirmed one (`Ale-Hop2211 E1C7`, company 0x7810) + * — the first capture that actually belongs to this coordinator. */ @OptIn(ExperimentalStdlibApi::class) object ColmiSmartHealthCoordinator : WearableCoordinator { override val deviceType: RingDeviceType = RingDeviceType.COLMI_SMART_HEALTH - /** The SmartHealth naming convention: model, one space, four hex digits. Anchored end to end. */ - private val namePattern = Regex("^[A-Za-z0-9]+( [A-Za-z0-9]+)* [0-9A-Fa-f]{4}$") + /** The SmartHealth naming convention: model, one space, four hex digits. Anchored end to end. + * The literal lives on [WearableModel.SMARTHEALTH_NAME_PATTERN] — this coordinator and the + * catalog card gate the *same* decision in series (the card decides whether + * `modelForAdvertisedName` returns non-null at all), so a second copy here can only drift. + * It did: both rejected the hyphen in `Ale-Hop2211 E1C7` (issue #56). */ + private val namePattern = Regex(WearableModel.SMARTHEALTH_NAME_PATTERN) /** The Yucheng SDK's company ID (0x7810, little-endian => `1078`), matched as a manufacturer- * data prefix. Demoted to corroborating evidence only — never overrides a name match, since a - * QRing-Colmi may carry the same company ID. */ + * QRing-Colmi may carry the same company ID. + * + * Confirmed on hardware by issue #56: an `Ale-Hop2211 E1C7` (JieLi, model string `TRINITY`, + * ships with SmartHealth) advertises `1078 d408 7700 …` alongside Heart Rate + `FEE7` — the + * same shape the TK5 capture shows, but without TK5's `6501` second word, which is why that + * coordinator stands aside for this one. Until the Android fix in [AdvertisementMatcher] this + * branch was unreachable, so the ring matched nothing at all. */ private const val MANUFACTURER_HEX_MARKER = "1078" private fun isSmartHealthName(name: String?): Boolean = name != null && namePattern.matches(name) diff --git a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt index af4c89b3..425c3cdd 100644 --- a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt +++ b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt @@ -40,6 +40,23 @@ data class WearableModel( val requiresOsBond: Boolean = false, ) { companion object { + /** + * The SmartHealth naming convention: model, one space, four hex digits — the + * space-versus-underscore split that separates a SmartHealth-flavoured Colmi (`R99 54DC`) + * from a QRing one (`R02_A1B2`). Anchored end to end. + * + * The model half allows `-` because resellers badge these rings under their own hyphenated + * names: the unit in issue #56 advertises as `Ale-Hop2211 E1C7`, a textbook SmartHealth + * name that the original `[A-Za-z0-9]`-only class rejected on the hyphen alone. Widening it + * cannot pull in a QRing-Colmi — those have no space before the hex — and this is the + * broadest card in [CATALOG], scanned last, so every narrower model still gets first shot. + * + * Shared with `ColmiSmartHealthCoordinator`, which used to keep its own copy of the same + * literal. Two copies of one convention is exactly how issue #56 slipped through; keep it + * at one. + */ + const val SMARTHEALTH_NAME_PATTERN = "^[A-Za-z0-9-]+( [A-Za-z0-9-]+)* [0-9A-Fa-f]{4}$" + // "jring" is intentionally lowercase — that's how the brand styles its name. val JRING = WearableModel( id = "jring", displayName = "jring", brand = "jring", family = RingDeviceType.JRING, @@ -131,7 +148,7 @@ data class WearableModel( id = "colmi-smarthealth", displayName = "Colmi / Yawell (SmartHealth app)", brand = "Colmi", family = RingDeviceType.COLMI_SMART_HEALTH, tint = PulseColors.hrv, blurb = "HR · SpO₂ · Sleep", - advertisedNamePatterns = listOf("^[A-Za-z0-9]+( [A-Za-z0-9]+)* [0-9A-Fa-f]{4}$"), + advertisedNamePatterns = listOf(SMARTHEALTH_NAME_PATTERN), ) // TK18 -- the LuckRing app / "K6" protocol (company ID 0xFF64). The only hardware-tested unit diff --git a/app/src/test/java/com/pulseloop/ring/AdvertisementMatcherTest.kt b/app/src/test/java/com/pulseloop/ring/AdvertisementMatcherTest.kt new file mode 100644 index 00000000..9b2343df --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/AdvertisementMatcherTest.kt @@ -0,0 +1,170 @@ +package com.pulseloop.ring + +import com.pulseloop.wearables.WearableModel +import org.junit.Assert.* +import org.junit.Test + +/** + * Regression cover for issue #56: the manufacturer-data fallback was unreachable on Android. + * + * Android's `ScanRecord.getManufacturerSpecificData()` is a `SparseArray` keyed by company ID, + * with the ID stripped from the value; the coordinators were ported from Swift, where + * CoreBluetooth leaves the ID in the bytes, so all of them match a little-endian company-ID + * prefix. `RingBLEClient` used to hand `valueAt(0)` straight through, so no coordinator's + * manufacturer branch could ever fire — and only the first entry was ever looked at. + */ +class AdvertisementMatcherTest { + + /** The registry, in the order `RingBLEClient` walks it. Order is load-bearing. */ + private val registry = listOf( + JringCoordinator, + YCBTCoordinator, + ColmiCoordinator, + ColmiSmartHealthCoordinator, + LuckRingCoordinator, + TK5Coordinator, + RWfitCoordinator, + CRPCoordinator, + ) + + private fun bytes(hex: String): ByteArray = + hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + // ── manufacturerBlocks: the company ID goes back in front, little-endian ────────── + + @Test + fun `company id is restored little-endian ahead of the value`() { + val blocks = AdvertisementMatcher.manufacturerBlocks(listOf(0x7810 to bytes("d40877"))) + assertEquals(1, blocks.size) + assertEquals("1078d40877", blocks[0]!!.joinToString("") { "%02x".format(it) }) + } + + @Test + fun `every manufacturer entry is offered, not just the first`() { + val blocks = AdvertisementMatcher.manufacturerBlocks( + listOf(0x004C to bytes("0215"), 0xFF64 to bytes("aabb")) + ) + assertEquals(2, blocks.size) + assertEquals("4c000215", blocks[0]!!.joinToString("") { "%02x".format(it) }) + assertEquals("64ffaabb", blocks[1]!!.joinToString("") { "%02x".format(it) }) + } + + @Test + fun `no manufacturer data still yields one null block so service and name matching runs`() { + assertEquals(listOf(null), AdvertisementMatcher.manufacturerBlocks(emptyList())) + } + + // ── The reported ring: Ale-Hop2211 E1C7 (issue #56) ────────────────────────────── + + /** Company 0x7810 (Yucheng) + the exact value bytes from the issue's nRF capture. */ + private val aleHopManufacturer = listOf(0x7810 to bytes("d408770058ddeb05e1c70000bf0c4362b6005c000058ddeb05e1c7")) + + @Test + fun `the reported YCBT ring is claimed by the SmartHealth coordinator`() { + val matched = AdvertisementMatcher.match( + registry, + name = "Ale-Hop2211 E1C7", + serviceUUIDs = listOf("0000180d", "0000fee7"), + manufacturerEntries = aleHopManufacturer, + ) + assertEquals(RingDeviceType.COLMI_SMART_HEALTH, matched) + } + + @Test + fun `the manufacturer branch alone recognizes the ring, and only with the company id`() { + // Isolate the manufacturer path from the name path: a name the catalog does not claim. + // With the company ID restored the Yucheng marker matches; with it stripped — the old + // valueAt(0) behaviour — nothing in the registry claims the device at all. + val serviceUUIDs = listOf("0000180d", "0000fee7") + assertEquals( + RingDeviceType.COLMI_SMART_HEALTH, + AdvertisementMatcher.match(registry, "Unlabeled", serviceUUIDs, aleHopManufacturer), + ) + val stripped = AdvertisementInfo( + serviceUUIDs, + bytes("d408770058ddeb05e1c70000bf0c4362b6005c000058ddeb05e1c7"), + ) + assertNull(registry.firstOrNull { it.matches("Unlabeled", stripped) }?.deviceType) + } + + @Test + fun `hyphenated SmartHealth names resolve to the SmartHealth catalog card`() { + assertEquals( + WearableModel.COLMI_SMARTHEALTH.id, + WearableModel.modelForAdvertisedName("Ale-Hop2211 E1C7")?.id, + ) + // The coordinator and the catalog gate the same decision — they must agree. + assertTrue(ColmiSmartHealthCoordinator.matches("Ale-Hop2211 E1C7", AdvertisementInfo(emptyList(), null))) + } + + @Test + fun `widening the name class does not pull in QRing-Colmi names`() { + // No space before the hex — the split that keeps the QRing rings on ColmiCoordinator. + for (name in listOf("R02_A1B2", "COLMI R10_9C3F", "R09_9D07", "R11C_BEEF")) { + assertFalse( + "SmartHealth must not claim $name", + ColmiSmartHealthCoordinator.matches(name, AdvertisementInfo(emptyList(), null)), + ) + } + } + + // ── The other coordinators whose manufacturer branch was equally dead ──────────── + + @Test + fun `TK5 matches its own longer manufacturer prefix`() { + val matched = AdvertisementMatcher.match( + registry, name = "Unlabeled", serviceUUIDs = emptyList(), + manufacturerEntries = listOf(0x7810 to bytes("6501aabb")), + ) + assertEquals(RingDeviceType.TK5, matched) + } + + @Test + fun `LuckRing matches on company 0xFF64 alone`() { + val matched = AdvertisementMatcher.match( + registry, name = "Unlabeled", serviceUUIDs = emptyList(), + manufacturerEntries = listOf(0xFF64 to bytes("0102030405")), + ) + assertEquals(RingDeviceType.LUCK_RING, matched) + } + + @Test + fun `RWfit matches its JieLi company prefix`() { + val matched = AdvertisementMatcher.match( + registry, name = "Whatever", serviceUUIDs = emptyList(), + manufacturerEntries = listOf(0x05D6 to bytes("0200aabb")), + ) + assertEquals(RingDeviceType.RWFIT, matched) + } + + @Test + fun `a family marker in a later entry is still found`() { + // Entry 0 is an unrelated iBeacon block; the ring's own block is second. + val matched = AdvertisementMatcher.match( + registry, name = "Unlabeled", serviceUUIDs = emptyList(), + manufacturerEntries = listOf(0x004C to bytes("021500"), 0xFF64 to bytes("0102")), + ) + assertEquals(RingDeviceType.LUCK_RING, matched) + } + + // ── Ordering and non-regression ───────────────────────────────────────────────── + + @Test + fun `registry order still wins over a later coordinators manufacturer match`() { + // A QRing service (ColmiCoordinator, 3rd) plus a LuckRing company ID (5th): Colmi wins. + val matched = AdvertisementMatcher.match( + registry, name = "Unlabeled", serviceUUIDs = listOf(ColmiUUIDs.SERVICE_V1), + manufacturerEntries = listOf(0xFF64 to bytes("0102")), + ) + assertEquals(RingDeviceType.COLMI_R02, matched) + } + + @Test + fun `an unrelated device with manufacturer data still matches nothing`() { + val matched = AdvertisementMatcher.match( + registry, name = "Galaxy Watch", serviceUUIDs = listOf("0000180f"), + manufacturerEntries = listOf(0x0075 to bytes("0102030405")), + ) + assertNull(matched) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/ColmiRealtimeHeartRateFallbackTest.kt b/app/src/test/java/com/pulseloop/ring/ColmiRealtimeHeartRateFallbackTest.kt new file mode 100644 index 00000000..7535eca5 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/ColmiRealtimeHeartRateFallbackTest.kt @@ -0,0 +1,155 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test + +/** + * Issue #55 — the Colmi R09 (`R09_9D07`, firmware `RT09_3.10.22_260420`) answers **every** `0x1E` + * realtime-HR frame with `9e ee`, so a workout showed no bpm at all. + * + * The vendor app never sends `0x1E`: no `BaseReqCmd` in the QRing decompile is built with opcode + * 30, and `BeanFactory` case 30 only *receives* it (`RealTimeHeartRateRsp`, a bare bpm push). + * `0x1E` as a request comes from GadgetBridge, which is where PulseLoop took it from. QRing's own + * live readings are `StartHeartRateReq.getSimpleReq(TYPE_HEARTRATE=1)` = `0x69 01 00`, stopped + * with `StopHeartRateReq.stopHeartRate` = `0x6A 01 00` — the path this ring answers. + */ +class ColmiRealtimeHeartRateFallbackTest { + + private class RecordingWriter : RingCommandWriter { + val commands = mutableListOf() + override fun enqueue(command: ByteArray) { commands.add(command) } + fun opcodes(): List = commands.map { it[0].toInt() and 0xFF } + fun clear() = commands.clear() + } + + /** The exact frame the R09 sends back, checksum included. */ + private fun rejection(): ByteArray = + ColmiPacket.frame(byteArrayOf(ColmiCommandID.REALTIME_HEART_RATE_ERROR.toByte(), 0xEE.toByte())) + + /** A `0x69` heart-rate stream frame: `[0x69, type=1, errCode, bpm]`. */ + private fun hrStreamFrame(bpm: Int, errCode: Int = 0): ByteArray = + ColmiPacket.frame(byteArrayOf( + ColmiCommandID.MANUAL_HEART_RATE.toByte(), + ColmiCommandID.RT_HEART_RATE.toByte(), + errCode.toByte(), + bpm.toByte(), + )) + + private fun engineWith(writer: RecordingWriter) = ColmiSyncEngine(writer, ColmiDecoder) + + @Test + fun `a rejected realtime request falls the session over to the 0x69 stream`() { + val writer = RecordingWriter() + val engine = engineWith(writer) + + engine.startHeartRate() + assertEquals( + "the probe is still 0x1E — rings that answer it keep the cheaper stream", + listOf(0x1E), writer.opcodes(), + ) + + writer.clear() + engine.handleRawNotify(rejection()) + + assertEquals(listOf(0x69), writer.opcodes()) + assertArrayEquals(byteArrayOf(0x69, 0x01), writer.commands.single()) + engine.destroy() + } + + @Test + fun `once rejected, later sessions start on 0x69 without re-probing`() { + val writer = RecordingWriter() + val engine = engineWith(writer) + + engine.startHeartRate() + engine.handleRawNotify(rejection()) + engine.stopHeartRate() + writer.clear() + + engine.startHeartRate() + assertEquals(listOf(0x69), writer.opcodes()) + engine.destroy() + } + + @Test + fun `the fallback stop reports the last bpm on 0x6A like QRing`() { + val writer = RecordingWriter() + val engine = engineWith(writer) + + engine.startHeartRate() + engine.handleRawNotify(rejection()) + // The ring streams; the engine tracks the newest reading for the stop frame. + for (frame in listOf(hrStreamFrame(71), hrStreamFrame(74))) { + engine.handleRawNotify(frame) + ColmiDecoder.decodeNormal(frame).forEach { engine.handle(it) } + } + writer.clear() + + engine.stopHeartRate() + assertArrayEquals(byteArrayOf(0x6A, 0x01, 74, 0x00), writer.commands.single()) + engine.destroy() + } + + @Test + fun `restarting an already-running fallback stream does not re-issue the start`() { + // RingSyncCoordinator.restartWorkoutHeartRateIfActive calls startHeartRate liberally. + val writer = RecordingWriter() + val engine = engineWith(writer) + + engine.startHeartRate() + engine.handleRawNotify(rejection()) + writer.clear() + + engine.startHeartRate() + engine.startHeartRate() + assertTrue("no duplicate 0x69 starts", writer.commands.isEmpty()) + engine.destroy() + } + + @Test + fun `an unsolicited rejection with no session running starts nothing`() { + val writer = RecordingWriter() + val engine = engineWith(writer) + + engine.handleRawNotify(rejection()) + assertTrue(writer.commands.isEmpty()) + + // …but it is remembered, so the next workout skips the probe. + engine.startHeartRate() + assertEquals(listOf(0x69), writer.opcodes()) + engine.destroy() + } + + @Test + fun `a ring that answers 0x1E keeps the realtime path and its continue keepalive`() { + val writer = RecordingWriter() + val engine = engineWith(writer) + + engine.startHeartRate() + // A well-behaved reply: [0x1E, bpm] (BeanFactory case 30 → RealTimeHeartRateRsp). + engine.handleRawNotify(ColmiPacket.frame(byteArrayOf(0x1E, 68))) + writer.clear() + + engine.stopHeartRate() + assertArrayEquals(byteArrayOf(0x1E, 0x02), writer.commands.single()) + engine.destroy() + } + + @Test + fun `the 0x9E opcode still decodes to a no-reading completion`() { + // Unchanged behaviour for a spot measure: 0x9E ends the attempt rather than idling out. + val events = ColmiDecoder.decodeNormal(rejection()) + assertTrue(events.single() is RingDecodedEvent.HeartRateComplete) + } + + @Test + fun `the 0x69 stream decodes to heart-rate samples`() { + val events = ColmiDecoder.decodeNormal(hrStreamFrame(66)) + assertEquals(66, (events.single() as RingDecodedEvent.HeartRateSample).bpm) + // errCode 1 is QRing's wearing-detection failure — a completion, not a sample. + assertTrue( + ColmiDecoder.decodeNormal(hrStreamFrame(0, errCode = 1)).single() + is RingDecodedEvent.HeartRateComplete + ) + } +} diff --git a/docs/qring-ble-adoption.md b/docs/qring-ble-adoption.md index dea00ffa..51fd29af 100644 --- a/docs/qring-ble-adoption.md +++ b/docs/qring-ble-adoption.md @@ -63,6 +63,10 @@ Decompiled refs: `sources/com/oudmon/ble/base/communication/req/BaseReqCmd.java` | `0x38` (56) | HRV auto | `HRVSettingReq` | `AUTO_HRV_PREF` | simple on/off pref | | `0x3A` (58) | Temp auto | (settings) | `AUTO_TEMP_PREF` | extra `0x03` framing byte | | `0x3C` (60) | **Device support / capabilities** | `DeviceSupportReq` / `DeviceSupportFunctionRsp` | `DEVICE_SUPPORT` (**added**) | carries `supportBlePair` — see §5 | +| `0x69` (105) | Spot / live measurement start | `StartHeartRateReq` / `StartHeartRateRsp` | `MANUAL_HEART_RATE` | the **only** measurement start QRing sends — see §4a | +| `0x6A` (106) | Measurement stop | `StopHeartRateReq` | `REALTIME_STOP` | `6a 00` | +| `0x1E` (30) | Realtime HR | **none — receive-only** | `REALTIME_HEART_RATE` | GadgetBridge-derived request; R09 rejects it — see §4a | +| `0x9E` (158) | Realtime-HR rejection | — | `REALTIME_HEART_RATE_ERROR` | `9e ee` = "I don't do `0x1E`" | | `0xBC` (188) | Big-data channel | `LargeDataHandler` | `BIG_DATA_V2` | **read/sync only** — NOT an enable switch | **Trap for the next investigator:** `0xBC` has `ACTION_Interval_Heart_Rate = 0x75` etc. @@ -113,6 +117,47 @@ reads only `v[2]`/`v[3]`, so it tolerates the longer reply unchanged. > misparse would make the seeding logic believe HR is already on and skip the enable. See > `ColmiSyncEngine.handleRawNotify`. +## 4a. Live workout HR — `0x1E` is not a QRing command, and the R09 refuses it + +**Symptom (issue #55):** on an R09 (`R09_9D07`, firmware `RT09_3.10.22_260420`) a workout showed +no bpm at all, while history sync, battery and spot SpO₂ all worked. + +**What the capture shows.** Every `0x1E` frame the app sent — start (`1e 01`), stop (`1e 02`) and +the 20 s continue (`1e 03`) alike — came back as `9e ee`, ~300 ms later, eleven for eleven. The +ring never streamed a single HR frame. In the same session `69 03 25` (spot SpO₂, the sibling +command) streamed warm-up frames for ~25 s and then returned real readings, so the link, the +sensor and the measurement family were all fine. + +**Where `0x1E` came from.** Not from the vendor. **No `BaseReqCmd` subclass in the QRing decompile +is constructed with opcode 30** — grep `super((byte)` across +`sources/com/oudmon/ble/base/communication/req/` and 30 is absent from the list. The SDK only +*receives* it: `BeanFactory` case 30 → `RealTimeHeartRateRsp`, whose `acceptData` reads a bare +`heart = bArr[0]`, i.e. an unsolicited bpm push. `0x1E` as a *request* is GadgetBridge's +(`YawellRingDeviceSupport.onEnableRealtimeHeartRateMeasurement` → `{CMD_REALTIME_HEART_RATE, +enable}`), and that is where PulseLoop's `realtimeHeartRate()` came from. GadgetBridge has no +handler for `0x9E` either, so it fails the same way on this firmware. + +**What QRing actually does for a live reading.** One command, `0x69`: +`StartHeartRateReq.getSimpleReq(TYPE_HEARTRATE = 1)` → `69 01 00`. The ring then streams +`[0x69, type, errCode, value]` frames (`StartHeartRateRsp`) until `StopHeartRateReq.stopHeartRate` +sends `6a 01 00`. `HeartActivity` just cancels its countdown on the first good frame; +`errCode == 1` is the wearing-detection failure. `StartHeartRateReq` *defines* a +`TYPE_REALTIMEHEARTRATE = 6` (`getRealtimeHeartRate`, with `ACTION_START/PAUSE/CONTINUE/STOP`), +but **nothing in the app calls it** — it is dead code, so it is a guess, not a reference. + +**The fix (implemented).** `ColmiSyncEngine` still probes with `0x1E` — it is one frame, and rings +that answer it keep the cheaper stream. A `0x9E` reply flips a sticky `realtimeRejected` flag, +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 the reading +away. `ColmiDecoder` already decoded `[0x69, 1, err, bpm]` into `HeartRateSample`, so nothing +downstream changed. Files: `ColmiSyncEngine.kt` (`onRealtimeHeartRateRejected`, +`startFallbackHeartRateStream`), tests in `ColmiRealtimeHeartRateFallbackTest.kt`. + +**Not yet hardware-validated:** that the R09 streams `0x69 01` continuously rather than stopping +after one reading. Both outcomes are an improvement over the current zero readings, but if a +capture shows the stream ending early, the re-arm window is the knob to shorten. + ## 5. Pairing — root causes and fixes PulseLoop's `RingBLEClient` had already adopted most of QRing's connection discipline From b66f7d88353219ca7697144797d19b47ee58d6b2 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 30 Aug 2026 13:30:55 -0700 Subject: [PATCH 2/2] fix(ring): address review findings, bump versionCode to 40 - 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 --- app/build.gradle.kts | 2 +- .../com/pulseloop/ring/ColmiSyncEngine.kt | 15 ++++++++++--- .../com/pulseloop/wearables/WearableModel.kt | 22 ++++++++++++++----- .../ring/AdvertisementMatcherTest.kt | 15 +++++++++++-- .../ColmiRealtimeHeartRateFallbackTest.kt | 22 +++++++++++++++++++ 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e3779f4..48c6b671 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,7 +21,7 @@ android { // versionCode/versionName are overridable from Gradle properties so the release CI // can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0). // Local builds fall back to the literals below. - versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 39 + versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 40 versionName = (project.findProperty("appVersionName") as String?) ?: "2.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt index 653dc6dc..af55537e 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt @@ -79,10 +79,14 @@ class ColmiSyncEngine( private val watchdogTimeoutMs = 10_000L private val activityWatchdogTimeoutMs = 20_000L - // Realtime HR keepalive - private var realtimeHRActive = false + // Realtime HR keepalive. + // Volatile: written on Main (start/stop actions), read on the notify thread — + // [handleRawNotify] → [onRealtimeHeartRateRejected] decides whether a `0x9E` is the reply to + // a live `0x1E` session or an unsolicited push, and a stale read there strands the workout + // with no bpm for its whole duration. + @Volatile private var realtimeHRActive = false private var realtimeKeepaliveJob: Job? = null - private var manualHRActive = false + @Volatile private var manualHRActive = false private var manualSpO2Active = false /** @@ -680,6 +684,11 @@ class ColmiSyncEngine( override fun stopSpO2() { if (!manualSpO2Active) return manualSpO2Active = false + // `0x6A` tears down the ring's whole realtime engine, HR included — that is exactly why + // RingSyncCoordinator follows every spot stop with restartWorkoutHeartRateIfActive(). Drop + // the HR bookkeeping with it, or that restart short-circuits on a stream the ring has + // already stopped and the workout shows no bpm until the idle keepalive re-arms. + manualHRActive = false writer?.enqueue(encoder.manualSpO2(enable = false)) } diff --git a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt index 425c3cdd..dc3a1f9d 100644 --- a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt +++ b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt @@ -222,15 +222,27 @@ data class WearableModel( * anchor themselves (`^…$`/`^…`), so `containsMatchIn` mirrors iOS's * `NSRegularExpression.firstMatch`. */ + /** + * Compiled once per distinct pattern, not once per call: four coordinators consult + * [modelForAdvertisedName] and [com.pulseloop.ring.AdvertisementMatcher] now walks the + * registry once per manufacturer block, so a single scan result used to recompile the + * whole catalog's regexes dozens of times on the scan callback thread. A pattern that + * fails to compile maps to `null` and simply never matches, as before. + */ + private val compiledNamePatterns: Map = + CATALOG.flatMap { it.advertisedNamePatterns }.distinct().associateWith { pattern -> + try { + Regex(pattern) + } catch (_: Exception) { + null + } + } + fun modelForAdvertisedName(advertisedName: String?): WearableModel? { if (advertisedName == null) return null return CATALOG.firstOrNull { model -> model.advertisedNamePatterns.any { pattern -> - try { - Regex(pattern).containsMatchIn(advertisedName) - } catch (_: Exception) { - false - } + compiledNamePatterns[pattern]?.containsMatchIn(advertisedName) == true } } } diff --git a/app/src/test/java/com/pulseloop/ring/AdvertisementMatcherTest.kt b/app/src/test/java/com/pulseloop/ring/AdvertisementMatcherTest.kt index 9b2343df..d43d26ac 100644 --- a/app/src/test/java/com/pulseloop/ring/AdvertisementMatcherTest.kt +++ b/app/src/test/java/com/pulseloop/ring/AdvertisementMatcherTest.kt @@ -59,12 +59,23 @@ class AdvertisementMatcherTest { /** Company 0x7810 (Yucheng) + the exact value bytes from the issue's nRF capture. */ private val aleHopManufacturer = listOf(0x7810 to bytes("d408770058ddeb05e1c70000bf0c4362b6005c000058ddeb05e1c7")) + /** + * The ring's advertised services, in the form `RingBLEClient` actually builds them + * (`scanRecord.serviceUuids.map { it.uuid.toString() }`): Android expands a 16-bit UUID to the + * full Bluetooth base UUID, so a short `"0000fee7"` here would silently miss any coordinator + * that matches on a service. + */ + private val aleHopServices = listOf( + "0000180d-0000-1000-8000-00805f9b34fb", // Heart Rate + "0000fee7-0000-1000-8000-00805f9b34fb", // Yucheng/Tencent + ) + @Test fun `the reported YCBT ring is claimed by the SmartHealth coordinator`() { val matched = AdvertisementMatcher.match( registry, name = "Ale-Hop2211 E1C7", - serviceUUIDs = listOf("0000180d", "0000fee7"), + serviceUUIDs = aleHopServices, manufacturerEntries = aleHopManufacturer, ) assertEquals(RingDeviceType.COLMI_SMART_HEALTH, matched) @@ -75,7 +86,7 @@ class AdvertisementMatcherTest { // Isolate the manufacturer path from the name path: a name the catalog does not claim. // With the company ID restored the Yucheng marker matches; with it stripped — the old // valueAt(0) behaviour — nothing in the registry claims the device at all. - val serviceUUIDs = listOf("0000180d", "0000fee7") + val serviceUUIDs = aleHopServices assertEquals( RingDeviceType.COLMI_SMART_HEALTH, AdvertisementMatcher.match(registry, "Unlabeled", serviceUUIDs, aleHopManufacturer), diff --git a/app/src/test/java/com/pulseloop/ring/ColmiRealtimeHeartRateFallbackTest.kt b/app/src/test/java/com/pulseloop/ring/ColmiRealtimeHeartRateFallbackTest.kt index 7535eca5..d882dd48 100644 --- a/app/src/test/java/com/pulseloop/ring/ColmiRealtimeHeartRateFallbackTest.kt +++ b/app/src/test/java/com/pulseloop/ring/ColmiRealtimeHeartRateFallbackTest.kt @@ -106,6 +106,28 @@ class ColmiRealtimeHeartRateFallbackTest { engine.destroy() } + @Test + fun `a spot SpO2 stop lets the workout stream be restarted straight away`() { + // 0x6A tears down the ring's whole realtime engine, so RingSyncCoordinator follows every + // spot stop with restartWorkoutHeartRateIfActive() -> startHeartRate(). That restart must + // actually re-issue the 0x69 start rather than short-circuit on stale HR bookkeeping, + // or the workout shows no bpm until the idle keepalive re-arms 30s later. + val writer = RecordingWriter() + val engine = engineWith(writer) + + engine.startHeartRate() + engine.handleRawNotify(rejection()) // fallback stream now running on 0x69 + + engine.startSpO2() + engine.stopSpO2() + writer.clear() + + engine.startHeartRate() // restartWorkoutHeartRateIfActive + assertEquals(listOf(0x69), writer.opcodes()) + assertArrayEquals(byteArrayOf(0x69, 0x01), writer.commands.single()) + engine.destroy() + } + @Test fun `an unsolicited rejection with no session running starts nothing`() { val writer = RecordingWriter()