Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions app/src/main/java/com/pulseloop/ring/LuckRingCoordinator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion app/src/main/java/com/pulseloop/ring/LuckRingDriver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
5 changes: 3 additions & 2 deletions app/src/main/java/com/pulseloop/ring/LuckRingHistorySync.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<UByte> = 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
)
Expand Down
31 changes: 31 additions & 0 deletions app/src/test/java/com/pulseloop/ring/LuckRingDriverTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<UByte>(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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class LuckRingHistorySyncTest {

@Test
fun `catalog and vitals subsets are what we expect`() {
assertEquals(listOf<UByte>(5u, 6u, 8u, 40u, 41u, 42u, 47u, 53u), LuckRingHistorySync.catalog)
assertEquals(listOf<UByte>(5u, 6u, 8u, 40u, 41u, 47u, 53u), LuckRingHistorySync.catalog)
assertEquals(listOf<UByte>(8u, 40u), LuckRingHistorySync.vitalsTypes)
}
}
2 changes: 1 addition & 1 deletion docs/ios-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading