diff --git a/app/src/main/java/com/pulseloop/ring/LuckRingCoordinator.kt b/app/src/main/java/com/pulseloop/ring/LuckRingCoordinator.kt index 065fa4bc..993a02bb 100644 --- a/app/src/main/java/com/pulseloop/ring/LuckRingCoordinator.kt +++ b/app/src/main/java/com/pulseloop/ring/LuckRingCoordinator.kt @@ -35,10 +35,19 @@ object LuckRingCoordinator : WearableCoordinator { /** * The baseline the LuckRing driver can decode: live + history HR, live + history SpO2 (with the - * all-day log), day steps, sleep, HRV, temperature, blood pressure, stress, and the in-band - * battery, plus the find-device buzz. All are unconditional promises -- every metric maps onto a + * all-day log), day steps, sleep, temperature, blood pressure, stress, and the in-band battery, + * plus the find-device buzz. All are unconditional promises -- every metric maps onto a * `LuckRing*` decoder path. * + * **HRV is deliberately omitted** (issue #112). The ring does send one HRV byte per reading + * (types 45 live / 42 history), and we decode it exactly as the vendor SDK's `K6_HeartStruct` + * does -- but nothing shows it is HRV in milliseconds. The TK18 sent 107-168 across one evening + * (masking the top bit didn't help), and no packet carries beat-to-beat intervals to compute HRV + * from. The official LuckRing app (1.6.3) stores the byte without ever displaying it: every HRV + * screen there shows `AppUtils.getRomHrv()`, a random number from the user's age band. So + * [LuckRingDriver] drops the byte, the history pass no longer requests it, and without + * [WearableCapability.HRV] the HRV card and everything keyed on it stay hidden for this family. + * * `bitmapGatedCapabilities` is empty on purpose. The K6 `FUNCTION_CONTROL` (dataType 22) bitmap is * obfuscated in the decompile, so no capability can yet be deferred to the connected unit; the * whole baseline stands as a family promise. TK18 is the only hardware-tested unit of this @@ -51,7 +60,6 @@ object LuckRingCoordinator : WearableCoordinator { WearableCapability.SPO2, WearableCapability.MANUAL_SPO2, WearableCapability.SPO2_HISTORY, WearableCapability.STEPS, WearableCapability.REALTIME_STEPS, WearableCapability.SLEEP, WearableCapability.BATTERY, - WearableCapability.HRV, WearableCapability.MANUAL_HRV, WearableCapability.TEMPERATURE, WearableCapability.BLOOD_PRESSURE, WearableCapability.MANUAL_BLOOD_PRESSURE, WearableCapability.STRESS, diff --git a/app/src/main/java/com/pulseloop/ring/LuckRingDriver.kt b/app/src/main/java/com/pulseloop/ring/LuckRingDriver.kt index 84f93c22..c95fdfc7 100644 --- a/app/src/main/java/com/pulseloop/ring/LuckRingDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/LuckRingDriver.kt @@ -56,8 +56,17 @@ class LuckRingDriver(private val writer: RingCommandWriter?) : WearableDriver { historySync.noteReceived(frame.dataType) } - return decoder.decode(frame) + return decoder.decode(frame).filterNot(::isHrv) } + /** + * The ring's HRV byte isn't kept (issue #112, see [LuckRingCoordinator.capabilities]). The + * history pass no longer asks for it, but the ring can still push a live HRV frame on its own. + * That frame is still ACKed above and still shows in the raw-packet feed. + */ + private fun isHrv(event: RingDecodedEvent): Boolean = + event is RingDecodedEvent.HrvSample || + (event is RingDecodedEvent.HistoryMeasurement && event.kind_field == MeasurementKind.HRV) + override fun makeSyncEngine(): RingSyncEngine = LuckRingSyncEngine(writer, historySync) } diff --git a/app/src/main/java/com/pulseloop/ring/LuckRingHistorySync.kt b/app/src/main/java/com/pulseloop/ring/LuckRingHistorySync.kt index 04ce8079..47c2f887 100644 --- a/app/src/main/java/com/pulseloop/ring/LuckRingHistorySync.kt +++ b/app/src/main/java/com/pulseloop/ring/LuckRingHistorySync.kt @@ -29,14 +29,15 @@ class LuckRingHistorySync( private val progressSink: ((PulseEvent) -> Unit)? = null, ) { companion object { - /** The full catalog, in request order. `mixSport` (10 -- workout records) is skipped in v1. */ + /** The full catalog, in request order. `mixSport` (10 -- workout records) is skipped in v1, + * and HRV (42) is never requested: the app doesn't use its value (issue #112, see + * [LuckRingCoordinator.capabilities]). */ val catalog: List = listOf( LuckRingDataType.HISTORY_SPORT, // 5 LuckRingDataType.SLEEP, // 6 LuckRingDataType.HISTORY_HEART, // 8 LuckRingDataType.HISTORY_O2, // 40 LuckRingDataType.HISTORY_BP, // 41 - LuckRingDataType.HISTORY_HRV, // 42 LuckRingDataType.HISTORY_TEMP, // 47 LuckRingDataType.STRESS_HISTORY, // 53 ) diff --git a/app/src/test/java/com/pulseloop/ring/LuckRingDriverTest.kt b/app/src/test/java/com/pulseloop/ring/LuckRingDriverTest.kt index 9f517ecb..ae22d5bc 100644 --- a/app/src/test/java/com/pulseloop/ring/LuckRingDriverTest.kt +++ b/app/src/test/java/com/pulseloop/ring/LuckRingDriverTest.kt @@ -42,6 +42,37 @@ class LuckRingDriverTest { assertEquals(90, battery.percent) } + // MARK: HRV omitted (issue #112) + + @Test + fun `hrv frames are acked but never surface as readings`() { + val writer = FakeWriter() + val driver = LuckRingDriver(writer) + // One `[time u32 LE][value u8]` record behind the `[total u16][items u8]` envelope -- the + // 141 is one of the TK18 readings from issue #112. + val record = listOf(5u, 0u, 1u, 0x80u, 0x4Fu, 0x09u, 0x69u, 141u) + + for ((seq, type) in listOf(LuckRingDataType.REAL_HRV, LuckRingDataType.HISTORY_HRV).withIndex()) { + val frame = LuckRingFrame(LuckRingCmdType.SEND, type, record, seq = seq.toUByte(), devType = 2u) + assertTrue("decoder still reads the record", LuckRingDecoder.decode(frame).any { + it is RingDecodedEvent.HrvSample || it is RingDecodedEvent.HistoryMeasurement + }) + val events = driver.ingest(packets(frame)[0], LuckRingUUIDs.NOTIFY) + assertTrue("type $type must not yield HRV, got $events", events.none { + it is RingDecodedEvent.HrvSample || + (it is RingDecodedEvent.HistoryMeasurement && it.kind_field == MeasurementKind.HRV) + }) + } + assertEquals("each HRV SEND is still ACKed", 2, writer.sent.size) + } + + @Test + fun `luckring declares no hrv capability`() { + val caps = LuckRingCoordinator.capabilities + assertFalse(WearableCapability.HRV in caps) + assertFalse(WearableCapability.MANUAL_HRV in caps) + } + @Test fun `device ack is not acked`() { val writer = FakeWriter() diff --git a/app/src/test/java/com/pulseloop/ring/LuckRingHistorySyncTest.kt b/app/src/test/java/com/pulseloop/ring/LuckRingHistorySyncTest.kt index e2218420..0128ac47 100644 --- a/app/src/test/java/com/pulseloop/ring/LuckRingHistorySyncTest.kt +++ b/app/src/test/java/com/pulseloop/ring/LuckRingHistorySyncTest.kt @@ -93,7 +93,7 @@ class LuckRingHistorySyncTest { @Test fun `catalog and vitals subsets are what we expect`() { - assertEquals(listOf(5u, 6u, 8u, 40u, 41u, 42u, 47u, 53u), LuckRingHistorySync.catalog) + assertEquals(listOf(5u, 6u, 8u, 40u, 41u, 47u, 53u), LuckRingHistorySync.catalog) assertEquals(listOf(8u, 40u), LuckRingHistorySync.vitalsTypes) } } diff --git a/docs/ios-sync.md b/docs/ios-sync.md index cbe848bd..4347e051 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -51,7 +51,7 @@ screen that does not exist yet. |---|------|----------------------|------|--------| | 1 | **#138 RWfit reliability (modern `0xAB` protocol)** | The JieLi path's whole session model: ordered init, capabilities from the `0263` function menu, legacy probe-then-fallback framing choice, legacy bind, buffered multi-frame deframer, write-confirmed + reply-matched command gate, paged history with `05 xx 30` deletes and a sleep journal, today's steps `051A`, `0609`-as-status + `02` live keys, one-at-a-time manual readings, sync outcome states. Per-item verdicts and the **vendor conflicts** (temperature, `0609` ACK, `0302` payload) are in the 2026-10-07 triage note — iOS followed the RWFitSDK GitHub SDK here, not the vendor app, so take bytes from `decompiled-rwfit-official/` wherever they differ. Start with the init + function menu (items 1–2) and paged history (item 7). | L | ✅ ready to start; ⛔ needs hardware to validate | | 2 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | -| 3 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | +| 3 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, and the only hardware report so far is one user's TK18 in Android issue #112. **Deliberate divergence (#112):** Android no longer declares `HRV`/`MANUAL_HRV` for this family, skips history type 42 and drops live type 45 in `LuckRingDriver`. The byte doesn't behave like HRV in ms (107–168), and the vendor app never shows it (`AppUtils.getRomHrv()` displays a random age-band value instead); see `LuckRingCoordinator.capabilities`. iOS still declares `.hrv, .manualHrv` (`LuckRingCoordinator.swift:55`), so it is an upstream candidate. | — | ⛔ needs hardware | | 4 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. The `S` is the *iOS* fix; Android has no Activity-trends screen at all, so the real scope is building the screen first. This is the one remaining **feature** gap — it is not blocked on hardware. | S (iOS) / L (Android) | ⛔ blocked — Android has no Activity-trends screen to fix | ### Not on this list, and why