From 32ca8d5095217c0dd74e72aa81e5421a6c1389a2 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 19:38:33 +0200 Subject: [PATCH 01/36] ci: test feature branches --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0e1e75e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: + - 'feature/**' + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + - name: Unit tests + run: ./gradlew --no-daemon testDebugUnitTest + - name: Build debug APK + run: ./gradlew --no-daemon :app:assembleDebug From b9fadad0d44108b4dfd6f570a8caaff027bbeee4 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 19:39:05 +0200 Subject: [PATCH 02/36] ci: allow manual feature builds --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e1e75e..4e56c77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: - 'feature/**' From 6be668f88a496fbab981098ba5a5bac09b382e66 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 20:05:17 +0200 Subject: [PATCH 03/36] feat: add YCBT R10M ring support --- .../java/com/pulseloop/ring/RingBLEClient.kt | 1 + .../com/pulseloop/ring/RingDecodedEvent.kt | 62 +++- .../com/pulseloop/ring/RingEventBridge.kt | 20 ++ .../com/pulseloop/ring/WearableCapability.kt | 7 +- .../java/com/pulseloop/ring/WearableDriver.kt | 8 + .../com/pulseloop/ring/YCBTCoordinator.kt | 76 +++++ .../java/com/pulseloop/ring/YCBTDecoder.kt | 164 +++++++++ .../java/com/pulseloop/ring/YCBTDriver.kt | 93 +++++ .../java/com/pulseloop/ring/YCBTEncoder.kt | 175 ++++++++++ .../com/pulseloop/ring/YCBTHealthRecords.kt | 255 ++++++++++++++ .../com/pulseloop/ring/YCBTHistoryTransfer.kt | 156 +++++++++ .../java/com/pulseloop/ring/YCBTProtocol.kt | 322 ++++++++++++++++++ .../java/com/pulseloop/ring/YCBTSyncEngine.kt | 126 +++++++ .../com/pulseloop/wearables/WearableModel.kt | 18 +- .../com/pulseloop/ring/YCBTDecoderTest.kt | 180 ++++++++++ .../java/com/pulseloop/ring/YCBTDriverTest.kt | 143 ++++++++ .../com/pulseloop/ring/YCBTEncoderTest.kt | 138 ++++++++ .../pulseloop/ring/YCBTHealthRecordsTest.kt | 284 +++++++++++++++ .../pulseloop/ring/YCBTHistoryTransferTest.kt | 260 ++++++++++++++ .../com/pulseloop/ring/YCBTProtocolTest.kt | 122 +++++++ 20 files changed, 2607 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt create mode 100644 app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt create mode 100644 app/src/main/java/com/pulseloop/ring/YCBTDriver.kt create mode 100644 app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt create mode 100644 app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt create mode 100644 app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt create mode 100644 app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt create mode 100644 app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt create mode 100644 app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt create mode 100644 app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt create mode 100644 app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt create mode 100644 app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt create mode 100644 app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt create mode 100644 app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 57ef796..f83879e 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -34,6 +34,7 @@ class RingBLEClient(private val context: Context) { /** Registry of supported wearables. Adding a wearable = append one entry. */ private val coordinators: List = listOf( JringCoordinator, + YCBTCoordinator, ColmiCoordinator, ) diff --git a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt index 7bcd706..0775382 100644 --- a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt +++ b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt @@ -14,7 +14,9 @@ enum class MeasurementKind(val key: String, val unit: String) { TEMPERATURE("temp", "°C"), BLOOD_PRESSURE_SYSTOLIC("bp_sys", "mmHg"), BLOOD_PRESSURE_DIASTOLIC("bp_dia", "mmHg"), - BLOOD_SUGAR("glucose", "mg/dL"); + BLOOD_SUGAR("glucose", "mg/dL"), + RESPIRATORY_RATE("respiratoryRate", "brpm"), + VO2MAX("vo2max", "mL/kg/min"); } /** @@ -81,6 +83,12 @@ sealed class RingDecodedEvent { is CommandAck -> Instant.EPOCH is FirmwareVersion -> Instant.EPOCH is BindNotify -> Instant.EPOCH + is MeasurementRejected -> Instant.EPOCH + is WearingStatus -> this._timestamp + is SupportFunctions -> Instant.EPOCH + is ChipScheme -> Instant.EPOCH + is BloodPressureSample -> this._timestamp + is BloodSugarSample -> this._timestamp is Unknown -> Instant.EPOCH } @@ -267,6 +275,58 @@ sealed class RingDecodedEvent { override val debugJSON = "{}" } + data class MeasurementRejected( + val mode: Int, + ) : RingDecodedEvent() { + override val kind = "measurement_rejected" + override val confidence = DecodeConfidence.KNOWN + override val debugJSON = """{"rejected_mode":$mode}""" + } + + data class WearingStatus( + val worn: Boolean, + val _timestamp: Instant, + ) : RingDecodedEvent() { + override val kind = "wearing_status" + override val confidence = DecodeConfidence.KNOWN + override val debugJSON = "{}" + } + + data class SupportFunctions( + val capabilities: Set, + ) : RingDecodedEvent() { + override val kind = "support_functions" + override val confidence = DecodeConfidence.KNOWN + override val debugJSON = "{}" + } + + data class ChipScheme( + val value: Int, + ) : RingDecodedEvent() { + override val kind = "chip_scheme" + override val confidence = DecodeConfidence.KNOWN + override val debugJSON = """{"scheme":$value}""" + } + + data class BloodPressureSample( + val systolic: Int, + val diastolic: Int, + val _timestamp: Instant, + ) : RingDecodedEvent() { + override val kind = "blood_pressure_sample" + override val confidence = DecodeConfidence.KNOWN + override val debugJSON = """{"sys":$systolic,"dia":$diastolic}""" + } + + data class BloodSugarSample( + val mgdl: Double, + val _timestamp: Instant, + ) : RingDecodedEvent() { + override val kind = "blood_sugar_sample" + override val confidence = DecodeConfidence.KNOWN + override val debugJSON = """{"mgdl":$mgdl}""" + } + data class Unknown( val commandId: UByte, val raw: ByteArray diff --git a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt index 3af9c17..2e8ba19 100644 --- a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt +++ b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt @@ -9,6 +9,7 @@ import java.time.temporal.ChronoUnit */ object RingEventBridge { private val hrRange = 30..220 + val spo2Range = 70..100 private val stressRange = 1..100 private val hrvRange = 1..300 private val temperatureRange = 30.0..45.0 @@ -88,6 +89,25 @@ object RingEventBridge { is RingDecodedEvent.BindNotify -> emptyList() // Bind/unbind handshake is driven in the sync engine / BLE client + + is RingDecodedEvent.MeasurementRejected, + is RingDecodedEvent.WearingStatus, + is RingDecodedEvent.SupportFunctions, + is RingDecodedEvent.ChipScheme -> + emptyList() // Debug-feed only; no product surface yet + + is RingDecodedEvent.BloodPressureSample -> { + // Live BP has no dedicated PulseEvent; surface as upserting history rows so the reading lands. + val events = mutableListOf() + if (decoded.systolic > 0) events.add(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, decoded.systolic.toDouble(), decoded._timestamp)) + if (decoded.diastolic > 0) events.add(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, decoded.diastolic.toDouble(), decoded._timestamp)) + events + } + + is RingDecodedEvent.BloodSugarSample -> { + if (decoded.mgdl > 0) listOf(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, decoded.mgdl, decoded._timestamp)) + else emptyList() + } } private fun isPlausibleSleepStart(start: Instant, now: Instant): Boolean { diff --git a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt index f92dfff..20b27ce 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt @@ -27,11 +27,14 @@ enum class WearableCapability(val key: String) { // Interaction capabilities MANUAL_HEART_RATE("manualHeartRate"), MANUAL_SPO2("manualSpo2"), + MANUAL_BLOOD_PRESSURE("manualBloodPressure"), + MANUAL_HRV("manualHrv"), REALTIME_HEART_RATE("realtimeHeartRate"), REALTIME_STEPS("realtimeSteps"), FIND_DEVICE("findDevice"), POWER_OFF("powerOff"), FACTORY_RESET("factoryReset"), + SPO2_HISTORY("spo2History"), // Configurable all-day measurement: the device exposes a settable HR sampling interval and // per-vital monitoring toggles (Colmi `0x16` + prefs). The generic jring has no such control, @@ -55,5 +58,7 @@ enum class RingDeviceType(val displayName: String) { JRING("SMART_RING"), // One protocol family covering the whole Colmi/Yawell line — the exact model comes from // WearableModel (iOS #49), so the family label stays honest about the ambiguity. - COLMI_R02("Colmi / Yawell ring"); + COLMI_R02("Colmi / Yawell ring"), + // Yucheng YCBT protocol family (SmartHealth app): TK5, R10M, and other SmartHealth-branded rings. + YCBT("YCBT ring"); } diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index 2a2fcab..6adff06 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -95,6 +95,10 @@ interface RingSyncEngine { fun runStartup() fun handle(event: RingDecodedEvent) + /** Re-run the full history sync without re-sending the connect handshake. No-op on devices + * that don't support staged history sync. */ + fun syncHistory() {} + /** On-demand, standalone sleep fetch — request just the sleep record without running the * whole history pipeline (which buries sleep behind activity/HR/stress/SpO₂ and can lose it * to a watchdog stage-skip). Mirrors the official QRing app, which fires a dedicated sleep @@ -106,6 +110,10 @@ interface RingSyncEngine { fun measureHeartRateSpot() { startHeartRate() } fun startSpO2() fun stopSpO2() + fun startHRV() {} + fun stopHRV() {} + fun startBloodPressure() {} + fun stopBloodPressure() {} /** Combined measurement: HR + systolic + diastolic + SpO₂ + fatigue + stress + blood sugar + HRV. No-op if unsupported. */ fun startCombinedMeasurement() {} fun stopCombinedMeasurement() {} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt new file mode 100644 index 0000000..b95528f --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt @@ -0,0 +1,76 @@ +package com.pulseloop.ring + +import com.pulseloop.wearables.WearableModel + +/** + * Ported from ColmiSmartHealthCoordinator.swift / TK5Coordinator.swift. + * Coordinator for YCBT rings (SmartHealth app) — R10M and siblings. + */ + +object YCBTCoordinator : WearableCoordinator { + override val deviceType = RingDeviceType.YCBT + + private val manufacturerHexMarker = "1078" + + override fun matches(name: String?, advertisement: AdvertisementInfo): Boolean { + // Disqualify QRing service outright — those belong to ColmiDriver. + val qringServices = listOf( + "6e40fff0-b5a3-f393-e0a9-e50e24dcca9e", + "de5bf728-d711-4ed6-8383-f4b9c014c20c", + ) + if (advertisement.serviceUUIDs.any { it in qringServices }) return false + + val model = WearableModel.modelForAdvertisedName(name) + if (model != null) { + return model.family == deviceType && isSmartHealthName(name) + } + + val manufacturer = advertisement.manufacturerData + if (manufacturer != null && manufacturer.toHexString().startsWith(manufacturerHexMarker)) { + // Manufacturer marker is corroborating evidence only; do not override a name match. + return true + } + return false + } + + private fun isSmartHealthName(name: String?): Boolean { + if (name == null) return false + return Regex("^[A-Za-z0-9]+( [A-Za-z0-9]+)* [0-9A-Fa-f]{4}$").matches(name) + } + + override val capabilities = setOf( + WearableCapability.HEART_RATE, + WearableCapability.SPO2, + WearableCapability.SPO2_HISTORY, + WearableCapability.STEPS, + WearableCapability.SLEEP, + WearableCapability.REM_SLEEP, + WearableCapability.BATTERY, + WearableCapability.MANUAL_HEART_RATE, + WearableCapability.MANUAL_SPO2, + WearableCapability.REALTIME_HEART_RATE, + WearableCapability.REALTIME_STEPS, + WearableCapability.FIND_DEVICE, + WearableCapability.MEASUREMENT_INTERVAL, + ) + + override val bitmapGatedCapabilities = setOf( + WearableCapability.TEMPERATURE, + WearableCapability.BLOOD_PRESSURE, + WearableCapability.MANUAL_BLOOD_PRESSURE, + WearableCapability.STRESS, + WearableCapability.FATIGUE, + WearableCapability.BLOOD_SUGAR, + WearableCapability.HRV, + WearableCapability.MANUAL_HRV, + ) + + override val iconSystemName = "circle.circle.fill" + + override fun makeDriver(writer: RingCommandWriter): WearableDriver { + return YCBTDriver(writer) + } +} + +/** Hex extension matching iOS hexString. */ +fun ByteArray.toHexString(): String = joinToString("") { String.format("%02x", it) } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt new file mode 100644 index 0000000..dfc11a3 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt @@ -0,0 +1,164 @@ +package com.pulseloop.ring + +import java.time.Instant + +/** + * Ported from YCBTDecoder.swift. + * Decodes inbound YCBT frames into the shared RingDecodedEvent. + */ + +class YCBTDecoder { + + fun decode(frame: YCBTFrame, now: Instant = Instant.now(), startedMode: Int? = null): List { + return when (frame.type) { + YCBTGroup.REAL -> decodeRealStream(frame, now) + YCBTGroup.DEV_CONTROL -> decodeDevControlPush(frame.cmd, payload = frame.payload, now = now) + YCBTGroup.GET -> decodeGetReply(frame) + YCBTGroup.APP_CONTROL -> if (frame.cmd == YCBTCommand.LIVE_MEASUREMENT) { + decodeMeasurementStartReply(frame.payload, startedMode = startedMode) + } else { + listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } + YCBTGroup.SETTING -> if (frame.cmd == YCBTSettingKey.SET_TIME) { + listOf(RingDecodedEvent.TimeSyncAck(_timestamp = now)) + } else { + listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } + else -> listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } + } + + private fun decodeMeasurementStartReply(payload: ByteArray, startedMode: Int?): List { + val ack: List = listOf(RingDecodedEvent.CommandAck(commandId = YCBTCommand.LIVE_MEASUREMENT.toUByte())) + if (payload.size != 1) return ack + val status = payload[0].toInt() and 0xFF + if (YCBTMeasurementMode.isAccepted(status) || startedMode == null) return ack + return listOf(RingDecodedEvent.MeasurementRejected(mode = startedMode)) + } + + private fun decodeRealStream(frame: YCBTFrame, now: Instant): List { + val p = frame.payload + return when (frame.cmd) { + YCBTCommand.LIVE_STATUS -> { + if (p.size < 2) return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + listOf(RingDecodedEvent.ActivityUpdate( + _timestamp = now, + steps = YCBTBytes.u16(p, 0), + distanceMeters = YCBTBytes.u16(p, 2), + calories = YCBTBytes.u16(p, 4), + )) + } + YCBTCommand.LIVE_HEART_RATE -> { + val bpm = p.firstOrNull()?.toInt() and 0xFF ?: return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + return listOf(RingDecodedEvent.HeartRateSample(bpm = bpm, _timestamp = now)) + } + YCBTCommand.LIVE_SPO2 -> { + val spo2 = p.firstOrNull()?.toInt() and 0xFF ?: return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + if (spo2 in RingEventBridge.spo2Range) { + return listOf(RingDecodedEvent.Spo2Result(value = spo2, _timestamp = now)) + } + return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } + YCBTCommand.LIVE_VITALS -> decodeLiveVitals(p, cmd = frame.cmd, now = now) + YCBTCommand.LIVE_BATTERY -> { + if (p.size < 2) return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + listOf(RingDecodedEvent.Battery(percent = p[1].toInt() and 0xFF)) + } + YCBTCommand.LIVE_WEARING_STATUS -> { + if (p.size < 5) return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + listOf(RingDecodedEvent.WearingStatus( + worn = p[4].toInt() and 0xFF != 0, + _timestamp = YCBTBytes.date(YCBTBytes.u32(p, 0)) + )) + } + else -> listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } + } + + private fun decodeLiveVitals(p: ByteArray, cmd: Int, now: Instant): List { + val events = mutableListOf() + if (p.size >= 2 && p[0].toInt() and 0xFF > 0 && p[1].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.BloodPressureSample(systolic = p[0].toInt() and 0xFF, diastolic = p[1].toInt() and 0xFF, _timestamp = now)) + } + if (p.size >= 3 && p[2].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HeartRateSample(bpm = p[2].toInt() and 0xFF, _timestamp = now)) + } + if (p.size >= 4 && p[3].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HrvSample(value = p[3].toInt() and 0xFF, _timestamp = now)) + } + if (p.size >= 5 && (p[4].toInt() and 0xFF) in RingEventBridge.spo2Range) { + events.add(RingDecodedEvent.Spo2Result(value = p[4].toInt() and 0xFF, _timestamp = now)) + } + if (p.size >= 7 && p[5].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.TemperatureSample( + celsius = YCBTHealthRecords.composite(p[5].toInt() and 0xFF, p[6].toInt() and 0xFF), + _timestamp = now + )) + } + return if (events.isEmpty()) listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) else events + } + + private fun decodeGetReply(frame: YCBTFrame): List { + return when (frame.cmd) { + YCBTCommand.GET_DEVICE_INFO -> decodeDeviceInfo(frame.payload) + YCBTCommand.GET_SUPPORT_FUNCTION -> decodeSupportFunction(frame.payload) + YCBTCommand.GET_CHIP_SCHEME -> decodeChipScheme(frame.payload) + else -> listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + } + } + + private fun decodeDeviceInfo(p: ByteArray): List { + val events = mutableListOf(RingDecodedEvent.Status(address = null)) + if (p.size >= 4) { + events.add(RingDecodedEvent.FirmwareVersion(version = String.format("%d.%02d", p[3].toInt() and 0xFF, p[2].toInt() and 0xFF))) + } + if (p.size >= 6) { + events.add(RingDecodedEvent.Battery(percent = p[5].toInt() and 0xFF)) + } + return events + } + + private fun decodeSupportFunction(p: ByteArray): List { + val caps = YCBTSupportFunction.capabilities(p) + return listOf(RingDecodedEvent.SupportFunctions(caps)) + } + + private fun decodeChipScheme(p: ByteArray): List { + val scheme = YCBTChipScheme.value(p) + return listOf(RingDecodedEvent.ChipScheme(value = scheme)) + } + + private fun decodeDevControlPush(cmd: Int, payload: ByteArray, now: Instant): List { + return when (cmd) { + YCBTDevControl.MEASUREMENT_STATUS -> { + val events = measurementStatusEvents(payload, now) + if (events.isEmpty()) listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) else events + } + YCBTDevControl.MEASUREMENT_RESULT -> { + listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) + } + else -> listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) + } + } + + private fun measurementStatusEvents(p: ByteArray, now: Instant): List { + if (p.size < 3) return emptyList() + val value = p[2].toInt() and 0xFF + val fraction = if (p.size >= 4) p[3].toInt() and 0xFF else 0 + return when (p[0].toInt() and 0xFF) { + YCBTMeasurementMode.HEART_RATE -> if (value > 0) listOf(RingDecodedEvent.HeartRateSample(bpm = value, _timestamp = now)) else emptyList() + YCBTMeasurementMode.BLOOD_PRESSURE -> if (value > 0 && fraction > 0) listOf( + RingDecodedEvent.BloodPressureSample(systolic = value, diastolic = fraction, _timestamp = now) + ) else emptyList() + YCBTMeasurementMode.SPO2 -> if (value in RingEventBridge.spo2Range) listOf(RingDecodedEvent.Spo2Result(value = value, _timestamp = now)) else emptyList() + YCBTMeasurementMode.TEMPERATURE -> if (value > 0) listOf( + RingDecodedEvent.TemperatureSample(celsius = YCBTHealthRecords.composite(value, fraction), _timestamp = now) + ) else emptyList() + YCBTMeasurementMode.BLOOD_SUGAR -> { + val tenths = value * 10 + fraction + if (tenths > 0) listOf(RingDecodedEvent.BloodSugarSample(mgdl = YCBTHealthRecords.bloodSugarMgdl(tenths), _timestamp = now)) else emptyList() + } + else -> emptyList() + } + } +} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt new file mode 100644 index 0000000..03edf33 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt @@ -0,0 +1,93 @@ +package com.pulseloop.ring + +/** + * Ported from YCBTDriver.swift. + * YCBT driver. Owns the length-prefixed CRC16 framing and the split-channel topology. + */ + +class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { + private val decoder = YCBTDecoder() + private val assembler = YCBTFrameAssembler() + private val transfer: YCBTHistoryTransfer = YCBTHistoryTransfer(writer = writer) + + /** Queue of pending measurement-reply modes: the mode for a start, null for a stop. */ + private val pendingMeasurementReplies = ArrayDeque(8) + + companion object { + private const val MAX_PENDING_MEASUREMENT_REPLIES = 8 + } + + override val serviceUUIDs = listOf(YCBTUUIDs.SERVICE) + override val writeUUID = YCBTUUIDs.COMMAND + override val notifyUUIDs = listOf(YCBTUUIDs.COMMAND, YCBTUUIDs.STREAM) + override val commandUUID = YCBTUUIDs.COMMAND + override val batteryServiceUUID: String? = null + override val batteryCharUUID: String? = null + + override fun frame(command: ByteArray): ByteArray { + val logical = command + noteLiveMeasurementCommand(logical) + return YCBTFrame.frame(logical) + } + + override fun usesCommandChannel(frame: ByteArray): Boolean = false + + private fun noteLiveMeasurementCommand(logical: ByteArray) { + if (logical.size >= 4 && + (logical[0].toInt() and 0xFF) == YCBTGroup.APP_CONTROL && + (logical[1].toInt() and 0xFF) == YCBTCommand.LIVE_MEASUREMENT) { + pendingMeasurementReplies.add(if (logical[2].toInt() and 0xFF == 1) logical[3].toInt() and 0xFF else null) + if (pendingMeasurementReplies.size > MAX_PENDING_MEASUREMENT_REPLIES) { + pendingMeasurementReplies.removeFirst() + } + } + } + + fun connectionDidStart() { + assembler.reset() + transfer.cancel() + pendingMeasurementReplies.clear() + } + + fun connectionDidEnd() { + assembler.reset() + transfer.cancel() + pendingMeasurementReplies.clear() + } + + override fun ingest(data: ByteArray, from: String): List { + val events = mutableListOf() + for (logical in assembler.append(data, from)) { + val frame = YCBTFrame.validating(logical) ?: run { + events.add(RingDecodedEvent.Unknown(commandId = logical.firstOrNull()?.toUByte() ?: 0u, raw = logical)) + continue + } + when (frame.type) { + YCBTGroup.HEALTH -> { + events.addAll(transfer.handle(cmd = frame.cmd, payload = frame.payload)) + } + YCBTGroup.DEV_CONTROL -> { + acknowledgePush(frame) + events.addAll(decoder.decode(frame)) + } + YCBTGroup.APP_CONTROL -> if (frame.cmd == YCBTCommand.LIVE_MEASUREMENT) { + val startedMode = if (pendingMeasurementReplies.isEmpty()) null else pendingMeasurementReplies.removeFirst() + events.addAll(decoder.decode(frame, startedMode = startedMode)) + } else { + events.addAll(decoder.decode(frame)) + } + else -> events.addAll(decoder.decode(frame)) + } + } + return events + } + + private fun acknowledgePush(frame: YCBTFrame) { + if (YCBTFrameError.detect(frame.payload) != null) return + writer.enqueue(YCBTDevControl.ack(key = frame.cmd)) + } + + override fun makeSyncEngine(): RingSyncEngine { + return YCBTSyncEngine(writer = writer, transfer = transfer) + } +} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt new file mode 100644 index 0000000..3a5802a --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt @@ -0,0 +1,175 @@ +package com.pulseloop.ring + +import java.time.Instant +import java.util.TimeZone + +/** + * Ported from YCBTEncoder.swift and YCBTSettingsEncoder.swift. + * Builds *logical* YCBT commands — [type, cmd, payload…] without the length field or CRC, + * which YCBTDriver.frame appends. + */ + +object YCBTSettingKey { + const val SET_TIME: Int = 0x00 + const val USER_INFO: Int = 0x03 + const val UNITS: Int = 0x04 + const val HEART_MONITOR: Int = 0x0c + const val LANGUAGE: Int = 0x12 + const val BLOOD_PRESSURE_MONITOR: Int = 0x1c + const val TEMPERATURE_MONITOR: Int = 0x20 + const val BLOOD_OXYGEN_MONITOR: Int = 0x26 + const val HRV_MONITOR: Int = 0x45 +} + +class YCBTSettingsEncoder { + companion object { + const val MINIMUM_INTERVAL_MINUTES = 30 + const val DEFAULT_INTERVAL_MINUTES = 60 + + fun clampInterval(minutes: Int): Int { + if (minutes <= 0) return DEFAULT_INTERVAL_MINUTES + return minOf(255, maxOf(MINIMUM_INTERVAL_MINUTES, minutes)) + } + } + + /** 01 00 + [year:u16 LE][month][day][hour][min][sec][weekday]. Weekday is Mon=0 … Sun=6. */ + fun setTime(date: Instant = Instant.now(), timeZone: TimeZone = TimeZone.getDefault()): ByteArray { + val calendar = java.util.Calendar.getInstance(timeZone).apply { time = java.util.Date(date.toEpochMilli()) } + val year = calendar.get(java.util.Calendar.YEAR) + val gregorianWeekday = calendar.get(java.util.Calendar.DAY_OF_WEEK) // 1=Sun, 2=Mon... + val weekday = if (gregorianWeekday == 1) 6 else gregorianWeekday - 2 + return byteArrayOf( + YCBTGroup.SETTING.toByte(), YCBTSettingKey.SET_TIME.toByte(), + (year and 0xFF).toByte(), ((year shr 8) and 0xFF).toByte(), + (calendar.get(java.util.Calendar.MONTH) + 1).toByte(), + calendar.get(java.util.Calendar.DAY_OF_MONTH).toByte(), + calendar.get(java.util.Calendar.HOUR_OF_DAY).toByte(), + calendar.get(java.util.Calendar.MINUTE).toByte(), + calendar.get(java.util.Calendar.SECOND).toByte(), + weekday.toByte(), + ) + } + + /** 01 03 + [heightCm][weightKg][sex][age]. */ + fun userInfo(profile: UserProfileValues): ByteArray { + return byteArrayOf( + YCBTGroup.SETTING.toByte(), YCBTSettingKey.USER_INFO.toByte(), + profile.heightCm.toByte(), profile.weightKg.toByte(), + (if (profile.gender == 0x01u.toUByte()) 1 else 0).toByte(), + profile.age.toByte(), + ) + } + + /** 01 04 + [distance][weight][temp][timeFormat][bloodSugar][uricAcid]. 0 = metric everywhere. */ + fun units(metric: Boolean, is24Hour: Boolean = true): ByteArray { + val imperial = if (metric) 0 else 1 + return byteArrayOf( + YCBTGroup.SETTING.toByte(), YCBTSettingKey.UNITS.toByte(), + imperial.toByte(), imperial.toByte(), imperial.toByte(), + (if (is24Hour) 0 else 1).toByte(), + 0, 0, + ) + } + + /** 01 12 + [languageCode]. */ + fun language(code: Int = 0): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.LANGUAGE.toByte(), code.toByte()) + } + + /** The five background samplers, each {enable, intervalMinutes}. */ + fun monitorCommands(settings: MeasurementSettings): List { + val interval = clampInterval(settings.hrIntervalMinutes).toByte() + return listOf( + heartMonitor(enabled = settings.hrEnabled, intervalMinutes = interval), + bloodPressureMonitor(enabled = settings.hrEnabled, intervalMinutes = interval), + temperatureMonitor(enabled = settings.temperatureEnabled, intervalMinutes = interval), + bloodOxygenMonitor(enabled = settings.spo2Enabled, intervalMinutes = interval), + hrvMonitor(enabled = settings.hrvEnabled, intervalMinutes = interval), + ) + } + + private fun heartMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.HEART_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes) + } + + private fun bloodPressureMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.BLOOD_PRESSURE_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes) + } + + private fun temperatureMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.TEMPERATURE_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes) + } + + private fun bloodOxygenMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.BLOOD_OXYGEN_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes) + } + + private fun hrvMonitor(enabled: Boolean, intervalMinutes: Byte): ByteArray { + return byteArrayOf(YCBTGroup.SETTING.toByte(), YCBTSettingKey.HRV_MONITOR.toByte(), if (enabled) 1 else 0, intervalMinutes, 0, 0, 0) + } +} + +class YCBTEncoder { + private val settings = YCBTSettingsEncoder() + + fun setTime(date: Instant = Instant.now()): ByteArray = settings.setTime(date) + + fun startupSequence( + date: Instant = Instant.now(), + measurement: MeasurementSettings = MeasurementSettings.ALL_ON_DEFAULT, + profile: UserProfileValues = UserProfileValues(metric = true, gender = 0x02u, age = 25u, heightCm = 175u, weightKg = 70u), + languageCode: Int = 0, + is24Hour: Boolean = true, + ): List { + val seq = mutableListOf() + seq.add(setTime(date)) + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, byteArrayOf(0x47, 0x43))) + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_SUPPORT_FUNCTION, byteArrayOf(0x47, 0x46))) + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_CHIP_SCHEME, byteArrayOf())) + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_NAME, byteArrayOf(0x47, 0x50))) + seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_USER_CONFIG, byteArrayOf(0x43, 0x46))) + seq.add(settings.language(languageCode)) + seq.add(settings.units(metric = profile.metric, is24Hour = is24Hour)) + seq.addAll(settings.monitorCommands(measurement)) + seq.add(settings.userInfo(profile)) + seq.add(enableLiveStatus()) + return seq + } + + fun monitorCommands(measurement: MeasurementSettings): List = + settings.monitorCommands(measurement) + + fun userInfo(profile: UserProfileValues): ByteArray = settings.userInfo(profile) + + fun deviceInfoRequest(): ByteArray = + logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, byteArrayOf(0x47, 0x43)) + + fun enableLiveStatus(): ByteArray = + logical(YCBTGroup.APP_CONTROL, YCBTCommand.LIVE_STATUS_PUSH, byteArrayOf(0x01, 0x00, 0x02)) + + fun healthHistoryRequest(type: YCBTHistoryType): ByteArray = + YCBTHealthCommand.historyRequest(type) + + fun historyBlockAck(status: Int): ByteArray = + YCBTHealthCommand.historyBlockAck(status) + + fun heartRateStart(): ByteArray = liveMeasurement(enable = true, mode = YCBTMeasurementMode.HEART_RATE) + fun heartRateStop(): ByteArray = liveMeasurement(enable = false, mode = YCBTMeasurementMode.HEART_RATE) + fun spo2Start(): ByteArray = liveMeasurement(enable = true, mode = YCBTMeasurementMode.SPO2) + fun spo2Stop(): ByteArray = liveMeasurement(enable = false, mode = YCBTMeasurementMode.SPO2) + fun hrvStart(): ByteArray = liveMeasurement(enable = true, mode = YCBTMeasurementMode.HRV) + fun hrvStop(): ByteArray = liveMeasurement(enable = false, mode = YCBTMeasurementMode.HRV) + fun bloodPressureStart(): ByteArray = liveMeasurement(enable = true, mode = YCBTMeasurementMode.BLOOD_PRESSURE) + fun bloodPressureStop(): ByteArray = liveMeasurement(enable = false, mode = YCBTMeasurementMode.BLOOD_PRESSURE) + + fun findDevice(): ByteArray = + logical(YCBTGroup.APP_CONTROL, YCBTCommand.FIND_DEVICE, byteArrayOf(0x01, 0x05, 0x02)) + + private fun liveMeasurement(enable: Boolean, mode: Int): ByteArray { + return logical(YCBTGroup.APP_CONTROL, YCBTCommand.LIVE_MEASUREMENT, byteArrayOf(if (enable) 1 else 0, mode.toByte())) + } + + private fun logical(group: Int, cmd: Int, payload: ByteArray): ByteArray { + return byteArrayOf(group.toByte(), cmd.toByte()) + payload + } +} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt new file mode 100644 index 0000000..d1b641d --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt @@ -0,0 +1,255 @@ +package com.pulseloop.ring + +import java.time.Instant + +/** + * Ported from YCBTHealthRecords.swift. + * Pure buffer→events decoders for the YCBT health-history record types. + */ + +object YCBTHealthRecords { + private const val TEMPERATURE_FILLER: Int = 15 + + fun decode(buffer: ByteArray, type: YCBTHistoryType): List { + return when (type) { + YCBTHistoryType.SPORT -> sport(buffer) + YCBTHistoryType.SLEEP -> sleep(buffer) + YCBTHistoryType.HEART -> heartRate(buffer) + YCBTHistoryType.BLOOD -> bloodPressure(buffer) + YCBTHistoryType.ALL -> combinedVitals(buffer) + YCBTHistoryType.SPO2 -> spo2(buffer) + YCBTHistoryType.TEMPERATURE -> temperature(buffer) + YCBTHistoryType.COMPREHENSIVE -> comprehensive(buffer) + YCBTHistoryType.BODY_DATA -> bodyData(buffer) + else -> emptyList() + } + } + + // MARK: Sport (query 0x02, 14-byte records) + + fun sport(buffer: ByteArray): List { + return records(buffer, 14).mapNotNull { r -> + val steps = YCBTBytes.u16(r, 8) + val distance = YCBTBytes.u16(r, 10) + if (steps <= 0 && distance <= 0) return@mapNotNull null + RingDecodedEvent.ActivityBucket( + _timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0)), + steps = steps, + distanceMeters = distance, + ) + } + } + + // MARK: Heart rate (query 0x06, 6-byte records) + + fun heartRate(buffer: ByteArray): List { + return records(buffer, 6).mapNotNull { r -> + val hr = r[5].toInt() and 0xFF + if (hr == 0) return@mapNotNull null + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.HEART_RATE, + value = hr.toDouble(), + _timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0)), + ) + } + } + + // MARK: Blood pressure (query 0x08, 8-byte records) + + fun bloodPressure(buffer: ByteArray): List { + val events = mutableListOf() + for (r in records(buffer, 8)) { + val ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) + events.addAll(bloodPressureEvents(systolic = r[5].toInt() and 0xFF, diastolic = r[6].toInt() and 0xFF, timestamp = ts)) + if (r[7].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.HEART_RATE, + value = (r[7].toInt() and 0xFF).toDouble(), + _timestamp = ts, + )) + } + } + return events + } + + // MARK: Combined vitals (query 0x09, 20-byte records) + + fun combinedVitals(buffer: ByteArray): List { + val events = mutableListOf() + for (r in records(buffer, 20)) { + val ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) + events.add(RingDecodedEvent.ActivityUpdate( + _timestamp = ts, + steps = YCBTBytes.u16(r, 4), + distanceMeters = 0, + calories = 0, + )) + events.addAll(bloodPressureEvents(systolic = r[7].toInt() and 0xFF, diastolic = r[8].toInt() and 0xFF, timestamp = ts)) + if (r[9].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.SPO2, value = (r[9].toInt() and 0xFF).toDouble(), _timestamp = ts)) + } + if (r[10].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.RESPIRATORY_RATE, value = (r[10].toInt() and 0xFF).toDouble(), _timestamp = ts)) + } + if (r[11].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.HRV, value = (r[11].toInt() and 0xFF).toDouble(), _timestamp = ts)) + } + events.addAll(temperatureEvents(integer = r[13].toInt() and 0xFF, fraction = r[14].toInt() and 0xFF, timestamp = ts)) + if (r[17].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.BLOOD_SUGAR, + value = bloodSugarMgdl(r[17].toInt() and 0xFF), + _timestamp = ts, + )) + } + } + return events + } + + // MARK: SpO₂ (query 0x1A, 6-byte records) + + fun spo2(buffer: ByteArray): List { + return records(buffer, 6).mapNotNull { r -> + if (r[5].toInt() and 0xFF == 0) return@mapNotNull null + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.SPO2, + value = (r[5].toInt() and 0xFF).toDouble(), + _timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0)), + ) + } + } + + // MARK: Temperature (query 0x1E, 7-byte records) + + fun temperature(buffer: ByteArray): List { + return records(buffer, 7).flatMap { r -> + temperatureEvents(integer = r[5].toInt() and 0xFF, fraction = r[6].toInt() and 0xFF, timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0))) + } + } + + // MARK: Comprehensive (query 0x2F, 44-byte records) + + fun comprehensive(buffer: ByteArray): List { + return records(buffer, 44).mapNotNull { r -> + val tenths = (r[5].toInt() and 0xFF) * 10 + (r[6].toInt() and 0xFF) + if (tenths <= 0) return@mapNotNull null + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.BLOOD_SUGAR, + value = bloodSugarMgdl(tenths), + _timestamp = YCBTBytes.date(YCBTBytes.u32(r, 0)), + ) + } + } + + // MARK: Body data (query 0x33, 28-byte records) + + fun bodyData(buffer: ByteArray): List { + val events = mutableListOf() + for (r in records(buffer, 28)) { + val ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) + if (r[6].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.HRV, value = composite(r[6].toInt() and 0xFF, r[7].toInt() and 0xFF), _timestamp = ts)) + } + if (r[8].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.STRESS, value = score(r[8].toInt() and 0xFF, r[9].toInt() and 0xFF), _timestamp = ts)) + } + if (r[10].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.FATIGUE, value = score(r[10].toInt() and 0xFF, r[11].toInt() and 0xFF), _timestamp = ts)) + } + if (r.size > 16 && r[16].toInt() and 0xFF > 0) { + events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.VO2MAX, value = (r[16].toInt() and 0xFF).toDouble(), _timestamp = ts)) + } + } + return events + } + + // MARK: Sleep (variable-length sessions) + + fun sleep(buffer: ByteArray): List { + val headerLength = 20 + val segmentLength = 8 + val events = mutableListOf() + var cursor = 0 + while (cursor + headerLength <= buffer.size) { + val recordLength = YCBTBytes.u16(buffer, cursor + 2) + val segmentsStart = cursor + headerLength + val declared = maxOf(0, recordLength - headerLength) / segmentLength + val available = (buffer.size - segmentsStart) / segmentLength + val segmentCount = minOf(declared, available) + + val stages = mutableListOf() + var sessionStart: Instant? = null + val seenStarts = mutableSetOf() + for (index in 0 until segmentCount) { + val offset = segmentsStart + index * segmentLength + val stage = sleepStage(buffer[offset].toInt() and 0xFF) ?: continue + val segmentStart = YCBTBytes.u32(buffer, offset + 1) + if (!seenStarts.add(segmentStart)) continue + val segmentSeconds = YCBTBytes.u24(buffer, offset + 5) + if (sessionStart == null) sessionStart = YCBTBytes.date(segmentStart) + val minutes = maxOf(1, (segmentSeconds / 60.0).toInt()) + repeat(minutes) { stages.add(stage) } + } + if (sessionStart != null && stages.isNotEmpty()) { + events.add(RingDecodedEvent.SleepTimeline(_timestamp = sessionStart, stages = stages)) + } + cursor = segmentsStart + segmentCount * segmentLength + } + return events + } + + private fun sleepStage(tag: Int): SleepStage? { + return when (tag and 0x0f) { + 1 -> SleepStage.DEEP + 2 -> SleepStage.LIGHT + 3 -> SleepStage.REM + 4 -> SleepStage.AWAKE + 5 -> SleepStage.UNKNOWN + else -> null + } + } + + // MARK: Shared field decoding + + private fun bloodPressureEvents(systolic: Int, diastolic: Int, timestamp: Instant): List { + if (systolic <= 0 || diastolic <= 0) return emptyList() + return listOf( + RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, value = systolic.toDouble(), _timestamp = timestamp), + RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, value = diastolic.toDouble(), _timestamp = timestamp), + ) + } + + private fun temperatureEvents(integer: Int, fraction: Int, timestamp: Instant): List { + if (integer <= 0 || fraction == TEMPERATURE_FILLER) return emptyList() + return listOf(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.TEMPERATURE, value = composite(integer, fraction), _timestamp = timestamp)) + } + + /** String-concatenated composite: integer and fraction digits concatenated with a decimal point. */ + fun composite(integer: Int, fraction: Int): Double { + return "$integer.$fraction".toDoubleOrNull() ?: integer.toDouble() + } + + /** Digit-concatenated score (used for stress/fatigue on a 1…100 scale). */ + fun score(integer: Int, fraction: Int): Double { + return "$integer$fraction".toDoubleOrNull() ?: integer.toDouble() + } + + const val MGDL_PER_MMOL = 18.016 + + fun bloodSugarMgdl(tenthsOfMmol: Int): Double { + return tenthsOfMmol / 10.0 * MGDL_PER_MMOL + } + + // MARK: Helpers + + private fun records(buffer: ByteArray, size: Int): List { + if (size <= 0) return emptyList() + val out = mutableListOf() + var i = 0 + while (i + size <= buffer.size) { + out.add(buffer.copyOfRange(i, i + size)) + i += size + } + return out + } +} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt new file mode 100644 index 0000000..7bf09b4 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt @@ -0,0 +1,156 @@ +package com.pulseloop.ring + +import kotlinx.coroutines.* +import java.time.Instant + +/** + * Ported from YCBTHistoryTransfer.swift. + * The YCBT history state machine — protocol-driven, not timer-driven. + */ + +class YCBTHistoryTransfer( + private val writer: RingCommandWriter?, + private val inactivitySeconds: Double = 10.0, + private val absoluteCapSeconds: Double = 30.0, +) { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private enum class State { + IDLE, REQUEST_SENT, RECEIVING + } + + private var state = State.IDLE + private var currentType: YCBTHistoryType? = null + private var queue: MutableList = mutableListOf() + private var buffer: ByteArray = ByteArray(0) + private var retriedCurrentType = false + private var unsupported: MutableSet = mutableSetOf() + private var bufferCap = DEFAULT_BUFFER_CAP + private var watchdogJob: Job? = null + private var typeDeadline: Long? = null + + companion object { + private const val DEFAULT_BUFFER_CAP = 64 * 1024 + } + + val isActive: Boolean get() = state != State.IDLE + + fun start(types: List) { + if (isActive) return + queue = types.filter { !unsupported.contains(it.queryKey) }.toMutableList() + advance() + } + + fun cancel() { + cancelWatchdog() + state = State.IDLE + queue.clear() + buffer = ByteArray(0) + } + + private fun advance(): List { + cancelWatchdog() + buffer = ByteArray(0) + bufferCap = DEFAULT_BUFFER_CAP + retriedCurrentType = false + if (queue.isEmpty()) { + state = State.IDLE + return listOf(RingDecodedEvent.HistorySyncFinished) + } + val next = queue.removeAt(0) + sendQuery(next) + return emptyList() + } + + private fun sendQuery(type: YCBTHistoryType) { + state = State.REQUEST_SENT + currentType = type + typeDeadline = System.currentTimeMillis() + (absoluteCapSeconds * 1000).toLong() + writer?.enqueue(YCBTHealthCommand.historyRequest(type)) + armWatchdog(type) + } + + /** Feed every validated Health-group (type == 0x05) frame here. */ + fun handle(cmd: Int, payload: ByteArray): List { + val type = currentType ?: return emptyList() + + YCBTFrameError.detect(payload)?.let { error -> + if (error.isPermanent) unsupported.add(type.queryKey) + return advance() + } + + return when (cmd) { + type.queryKey -> handleHeader(type, payload) + type.ackKey -> { + appendData(payload) + armWatchdog(type) + emptyList() + } + YCBTHealth.TERMINAL_BLOCK -> handleTerminal(type, payload) + else -> emptyList() + } + } + + private fun handleHeader(type: YCBTHistoryType, payload: ByteArray): List { + if (payload.size < YCBTHealth.HEADER_PAYLOAD_LENGTH) return advance() + val totalBytes = YCBTBytes.u32(payload, 6) + buffer = ByteArray(0) + bufferCap = maxOf(totalBytes, DEFAULT_BUFFER_CAP) + state = State.RECEIVING + armWatchdog(type) + return listOf(RingDecodedEvent.HistorySyncProgress(stage = "Syncing ${type.label}…")) + } + + private fun appendData(payload: ByteArray) { + if (buffer.size + payload.size <= bufferCap) { + buffer += payload + } + } + + private fun handleTerminal(type: YCBTHistoryType, payload: ByteArray): List { + if (state == State.REQUEST_SENT && buffer.isEmpty()) return emptyList() + if (payload.size < YCBTHealth.TERMINAL_PAYLOAD_LENGTH) return advance() + val expected = YCBTBytes.u16(payload, 4) + val matches = YCBTFrame.crc16(buffer) == expected + + writer?.enqueue(YCBTHealthCommand.historyBlockAck(status = if (matches) YCBTHealth.ACK_ACCEPTED else YCBTHealth.ACK_CRC_FAILURE)) + + if (!matches) return retryOrSkip(type) + return YCBTHealthRecords.decode(buffer, type) + advance() + } + + private fun retryOrSkip(type: YCBTHistoryType): List { + if (retriedCurrentType) return advance() + retriedCurrentType = true + buffer = ByteArray(0) + sendQuery(type) + return emptyList() + } + + // MARK: Stall watchdog (safety net) + + private fun armWatchdog(type: YCBTHistoryType) { + watchdogJob?.cancel() + val deadline = typeDeadline ?: (System.currentTimeMillis() + (absoluteCapSeconds * 1000).toLong()) + val fireAt = minOf(System.currentTimeMillis() + (inactivitySeconds * 1000).toLong(), deadline) + val delayMs = maxOf(0, fireAt - System.currentTimeMillis()) + watchdogJob = scope.launch { + delay(delayMs) + if (!isActive) return@launch + if (currentType != type) return@launch + publishOutOfBand(advance()) + } + } + + private fun cancelWatchdog() { + watchdogJob?.cancel() + watchdogJob = null + typeDeadline = null + } + + private fun publishOutOfBand(events: List) { + if (events.any { it is RingDecodedEvent.HistorySyncFinished }) { + PulseEventBus.publishBlocking(PulseEvent.SyncProgress("done")) + } + } +} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt new file mode 100644 index 0000000..762771f --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt @@ -0,0 +1,322 @@ +package com.pulseloop.ring + +import java.time.Instant +import java.util.TimeZone +import java.util.UUID + +/** + * Ported from YCBTProtocol.swift. + * Yucheng YCBT protocol primitives — GATT topology, framing, byte/epoch helpers, opcodes, + * and the health-record type table. This is the wire language the R10M speaks. + * + * Wire format (both command channel be940001 and async stream be940003): + * [type:1][cmd:1][len:2 LE][payload:N][crc16:2 LE] + * where len is the *total* frame length (header + payload + crc) and the CRC is + * CRC16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflection) over every byte before it. + */ + +object YCBTUUIDs { + const val SERVICE = "be940000-7333-be46-b7ae-689e71722bd5" + const val COMMAND = "be940001-7333-be46-b7ae-689e71722bd5" + const val STREAM = "be940003-7333-be46-b7ae-689e71722bd5" +} + +class YCBTFrame( + val type: Int, + val cmd: Int, + val payload: ByteArray, +) { + companion object { + /** Parse and CRC-validate one inbound frame. Returns null on a short frame or CRC mismatch. */ + fun validating(data: ByteArray): YCBTFrame? { + val bytes = data + if (bytes.size < 6) return null + val declared = (bytes[2].toInt() and 0xFF) or ((bytes[3].toInt() and 0xFF) shl 8) + if (declared != bytes.size) return null + val crcGiven = (bytes[bytes.size - 2].toInt() and 0xFF) or ((bytes[bytes.size - 1].toInt() and 0xFF) shl 8) + if (crc16(bytes, 0, bytes.size - 2) != crcGiven) return null + return YCBTFrame( + type = bytes[0].toInt() and 0xFF, + cmd = bytes[1].toInt() and 0xFF, + payload = bytes.copyOfRange(4, bytes.size - 2) + ) + } + + /** Build a framed packet from a logical command [type, cmd, payload…]: insert the total-length + * field after the two header bytes and append the little-endian CRC16. */ + fun frame(logical: ByteArray): ByteArray { + if (logical.size < 2) return logical.copyOf() + val total = logical.size + 4 // + 2-byte length field + 2-byte CRC + val out = ByteArray(total) + out[0] = logical[0] + out[1] = logical[1] + out[2] = (total and 0xFF).toByte() + out[3] = ((total shr 8) and 0xFF).toByte() + System.arraycopy(logical, 2, out, 4, logical.size - 2) + val crc = crc16(out, 0, out.size - 2) + out[out.size - 2] = (crc and 0xFF).toByte() + out[out.size - 1] = ((crc shr 8) and 0xFF).toByte() + return out + } + + /** CRC16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no input/output reflection, no final xor). */ + fun crc16(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size): Int { + var crc = 0xFFFF + for (i in offset until offset + length) { + val b = bytes[i].toInt() and 0xFF + crc = crc xor (b shl 8) + repeat(8) { + crc = if ((crc and 0x8000) != 0) ((crc shl 1) xor 0x1021) and 0xFFFF else (crc shl 1) and 0xFFFF + } + } + return crc and 0xFFFF + } + + fun crc16(bytes: ByteArray): Int = crc16(bytes, 0, bytes.size) + } +} + +/** Little-endian + epoch helpers. YCBT timestamps are seconds since 2000-01-01 UTC. */ +object YCBTBytes { + const val EPOCH_OFFSET = 946_684_800L + + fun u16(bytes: ByteArray, i: Int): Int { + if (bytes.size < i + 2) return 0 + return (bytes[i].toInt() and 0xFF) or ((bytes[i + 1].toInt() and 0xFF) shl 8) + } + + fun u24(bytes: ByteArray, i: Int): Int { + if (bytes.size < i + 3) return 0 + return (bytes[i].toInt() and 0xFF) or ((bytes[i + 1].toInt() and 0xFF) shl 8) or ((bytes[i + 2].toInt() and 0xFF) shl 16) + } + + fun u32(bytes: ByteArray, i: Int): Int { + if (bytes.size < i + 4) return 0 + return (bytes[i].toInt() and 0xFF) or ((bytes[i + 1].toInt() and 0xFF) shl 8) or + ((bytes[i + 2].toInt() and 0xFF) shl 16) or ((bytes[i + 3].toInt() and 0xFF) shl 24) + } + + /** Convert ring seconds (2000-epoch) to an Instant. The ring clock is local wall-clock; decoding + * must un-apply the device's UTC offset to recover the true absolute instant. */ + fun date(ringSeconds: Int, timeZone: TimeZone = TimeZone.getDefault()): Instant { + val offset = timeZone.rawOffset / 1000 + return Instant.ofEpochSecond(ringSeconds.toLong() + EPOCH_OFFSET - offset) + } + + /** Convert an Instant to ring seconds (2000-epoch), the inverse of date(). */ + fun ringSeconds(date: Instant, timeZone: TimeZone = TimeZone.getDefault()): Int { + val offset = timeZone.getOffset(date.toEpochMilli()) / 1000 + return (date.epochSecond - EPOCH_OFFSET + offset).toInt() + } +} + +/** Frame type byte — the command group. */ +object YCBTGroup { + const val SETTING: Int = 0x01 + const val GET: Int = 0x02 + const val APP_CONTROL: Int = 0x03 + const val DEV_CONTROL: Int = 0x04 + const val HEALTH: Int = 0x05 + const val REAL: Int = 0x06 +} + +/** The cmd bytes we act on, by group. */ +object YCBTCommand { + // Group 0x02 (Get) + const val GET_DEVICE_INFO: Int = 0x00 + const val GET_SUPPORT_FUNCTION: Int = 0x01 + const val GET_DEVICE_NAME: Int = 0x03 + const val GET_USER_CONFIG: Int = 0x07 + const val GET_CHIP_SCHEME: Int = 0x1b + + // Group 0x03 (AppControl) + const val FIND_DEVICE: Int = 0x00 + const val LIVE_MEASUREMENT: Int = 0x2f + const val LIVE_STATUS_PUSH: Int = 0x09 + + // Group 0x06 (Real — async stream) + const val LIVE_STATUS: Int = 0x00 + const val LIVE_HEART_RATE: Int = 0x01 + const val LIVE_SPO2: Int = 0x02 + const val LIVE_VITALS: Int = 0x03 + const val LIVE_WEARING_STATUS: Int = 0x13 + const val LIVE_BATTERY: Int = 0x15 +} + +/** One health-history record type: query key, ack key, record stride, and label. */ +data class YCBTHistoryType( + val queryKey: Int, + val ackKey: Int, + val recordStride: Int?, + val label: String, +) { + companion object { + val SPORT = YCBTHistoryType(0x02, 0x11, 14, "activity") + val SLEEP = YCBTHistoryType(0x04, 0x13, null, "sleep") + val HEART = YCBTHistoryType(0x06, 0x15, 6, "heart rate") + val BLOOD = YCBTHistoryType(0x08, 0x17, 8, "blood pressure") + val ALL = YCBTHistoryType(0x09, 0x18, 20, "vitals") + val SPO2 = YCBTHistoryType(0x1a, 0x22, 6, "blood oxygen") + val TEMPERATURE = YCBTHistoryType(0x1e, 0x26, 7, "temperature") + val COMPREHENSIVE = YCBTHistoryType(0x2f, 0x30, 44, "metabolic") + val BODY_DATA = YCBTHistoryType(0x33, 0x34, 28, "body data") + + val CATALOG: List = listOf( + SPORT, SLEEP, HEART, BLOOD, ALL, SPO2, TEMPERATURE, COMPREHENSIVE, BODY_DATA + ) + } +} + +/** The measurement-mode byte shared by start/stop and status/result push. */ +object YCBTMeasurementMode { + const val HEART_RATE: Int = 0x00 + const val BLOOD_PRESSURE: Int = 0x01 + const val SPO2: Int = 0x02 + const val RESPIRATORY_RATE: Int = 0x03 + const val TEMPERATURE: Int = 0x04 + const val BLOOD_SUGAR: Int = 0x05 + const val URIC_ACID: Int = 0x06 + const val BLOOD_KETONE: Int = 0x07 + const val BLOOD_FAT: Int = 0x09 + const val HRV: Int = 0x0a + const val STRESS: Int = 0x0c + + fun isAccepted(status: Int): Boolean = status == 0x00 +} + +/** Group 4 (DevControl) — ring→app push keys. */ +object YCBTDevControl { + const val FIND_PHONE: Int = 0x00 + const val SOS: Int = 0x05 + const val MEASUREMENT_RESULT: Int = 0x0e + const val MEASUREMENT_STATUS: Int = 0x13 + const val SEDENTARY_REMINDER: Int = 0x16 + const val SOS_CALL: Int = 0x17 + + fun ack(key: Int): ByteArray = byteArrayOf(YCBTGroup.DEV_CONTROL.toByte(), key.toByte(), 0x00) + + const val RESULT_SUCCESS: Int = 0x01 +} + +/** Health-group control keys and ACK status bytes. */ +object YCBTHealth { + const val TERMINAL_BLOCK: Int = 0x80 + const val ACK_ACCEPTED: Int = 0x00 + const val ACK_CRC_FAILURE: Int = 0x04 + const val HEADER_PAYLOAD_LENGTH: Int = 10 + const val TERMINAL_PAYLOAD_LENGTH: Int = 6 +} + +/** Logical (unframed) Health-group commands. */ +object YCBTHealthCommand { + fun historyRequest(type: YCBTHistoryType): ByteArray = + byteArrayOf(YCBTGroup.HEALTH.toByte(), type.queryKey.toByte()) + + fun historyBlockAck(status: Int): ByteArray = + byteArrayOf(YCBTGroup.HEALTH.toByte(), YCBTHealth.TERMINAL_BLOCK.toByte(), status.toByte()) +} + +/** Device-side rejection of a command. */ +enum class YCBTFrameError(val code: Int) { + UNSUPPORTED_COMMAND(0xfb), + UNSUPPORTED_KEY(0xfc), + LENGTH(0xfd), + DATA(0xfe), + CRC(0xff); + + companion object { + fun detect(payload: ByteArray): YCBTFrameError? { + if (payload.size != 1) return null + return entries.find { it.code == (payload[0].toInt() and 0xFF) } + } + } + + val isPermanent: Boolean get() = this == UNSUPPORTED_COMMAND || this == UNSUPPORTED_KEY +} + +/** Reassembles GATT notifications into whole logical frames. */ +class YCBTFrameAssembler { + private val minFrameLength = 6 + private val maxFrameLength = 1024 + private val pending = mutableMapOf() + + fun reset() { + pending.clear() + } + + /** Feed one notification; returns the complete logical frames it completed (0, 1, or several). */ + fun append(data: ByteArray, fromCharacteristic: String): List { + var buffer = pending[fromCharacteristic] ?: ByteArray(0) + buffer += data + + val frames = mutableListOf() + while (buffer.size >= 4) { + val declared = (buffer[2].toInt() and 0xFF) or ((buffer[3].toInt() and 0xFF) shl 8) + if (!isPlausibleGroup(buffer[0]) || declared < minFrameLength || declared > maxFrameLength) { + buffer = buffer.copyOfRange(1, buffer.size) + continue + } + if (buffer.size < declared) break + frames.add(buffer.copyOfRange(0, declared)) + buffer = buffer.copyOfRange(declared, buffer.size) + } + pending[fromCharacteristic] = buffer + return frames + } + + private fun isPlausibleGroup(byte: Byte): Boolean { + val b = byte.toInt() and 0xFF + return b in YCBTGroup.SETTING..YCBTGroup.REAL + } +} + +/** Parser for the 02 01 SupportFunction reply: variable-length bit array. */ +object YCBTSupportFunction { + private data class Bit(val byte: Int, val bit: Int, val minLength: Int, val capability: WearableCapability) + + private val bits = listOf( + Bit(0, 7, 14, WearableCapability.STEPS), // ISHASSTEPCOUNT + Bit(0, 6, 14, WearableCapability.SLEEP), // ISHASSLEEP + Bit(0, 3, 14, WearableCapability.HEART_RATE), // ISHASHEARTRATE + Bit(0, 0, 14, WearableCapability.BLOOD_PRESSURE), // ISHASBLOOD + Bit(1, 3, 14, WearableCapability.SPO2), // ISHASBLOODOXYGEN + Bit(1, 1, 14, WearableCapability.HRV), // ISHASHRV + Bit(8, 0, 14, WearableCapability.TEMPERATURE), // ISHASTEMP + Bit(17, 3, 18, WearableCapability.BLOOD_SUGAR), // ISHASBLOODSUGAR + Bit(22, 6, 23, WearableCapability.STRESS), // IS_HAS_PRESSURE + Bit(22, 6, 23, WearableCapability.FATIGUE), // IS_HAS_PRESSURE (same record) + Bit(6, 4, 14, WearableCapability.FIND_DEVICE), // ISHASFINDDEVICE + Bit(15, 1, 18, WearableCapability.MANUAL_HEART_RATE), // ISHATESTHEART + Bit(15, 2, 18, WearableCapability.MANUAL_BLOOD_PRESSURE), // ISHASTESTBLOOD + Bit(15, 3, 18, WearableCapability.MANUAL_SPO2), // ISHASTESTSPO2 + Bit(23, 0, 24, WearableCapability.MANUAL_HRV), // IS_HAS_HRV_MEASUREMENT + ) + + fun capabilities(payload: ByteArray): Set { + val out = mutableSetOf() + for (b in bits) { + if (payload.size >= b.minLength && b.byte < payload.size) { + if (((payload[b.byte].toInt() and 0xFF) shr b.bit) and 1 == 1) { + out.add(b.capability) + } + } + } + return out + } + + fun rawBits(payload: ByteArray): List { + return payload.flatMap { byte -> + (0..7).map { ((byte.toInt() and 0xFF) shr (7 - it)) and 1 == 1 } + } + } +} + +/** The 02 1b chipScheme reply — diagnostic only. */ +object YCBTChipScheme { + fun value(payload: ByteArray): Int { + val first = payload.firstOrNull()?.toInt() and 0xFF ?: return 0 + return if (first >= 240) 0 else first + } + + fun isJieLi(value: Int): Boolean = value in 3..5 +} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt new file mode 100644 index 0000000..30822ce --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -0,0 +1,126 @@ +package com.pulseloop.ring + +import java.time.Instant + +/** + * Ported from YCBTSyncEngine.swift. + * YCBT sync engine. Connect is a parameterized handshake, followed by the history sync. + */ + +class YCBTSyncEngine( + private var writer: RingCommandWriter?, + private val transfer: YCBTHistoryTransfer, +) : RingSyncEngine { + private val encoder = YCBTEncoder() + + private var measurementSettings = MeasurementSettings.ALL_ON_DEFAULT + private var userProfile = UserProfileValues(metric = true, gender = 0x02u, age = 25u, heightCm = 175u, weightKg = 70u) + + companion object { + private val HISTORY_TYPES: List = listOf( + YCBTHistoryType.SPORT, YCBTHistoryType.SLEEP, YCBTHistoryType.HEART, + YCBTHistoryType.BLOOD, YCBTHistoryType.ALL, YCBTHistoryType.SPO2, + YCBTHistoryType.TEMPERATURE, YCBTHistoryType.COMPREHENSIVE, YCBTHistoryType.BODY_DATA, + ) + private val VITALS_TYPES: List = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL, YCBTHistoryType.SPO2) + } + + override fun runStartup() { + for (command in encoder.startupSequence(measurement = measurementSettings, profile = userProfile)) { + writer?.enqueue(command) + } + transfer.start(types = HISTORY_TYPES) + } + + override fun handle(event: RingDecodedEvent) { + // History is protocol-driven now — nothing here advances it. + } + + override fun syncHistory() { + transfer.start(types = HISTORY_TYPES) + } + + fun syncVitalsHistory() { + transfer.start(types = VITALS_TYPES) + } + + override fun setMeasurementSettings(settings: MeasurementSettings?) { + if (settings != null) measurementSettings = settings + } + + override fun applyMeasurementSettings(settings: MeasurementSettings) { + measurementSettings = settings + for (command in encoder.monitorCommands(settings)) { + writer?.enqueue(command) + } + } + + override fun setUserProfile(profile: UserProfileValues) { + userProfile = profile + } + + override fun applyUserProfile(profile: UserProfileValues) { + userProfile = profile + writer?.enqueue(encoder.userInfo(profile)) + } + + override fun resyncTime() { + writer?.enqueue(encoder.setTime()) + } + + override fun requestBattery() { + writer?.enqueue(encoder.deviceInfoRequest()) + } + + override fun startHeartRate() { + writer?.enqueue(encoder.heartRateStart()) + } + + override fun stopHeartRate() { + writer?.enqueue(encoder.heartRateStop()) + } + + override fun startSpO2() { + writer?.enqueue(encoder.spo2Start()) + } + + override fun stopSpO2() { + writer?.enqueue(encoder.spo2Stop()) + } + + override fun startHRV() { + writer?.enqueue(encoder.hrvStart()) + } + + override fun stopHRV() { + writer?.enqueue(encoder.hrvStop()) + } + + override fun startBloodPressure() { + writer?.enqueue(encoder.bloodPressureStart()) + } + + override fun stopBloodPressure() { + writer?.enqueue(encoder.bloodPressureStop()) + } + + override fun findDevice() { + writer?.enqueue(encoder.findDevice()) + } + + override fun setGoal(steps: Int) { + // Unverified for this ring; goal is persisted app-side. + } + + override fun powerOff() {} + override fun factoryReset() {} + override fun startCombinedMeasurement() {} + override fun stopCombinedMeasurement() {} + override fun setUserInfo(ageYears: Int, isMale: Boolean, heightCm: Int, weightKg: Int) {} + override fun setBloodPressureAdjust(systolic: Int, diastolic: Int) {} + override fun setAppId(appId: String) {} + override fun setOnMeasurementConfigSeeded(callback: (MeasurementSettings) -> Unit) {} + override fun setOnBondRequested(callback: () -> Unit) {} + override fun syncSleepNow() {} + override fun handleRawNotify(data: ByteArray) {} +} diff --git a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt index 36f6641..51ca03c 100644 --- a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt +++ b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt @@ -61,6 +61,9 @@ data class WearableModel( val COLMI_R11 = colmi("colmi-r11", "Colmi R11", "Colmi", "^R11C_[0-9A-F]{4}$", R.drawable.ring_yawell_r11) val COLMI_R12 = colmi("colmi-r12", "Colmi R12", "Colmi", "^COLMI R12_.*", R.drawable.ring_colmi_r12) + // YCBT / SmartHealth family (distinct protocol from QRing Colmi) + val R10M = ycbt("r10m", "R10M", "Yucheng", "^R10M[ _][0-9A-F]{4}$", R.drawable.ring_colmi_r10) + // Yawell-branded variants val YAWELL_R05 = colmi("yawell-r05", "Yawell R05", "Yawell", "^R05_[0-9A-F]{4}$", R.drawable.ring_yawell_r05) val YAWELL_R10 = colmi("yawell-r10", "Yawell R10", "Yawell", "^R10_[0-9A-F]{4}$", R.drawable.ring_yawell_r10) @@ -82,11 +85,24 @@ data class WearableModel( requiresOsBond = requiresOsBond, ) + private fun ycbt( + id: String, + name: String, + brand: String, + pattern: String, + @DrawableRes imageRes: Int?, + ) = WearableModel( + id = id, displayName = name, brand = brand, family = RingDeviceType.YCBT, + tint = PulseColors.hrv, blurb = "HR · SpO₂ · BP · HRV · Temp · Sleep · Stress", + advertisedNamePatterns = listOf(pattern), + imageRes = imageRes, + ) + /** Every supported model. The pairing screen groups by brand and sorts each tab alphabetically. */ val CATALOG: List = listOf( COLMI_R02, COLMI_R06, COLMI_R10, YAWELL_R11, JRING, COLMI_R03, COLMI_R07, COLMI_R09, COLMI_R11, COLMI_R12, - YAWELL_R05, YAWELL_R10, H59, + YAWELL_R05, YAWELL_R10, H59, R10M, ) fun model(id: String?): WearableModel? { diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt new file mode 100644 index 0000000..fbff147 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt @@ -0,0 +1,180 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test +import java.time.Instant +import java.util.TimeZone + +class YCBTDecoderTest { + private val decoder = YCBTDecoder() + + @Test + fun `live heart rate decodes`() { + val frame = YCBTFrame.validating(hexToBytes("06010700521a55"))!! + val events = decoder.decode(frame) + val hr = events.first() as RingDecodedEvent.HeartRateSample + assertEquals(82, hr.bpm) + } + + @Test + fun `live status decodes steps distance calories`() { + val frame = YCBTFrame.validating(hexToBytes("06000c007b0297011a00b60d"))!! + val events = decoder.decode(frame) + val activity = events.first() as RingDecodedEvent.ActivityUpdate + assertEquals(635, activity.steps) + assertEquals(407, activity.distanceMeters) + assertEquals(26, activity.calories) + } + + @Test + fun `live vitals decodes BP shape`() { + val frame = YCBTFrame.validating(hexToBytes("060314006f4a44000000000000000000000074f1"))!! + val events = decoder.decode(frame) + val bp = events[0] as RingDecodedEvent.BloodPressureSample + assertEquals(111, bp.systolic) + assertEquals(74, bp.diastolic) + val hr = events.first { it is RingDecodedEvent.HeartRateSample } as RingDecodedEvent.HeartRateSample + assertEquals(68, hr.bpm) + } + + @Test + fun `live vitals decodes HRV shape`() { + val frame = YCBTFrame.validating(hexToBytes("06031400000000b1000000000000000000001579"))!! + val events = decoder.decode(frame) + val hrv = events.first() as RingDecodedEvent.HrvSample + assertEquals(177, hrv.value) + assertEquals(1, events.size) + } + + @Test + fun `live vitals decodes SpO2 and temperature tail`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x06, 0x03, 118, 79, 70, 42, 97, 36, 4)))!! + val events = decoder.decode(frame) + val spo2 = events.first { it is RingDecodedEvent.Spo2Result } as RingDecodedEvent.Spo2Result + assertEquals(97, spo2.value) + val temp = events.first { it is RingDecodedEvent.TemperatureSample } as RingDecodedEvent.TemperatureSample + assertEquals(36.4, temp.celsius, 0.001) + } + + @Test + fun `battery push decodes`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x06, 0x15, 0x01, 0x5b)))!! + val events = decoder.decode(frame) + val battery = events.first() as RingDecodedEvent.Battery + assertEquals(91, battery.percent) + } + + @Test + fun `wearing status push decodes worn and not worn`() { + val seconds = YCBTBytes.ringSeconds(Instant.now()) + val ts = byteArrayOf( + (seconds and 0xFF).toByte(), + ((seconds shr 8) and 0xFF).toByte(), + ((seconds shr 16) and 0xFF).toByte(), + ((seconds shr 24) and 0xFF).toByte(), + ) + val worn = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x06, 0x13) + ts + byteArrayOf(0x01)))!! + val ws = decoder.decode(worn).first() as RingDecodedEvent.WearingStatus + assertTrue(ws.worn) + + val removed = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x06, 0x13) + ts + byteArrayOf(0x00)))!! + val ws2 = decoder.decode(removed).first() as RingDecodedEvent.WearingStatus + assertFalse(ws2.worn) + } + + @Test + fun `measurement status push decodes per type`() { + fun decodeStatus(payload: ByteArray): RingDecodedEvent? { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x13) + payload))!! + return decoder.decode(frame).firstOrNull() + } + + val hr = decodeStatus(byteArrayOf(0x00, 0x01, 72)) as RingDecodedEvent.HeartRateSample + assertEquals(72, hr.bpm) + + val bp = decodeStatus(byteArrayOf(0x01, 0x01, 118, 79)) as RingDecodedEvent.BloodPressureSample + assertEquals(118, bp.systolic) + assertEquals(79, bp.diastolic) + + val spo2 = decodeStatus(byteArrayOf(0x02, 0x01, 98)) as RingDecodedEvent.Spo2Result + assertEquals(98, spo2.value) + + val temp = decodeStatus(byteArrayOf(0x04, 0x01, 36, 5)) as RingDecodedEvent.TemperatureSample + assertEquals(36.5, temp.celsius, 0.001) + + val sugar = decodeStatus(byteArrayOf(0x05, 0x01, 5, 5)) as RingDecodedEvent.BloodSugarSample + assertEquals(5.5 * YCBTHealthRecords.MGDL_PER_MMOL, sugar.mgdl, 0.001) + } + + @Test + fun `measurement status push with no value acks`() { + fun decodeStatus(payload: ByteArray): RingDecodedEvent? { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x13) + payload))!! + return decoder.decode(frame).firstOrNull() + } + assertTrue(decodeStatus(byteArrayOf(0x00, 0x00, 0x00)) is RingDecodedEvent.CommandAck) + assertTrue(decodeStatus(byteArrayOf(0x03, 0x01, 16)) is RingDecodedEvent.CommandAck) + } + + @Test + fun `measurement result push acks`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x0e, 0x01, 0x02)))!! + val events = decoder.decode(frame) + assertTrue(events.first() is RingDecodedEvent.CommandAck) + } + + @Test + fun `measurement start reply distinguishes acceptance from refusal`() { + fun reply(status: Int, startedMode: Int?): RingDecodedEvent { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, status.toByte())))!! + return decoder.decode(frame, startedMode = startedMode).first() + } + val rejected = reply(0x01, YCBTMeasurementMode.HRV) as RingDecodedEvent.MeasurementRejected + assertEquals(YCBTMeasurementMode.HRV, rejected.mode) + + assertTrue(reply(0x00, YCBTMeasurementMode.HRV) is RingDecodedEvent.CommandAck) + assertTrue(reply(0x01, null) is RingDecodedEvent.CommandAck) + } + + @Test + fun `device info decodes battery and firmware`() { + val frame = YCBTFrame.validating(hexToBytes("02001e00a30012010064000100030000000001000000010000000000ef10"))!! + val events = decoder.decode(frame) + val battery = events.first { it is RingDecodedEvent.Battery } as RingDecodedEvent.Battery + assertEquals(100, battery.percent) + val status = events.first { it is RingDecodedEvent.Status } as RingDecodedEvent.Status + assertEquals("1.18", status.firmware) + } + + @Test + fun `firmware sub version is zero padded`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x02, 0x00, 0xa3.toByte(), 0x00, 0x05, 0x01, 0x00, 0x64)))!! + val events = decoder.decode(frame) + val status = events.first { it is RingDecodedEvent.Status } as RingDecodedEvent.Status + assertEquals("1.05", status.firmware) + } + + @Test + fun `date decode recovers true instant across time zone`() { + val tz = TimeZone.getTimeZone("America/New_York") + val trueInstant = Instant.ofEpochSecond((System.currentTimeMillis() / 1000).toLong()) + val calendar = java.util.Calendar.getInstance(tz).apply { time = java.util.Date(trueInstant.toEpochMilli()) } + val utcCalendar = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) + utcCalendar.set(calendar.get(java.util.Calendar.YEAR), calendar.get(java.util.Calendar.MONTH), calendar.get(java.util.Calendar.DAY_OF_MONTH), calendar.get(java.util.Calendar.HOUR_OF_DAY), calendar.get(java.util.Calendar.MINUTE), calendar.get(java.util.Calendar.SECOND)) + val ringSeconds = (utcCalendar.timeInMillis / 1000 - YCBTBytes.EPOCH_OFFSET).toInt() + val decoded = YCBTBytes.date(ringSeconds, tz) + assertEquals(trueInstant.epochSecond, decoded.epochSecond, 1) + } + + @Test + fun `u24 reads three bytes little endian`() { + assertEquals(72000, YCBTBytes.u24(byteArrayOf(0x40, 0x19, 0x01), 0)) + assertEquals(0, YCBTBytes.u24(byteArrayOf(0x00, 0x00), 0)) + } + + private fun hexToBytes(hex: String): ByteArray { + val clean = hex.replace(" ", "") + require(clean.length % 2 == 0) + return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt new file mode 100644 index 0000000..de9422e --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt @@ -0,0 +1,143 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test + +class YCBTDriverTest { + + private class FakeWriter : RingCommandWriter { + val sent = mutableListOf() + override fun enqueue(command: ByteArray) { sent.add(command.copyOf()) } + } + + private val streamUUID = YCBTUUIDs.STREAM + + @Test + fun `DevControl push is acked and decoded`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + val push = YCBTFrame.frame(byteArrayOf(0x04, 0x13, 0x00, 0x01, 72)) + val events = driver.ingest(push, streamUUID) + + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x04, 0x13, 0x00), writer.sent[0]) + val hr = events.first() as RingDecodedEvent.HeartRateSample + assertEquals(72, hr.bpm) + } + + @Test + fun `unhandled DevControl push is still acked`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + val events = driver.ingest(YCBTFrame.frame(byteArrayOf(0x04, 0x00, 0x01)), streamUUID) + + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x04, 0x00, 0x00), writer.sent[0]) + assertTrue(events.first() is RingDecodedEvent.CommandAck) + } + + @Test + fun `DevControl error frame is not acked`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.ingest(YCBTFrame.frame(byteArrayOf(0x04, 0x13, 0xfc.toByte())), streamUUID) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `live stream frames are not acked`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.ingest(YCBTFrame.frame(byteArrayOf(0x06, 0x01, 82)), streamUUID) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `connectionDidEnd abandons the in flight history transfer`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + val engine = driver.makeSyncEngine() + + engine.syncHistory() + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) + + writer.sent.clear() + engine.syncHistory() + assertTrue(writer.sent.isEmpty()) + + driver.connectionDidEnd() + engine.syncHistory() + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) + } + + @Test + fun `driver pairs a refusal with the start it sent and only once`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + val refusal = YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, 0x0a)) + val events1 = driver.ingest(refusal, streamUUID) + assertTrue(events1.first() is RingDecodedEvent.MeasurementRejected) + assertEquals(0x0a, (events1.first() as RingDecodedEvent.MeasurementRejected).mode) + + val events2 = driver.ingest(refusal, streamUUID) + assertTrue(events2.first() is RingDecodedEvent.CommandAck) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x00, 0x0a)) + val events3 = driver.ingest(refusal, streamUUID) + assertTrue(events3.first() is RingDecodedEvent.CommandAck) + } + + @Test + fun `a stops reply does not consume the mode of the start queued behind it`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x00, 0x02)) // stop SpO2 + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, 0x00)) // start HR + + val ackStop = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x00)), streamUUID) + assertTrue(ackStop.first() is RingDecodedEvent.CommandAck) + + val refusal = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + assertTrue(refusal.first() is RingDecodedEvent.MeasurementRejected) + assertEquals(0x00, (refusal.first() as RingDecodedEvent.MeasurementRejected).mode) + } + + @Test + fun `a NAKed stop cannot cancel the measurement started behind it`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x00, 0x0a)) // stop HRV + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, 0x00)) // start HR + + val nak = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x02)), streamUUID) + assertTrue(nak.first() is RingDecodedEvent.CommandAck) + + val refusal = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + assertTrue(refusal.first() is RingDecodedEvent.MeasurementRejected) + assertEquals(0x00, (refusal.first() as RingDecodedEvent.MeasurementRejected).mode) + } + + @Test + fun `a reconnect drops the commands the old link never answered`() { + val writer = FakeWriter() + val driver = YCBTDriver(writer) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, 0x0a)) + driver.connectionDidEnd() + driver.connectionDidStart() + + val events = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + assertTrue(events.first() is RingDecodedEvent.CommandAck) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt new file mode 100644 index 0000000..90dba7c --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -0,0 +1,138 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test +import java.time.Instant +import java.util.TimeZone + +class YCBTEncoderTest { + private val encoder = YCBTEncoder() + + @Test + fun `setTime weekday byte is correct for every day`() { + val calendar = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.set(2026, java.util.Calendar.JULY, 6, 12, 34, 14) + for (offset in 0..6) { + val date = Instant.ofEpochMilli(calendar.timeInMillis + offset * 86_400_000L) + val command = encoder.setTime(date) + assertEquals(offset.toByte(), command.last()) + } + } + + @Test + fun `setTime matches captured frame`() { + val calendar = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.set(2026, java.util.Calendar.JULY, 6, 12, 34, 14) + val date = Instant.ofEpochMilli(calendar.timeInMillis) + val command = encoder.setTime(date) + assertArrayEquals(byteArrayOf(0x01, 0x00, 0xea.toByte(), 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00), command) + } + + @Test + fun `monitor commands clamp interval to thirty minutes`() { + val settings = MeasurementSettings( + hrEnabled = true, hrIntervalMinutes = 5, + spo2Enabled = true, stressEnabled = true, hrvEnabled = true, temperatureEnabled = true, + ) + val commands = encoder.monitorCommands(settings) + assertEquals(5, commands.size) + assertArrayEquals(byteArrayOf(0x01, 0x0c, 0x01, 30), commands[0]) + assertArrayEquals(byteArrayOf(0x01, 0x1c, 0x01, 30), commands[1]) + assertArrayEquals(byteArrayOf(0x01, 0x20, 0x01, 30), commands[2]) + assertArrayEquals(byteArrayOf(0x01, 0x26, 0x01, 30), commands[3]) + assertArrayEquals(byteArrayOf(0x01, 0x45, 0x01, 30, 0, 0, 0), commands[4]) + } + + @Test + fun `monitor commands honour interval above floor and disabled flags`() { + val settings = MeasurementSettings( + hrEnabled = true, hrIntervalMinutes = 60, + spo2Enabled = false, stressEnabled = false, hrvEnabled = false, temperatureEnabled = false, + ) + val commands = encoder.monitorCommands(settings) + assertArrayEquals(byteArrayOf(0x01, 0x0c, 0x01, 60), commands[0]) + assertArrayEquals(byteArrayOf(0x01, 0x1c, 0x01, 60), commands[1]) + assertArrayEquals(byteArrayOf(0x01, 0x20, 0x00, 60), commands[2]) + assertArrayEquals(byteArrayOf(0x01, 0x26, 0x00, 60), commands[3]) + assertArrayEquals(byteArrayOf(0x01, 0x45, 0x00, 60, 0, 0, 0), commands[4]) + } + + @Test + fun `userInfo carries real profile`() { + val profile = UserProfileValues(metric = true, gender = 0x01u, age = 31u, heightCm = 183u, weightKg = 78u) + assertArrayEquals(byteArrayOf(0x01, 0x03, 183.toByte(), 78.toByte(), 1, 31.toByte()), encoder.userInfo(profile)) + + val female = UserProfileValues(metric = false, gender = 0x00u, age = 29u, heightCm = 165u, weightKg = 60u) + assertArrayEquals(byteArrayOf(0x01, 0x03, 165.toByte(), 60.toByte(), 0, 29.toByte()), encoder.userInfo(female)) + } + + @Test + fun `startup sends no health delete or retired get opcodes`() { + val sequence = encoder.startupSequence() + for (command in sequence) { + val group = command[0].toInt() and 0xFF + val cmd = command[1].toInt() and 0xFF + assertFalse("05 ${String.format("%02x", cmd)} is a Health-DELETE opcode", group == 0x05 && cmd in 0x40..0x4e) + assertFalse("handshake must not touch Health group", group == 0x05) + assertFalse("02 ${String.format("%02x", cmd)} is retired Get opcode", group == 0x02 && cmd in listOf(0x24, 0x26, 0x28)) + } + } + + @Test + fun `startup order mirrors SmartHealth handshake`() { + val sequence = encoder.startupSequence().map { it.copyOfRange(0, 2) } + assertArrayEquals(byteArrayOf(0x01, 0x00), sequence.first()) + assertArrayEquals(byteArrayOf(0x03, 0x09), sequence.last()) + assertEquals(listOf( + byteArrayOf(0x02, 0x00), + byteArrayOf(0x02, 0x01), + byteArrayOf(0x02, 0x1b), + byteArrayOf(0x02, 0x03), + byteArrayOf(0x02, 0x07), + ), sequence.subList(1, 6)) + assertEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02).toList(), encoder.startupSequence().last().toList()) + val settings = sequence.subList(6, sequence.size) + assertEquals(listOf( + byteArrayOf(0x01, 0x12), + byteArrayOf(0x01, 0x04), + byteArrayOf(0x01, 0x0c), + byteArrayOf(0x01, 0x1c), + byteArrayOf(0x01, 0x20), + byteArrayOf(0x01, 0x26), + byteArrayOf(0x01, 0x45), + byteArrayOf(0x01, 0x03), + byteArrayOf(0x03, 0x09), + ), settings) + } + + @Test + fun `history request and block ack bytes`() { + assertArrayEquals(byteArrayOf(0x05, 0x06), encoder.healthHistoryRequest(YCBTHistoryType.HEART)) + assertArrayEquals(byteArrayOf(0x05, 0x09), encoder.healthHistoryRequest(YCBTHistoryType.ALL)) + assertArrayEquals(byteArrayOf(0x05, 0x04), encoder.healthHistoryRequest(YCBTHistoryType.SLEEP)) + assertArrayEquals(byteArrayOf(0x05, 0x80, 0x00), encoder.historyBlockAck(status = 0x00)) + assertArrayEquals(byteArrayOf(0x05, 0x80, 0x04), encoder.historyBlockAck(status = 0x04)) + } + + @Test + fun `live measurement modes use distinct sensors`() { + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x01, 0x00), encoder.heartRateStart()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x01, 0x01), encoder.bloodPressureStart()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x01, 0x02), encoder.spo2Start()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x01, 0x0a), encoder.hrvStart()) + } + + @Test + fun `live measurement stop echoes its own mode`() { + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x00, 0x00), encoder.heartRateStop()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x00, 0x01), encoder.bloodPressureStop()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x00, 0x02), encoder.spo2Stop()) + assertArrayEquals(byteArrayOf(0x03, 0x2f, 0x00, 0x0a), encoder.hrvStop()) + } + + @Test + fun `findDevice uses AppControl not DevControl ack`() { + assertArrayEquals(byteArrayOf(0x03, 0x00, 0x01, 0x05, 0x02), encoder.findDevice()) + assertNotEquals(0x04.toByte(), encoder.findDevice()[0]) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt new file mode 100644 index 0000000..076d00c --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt @@ -0,0 +1,284 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test + +class YCBTHealthRecordsTest { + + private fun bytes(hex: String): ByteArray { + val clean = hex.replace(" ", "") + require(clean.length % 2 == 0) + return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private fun values(kind: MeasurementKind, events: List): List { + return events.mapNotNull { event -> + if (event is RingDecodedEvent.HistoryMeasurement && event.kind_field == kind) event.value else null + } + } + + private fun timestamps(events: List): List { + return events.mapNotNull { event -> + if (event is RingDecodedEvent.HistoryMeasurement) event._timestamp else null + } + } + + // Fixtures + private val capturedBodyRecord = bytes("1cf0de3103023e0505030402050530002a0c3700b00484030d000000") + private val capturedHeartRecords = bytes( + "1cf0de3100471afede310042260cdf31003f3b1adf31003e4328df3100425136df31003c6444df3100419852df31003a" + ) + private val capturedAllRecords = bytes( + "1cf0de31080d47734c620e3404000f00000033de1afede31000042704a610d2b02000f000000d24b260cdf3100003f70" + + "49610db106000f000000ce273b1adf3100003e6d49600c5f02000f00000077a54328df310000426f49610d2105000f00" + + "0000474b5136df3100003c6f47600c2104000f00000024f66444df310000416e49610d3d05000f00000015769852df31" + + "00003a6a465f0c8002000f000000d89d" + ) + private val capturedNight = bytes( + "affaa4019fe9de31bd58df31ffff971efb15733af29fe9de313c0500f1dceede312d0100f30af0de31d90100f2e4f1de31c9" + + "0400f1aef6de31320100f3e1f7de31c10100f2a3f9de31b50400f159fede319b0100f3f5ffde31b00100f2a501df31660200" + + "f10b04df313d0500f34809df31ae0800f2f611df31cc0100f1c213df31170200f3d915df317d0100f25617df31ef0000f345" + + "18df31060100f24b19df31670200f1b21bdf31910000f2431cdf31030000f3461cdf314c0000f2921cdf31f70100f3891edf" + + "31720200f2fb20df31e00000f3db21df31590100f23423df310e0100f34224df31d30100f21526df317a0000f38f26df31c1" + + "0000f25027df31be0400f10f2cdf312f0100f33f2ddf31aa0100f2ea2edf317a0500f16534df316b0100f3d135df319e0000" + + "f17036df319e0100f20e38df31450500f1543ddf318b0100f3e03edf31de0100f2bf40df31000500f1c045df315e0100f31f" + + "47df31730100f29348df31a00000f13449df319c0000f2d049df316d0500f13e4fdf31410100f38050df31d80100f25952df" + + "31050100f15f53df311e0100f27d54df31400400" + ) + + @Test + fun `heart rate records decode every record`() { + val events = YCBTHealthRecords.heartRate(capturedHeartRecords) + assertEquals(listOf(71.0, 66.0, 63.0, 62.0, 66.0, 60.0, 65.0, 58.0), values(MeasurementKind.HEART_RATE, events)) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } + + @Test + fun `heart rate drops zero samples`() { + val buffer = bytes("1cf0de310000" + "1afede310042") + assertEquals(1, YCBTHealthRecords.heartRate(buffer).size) + } + + @Test + fun `combined vitals decodes steps BP SpO2 and HRV`() { + val events = YCBTHealthRecords.combinedVitals(capturedAllRecords) + assertEquals(listOf(98.0, 97.0, 97.0, 96.0, 97.0, 96.0, 97.0, 95.0), values(MeasurementKind.SPO2, events)) + assertEquals(listOf(52.0, 43.0, 177.0, 95.0, 33.0, 33.0, 61.0, 128.0), values(MeasurementKind.HRV, events)) + assertEquals(listOf(115.0, 112.0, 112.0, 109.0, 111.0, 111.0, 110.0, 106.0), values(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, events)) + assertEquals(70.0, values(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, events).last(), 0.001) + assertFalse(events.any { it is RingDecodedEvent.BloodPressureSample }) + + val steps = events.mapNotNull { if (it is RingDecodedEvent.ActivityUpdate) it.steps else null } + assertEquals(3336, steps.max()) + } + + @Test + fun `combined vitals decodes respiratory rate`() { + val events = YCBTHealthRecords.combinedVitals(capturedAllRecords) + assertEquals(listOf(14.0, 13.0, 13.0, 12.0, 13.0, 12.0, 13.0, 12.0), values(MeasurementKind.RESPIRATORY_RATE, events)) + } + + @Test + fun `combined vitals skips unmeasured temperature and blood sugar`() { + val events = YCBTHealthRecords.combinedVitals(capturedAllRecords) + assertTrue(values(MeasurementKind.TEMPERATURE, events).isEmpty()) + assertTrue(values(MeasurementKind.BLOOD_SUGAR, events).isEmpty()) + } + + @Test + fun `combined vitals decodes temperature and blood sugar when present`() { + val events = YCBTHealthRecords.combinedVitals(bytes("1cf0de31721046764f610f3a0324061504370000")) + assertEquals(36.6, values(MeasurementKind.TEMPERATURE, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(99.088, values(MeasurementKind.BLOOD_SUGAR, events).firstOrNull() ?: 0.0, 0.001) + } + + @Test + fun `unworn combined vitals record yields steps only`() { + val events = YCBTHealthRecords.combinedVitals(bytes("1cf0de31080d4700000000000000000000000000")) + assertEquals(1, events.size) + assertTrue(events.first() is RingDecodedEvent.ActivityUpdate) + } + + @Test + fun `partial trailing record is dropped`() { + val events = YCBTHealthRecords.heartRate(bytes("1cf0de310047" + "1afede3100")) + assertEquals(1, events.size) + } + + @Test + fun `sleep decodes a full night matching the app`() { + val event = YCBTHealthRecords.sleep(capturedNight).first() as RingDecodedEvent.SleepTimeline + val deep = event.stages.count { it == SleepStage.DEEP } + val light = event.stages.count { it == SleepStage.LIGHT } + val rem = event.stages.count { it == SleepStage.REM } + assertEquals(93, deep, 3) + assertEquals(249, light, 3) + assertEquals(130, rem, 3) + assertFalse(event.stages.contains(SleepStage.AWAKE)) + } + + @Test + fun `multiple sessions in one buffer`() { + val timelines = YCBTHealthRecords.sleep(capturedNight + capturedNight).filterIsInstance() + assertEquals(2, timelines.size) + } + + @Test + fun `nap segment does not truncate the night`() { + val session = sleepSession(listOf( + 0xf2 to 60 * 60, + 0xf5 to 20 * 60, + 0xf1 to 30 * 60, + )) + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + assertEquals(60, event.stages.count { it == SleepStage.LIGHT }) + assertEquals(20, event.stages.count { it == SleepStage.UNKNOWN }) + assertEquals(30, event.stages.count { it == SleepStage.DEEP }) + } + + @Test + fun `segment duration is u24`() { + val session = sleepSession(listOf(0xf2 to 72_000)) + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + assertEquals(1200, event.stages.size) + } + + @Test + fun `repeated segment is counted once`() { + var session = sleepSession(listOf(0xf2 to 30 * 60, 0xf1 to 30 * 60, 0xf3 to 30 * 60)) + val deep = session.copyOfRange(20 + 8, 20 + 16) + session[2] = (20 + 4 * 8).toByte() + session[3] = 0 + session += deep + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + assertEquals(30, event.stages.count { it == SleepStage.DEEP }) + assertEquals(90, event.stages.size) + } + + @Test + fun `truncated session is clamped`() { + var session = sleepSession(listOf(0xf2 to 600, 0xf1 to 600)) + session = session.copyOfRange(0, session.size - 8) + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + assertEquals(10, event.stages.size) + } + + @Test + fun `SpO2 records decode`() { + val events = YCBTHealthRecords.spo2(bytes("1cf0de3100611afede310100260cdf31005f")) + assertEquals(listOf(97.0, 95.0), values(MeasurementKind.SPO2, events)) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } + + @Test + fun `blood pressure records decode`() { + val events = YCBTHealthRecords.bloodPressure(bytes("1cf0de3101764f401afede3100000000")) + assertEquals(listOf(118.0), values(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, events)) + assertEquals(listOf(79.0), values(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, events)) + assertEquals(listOf(64.0), values(MeasurementKind.HEART_RATE, events)) + assertFalse(events.any { it is RingDecodedEvent.BloodPressureSample }) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } + + @Test + fun `body data decodes HRV in ms and stress fatigue on apps hundred scale`() { + val events = YCBTHealthRecords.bodyData(capturedBodyRecord) + assertEquals(62.5, values(MeasurementKind.HRV, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(53.0, values(MeasurementKind.STRESS, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(42.0, values(MeasurementKind.FATIGUE, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(42.0, values(MeasurementKind.VO2MAX, events).firstOrNull() ?: 0.0, 0.001) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } + + @Test + fun `stress score is digit concatenated not a decimal composite`() { + assertEquals(53.0, YCBTHealthRecords.score(5, 3), 0.001) + assertEquals(70.0, YCBTHealthRecords.score(7, 0), 0.001) + assertEquals(100.0, YCBTHealthRecords.score(10, 0), 0.001) + assertEquals(5.3, YCBTHealthRecords.composite(5, 3), 0.001) + } + + @Test + fun `short body data record is dropped not misread`() { + val events = YCBTHealthRecords.bodyData(capturedBodyRecord + capturedBodyRecord.copyOfRange(0, 17)) + assertEquals(1, values(MeasurementKind.HRV, events).size) + assertEquals(listOf(42.0), values(MeasurementKind.VO2MAX, events)) + } + + @Test + fun `sport records decode to activity buckets`() { + val events = YCBTHealthRecords.sport(bytes("1cf0de31a0f3de318002e00119001afede319e01df31000000000000")) + assertEquals(1, events.size) + val bucket = events.first() as RingDecodedEvent.ActivityBucket + assertEquals(YCBTBytes.date(836_694_044), bucket._timestamp) + assertEquals(640, bucket.steps) + assertEquals(480, bucket.distanceMeters) + } + + @Test + fun `temperature records decode with string concat fraction`() { + val events = YCBTHealthRecords.temperature(bytes("1cf0de310024051afede31002419260cdf3100000f")) + val temps = values(MeasurementKind.TEMPERATURE, events) + assertEquals(2, temps.size) + assertEquals(36.5, temps[0], 0.001) + assertEquals(36.25, temps[1], 0.001) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } + + @Test + fun `temperature filler fraction is dropped even with non zero integer`() { + val events = YCBTHealthRecords.temperature(bytes("1cf0de3100240f" + "1afede3100000f")) + assertTrue(values(MeasurementKind.TEMPERATURE, events).isEmpty()) + } + + @Test + fun `comprehensive decodes blood sugar as mgdl`() { + val events = YCBTHealthRecords.comprehensive(bytes( + "1cf0de3101050500000000000000000000000000000000000000000000000000000000000000000000000000" + + "1afede31010000000000000000000000000000000000000000000000000000000000000000000000000000000000" + )) + val sugar = values(MeasurementKind.BLOOD_SUGAR, events) + assertEquals(1, sugar.size) + assertEquals(99.088, sugar[0], 0.001) + assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) + } + + @Test + fun `every catalog type decodes through the type table`() { + val spo2 = bytes("1cf0de3100611afede310100260cdf31005f") + val blood = bytes("1cf0de3101764f401afede3100000000") + val sport = bytes("1cf0de31a0f3de318002e00119001afede319e01df31000000000000") + val temperature = bytes("1cf0de310024051afede31002419260cdf3100000f") + + assertEquals(2, YCBTHealthRecords.decode(spo2, YCBTHistoryType.SPO2).size) + assertEquals(3, YCBTHealthRecords.decode(blood, YCBTHistoryType.BLOOD).size) + assertEquals(1, YCBTHealthRecords.decode(sport, YCBTHistoryType.SPORT).size) + assertEquals(2, YCBTHealthRecords.decode(temperature, YCBTHistoryType.TEMPERATURE).size) + assertEquals(4, YCBTHealthRecords.decode(capturedBodyRecord, YCBTHistoryType.BODY_DATA).size) + assertEquals(8 * 6, YCBTHealthRecords.decode(capturedAllRecords, YCBTHistoryType.ALL).size) + assertFalse(YCBTHealthRecords.decode(capturedNight, YCBTHistoryType.SLEEP).isEmpty()) + assertFalse(YCBTHealthRecords.decode(capturedHeartRecords, YCBTHistoryType.HEART).isEmpty()) + } + + private fun sleepSession(segments: List>): ByteArray { + val recordLength = 20 + segments.size * 8 + val out = mutableListOf() + out.add(0xaf.toByte()) + out.add(0xfa.toByte()) + out.add((recordLength and 0xFF).toByte()) + out.add((recordLength shr 8).toByte()) + repeat(16) { out.add(0) } + for ((index, segment) in segments.withIndex()) { + val start = 0x31def01c + index * 3600 + out.add(segment.first.toByte()) + out.add((start and 0xFF).toByte()) + out.add(((start shr 8) and 0xFF).toByte()) + out.add(((start shr 16) and 0xFF).toByte()) + out.add(((start shr 24) and 0xFF).toByte()) + out.add((segment.second and 0xFF).toByte()) + out.add(((segment.second shr 8) and 0xFF).toByte()) + out.add(((segment.second shr 16) and 0xFF).toByte()) + } + return out.toByteArray() + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt new file mode 100644 index 0000000..83f26ae --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt @@ -0,0 +1,260 @@ +package com.pulseloop.ring + +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +class YCBTHistoryTransferTest { + + private class FakeWriter : RingCommandWriter { + val sent = mutableListOf() + override fun enqueue(command: ByteArray) { sent.add(command.copyOf()) } + } + + private val heartQuery = byteArrayOf(0x05, 0x06) + private val allQuery = byteArrayOf(0x05, 0x09) + private val ackAccepted = byteArrayOf(0x05, 0x80, 0x00) + private val ackCrcFailure = byteArrayOf(0x05, 0x80, 0x04) + + private fun header(records: Int, packets: Int, bytes: Int): ByteArray { + return byteArrayOf( + (records and 0xFF).toByte(), (records shr 8).toByte(), + (packets and 0xFF).toByte(), (packets shr 8).toByte(), 0, 0, + (bytes and 0xFF).toByte(), (bytes shr 8).toByte(), 0, 0, + ) + } + + private fun terminal(packets: Int, buffer: ByteArray, crc: Int? = null): ByteArray { + val checksum = crc ?: YCBTFrame.crc16(buffer) + return byteArrayOf( + (packets and 0xFF).toByte(), (packets shr 8).toByte(), + (buffer.size and 0xFF).toByte(), ((buffer.size shr 8) and 0xFF).toByte(), + (checksum and 0xFF).toByte(), ((checksum shr 8) and 0xFF).toByte(), + ) + } + + private val heartBuffer = byteArrayOf( + 0x1c, 0xf0, 0xde, 0x31, 0x00, 0x47, + 0x1a, 0xfe, 0xde, 0x31, 0x00, 0x42, + ) + + private fun heartRates(events: List): List { + return events.mapNotNull { event -> + if (event is RingDecodedEvent.HistoryMeasurement && event.kind_field == MeasurementKind.HEART_RATE) event.value else null + } + } + + @Test + fun `full cycle acks and advances to next type`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + assertEquals(1, writer.sent.size) + assertArrayEquals(heartQuery, writer.sent[0]) + + val progress = transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + assertTrue(progress.first() is RingDecodedEvent.HistorySyncProgress) + assertEquals("Syncing heart rate…", (progress.first() as RingDecodedEvent.HistorySyncProgress).stage) + + assertTrue(transfer.handle(cmd = 0x15, payload = heartBuffer).isEmpty()) + + writer.sent.clear() + val done = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) + + assertEquals(listOf(71.0, 66.0), heartRates(done)) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackAccepted, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + } + + @Test + fun `record straddling two data frames survives`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART)) + + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 2, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer.copyOfRange(0, 9)) + transfer.handle(cmd = 0x15, payload = heartBuffer.copyOfRange(9, heartBuffer.size)) + val done = transfer.handle(cmd = 0x80, payload = terminal(packets = 2, buffer = heartBuffer)) + + assertEquals(listOf(71.0, 66.0), heartRates(done)) + } + + @Test + fun `CRC mismatch nacks and retries the type once`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + + writer.sent.clear() + val first = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer, crc = 0xdead)) + assertTrue(heartRates(first).isEmpty()) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(heartQuery, writer.sent[1]) + + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + writer.sent.clear() + transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer, crc = 0xdead)) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + } + + @Test + fun `no data header advances without acking`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + + writer.sent.clear() + transfer.handle(cmd = 0x06, payload = byteArrayOf(0x00)) + assertEquals(1, writer.sent.size) + assertArrayEquals(allQuery, writer.sent[0]) + } + + @Test + fun `error frame advances and unsupported type is not requested again`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + + writer.sent.clear() + transfer.handle(cmd = 0x06, payload = byteArrayOf(0xfc.toByte())) + assertEquals(1, writer.sent.size) + assertArrayEquals(allQuery, writer.sent[0]) + + transfer.handle(cmd = 0x09, payload = byteArrayOf(0xfc.toByte())) + writer.sent.clear() + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `engine requests every history type in order`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val engine = YCBTSyncEngine(writer = writer, transfer = transfer) + engine.runStartup() + + val requested = mutableListOf() + for (i in 0 until YCBTHistoryType.CATALOG.size) { + val query = writer.sent.lastOrNull { it.size == 2 && it[0] == 0x05.toByte() } + if (query == null) break + requested.add(query[1].toInt() and 0xFF) + transfer.handle(cmd = query[1].toInt() and 0xFF, payload = byteArrayOf(0x00)) + } + assertEquals(listOf(0x02, 0x04, 0x06, 0x08, 0x09, 0x1a, 0x1e, 0x2f, 0x33), requested) + } + + @Test + fun `sync vitals history queues only the three vitals types`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val engine = YCBTSyncEngine(writer = writer, transfer = transfer) + engine.syncVitalsHistory() + + val requested = mutableListOf() + while (true) { + val query = writer.sent.lastOrNull { it.size == 2 && it[0] == 0x05.toByte() } ?: break + requested.add(query[1].toInt() and 0xFF) + writer.sent.clear() + transfer.handle(cmd = query[1].toInt() and 0xFF, payload = byteArrayOf(0x00)) + } + assertEquals(listOf(0x06, 0x09, 0x1a), requested) + } + + @Test + fun `sync history reruns the full catalog`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val engine = YCBTSyncEngine(writer = writer, transfer = transfer) + engine.syncHistory() + + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) + assertTrue(writer.sent.all { it[0] == 0x05.toByte() }) + } + + @Test + fun `start is ignored while a transfer is in flight`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + assertTrue(transfer.isActive) + + writer.sent.clear() + transfer.start(types = listOf(YCBTHistoryType.SLEEP)) + assertTrue(writer.sent.isEmpty()) + + transfer.handle(cmd = 0x15, payload = heartBuffer) + val done = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) + assertEquals(listOf(71.0, 66.0), heartRates(done)) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackAccepted, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + } + + @Test + fun `frames while idle are ignored`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + assertTrue(transfer.handle(cmd = 0x15, payload = heartBuffer).isEmpty()) + assertTrue(transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = byteArrayOf())).isEmpty()) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `watchdog skips a stalled type and never acks`() = runBlocking { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer, inactivitySeconds = 0.05, absoluteCapSeconds = 0.2) + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + + delay(300) + + assertEquals(2, writer.sent.size) + assertArrayEquals(heartQuery, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + assertFalse(writer.sent.any { it.size >= 2 && it[0] == 0x05.toByte() && it[1] == 0x80.toByte() }) + } + + @Test + fun `watchdog is not armed after completion`() = runBlocking { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer, inactivitySeconds = 0.05, absoluteCapSeconds = 0.2) + + transfer.start(types = listOf(YCBTHistoryType.HEART)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) + + writer.sent.clear() + delay(300) + assertTrue(writer.sent.isEmpty()) + } + + @Test + fun `cancel stops the watchdog from walking the queue`() = runBlocking { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer, inactivitySeconds = 0.05, absoluteCapSeconds = 0.2) + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + transfer.cancel() + writer.sent.clear() + + delay(300) + assertTrue(writer.sent.isEmpty()) + assertFalse(transfer.isActive) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt new file mode 100644 index 0000000..c1bd379 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt @@ -0,0 +1,122 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test + +class YCBTProtocolTest { + + private val streamUUID = YCBTUUIDs.STREAM + private val commandUUID = YCBTUUIDs.COMMAND + + private fun frameBytes(cmd: Int, payloadLength: Int, fill: Int = 0xaa): ByteArray { + return YCBTFrame.frame(byteArrayOf(YCBTGroup.HEALTH.toByte(), cmd.toByte()) + ByteArray(payloadLength) { fill.toByte() }) + } + + @Test + fun `frame builds length and CRC matching captured setTime`() { + val logical = byteArrayOf(0x01, 0x00, 0xea.toByte(), 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00) + val framed = YCBTFrame.frame(logical) + assertEquals("01000e00ea0707060c220e0026c7", framed.toHexString()) + } + + @Test + fun `CRC16 matches captured setTime body`() { + val body = hexToBytes("01000e00ea0707060c220e00") + val crc = YCBTFrame.crc16(body) + assertEquals(0xc726, crc) + } + + @Test + fun `validating rejects bad CRC`() { + val raw = hexToBytes("01000e00ea0707060c220e0026c7") + raw[raw.size - 1] = (raw[raw.size - 1].toInt() xor 0xff).toByte() + assertNull(YCBTFrame.validating(raw)) + } + + @Test + fun `validating rejects wrong declared length`() { + assertNull(YCBTFrame.validating(hexToBytes("0100ff00ea0707060c220e0026c7"))) + } + + @Test + fun `frame split across three notifications is reassembled`() { + val assembler = YCBTFrameAssembler() + val whole = frameBytes(cmd = 0x15, payloadLength = 60) + + assertTrue(assembler.append(whole.copyOfRange(0, 20), streamUUID).isEmpty()) + assertTrue(assembler.append(whole.copyOfRange(20, 40), streamUUID).isEmpty()) + val done = assembler.append(whole.copyOfRange(40, whole.size), streamUUID) + + assertEquals(1, done.size) + assertArrayEquals(whole, done[0]) + assertNotNull(YCBTFrame.validating(done[0])) + } + + @Test + fun `two frames in one notification both emerge`() { + val assembler = YCBTFrameAssembler() + val first = frameBytes(cmd = 0x15, payloadLength = 6) + val second = frameBytes(cmd = 0x18, payloadLength = 20) + + val done = assembler.append(first + second, streamUUID) + assertEquals(2, done.size) + assertArrayEquals(first, done[0]) + assertArrayEquals(second, done[1]) + } + + @Test + fun `fragment then whole frame in one notification`() { + val assembler = YCBTFrameAssembler() + val first = frameBytes(cmd = 0x15, payloadLength = 30) + val second = frameBytes(cmd = 0x18, payloadLength = 4) + + assertTrue(assembler.append(first.copyOfRange(0, 10), streamUUID).isEmpty()) + val done = assembler.append(first.copyOfRange(10, first.size) + second, streamUUID) + assertEquals(2, done.size) + assertArrayEquals(first, done[0]) + assertArrayEquals(second, done[1]) + } + + @Test + fun `garbage prefix resyncs to next valid frame`() { + val assembler = YCBTFrameAssembler() + val good = frameBytes(cmd = 0x15, payloadLength = 6) + val garbage = byteArrayOf(0xff.toByte(), 0x00, 0xff.toByte(), 0xff.toByte(), 0x7f) + + val done = assembler.append(garbage + good, streamUUID) + assertEquals(1, done.size) + assertArrayEquals(good, done[0]) + } + + @Test + fun `channels buffer independently`() { + val assembler = YCBTFrameAssembler() + val streamFrame = frameBytes(cmd = 0x15, payloadLength = 30) + val commandFrame = YCBTFrame.frame(byteArrayOf(0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64)) + + assertTrue(assembler.append(streamFrame.copyOfRange(0, 10), streamUUID).isEmpty()) + val cmdDone = assembler.append(commandFrame, commandUUID) + assertEquals(1, cmdDone.size) + assertArrayEquals(commandFrame, cmdDone[0]) + + val streamDone = assembler.append(streamFrame.copyOfRange(10, streamFrame.size), streamUUID) + assertEquals(1, streamDone.size) + assertArrayEquals(streamFrame, streamDone[0]) + } + + @Test + fun `reset drops partial frames`() { + val assembler = YCBTFrameAssembler() + val whole = frameBytes(cmd = 0x15, payloadLength = 30) + + assertTrue(assembler.append(whole.copyOfRange(0, 10), streamUUID).isEmpty()) + assembler.reset() + assertTrue(assembler.append(whole.copyOfRange(10, whole.size), streamUUID).isEmpty()) + } + + private fun hexToBytes(hex: String): ByteArray { + val clean = hex.replace(" ", "") + require(clean.length % 2 == 0) + return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} From ffea7eaecc26be9d6bc9adcc4eefc28a45abc5bd Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 20:10:01 +0200 Subject: [PATCH 04/36] fix: resolve YCBT compile integration errors --- app/src/main/java/com/pulseloop/ring/WearableDriver.kt | 2 ++ app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt | 8 +++++--- app/src/main/java/com/pulseloop/ring/YCBTDriver.kt | 3 ++- app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt | 2 +- app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt | 4 ++-- app/src/main/java/com/pulseloop/service/MetricsService.kt | 3 +++ .../java/com/pulseloop/ui/components/DeviceHeroCard.kt | 1 + .../java/com/pulseloop/ui/components/MetricThresholds.kt | 1 + app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt | 4 ++++ 9 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index 6adff06..832e688 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -187,6 +187,8 @@ data class AdvertisementInfo( interface WearableCoordinator { val deviceType: RingDeviceType val capabilities: Set + /** Capabilities that are advertised statically but must be confirmed from a device bitmap. */ + val bitmapGatedCapabilities: Set get() = emptySet() val iconSystemName: String val displayName: String get() = deviceType.displayName diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt index dfc11a3..6b6af85 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt @@ -49,11 +49,11 @@ class YCBTDecoder { )) } YCBTCommand.LIVE_HEART_RATE -> { - val bpm = p.firstOrNull()?.toInt() and 0xFF ?: return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + val bpm = (p.firstOrNull()?.toInt() ?: return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte()))) and 0xFF return listOf(RingDecodedEvent.HeartRateSample(bpm = bpm, _timestamp = now)) } YCBTCommand.LIVE_SPO2 -> { - val spo2 = p.firstOrNull()?.toInt() and 0xFF ?: return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + val spo2 = (p.firstOrNull()?.toInt() ?: return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte()))) and 0xFF if (spo2 in RingEventBridge.spo2Range) { return listOf(RingDecodedEvent.Spo2Result(value = spo2, _timestamp = now)) } @@ -110,7 +110,9 @@ class YCBTDecoder { private fun decodeDeviceInfo(p: ByteArray): List { val events = mutableListOf(RingDecodedEvent.Status(address = null)) if (p.size >= 4) { - events.add(RingDecodedEvent.FirmwareVersion(version = String.format("%d.%02d", p[3].toInt() and 0xFF, p[2].toInt() and 0xFF))) + val major = p[3].toInt() and 0xFF + val minor = p[2].toInt() and 0xFF + events.add(RingDecodedEvent.FirmwareVersion(version = major * 100 + minor)) } if (p.size >= 6) { events.add(RingDecodedEvent.Battery(percent = p[5].toInt() and 0xFF)) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt index 03edf33..0b63aae 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt @@ -58,7 +58,8 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { override fun ingest(data: ByteArray, from: String): List { val events = mutableListOf() for (logical in assembler.append(data, from)) { - val frame = YCBTFrame.validating(logical) ?: run { + val frame = YCBTFrame.validating(logical) + if (frame == null) { events.add(RingDecodedEvent.Unknown(commandId = logical.firstOrNull()?.toUByte() ?: 0u, raw = logical)) continue } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt index 762771f..256c728 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt @@ -314,7 +314,7 @@ object YCBTSupportFunction { /** The 02 1b chipScheme reply — diagnostic only. */ object YCBTChipScheme { fun value(payload: ByteArray): Int { - val first = payload.firstOrNull()?.toInt() and 0xFF ?: return 0 + val first = (payload.firstOrNull()?.toInt() ?: return 0) and 0xFF return if (first >= 240) 0 else first } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index 30822ce..b51b9fe 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -64,11 +64,11 @@ class YCBTSyncEngine( writer?.enqueue(encoder.userInfo(profile)) } - override fun resyncTime() { + fun resyncTime() { writer?.enqueue(encoder.setTime()) } - override fun requestBattery() { + fun requestBattery() { writer?.enqueue(encoder.deviceInfoRequest()) } diff --git a/app/src/main/java/com/pulseloop/service/MetricsService.kt b/app/src/main/java/com/pulseloop/service/MetricsService.kt index e8ada38..6f11334 100644 --- a/app/src/main/java/com/pulseloop/service/MetricsService.kt +++ b/app/src/main/java/com/pulseloop/service/MetricsService.kt @@ -109,6 +109,7 @@ object MetricsService { MeasurementKind.TEMPERATURE -> caps.contains(WearableCapability.TEMPERATURE) MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> caps.contains(WearableCapability.BLOOD_PRESSURE) MeasurementKind.BLOOD_SUGAR -> caps.contains(WearableCapability.BLOOD_SUGAR) + MeasurementKind.RESPIRATORY_RATE, MeasurementKind.VO2MAX -> false } } @@ -125,6 +126,8 @@ object MetricsService { MeasurementKind.BLOOD_PRESSURE_SYSTOLIC -> (110..130).random().toDouble() MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> (70..85).random().toDouble() MeasurementKind.BLOOD_SUGAR -> (80..140).random().toDouble() + MeasurementKind.RESPIRATORY_RATE -> (12..20).random().toDouble() + MeasurementKind.VO2MAX -> (30..55).random().toDouble() } val unit = when (kind) { MeasurementKind.HEART_RATE -> "bpm" diff --git a/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt b/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt index b38d329..20992b3 100644 --- a/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt +++ b/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt @@ -183,5 +183,6 @@ fun DeviceHeroCard( */ private fun fallbackRingImage(type: RingDeviceType?): Int? = when (type) { RingDeviceType.JRING -> R.drawable.ring_jring + RingDeviceType.YCBT -> R.drawable.ring_colmi_r10 RingDeviceType.COLMI_R02, null -> null } diff --git a/app/src/main/java/com/pulseloop/ui/components/MetricThresholds.kt b/app/src/main/java/com/pulseloop/ui/components/MetricThresholds.kt index 6e6f8b2..cf540b2 100644 --- a/app/src/main/java/com/pulseloop/ui/components/MetricThresholds.kt +++ b/app/src/main/java/com/pulseloop/ui/components/MetricThresholds.kt @@ -44,6 +44,7 @@ object MetricThresholdTable { MeasurementKind.BLOOD_PRESSURE_SYSTOLIC -> bloodPressure MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> bloodPressure MeasurementKind.BLOOD_SUGAR -> bloodSugar + MeasurementKind.RESPIRATORY_RATE, MeasurementKind.VO2MAX -> null } /** Returns a synthetic thresholds entry for the combined "bp" detail screen. */ diff --git a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt index 48c36d8..0925ff5 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt @@ -259,6 +259,8 @@ private fun labelFor(event: PulseEvent): String = when (event) { com.pulseloop.ring.MeasurementKind.BLOOD_PRESSURE_SYSTOLIC -> "BP Sys" com.pulseloop.ring.MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> "BP Dia" com.pulseloop.ring.MeasurementKind.BLOOD_SUGAR -> "Glucose" + com.pulseloop.ring.MeasurementKind.RESPIRATORY_RATE -> "Resp Rate" + com.pulseloop.ring.MeasurementKind.VO2MAX -> "VO₂ max" } is PulseEvent.BatteryLevel -> "Battery" is PulseEvent.ActivityUpdate -> "Activity" @@ -315,6 +317,8 @@ private fun colorFor(event: PulseEvent): Color = when (event) { com.pulseloop.ring.MeasurementKind.BLOOD_PRESSURE_SYSTOLIC -> Color(0xFFE91E63) com.pulseloop.ring.MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> Color(0xFFF06292) com.pulseloop.ring.MeasurementKind.BLOOD_SUGAR -> Color(0xFF00BCD4) + com.pulseloop.ring.MeasurementKind.RESPIRATORY_RATE -> Color(0xFF26A69A) + com.pulseloop.ring.MeasurementKind.VO2MAX -> Color(0xFF5C6BC0) } is PulseEvent.BatteryLevel -> Color(0xFF66BB6A) is PulseEvent.ActivityUpdate, is PulseEvent.ActivityBucket -> Color(0xFFFF9800) From 54cba1e89e59774169dc57440bfdf4e52b82a68c Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 20:15:10 +0200 Subject: [PATCH 05/36] test: fix YCBT Kotlin fixtures --- app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt | 9 +++++---- app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt | 2 +- app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt | 4 ++-- .../java/com/pulseloop/ring/YCBTHealthRecordsTest.kt | 6 +++--- .../java/com/pulseloop/ring/YCBTHistoryTransferTest.kt | 8 ++++---- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt index 6b6af85..c5a7041 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt @@ -108,10 +108,11 @@ class YCBTDecoder { } private fun decodeDeviceInfo(p: ByteArray): List { - val events = mutableListOf(RingDecodedEvent.Status(address = null)) - if (p.size >= 4) { - val major = p[3].toInt() and 0xFF - val minor = p[2].toInt() and 0xFF + val major = p.getOrNull(3)?.toInt()?.and(0xFF) + val minor = p.getOrNull(2)?.toInt()?.and(0xFF) + val firmware = if (major != null && minor != null) String.format("%d.%02d", major, minor) else null + val events = mutableListOf(RingDecodedEvent.Status(address = null, firmware = firmware)) + if (major != null && minor != null) { events.add(RingDecodedEvent.FirmwareVersion(version = major * 100 + minor)) } if (p.size >= 6) { diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt index fbff147..870c3ab 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt @@ -163,7 +163,7 @@ class YCBTDecoderTest { utcCalendar.set(calendar.get(java.util.Calendar.YEAR), calendar.get(java.util.Calendar.MONTH), calendar.get(java.util.Calendar.DAY_OF_MONTH), calendar.get(java.util.Calendar.HOUR_OF_DAY), calendar.get(java.util.Calendar.MINUTE), calendar.get(java.util.Calendar.SECOND)) val ringSeconds = (utcCalendar.timeInMillis / 1000 - YCBTBytes.EPOCH_OFFSET).toInt() val decoded = YCBTBytes.date(ringSeconds, tz) - assertEquals(trueInstant.epochSecond, decoded.epochSecond, 1) + assertTrue(kotlin.math.abs(trueInstant.epochSecond - decoded.epochSecond) <= 1) } @Test diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt index 90dba7c..4cf63c2 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -110,8 +110,8 @@ class YCBTEncoderTest { assertArrayEquals(byteArrayOf(0x05, 0x06), encoder.healthHistoryRequest(YCBTHistoryType.HEART)) assertArrayEquals(byteArrayOf(0x05, 0x09), encoder.healthHistoryRequest(YCBTHistoryType.ALL)) assertArrayEquals(byteArrayOf(0x05, 0x04), encoder.healthHistoryRequest(YCBTHistoryType.SLEEP)) - assertArrayEquals(byteArrayOf(0x05, 0x80, 0x00), encoder.historyBlockAck(status = 0x00)) - assertArrayEquals(byteArrayOf(0x05, 0x80, 0x04), encoder.historyBlockAck(status = 0x04)) + assertArrayEquals(byteArrayOf(0x05, 0x80.toByte(), 0x00), encoder.historyBlockAck(status = 0x00)) + assertArrayEquals(byteArrayOf(0x05, 0x80.toByte(), 0x04), encoder.historyBlockAck(status = 0x04)) } @Test diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt index 076d00c..c22d2ac 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt @@ -111,9 +111,9 @@ class YCBTHealthRecordsTest { val deep = event.stages.count { it == SleepStage.DEEP } val light = event.stages.count { it == SleepStage.LIGHT } val rem = event.stages.count { it == SleepStage.REM } - assertEquals(93, deep, 3) - assertEquals(249, light, 3) - assertEquals(130, rem, 3) + assertTrue(kotlin.math.abs(93 - deep) <= 3) + assertTrue(kotlin.math.abs(249 - light) <= 3) + assertTrue(kotlin.math.abs(130 - rem) <= 3) assertFalse(event.stages.contains(SleepStage.AWAKE)) } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt index 83f26ae..1b9b4b0 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt @@ -14,8 +14,8 @@ class YCBTHistoryTransferTest { private val heartQuery = byteArrayOf(0x05, 0x06) private val allQuery = byteArrayOf(0x05, 0x09) - private val ackAccepted = byteArrayOf(0x05, 0x80, 0x00) - private val ackCrcFailure = byteArrayOf(0x05, 0x80, 0x04) + private val ackAccepted = byteArrayOf(0x05, 0x80.toByte(), 0x00) + private val ackCrcFailure = byteArrayOf(0x05, 0x80.toByte(), 0x04) private fun header(records: Int, packets: Int, bytes: Int): ByteArray { return byteArrayOf( @@ -35,8 +35,8 @@ class YCBTHistoryTransferTest { } private val heartBuffer = byteArrayOf( - 0x1c, 0xf0, 0xde, 0x31, 0x00, 0x47, - 0x1a, 0xfe, 0xde, 0x31, 0x00, 0x42, + 0x1c, 0xf0.toByte(), 0xde.toByte(), 0x31, 0x00, 0x47, + 0x1a, 0xfe.toByte(), 0xde.toByte(), 0x31, 0x00, 0x42, ) private fun heartRates(events: List): List { From d96332915bb9cb81155809a974f97522c528dae6 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 20:18:20 +0200 Subject: [PATCH 06/36] feat: integrate YCBT lifecycle and capabilities --- .../java/com/pulseloop/ring/RingBLEClient.kt | 19 +++++++++++++++ .../com/pulseloop/ring/WearableCapability.kt | 2 +- .../java/com/pulseloop/ring/WearableDriver.kt | 4 ++++ .../com/pulseloop/ring/YCBTCoordinator.kt | 5 +--- .../java/com/pulseloop/ring/YCBTDriver.kt | 4 ++-- .../com/pulseloop/ring/PairingMatchingTest.kt | 24 +++++++++++++++++-- 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index f83879e..22cca2f 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -620,6 +620,7 @@ class RingBLEClient(private val context: Context) { // can collide with the orphaned handle. A fresh GATT mirrors the proven // force-close-and-reopen recovery path. bluetoothGatt?.let { old -> + activeDriver?.connectionDidEnd() try { old.disconnect() } catch (_: Exception) {} closeGattQuietly(old) // refresh + close, official-app teardown discipline } @@ -741,6 +742,7 @@ class RingBLEClient(private val context: Context) { val driver = coordinator.makeDriver { enqueueWrite(it) } activeCoordinator = coordinator activeDriver = driver + driver.connectionDidStart() activeSyncEngine = driver.makeSyncEngine() // Capability-gated bonding: the engine fires this only when the ring's device-support // reply advertises supportBlePair (Colmi R09 and newer). See docs/qring-ble-adoption.md. @@ -1201,6 +1203,21 @@ class RingBLEClient(private val context: Context) { } continue } + if (decoded is RingDecodedEvent.SupportFunctions) { + activeCoordinator?.let { coordinator -> + val dynamic = decoded.capabilities.intersect(coordinator.bitmapGatedCapabilities) + val merged = coordinator.capabilities + dynamic + updateState { copy(activeCapabilities = merged) } + PulseEventBus.publishBlocking( + PulseEvent.DeviceIdentified( + deviceType = coordinator.deviceType, + wearableModelID = _state.value.activeWearableModelID, + advertisedName = activeAdvertisedName ?: connectingName, + capabilities = merged, + ) + ) + } + } PulseEventBus.publishBlocking( PulseEvent.RawPacket(PacketDirection.INCOMING, value, decoded) ) @@ -1318,6 +1335,7 @@ class RingBLEClient(private val context: Context) { } resetOpQueue() stopKeepalive() + activeDriver?.connectionDidEnd() // Release the dead client immediately (the link is already down, so no // disconnect/delay needed): refresh the GATT cache and close, matching the @@ -1342,6 +1360,7 @@ class RingBLEClient(private val context: Context) { // The scope is about to die, so the graceful delayed close in disconnect() // would never run — tear the GATT down synchronously instead. bluetoothGatt?.let { gatt -> + activeDriver?.connectionDidEnd() try { gatt.disconnect() } catch (_: Exception) {} closeGattQuietly(gatt) } diff --git a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt index 20b27ce..124013e 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt @@ -60,5 +60,5 @@ enum class RingDeviceType(val displayName: String) { // WearableModel (iOS #49), so the family label stays honest about the ambiguity. COLMI_R02("Colmi / Yawell ring"), // Yucheng YCBT protocol family (SmartHealth app): TK5, R10M, and other SmartHealth-branded rings. - YCBT("YCBT ring"); + YCBT("YCBT / SmartHealth ring"); } diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index 832e688..f9e7f50 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -31,6 +31,10 @@ interface WearableDriver { /** Build the per-device sync engine. */ fun makeSyncEngine(): RingSyncEngine + + /** Reset connection-scoped protocol state. */ + fun connectionDidStart() {} + fun connectionDidEnd() {} } /** diff --git a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt index b95528f..d42b872 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt @@ -14,10 +14,7 @@ object YCBTCoordinator : WearableCoordinator { override fun matches(name: String?, advertisement: AdvertisementInfo): Boolean { // Disqualify QRing service outright — those belong to ColmiDriver. - val qringServices = listOf( - "6e40fff0-b5a3-f393-e0a9-e50e24dcca9e", - "de5bf728-d711-4ed6-8383-f4b9c014c20c", - ) + val qringServices = listOf(ColmiUUIDs.SERVICE_V1, ColmiUUIDs.SERVICE_V2) if (advertisement.serviceUUIDs.any { it in qringServices }) return false val model = WearableModel.modelForAdvertisedName(name) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt index 0b63aae..8489121 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt @@ -43,13 +43,13 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { } } - fun connectionDidStart() { + override fun connectionDidStart() { assembler.reset() transfer.cancel() pendingMeasurementReplies.clear() } - fun connectionDidEnd() { + override fun connectionDidEnd() { assembler.reset() transfer.cancel() pendingMeasurementReplies.clear() diff --git a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt index 6b1f11a..67b2d80 100644 --- a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt +++ b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt @@ -22,12 +22,14 @@ class PairingMatchingTest { val types = RingDeviceType.entries.map { it.name } assertTrue(types.contains("JRING")) assertTrue(types.contains("COLMI_R02")) + assertTrue(types.contains("YCBT")) } @Test fun `device type display names are meaningful`() { assertEquals("SMART_RING", RingDeviceType.JRING.displayName) assertEquals("Colmi / Yawell ring", RingDeviceType.COLMI_R02.displayName) + assertEquals("YCBT / SmartHealth ring", RingDeviceType.YCBT.displayName) } // ── Coordinator name matching (delegated to the WearableModel catalog, iOS #49) ──── @@ -46,7 +48,7 @@ class PairingMatchingTest { @Test fun `non-colmi names do not match`() { - for (name in listOf("SMART_RING", "Mi Band 5", "Galaxy Watch", "R0X_NOPE", "Random")) { + for (name in listOf("SMART_RING", "R10M FCF4", "Mi Band 5", "Galaxy Watch", "R0X_NOPE", "Random")) { assertFalse("did not expect Colmi match for $name", colmiMatches(name)) } } @@ -63,9 +65,25 @@ class PairingMatchingTest { assertTrue(JringCoordinator.matches("SMART_RING", noAdv)) } + @Test + fun `R10M is claimed only by YCBT`() { + for (name in listOf("R10M FCF4", "R10M_FCF4")) { + assertTrue(YCBTCoordinator.matches(name, noAdv)) + assertFalse(ColmiCoordinator.matches(name, noAdv)) + assertFalse(JringCoordinator.matches(name, noAdv)) + } + } + + @Test + fun `YCBT manufacturer marker does not override QRing service`() { + val manufacturer = byteArrayOf(0x10, 0x78) + assertTrue(YCBTCoordinator.matches("Unlabeled", AdvertisementInfo(emptyList(), manufacturer))) + assertFalse(YCBTCoordinator.matches("Unlabeled", AdvertisementInfo(listOf(ColmiUUIDs.SERVICE_V2), manufacturer))) + } + @Test fun `catalog families all have a registered coordinator`() { - val registeredTypes = setOf(JringCoordinator.deviceType, ColmiCoordinator.deviceType) + val registeredTypes = setOf(JringCoordinator.deviceType, ColmiCoordinator.deviceType, YCBTCoordinator.deviceType) for (model in WearableModel.CATALOG) { assertTrue("no coordinator for ${model.displayName}", registeredTypes.contains(model.family)) } @@ -89,6 +107,8 @@ class PairingMatchingTest { "R10_DEAD" to "yawell-r10", "R11_BEEF" to "yawell-r11", "H59_anything" to "h59", + "R10M FCF4" to "r10m", + "R10M_FCF4" to "r10m", ) for ((name, modelID) in expected) { assertEquals(name, modelID, WearableModel.modelForAdvertisedName(name)?.id) From 4cc085fe9a3eaa451f13e1653692ec4a3098dec2 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 20:30:55 +0200 Subject: [PATCH 07/36] fix: harden YCBT protocol edge cases --- .../java/com/pulseloop/ring/YCBTCoordinator.kt | 15 +++++++-------- .../main/java/com/pulseloop/ring/YCBTDecoder.kt | 2 +- .../com/pulseloop/ring/YCBTHealthRecords.kt | 2 +- .../com/pulseloop/ring/YCBTHistoryTransfer.kt | 17 +++++++++++++---- .../java/com/pulseloop/ring/YCBTProtocol.kt | 4 +++- .../com/pulseloop/ring/PairingMatchingTest.kt | 2 +- .../java/com/pulseloop/ring/YCBTEncoderTest.kt | 10 ++++++---- 7 files changed, 32 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt index d42b872..6be5943 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt @@ -10,7 +10,6 @@ import com.pulseloop.wearables.WearableModel object YCBTCoordinator : WearableCoordinator { override val deviceType = RingDeviceType.YCBT - private val manufacturerHexMarker = "1078" override fun matches(name: String?, advertisement: AdvertisementInfo): Boolean { // Disqualify QRing service outright — those belong to ColmiDriver. @@ -22,17 +21,17 @@ object YCBTCoordinator : WearableCoordinator { return model.family == deviceType && isSmartHealthName(name) } - val manufacturer = advertisement.manufacturerData - if (manufacturer != null && manufacturer.toHexString().startsWith(manufacturerHexMarker)) { - // Manufacturer marker is corroborating evidence only; do not override a name match. - return true - } - return false + val hasYcbtService = advertisement.serviceUUIDs.contains(YCBTUUIDs.SERVICE) + val hasKnownName = isSmartHealthName(name) + // The 0x7810 manufacturer marker is common enough that it is corroborating + // evidence only, never sufficient to route an unknown peripheral. + return hasYcbtService || hasKnownName } private fun isSmartHealthName(name: String?): Boolean { if (name == null) return false - return Regex("^[A-Za-z0-9]+( [A-Za-z0-9]+)* [0-9A-Fa-f]{4}$").matches(name) + val normalized = name.trim().uppercase() + return Regex("^(?:R10M|TK5|T50|SR09|SR08|R08|R09)(?:[ _-][0-9A-Z]+)?$").matches(normalized) } override val capabilities = setOf( diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt index c5a7041..5208cae 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt @@ -40,7 +40,7 @@ class YCBTDecoder { val p = frame.payload return when (frame.cmd) { YCBTCommand.LIVE_STATUS -> { - if (p.size < 2) return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) + if (p.size < 6) return listOf(RingDecodedEvent.CommandAck(commandId = frame.cmd.toUByte())) listOf(RingDecodedEvent.ActivityUpdate( _timestamp = now, steps = YCBTBytes.u16(p, 0), diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt index d1b641d..dc75623 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt @@ -187,7 +187,7 @@ object YCBTHealthRecords { if (!seenStarts.add(segmentStart)) continue val segmentSeconds = YCBTBytes.u24(buffer, offset + 5) if (sessionStart == null) sessionStart = YCBTBytes.date(segmentStart) - val minutes = maxOf(1, (segmentSeconds / 60.0).toInt()) + val minutes = maxOf(1, kotlin.math.round(segmentSeconds / 60.0).toInt()) repeat(minutes) { stages.add(stage) } } if (sessionStart != null && stages.isNotEmpty()) { diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt index 7bf09b4..a229f15 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt @@ -31,16 +31,20 @@ class YCBTHistoryTransfer( companion object { private const val DEFAULT_BUFFER_CAP = 64 * 1024 + private const val MAX_BUFFER_CAP = 512 * 1024 } + @get:Synchronized val isActive: Boolean get() = state != State.IDLE + @Synchronized fun start(types: List) { if (isActive) return queue = types.filter { !unsupported.contains(it.queryKey) }.toMutableList() advance() } + @Synchronized fun cancel() { cancelWatchdog() state = State.IDLE @@ -71,6 +75,7 @@ class YCBTHistoryTransfer( } /** Feed every validated Health-group (type == 0x05) frame here. */ + @Synchronized fun handle(cmd: Int, payload: ByteArray): List { val type = currentType ?: return emptyList() @@ -95,7 +100,7 @@ class YCBTHistoryTransfer( if (payload.size < YCBTHealth.HEADER_PAYLOAD_LENGTH) return advance() val totalBytes = YCBTBytes.u32(payload, 6) buffer = ByteArray(0) - bufferCap = maxOf(totalBytes, DEFAULT_BUFFER_CAP) + bufferCap = totalBytes.coerceIn(0, MAX_BUFFER_CAP) state = State.RECEIVING armWatchdog(type) return listOf(RingDecodedEvent.HistorySyncProgress(stage = "Syncing ${type.label}…")) @@ -136,12 +141,16 @@ class YCBTHistoryTransfer( val delayMs = maxOf(0, fireAt - System.currentTimeMillis()) watchdogJob = scope.launch { delay(delayMs) - if (!isActive) return@launch - if (currentType != type) return@launch - publishOutOfBand(advance()) + watchdogFired(type) } } + @Synchronized + private fun watchdogFired(type: YCBTHistoryType) { + if (state == State.IDLE || currentType != type) return + publishOutOfBand(advance()) + } + private fun cancelWatchdog() { watchdogJob?.cancel() watchdogJob = null diff --git a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt index 256c728..0bb4e22 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt @@ -99,7 +99,9 @@ object YCBTBytes { /** Convert ring seconds (2000-epoch) to an Instant. The ring clock is local wall-clock; decoding * must un-apply the device's UTC offset to recover the true absolute instant. */ fun date(ringSeconds: Int, timeZone: TimeZone = TimeZone.getDefault()): Instant { - val offset = timeZone.rawOffset / 1000 + // The ring stores local wall-clock seconds. Match Foundation's + // secondsFromGMT() approximation and include the currently active DST offset. + val offset = timeZone.getOffset(System.currentTimeMillis()) / 1000 return Instant.ofEpochSecond(ringSeconds.toLong() + EPOCH_OFFSET - offset) } diff --git a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt index 67b2d80..6c8c533 100644 --- a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt +++ b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt @@ -77,7 +77,7 @@ class PairingMatchingTest { @Test fun `YCBT manufacturer marker does not override QRing service`() { val manufacturer = byteArrayOf(0x10, 0x78) - assertTrue(YCBTCoordinator.matches("Unlabeled", AdvertisementInfo(emptyList(), manufacturer))) + assertFalse(YCBTCoordinator.matches("Unlabeled", AdvertisementInfo(emptyList(), manufacturer))) assertFalse(YCBTCoordinator.matches("Unlabeled", AdvertisementInfo(listOf(ColmiUUIDs.SERVICE_V2), manufacturer))) } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt index 4cf63c2..0803170 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -83,16 +83,17 @@ class YCBTEncoderTest { val sequence = encoder.startupSequence().map { it.copyOfRange(0, 2) } assertArrayEquals(byteArrayOf(0x01, 0x00), sequence.first()) assertArrayEquals(byteArrayOf(0x03, 0x09), sequence.last()) - assertEquals(listOf( + val expectedGets = listOf( byteArrayOf(0x02, 0x00), byteArrayOf(0x02, 0x01), byteArrayOf(0x02, 0x1b), byteArrayOf(0x02, 0x03), byteArrayOf(0x02, 0x07), - ), sequence.subList(1, 6)) + ) + assertEquals(expectedGets.map { it.toList() }, sequence.subList(1, 6).map { it.toList() }) assertEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02).toList(), encoder.startupSequence().last().toList()) val settings = sequence.subList(6, sequence.size) - assertEquals(listOf( + val expectedSettings = listOf( byteArrayOf(0x01, 0x12), byteArrayOf(0x01, 0x04), byteArrayOf(0x01, 0x0c), @@ -102,7 +103,8 @@ class YCBTEncoderTest { byteArrayOf(0x01, 0x45), byteArrayOf(0x01, 0x03), byteArrayOf(0x03, 0x09), - ), settings) + ) + assertEquals(expectedSettings.map { it.toList() }, settings.map { it.toList() }) } @Test From 39cff091338f545b94ca10d5e0add93b7d3c7c43 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 20:35:11 +0200 Subject: [PATCH 08/36] feat: complete YCBT refresh and spot flows --- .../java/com/pulseloop/ring/PulseEventBus.kt | 2 + .../com/pulseloop/ring/RingEventBridge.kt | 4 +- .../java/com/pulseloop/ring/WearableDriver.kt | 3 + .../java/com/pulseloop/ring/YCBTSyncEngine.kt | 8 +- .../pulseloop/service/RingSyncCoordinator.kt | 93 ++++++++++++++++++- .../com/pulseloop/ring/RingEventBridgeTest.kt | 8 ++ .../com/pulseloop/ring/YCBTSyncEngineTest.kt | 39 ++++++++ 7 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index cfb13f4..62c8482 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -37,6 +37,8 @@ sealed class PulseEvent { data class Spo2Result(val value: Int, val timestamp: java.time.Instant) : PulseEvent() /** The ring ended a live-SpO₂ run (error or natural finish) — no more results coming. */ data class Spo2Complete(val timestamp: java.time.Instant) : PulseEvent() + /** A live measurement command was refused (not worn, sensor busy, unsupported). */ + data class MeasurementRejected(val mode: Int) : PulseEvent() data class HistoryMeasurement(val kind: MeasurementKind, val value: Double, val timestamp: java.time.Instant) : PulseEvent() data class StressSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() data class HrvSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() diff --git a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt index 2e8ba19..a5161e8 100644 --- a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt +++ b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt @@ -90,7 +90,9 @@ object RingEventBridge { is RingDecodedEvent.BindNotify -> emptyList() // Bind/unbind handshake is driven in the sync engine / BLE client - is RingDecodedEvent.MeasurementRejected, + is RingDecodedEvent.MeasurementRejected -> + listOf(PulseEvent.MeasurementRejected(decoded.mode)) + is RingDecodedEvent.WearingStatus, is RingDecodedEvent.SupportFunctions, is RingDecodedEvent.ChipScheme -> diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index f9e7f50..1a570a7 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -103,6 +103,9 @@ interface RingSyncEngine { * that don't support staged history sync. */ fun syncHistory() {} + /** Refresh the recent vital series after a live workout without replaying all history. */ + fun syncVitalsHistory() {} + /** On-demand, standalone sleep fetch — request just the sleep record without running the * whole history pipeline (which buries sleep behind activity/HR/stress/SpO₂ and can lose it * to a watchdog stage-skip). Mirrors the official QRing app, which fires a dedicated sleep diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index b51b9fe..43f6f85 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -40,10 +40,14 @@ class YCBTSyncEngine( transfer.start(types = HISTORY_TYPES) } - fun syncVitalsHistory() { + override fun syncVitalsHistory() { transfer.start(types = VITALS_TYPES) } + override fun syncSleepNow() { + transfer.start(types = listOf(YCBTHistoryType.SLEEP)) + } + override fun setMeasurementSettings(settings: MeasurementSettings?) { if (settings != null) measurementSettings = settings } @@ -121,6 +125,6 @@ class YCBTSyncEngine( override fun setAppId(appId: String) {} override fun setOnMeasurementConfigSeeded(callback: (MeasurementSettings) -> Unit) {} override fun setOnBondRequested(callback: () -> Unit) {} - override fun syncSleepNow() {} + override fun handleRawNotify(data: ByteArray) {} } diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index 2ed24b2..92dddcd 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -29,6 +29,10 @@ class RingSyncCoordinator( private set var spo2State: MeasureState = MeasureState.IDLE private set + var hrvState: MeasureState = MeasureState.IDLE + private set + var bloodPressureState: MeasureState = MeasureState.IDLE + private set var combinedState: MeasureState = MeasureState.IDLE private set var lastSyncAt: Long? = null @@ -57,11 +61,19 @@ class RingSyncCoordinator( /** Latest live SpO2 %, mirrored for UI without a query. */ var latestSpO2Value: Int? = null private set + var latestHrvValue: Int? = null + private set + var latestBloodPressure: Pair? = null + private set var workoutHRActive = false private set private var hrNoReadingReported = false private var spo2NoReadingReported = false + private var hrvNoReadingReported = false + private var bloodPressureNoReadingReported = false + private var pendingSystolic: Int? = null + private var pendingDiastolic: Int? = null private var measurementReceivedReading = false val connectionState: RingConnectionState get() = client.state.value.connectionState @@ -81,10 +93,12 @@ class RingSyncCoordinator( const val HR_SETTLE_SECONDS = 4 /** Window for the live-SpO₂ leg of a spot measurement. */ const val SPO2_MEASURE_SECONDS = 40 + const val BP_MEASURE_SECONDS = 40 + const val HRV_MEASURE_SECONDS = 40 /** Upper-bound for a sequential HR+SpO₂ spot measurement; drives the UI countdown. * Derived from the legs so the countdown can't desync when one is tuned. Each leg * returns early once it gets a reading, so it usually finishes well sooner. */ - const val SPOT_MEASURE_SECONDS = HR_MEASURE_SECONDS + HR_SETTLE_SECONDS + SPO2_MEASURE_SECONDS + 1 + const val SPOT_MEASURE_SECONDS = HR_MEASURE_SECONDS + HR_SETTLE_SECONDS + SPO2_MEASURE_SECONDS + BP_MEASURE_SECONDS + HRV_MEASURE_SECONDS + 3 /** Max time to wait for the pre-factory-reset history sync before resetting anyway. */ const val SYNC_BEFORE_RESET_TIMEOUT_MS = 30_000L } @@ -205,7 +219,9 @@ class RingSyncCoordinator( fun syncNow() { if (!isConnected) return - runStartupSequence() + beginSyncProgress() + engine?.syncHistory() + lastSyncAt = System.currentTimeMillis() } /** @@ -223,7 +239,7 @@ class RingSyncCoordinator( /** Pull-to-refresh entry point. */ suspend fun pullToRefresh() { if (isConnected) { - runStartupSequence() + syncNow() } else if (client.state.value.activeDeviceType != null) { client.connectLastKnown() } else { @@ -244,11 +260,12 @@ class RingSyncCoordinator( if (!workoutHRActive) return engine?.stopHeartRate() workoutHRActive = false + engine?.syncVitalsHistory() } fun querySleep() { if (!isConnected) return - engine?.runStartup() + engine?.syncSleepNow() } fun findRing() { @@ -334,6 +351,8 @@ class RingSyncCoordinator( val caps = client.state.value.activeCapabilities if (caps.contains(WearableCapability.MANUAL_HEART_RATE)) measureHR() if (caps.contains(WearableCapability.MANUAL_SPO2)) measureSpO2() + if (caps.contains(WearableCapability.MANUAL_BLOOD_PRESSURE)) measureBloodPressure() + if (caps.contains(WearableCapability.MANUAL_HRV)) measureHRV() } suspend fun measureHR(): Int? { @@ -382,6 +401,46 @@ class RingSyncCoordinator( return result } + suspend fun measureBloodPressure(): Pair? { + if (bloodPressureState == MeasureState.MEASURING) return null + if (!isConnected) { bloodPressureState = MeasureState.FAILED; return null } + bloodPressureState = MeasureState.MEASURING + latestBloodPressure = null + pendingSystolic = null + pendingDiastolic = null + bloodPressureNoReadingReported = false + engine?.startBloodPressure() + var result: Pair? = null + try { + for (step in 0 until BP_MEASURE_SECONDS * 2) { + result = latestBloodPressure + if (result != null || bloodPressureNoReadingReported) break + delay(500) + } + } finally { + engine?.stopBloodPressure() + bloodPressureState = if (result != null) MeasureState.DONE else MeasureState.FAILED + } + return result + } + + suspend fun measureHRV(): Int? { + if (hrvState == MeasureState.MEASURING) return null + if (!isConnected) { hrvState = MeasureState.FAILED; return null } + hrvState = MeasureState.MEASURING + latestHrvValue = null + hrvNoReadingReported = false + engine?.startHRV() + var result: Int? = null + try { + result = pollForValue(HRV_MEASURE_SECONDS.toLong(), { latestHrvValue }, { hrvNoReadingReported }) + } finally { + engine?.stopHRV() + hrvState = if (result != null) MeasureState.DONE else MeasureState.FAILED + } + return result + } + /** * Trigger the combined spot measurement (0x23). The ring replies with 0x24 carrying * blood pressure, SpO₂, stress, fatigue and blood sugar in one packet; those decode @@ -431,11 +490,20 @@ class RingSyncCoordinator( is PulseEvent.Spo2Result -> { latestSpO2Value = event.value } + is PulseEvent.HrvSample -> latestHrvValue = event.value is PulseEvent.Spo2Complete -> { if (spo2State == MeasureState.MEASURING && latestSpO2Value == null) { spo2NoReadingReported = true } } + is PulseEvent.MeasurementRejected -> { + when (event.mode) { + YCBTMeasurementMode.HEART_RATE -> hrNoReadingReported = true + YCBTMeasurementMode.SPO2 -> spo2NoReadingReported = true + YCBTMeasurementMode.BLOOD_PRESSURE -> bloodPressureNoReadingReported = true + YCBTMeasurementMode.HRV -> hrvNoReadingReported = true + } + } is PulseEvent.DeviceStateChanged -> { when (event.state) { RingConnectionState.CONNECTED -> lastSyncAt = System.currentTimeMillis() @@ -450,7 +518,22 @@ class RingSyncCoordinator( is PulseEvent.ActivityBucket -> advanceSyncProgress(event.timestamp.toEpochMilli()) is PulseEvent.ActivityUpdate -> advanceSyncProgress(event.timestamp.toEpochMilli()) is PulseEvent.SleepTimeline -> advanceSyncProgress(event.timestamp.toEpochMilli()) - is PulseEvent.HistoryMeasurement -> advanceSyncProgress(event.timestamp.toEpochMilli()) + is PulseEvent.HistoryMeasurement -> { + when (event.kind) { + MeasurementKind.HRV -> latestHrvValue = event.value.toInt() + MeasurementKind.BLOOD_PRESSURE_SYSTOLIC -> pendingSystolic = event.value.toInt() + MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> pendingDiastolic = event.value.toInt() + else -> {} + } + val systolic = pendingSystolic + val diastolic = pendingDiastolic + if (systolic != null && diastolic != null) { + latestBloodPressure = systolic to diastolic + pendingSystolic = null + pendingDiastolic = null + } + advanceSyncProgress(event.timestamp.toEpochMilli()) + } is PulseEvent.SyncProgress -> if (event.stage == "done") finishSyncProgressSoon() else -> {} } diff --git a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt index a91e802..9d48e52 100644 --- a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt +++ b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt @@ -257,4 +257,12 @@ class RingEventBridgeTest { val ts = RingDecodedEvent.TimeSyncAck(_timestamp = now) assertTrue(RingEventBridge.eventsFor(ts, now).isEmpty()) } + + @Test + fun `measurement rejection reaches product orchestration`() { + val events = RingEventBridge.eventsFor( + RingDecodedEvent.MeasurementRejected(YCBTMeasurementMode.SPO2), now + ) + assertEquals(listOf(PulseEvent.MeasurementRejected(YCBTMeasurementMode.SPO2)), events) + } } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt new file mode 100644 index 0000000..b20b18a --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt @@ -0,0 +1,39 @@ +package com.pulseloop.ring + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class YCBTSyncEngineTest { + private class FakeWriter : RingCommandWriter { + val sent = mutableListOf() + override fun enqueue(command: ByteArray) { + sent += command.copyOf() + } + } + + private fun engine(writer: FakeWriter): YCBTSyncEngine { + val transfer = YCBTHistoryTransfer(writer) + return YCBTSyncEngine(writer, transfer) + } + + @Test + fun `full refresh begins with sport history`() { + val writer = FakeWriter() + engine(writer).syncHistory() + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent.single()) + } + + @Test + fun `post workout refresh begins with recent heart history`() { + val writer = FakeWriter() + engine(writer).syncVitalsHistory() + assertArrayEquals(byteArrayOf(0x05, 0x06), writer.sent.single()) + } + + @Test + fun `sleep refresh requests only sleep history first`() { + val writer = FakeWriter() + engine(writer).syncSleepNow() + assertArrayEquals(byteArrayOf(0x05, 0x04), writer.sent.single()) + } +} From c128c497648757a3ff0a4bae0faa174c825107dc Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Thu, 16 Jul 2026 21:59:39 +0200 Subject: [PATCH 09/36] ci: publish YCBT test APK --- .github/workflows/ci.yml | 7 +++++++ .../com/pulseloop/service/EventPersistenceSubscriber.kt | 1 + app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt | 1 + 3 files changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e56c77..96de8a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,10 @@ jobs: run: ./gradlew --no-daemon testDebugUnitTest - name: Build debug APK run: ./gradlew --no-daemon :app:assembleDebug + - name: Upload test APK + uses: actions/upload-artifact@v4 + with: + name: pulseloop-ycbt-r10m-debug + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + retention-days: 14 diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index a810144..1cf894a 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -197,6 +197,7 @@ class EventPersistenceSubscriber( is PulseEvent.SyncProgress -> {} // UI feedback, no persistence needed is PulseEvent.HeartRateComplete -> {} is PulseEvent.Spo2Complete -> {} + is PulseEvent.MeasurementRejected -> {} // Product orchestration only; no persistence. is PulseEvent.RawPacket -> { db.rawPacketDao().insert(RawPacketEntity( directionRaw = event.direction.name, diff --git a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt index 0925ff5..41e9c36 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt @@ -249,6 +249,7 @@ private fun labelFor(event: PulseEvent): String = when (event) { is PulseEvent.HeartRateComplete -> "HR Done" is PulseEvent.Spo2Result -> "SpO₂" is PulseEvent.Spo2Complete -> "SpO₂ Done" + is PulseEvent.MeasurementRejected -> "Measure Rejected" is PulseEvent.HistoryMeasurement -> when (event.kind) { com.pulseloop.ring.MeasurementKind.HEART_RATE -> "HR History" com.pulseloop.ring.MeasurementKind.SPO2 -> "SpO₂ History" From 033621fdfcce0a213fe2e3fb1c9c9916b795ab18 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 10:29:25 +0200 Subject: [PATCH 10/36] fix: keep every ring brand visible in pairing --- .../com/pulseloop/ui/screens/PairingScreen.kt | 56 ++++++++++--------- .../com/pulseloop/wearables/WearableModel.kt | 5 +- .../com/pulseloop/ring/PairingMatchingTest.kt | 7 +++ 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt index 17509e7..2f1cff1 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -282,32 +281,39 @@ private fun BrandTabs( selectedBrand: String, onSelect: (String) -> Unit, ) { - Row( - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - brands.forEach { brand -> - val isSelected = brand == selectedBrand - Box( - Modifier - .heightIn(min = 44.dp) - .clickable { onSelect(brand) }, - contentAlignment = Alignment.Center, + // Never hide a brand behind an undiscoverable horizontal gesture. Three pills per row + // fits the supported labels on narrow phones and naturally grows as models are added. + brands.chunked(3).forEach { rowBrands -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - brand, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = if (isSelected) Color.White else PulseColors.textSecondary, - modifier = Modifier - .clip(CircleShape) - .background(if (isSelected) PulseColors.accent else PulseColors.card) - .border(1.dp, if (isSelected) Color.Transparent else PulseColors.borderSubtle, CircleShape) - .padding(horizontal = 14.dp, vertical = 8.dp), - ) + rowBrands.forEach { brand -> + val isSelected = brand == selectedBrand + Box( + Modifier + .heightIn(min = 44.dp) + .clickable { onSelect(brand) }, + contentAlignment = Alignment.Center, + ) { + Text( + brand, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (isSelected) Color.White else PulseColors.textSecondary, + modifier = Modifier + .clip(CircleShape) + .background(if (isSelected) PulseColors.accent else PulseColors.card) + .border(1.dp, if (isSelected) Color.Transparent else PulseColors.borderSubtle, CircleShape) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) + } + } } } } diff --git a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt index 51ca03c..a66bbc8 100644 --- a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt +++ b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt @@ -62,7 +62,10 @@ data class WearableModel( val COLMI_R12 = colmi("colmi-r12", "Colmi R12", "Colmi", "^COLMI R12_.*", R.drawable.ring_colmi_r12) // YCBT / SmartHealth family (distinct protocol from QRing Colmi) - val R10M = ycbt("r10m", "R10M", "Yucheng", "^R10M[ _][0-9A-F]{4}$", R.drawable.ring_colmi_r10) + val R10M = ycbt( + "r10m", "LittleMeatball R10M", "LittleMeatball", + "^R10M[ _][0-9A-F]{4}$", R.drawable.ring_colmi_r10, + ) // Yawell-branded variants val YAWELL_R05 = colmi("yawell-r05", "Yawell R05", "Yawell", "^R05_[0-9A-F]{4}$", R.drawable.ring_yawell_r05) diff --git a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt index 6c8c533..f1ee7b8 100644 --- a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt +++ b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt @@ -74,6 +74,13 @@ class PairingMatchingTest { } } + @Test + fun `R10M catalog entry uses the discoverable retail name`() { + assertTrue(WearableModel.R10M in WearableModel.CATALOG) + assertEquals("LittleMeatball", WearableModel.R10M.brand) + assertEquals("LittleMeatball R10M", WearableModel.R10M.displayName) + } + @Test fun `YCBT manufacturer marker does not override QRing service`() { val manufacturer = byteArrayOf(0x10, 0x78) From 9935894bdb3ee9a530fd788d2c93247573e54cc4 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 10:42:39 +0200 Subject: [PATCH 11/36] fix: subscribe to YCBT command replies --- .../java/com/pulseloop/ring/RingBLEClient.kt | 40 ++++++++++++------- .../java/com/pulseloop/ring/YCBTDriverTest.kt | 6 +++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 22cca2f..6023c63 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1019,11 +1019,11 @@ class RingBLEClient(private val context: Context) { } } else { updateState { copy(lastError = "GATT connect failed: $status") } - handleDisconnect(gatt) + handleDisconnect(gatt, status) } } BluetoothProfile.STATE_DISCONNECTED -> { - handleDisconnect(gatt) + handleDisconnect(gatt, status) } } } @@ -1070,18 +1070,19 @@ class RingBLEClient(private val context: Context) { for (ch in service.characteristics) { val uuid = ch.uuid.toString() - when { - uuid == driver.writeUUID -> writeChar = ch - uuid == driver.commandUUID -> commandChar = ch - driver.notifyUUIDs.any { it == uuid } -> { - notifyChars[ch.uuid] = ch - gatt.setCharacteristicNotification(ch, true) - ch.getDescriptor(CCCD_UUID)?.let { desc -> - enqueueOp(GattOp.DescriptorWrite(desc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)) - } + // A characteristic may be both writable and notifiable. YCBT's BE94-0001 + // command channel is exactly that; the old mutually-exclusive `when` stored + // it as writeChar but silently skipped its CCCD, losing every command reply. + if (uuid == driver.writeUUID) writeChar = ch + if (uuid == driver.commandUUID) commandChar = ch + if (driver.notifyUUIDs.any { it == uuid }) { + notifyChars[ch.uuid] = ch + gatt.setCharacteristicNotification(ch, true) + ch.getDescriptor(CCCD_UUID)?.let { desc -> + enqueueOp(GattOp.DescriptorWrite(desc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)) } - uuid == driver.batteryCharUUID -> batteryChar = ch } + if (uuid == driver.batteryCharUUID) batteryChar = ch } } @@ -1237,6 +1238,11 @@ class RingBLEClient(private val context: Context) { // Retire the in-flight op before any early return, so the queue drains. completeOp { it is GattOp.DescriptorWrite && it.descriptor === descriptor } + if (status != BluetoothGatt.GATT_SUCCESS) { + updateState { copy(lastError = "Could not enable ring notifications (GATT $status)") } + return + } + // Notification enabled — fire onConnected once at least one notify is live val driver = activeDriver ?: return val ch = descriptor.characteristic @@ -1319,7 +1325,7 @@ class RingBLEClient(private val context: Context) { } } - private fun handleDisconnect(gatt: BluetoothGatt) { + private fun handleDisconnect(gatt: BluetoothGatt, status: Int = BluetoothGatt.GATT_SUCCESS) { // Ignore late callbacks from a GATT we already superseded during a reconnect // (we close the old handle in beginConnect). Acting on them would clobber the // CONNECTING state of the fresh attempt with a spurious DISCONNECTED. @@ -1350,7 +1356,13 @@ class RingBLEClient(private val context: Context) { PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) ) - updateState { copy(connectionState = RingConnectionState.DISCONNECTED) } + val requestedByUser = prefs.getBoolean(USER_DISCONNECTED_KEY, false) + updateState { + copy( + connectionState = RingConnectionState.DISCONNECTED, + lastError = if (requestedByUser) lastError else "Ring disconnected (GATT $status)", + ) + } } fun destroy() { diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt index de9422e..b44812c 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt @@ -5,6 +5,12 @@ import org.junit.Test class YCBTDriverTest { + @Test + fun `command characteristic is both writable and notifiable`() { + val driver = YCBTDriver(RingCommandWriter { }) + assertTrue(driver.notifyUUIDs.contains(driver.writeUUID)) + } + private class FakeWriter : RingCommandWriter { val sent = mutableListOf() override fun enqueue(command: ByteArray) { sent.add(command.copyOf()) } From 5ae43b658be688323fb4b93bef6b732dab71ca0b Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 11:00:38 +0200 Subject: [PATCH 12/36] fix: keep BLE disconnect diagnostics visible --- .../java/com/pulseloop/ring/RingBLEClient.kt | 28 +++++++++++++-- .../com/pulseloop/ui/screens/PairingScreen.kt | 35 +++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 6023c63..852acb3 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -51,6 +51,8 @@ class RingBLEClient(private val context: Context) { val activeWearableModelID: String? = null, val activeCapabilities: Set = emptySet(), val firmwareVersion: String? = null, + /** Bounded, persistent-for-this-process BLE trace for hardware diagnosis. */ + val diagnostics: List = emptyList(), ) data class DiscoveredRing( @@ -99,6 +101,7 @@ class RingBLEClient(private val context: Context) { private var commandChar: BluetoothGattCharacteristic? = null private var notifyChars: MutableMap = mutableMapOf() private var batteryChar: BluetoothGattCharacteristic? = null + private val pendingRingNotifyDescriptors = java.util.Collections.synchronizedSet(mutableSetOf()) // MARK: Active driver/engine @@ -626,6 +629,7 @@ class RingBLEClient(private val context: Context) { } bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null + pendingRingNotifyDescriptors.clear() resetOpQueue() serviceDiscoveryStarted.set(false) val coordinator = coordinators.firstOrNull { it.deviceType == deviceType } ?: JringCoordinator @@ -638,7 +642,8 @@ class RingBLEClient(private val context: Context) { family = coordinator.deviceType, )?.id installDriver(coordinator) - updateState { copy(activeWearableModelID = resolvedModelID) } + updateState { copy(activeWearableModelID = resolvedModelID, diagnostics = emptyList()) } + recordDiagnostic("connect ${advertisedName ?: "unknown"}") connectingStartedAt = System.currentTimeMillis() updateState { copy(connectionState = RingConnectionState.CONNECTING) } // Mirror the attempt to the persisted state so the Today/Settings views show @@ -930,6 +935,13 @@ class RingBLEClient(private val context: Context) { _state.value = _state.value.update() } + @Synchronized + private fun recordDiagnostic(message: String) { + Log.i("RingBLEClient", message) + val current = _state.value + _state.value = current.copy(diagnostics = (current.diagnostics + message).takeLast(6)) + } + // MARK: Scan callback private val scanCallback = object : ScanCallback() { @@ -986,6 +998,7 @@ class RingBLEClient(private val context: Context) { private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + recordDiagnostic("state status=$status new=$newState") when (newState) { BluetoothProfile.STATE_CONNECTED -> { if (status == BluetoothGatt.GATT_SUCCESS) { @@ -1029,7 +1042,11 @@ class RingBLEClient(private val context: Context) { } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - if (status != BluetoothGatt.GATT_SUCCESS) return + recordDiagnostic("services status=$status count=${gatt.services.size}") + if (status != BluetoothGatt.GATT_SUCCESS) { + updateState { copy(lastError = "Service discovery failed (GATT $status)") } + return + } // Log all discovered service UUIDs for diagnostics val serviceUuids = gatt.services.map { it.uuid.toString() } @@ -1062,6 +1079,7 @@ class RingBLEClient(private val context: Context) { // ring becomes usable as soon as possible instead of waiting behind firmware/ // battery reads. (This is the R10 fix: its 0x180A DIS firmware read used to be // issued ahead of the CCCD write and silently dropped it, so it never connected.) + pendingRingNotifyDescriptors.clear() for (service in gatt.services) { val svcUuid = service.uuid.toString() val isRingSvc = driver.serviceUUIDs.any { it == svcUuid } @@ -1079,6 +1097,7 @@ class RingBLEClient(private val context: Context) { notifyChars[ch.uuid] = ch gatt.setCharacteristicNotification(ch, true) ch.getDescriptor(CCCD_UUID)?.let { desc -> + pendingRingNotifyDescriptors.add(ch.uuid) enqueueOp(GattOp.DescriptorWrite(desc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)) } } @@ -1153,6 +1172,7 @@ class RingBLEClient(private val context: Context) { override fun onCharacteristicWrite( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { + recordDiagnostic("write ${characteristic.uuid.toString().take(8)} status=$status") if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // GATT ACK — link is alive resetOpFailures() // a real completion — the stack is responsive again @@ -1232,6 +1252,7 @@ class RingBLEClient(private val context: Context) { override fun onDescriptorWrite( gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { + recordDiagnostic("notify ${descriptor.characteristic.uuid.toString().take(8)} status=$status") if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // descriptor ACK — link is alive resetOpFailures() @@ -1247,6 +1268,8 @@ class RingBLEClient(private val context: Context) { val driver = activeDriver ?: return val ch = descriptor.characteristic if (!driver.notifyUUIDs.any { it == ch.uuid.toString() }) return + pendingRingNotifyDescriptors.remove(ch.uuid) + if (pendingRingNotifyDescriptors.isNotEmpty()) return if (_state.value.connectionState == RingConnectionState.CONNECTED) return updateState { copy(connectionState = RingConnectionState.CONNECTED) } @@ -1350,6 +1373,7 @@ class RingBLEClient(private val context: Context) { // and always starts from a fresh connectGatt. bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null + pendingRingNotifyDescriptors.clear() closeGattQuietly(gatt) PulseEventBus.publishBlocking( diff --git a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt index 2f1cff1..104bf33 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt @@ -119,9 +119,14 @@ fun PairingScreen( previous != RingConnectionState.CONNECTED && !didFireConnected ) { - didFireConnected = true - isLooking = false - onConnected() + // Do not navigate away for a link that only survives a few callbacks. Keeping + // this screen visible makes the disconnect trace readable on real hardware. + delay(2_000) + if (bleClient.state.value.connectionState == RingConnectionState.CONNECTED) { + didFireConnected = true + isLooking = false + onConnected() + } } previous = s.connectionState } @@ -206,6 +211,30 @@ fun PairingScreen( } } + // A fixed-height trace avoids the connect/disconnect layout jump and remains visible + // while the scrollable pairing content changes underneath it. + if (isLooking || state.diagnostics.isNotEmpty()) { + val trace = buildList { + addAll(state.diagnostics.takeLast(4)) + state.lastError?.let { if (it !in this) add(it) } + }.joinToString("\n").ifBlank { "Waiting for BLE connection events…" } + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 88.dp) + .background(PulseColors.card) + .border(1.dp, PulseColors.borderSubtle) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + trace, + fontSize = 11.sp, + color = if (state.lastError != null) PulseColors.danger else PulseColors.textMuted, + ) + } + } + if (showsFooter) { Column { HorizontalDivider(thickness = 1.dp, color = PulseColors.borderSubtle) From 5e54aa3e756f2f10a329572e94857b40b48a97b3 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 11:19:25 +0200 Subject: [PATCH 13/36] fix: use conservative BLE setup for YCBT rings --- .../java/com/pulseloop/ring/RingBLEClient.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 852acb3..a8e6386 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -857,6 +857,10 @@ class RingBLEClient(private val context: Context) { RingDecodedEvent.CommandAck(commandId = if (op.data.isNotEmpty()) op.data[0].toUByte() else 0u)) ) } + if (op.attempts == 0) { + val prefix = op.data.take(4).joinToString("") { "%02x".format(it.toInt() and 0xFF) } + recordDiagnostic("write issue $prefix len=${op.data.size}") + } gatt.writeCharacteristic(target) } } @@ -1013,8 +1017,17 @@ class RingBLEClient(private val context: Context) { // app (which also bonds post-discovery, gated on supportBlePair). Rings // that don't advertise the bit (jring 56ff, older R02) are never bonded, // preserving prior behaviour. See docs/qring-ble-adoption.md §Pairing. - // Request a high-priority connection interval, matching the official app - // (BluetoothLeService.requestConnectionPriority(1) on connect). + // YCBT firmware is not QRing firmware. Cheap BE94 controllers have been + // observed terminating Android links after aggressive connection-parameter + // and MTU requests. CoreBluetooth's working YCBT path makes neither request, + // and the frame assembler already handles fragmentation, so use the + // conservative default link for this family. + if (activeCoordinator?.deviceType == RingDeviceType.YCBT) { + recordDiagnostic("YCBT default MTU/priority") + startServiceDiscovery(gatt) + return + } + // QRing/Jring path: request the parameters used by their Android clients. gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) // Request a larger MTU (helps history-sync throughput), then discover // services ONLY after the MTU exchange completes — never overlap the two From 1e3c994ec85d8259286d0ca6cb6f029e55514b79 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 11:34:53 +0200 Subject: [PATCH 14/36] fix: skip unsupported R10M chip query --- app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt | 4 +++- app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt index 3a5802a..5271e93 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt @@ -125,7 +125,9 @@ class YCBTEncoder { seq.add(setTime(date)) seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, byteArrayOf(0x47, 0x43))) seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_SUPPORT_FUNCTION, byteArrayOf(0x47, 0x46))) - seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_CHIP_SCHEME, byteArrayOf())) + // Do not query GetChipScheme (02 1B) during startup. The TK5 accepts it, but the + // R10M closes an otherwise healthy connection with HCI 0x13 immediately on receipt. + // Chip-scheme metadata is informational and no Android feature depends on it. seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_NAME, byteArrayOf(0x47, 0x50))) seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_USER_CONFIG, byteArrayOf(0x43, 0x46))) seq.add(settings.language(languageCode)) diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt index 0803170..0770197 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -75,6 +75,7 @@ class YCBTEncoderTest { assertFalse("05 ${String.format("%02x", cmd)} is a Health-DELETE opcode", group == 0x05 && cmd in 0x40..0x4e) assertFalse("handshake must not touch Health group", group == 0x05) assertFalse("02 ${String.format("%02x", cmd)} is retired Get opcode", group == 0x02 && cmd in listOf(0x24, 0x26, 0x28)) + assertFalse("R10M disconnects on GetChipScheme", group == 0x02 && cmd == 0x1b) } } @@ -86,13 +87,12 @@ class YCBTEncoderTest { val expectedGets = listOf( byteArrayOf(0x02, 0x00), byteArrayOf(0x02, 0x01), - byteArrayOf(0x02, 0x1b), byteArrayOf(0x02, 0x03), byteArrayOf(0x02, 0x07), ) - assertEquals(expectedGets.map { it.toList() }, sequence.subList(1, 6).map { it.toList() }) + assertEquals(expectedGets.map { it.toList() }, sequence.subList(1, 5).map { it.toList() }) assertEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02).toList(), encoder.startupSequence().last().toList()) - val settings = sequence.subList(6, sequence.size) + val settings = sequence.subList(5, sequence.size) val expectedSettings = listOf( byteArrayOf(0x01, 0x12), byteArrayOf(0x01, 0x04), From c6cdaf4eb6a217519819fb1af9ab7a2fd0672b62 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 11:49:31 +0200 Subject: [PATCH 15/36] fix: wake R10M immediately after subscription --- app/src/main/java/com/pulseloop/ring/RingBLEClient.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index a8e6386..e7d1568 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1322,7 +1322,16 @@ class RingBLEClient(private val context: Context) { ) ) } - readBattery() + if (activeCoordinator?.deviceType == RingDeviceType.YCBT) { + // R10M has a very short post-subscription watchdog. The normal startup callback + // loads Room/DataStore settings before its first write, which can leave the ring + // idle long enough to terminate an otherwise healthy link with HCI 0x13. Issue a + // known-safe device-info request synchronously; the full startup follows normally. + recordDiagnostic("YCBT immediate wakeup") + activeSyncEngine?.requestBattery() + } else { + readBattery() + } scope.launch { onConnected?.invoke() } } From f90679634df6dbb28a95b3b886daf0255cbcfe9a Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 11:54:26 +0200 Subject: [PATCH 16/36] fix: call YCBT wakeup through concrete engine --- app/src/main/java/com/pulseloop/ring/RingBLEClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index e7d1568..9329fbd 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1328,7 +1328,7 @@ class RingBLEClient(private val context: Context) { // idle long enough to terminate an otherwise healthy link with HCI 0x13. Issue a // known-safe device-info request synchronously; the full startup follows normally. recordDiagnostic("YCBT immediate wakeup") - activeSyncEngine?.requestBattery() + (activeSyncEngine as? YCBTSyncEngine)?.requestBattery() } else { readBattery() } From 90b3a5aab8880f8d8896e90f188fa94e86232460 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 12:16:06 +0200 Subject: [PATCH 17/36] fix: serialize YCBT writes by protocol reply --- .../java/com/pulseloop/ring/RingBLEClient.kt | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 9329fbd..58f78b1 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1189,7 +1189,9 @@ class RingBLEClient(private val context: Context) { if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // GATT ACK — link is alive resetOpFailures() // a real completion — the stack is responsive again - completeOp { it is GattOp.CommandWrite } + if (activeCoordinator?.deviceType != RingDeviceType.YCBT || status != BluetoothGatt.GATT_SUCCESS) { + completeOp { it is GattOp.CommandWrite } + } } override fun onCharacteristicChanged( @@ -1226,7 +1228,8 @@ class RingBLEClient(private val context: Context) { // (e.g. Colmi pref-read replies seeding the measurement config). if (!forgetPending) activeSyncEngine?.handleRawNotify(value) - for (decoded in driver.ingest(value, characteristic.uuid.toString())) { + val decodedEvents = driver.ingest(value, characteristic.uuid.toString()) + for (decoded in decodedEvents) { // A forget is in flight: don't persist any more data or re-publish a // "connected" device state (which would re-create the row we're clearing). // Just watch for the ring's unbind ack (6 = UNBOND_ACK, 3 = ACK_CANCEL). @@ -1260,6 +1263,13 @@ class RingBLEClient(private val context: Context) { } activeSyncEngine?.handle(decoded) } + // The Yucheng SDK serializes commands by *protocol reply*, not by Android's local + // onCharacteristicWrite callback. Advancing on the latter blasts the whole startup + // sequence before the ring has processed the first frame and the R10M drops the link. + // A decoded BE94 frame is the remote acknowledgement that releases the next write. + if (activeCoordinator?.deviceType == RingDeviceType.YCBT && decodedEvents.isNotEmpty()) { + completeOp { it is GattOp.CommandWrite } + } } override fun onDescriptorWrite( @@ -1326,9 +1336,10 @@ class RingBLEClient(private val context: Context) { // R10M has a very short post-subscription watchdog. The normal startup callback // loads Room/DataStore settings before its first write, which can leave the ring // idle long enough to terminate an otherwise healthy link with HCI 0x13. Issue a - // known-safe device-info request synchronously; the full startup follows normally. - recordDiagnostic("YCBT immediate wakeup") - (activeSyncEngine as? YCBTSyncEngine)?.requestBattery() + // mandatory time-sync command synchronously; the official Yucheng Android SDK + // does the same immediately after notifications become active. + recordDiagnostic("YCBT immediate time sync") + (activeSyncEngine as? YCBTSyncEngine)?.resyncTime() } else { readBattery() } From b5455e1ddc498262bc592238a8c3b84bc8226c40 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 12:23:23 +0200 Subject: [PATCH 18/36] fix: match vendor YCBT write callback queue --- .../main/java/com/pulseloop/ring/RingBLEClient.kt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 58f78b1..172a56c 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1189,9 +1189,7 @@ class RingBLEClient(private val context: Context) { if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // GATT ACK — link is alive resetOpFailures() // a real completion — the stack is responsive again - if (activeCoordinator?.deviceType != RingDeviceType.YCBT || status != BluetoothGatt.GATT_SUCCESS) { - completeOp { it is GattOp.CommandWrite } - } + completeOp { it is GattOp.CommandWrite } } override fun onCharacteristicChanged( @@ -1263,13 +1261,7 @@ class RingBLEClient(private val context: Context) { } activeSyncEngine?.handle(decoded) } - // The Yucheng SDK serializes commands by *protocol reply*, not by Android's local - // onCharacteristicWrite callback. Advancing on the latter blasts the whole startup - // sequence before the ring has processed the first frame and the R10M drops the link. - // A decoded BE94 frame is the remote acknowledgement that releases the next write. - if (activeCoordinator?.deviceType == RingDeviceType.YCBT && decodedEvents.isNotEmpty()) { - completeOp { it is GattOp.CommandWrite } - } + } override fun onDescriptorWrite( From 945e945cb53006d4cc4e6713ab45384e5639b30f Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 12:42:01 +0200 Subject: [PATCH 19/36] fix: reproduce current SmartHealth YCBT handshake --- app/src/main/java/com/pulseloop/ring/RingBLEClient.kt | 8 ++++---- app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt | 3 +++ app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt | 6 ++++++ app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt | 8 ++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 172a56c..e2bc757 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1328,10 +1328,10 @@ class RingBLEClient(private val context: Context) { // R10M has a very short post-subscription watchdog. The normal startup callback // loads Room/DataStore settings before its first write, which can leave the ring // idle long enough to terminate an otherwise healthy link with HCI 0x13. Issue a - // mandatory time-sync command synchronously; the official Yucheng Android SDK - // does the same immediately after notifications become active. - recordDiagnostic("YCBT immediate time sync") - (activeSyncEngine as? YCBTSyncEngine)?.resyncTime() + // Current SmartHealth calls GetDeviceName (02 03, payload 47 50) first and then + // queues SettingTime. This ordering differs from older public YCBT SDKs. + recordDiagnostic("YCBT vendor handshake") + (activeSyncEngine as? YCBTSyncEngine)?.beginVendorHandshake() } else { readBattery() } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt index 5271e93..9e22534 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt @@ -146,6 +146,9 @@ class YCBTEncoder { fun deviceInfoRequest(): ByteArray = logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, byteArrayOf(0x47, 0x43)) + fun deviceNameRequest(): ByteArray = + logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_NAME, byteArrayOf(0x47, 0x50)) + fun enableLiveStatus(): ByteArray = logical(YCBTGroup.APP_CONTROL, YCBTCommand.LIVE_STATUS_PUSH, byteArrayOf(0x01, 0x00, 0x02)) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index 43f6f85..5b39c24 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -72,6 +72,12 @@ class YCBTSyncEngine( writer?.enqueue(encoder.setTime()) } + /** Exact current SmartHealth/YCBT post-CCCD sequence: identify device, then set time. */ + fun beginVendorHandshake() { + writer?.enqueue(encoder.deviceNameRequest()) + writer?.enqueue(encoder.setTime()) + } + fun requestBattery() { writer?.enqueue(encoder.deviceInfoRequest()) } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt index 0770197..d9265c6 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -19,6 +19,14 @@ class YCBTEncoderTest { } } + @Test + fun `device name request matches current SmartHealth handshake vector`() { + assertArrayEquals( + byteArrayOf(0x02, 0x03, 0x08, 0x00, 0x47, 0x50, 0xef.toByte(), 0x20), + YCBTProtocol.frame(encoder.deviceNameRequest()), + ) + } + @Test fun `setTime matches captured frame`() { val calendar = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) From fc2e61e6a22daaee6001a872b778cba4fa709a18 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 12:45:59 +0200 Subject: [PATCH 20/36] test: use YCBT frame helper for vendor vector --- app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt index d9265c6..cbb63c1 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -23,7 +23,7 @@ class YCBTEncoderTest { fun `device name request matches current SmartHealth handshake vector`() { assertArrayEquals( byteArrayOf(0x02, 0x03, 0x08, 0x00, 0x47, 0x50, 0xef.toByte(), 0x20), - YCBTProtocol.frame(encoder.deviceNameRequest()), + YCBTFrame.frame(encoder.deviceNameRequest()), ) } From 178a08e4a37feeb5dad5f8c58f3a82f385cbfcca Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 13:01:23 +0200 Subject: [PATCH 21/36] fix: enable YCBT reply channels as indications --- app/src/main/java/com/pulseloop/ring/RingBLEClient.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index e2bc757..65ae46d 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1111,7 +1111,16 @@ class RingBLEClient(private val context: Context) { gatt.setCharacteristicNotification(ch, true) ch.getDescriptor(CCCD_UUID)?.let { desc -> pendingRingNotifyDescriptors.add(ch.uuid) - enqueueOp(GattOp.DescriptorWrite(desc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)) + // Current SmartHealth enables both BE94-0001 and BE94-0003 as + // indications (CCCD 02 00), even though older ports commonly call + // them notifications. Writing 01 00 succeeds locally but leaves the + // R10M without its command-reply path and it closes with HCI 0x13. + val cccdValue = if (activeCoordinator?.deviceType == RingDeviceType.YCBT) { + BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + } else { + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + } + enqueueOp(GattOp.DescriptorWrite(desc, cccdValue)) } } if (uuid == driver.batteryCharUUID) batteryChar = ch From 2816f8aedc44ebdbc4f30b9716525e3cc8506d4a Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 13:23:36 +0200 Subject: [PATCH 22/36] fix: route YCBT vitals through spot measurement modes --- app/src/main/java/com/pulseloop/ring/JringDriver.kt | 1 + app/src/main/java/com/pulseloop/ring/WearableDriver.kt | 4 ++++ .../java/com/pulseloop/service/RingSyncCoordinator.kt | 3 +++ app/src/main/java/com/pulseloop/ui/screens/Screens.kt | 10 ++++++++-- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/JringDriver.kt b/app/src/main/java/com/pulseloop/ring/JringDriver.kt index 05d1f01..e0286f1 100644 --- a/app/src/main/java/com/pulseloop/ring/JringDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/JringDriver.kt @@ -71,6 +71,7 @@ class JringDriver(private val writer: RingCommandWriter) : WearableDriver { * Fire-and-forget sync engine for jring devices. */ class JringSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { + override val supportsCombinedMeasurement: Boolean = true private val encoder = RingEncoder override fun runStartup() { diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index 1a570a7..99cc053 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -96,6 +96,10 @@ data class UserProfileValues( * Per-device orchestration of command flows. */ interface RingSyncEngine { + /** True only for protocols with one native command that returns a combined vitals packet. + * Capability bits such as manual BP/glucose do not imply this transport feature. */ + val supportsCombinedMeasurement: Boolean get() = false + fun runStartup() fun handle(event: RingDecodedEvent) diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index 92dddcd..94256ab 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -78,6 +78,9 @@ class RingSyncCoordinator( val connectionState: RingConnectionState get() = client.state.value.connectionState val isConnected: Boolean get() = connectionState == RingConnectionState.CONNECTED + /** Selects the single-packet Jring measurement flow. YCBT advertises manual BP/glucose + * capabilities but measures each vital with separate AppStartMeasurement modes. */ + val supportsCombinedMeasurement: Boolean get() = engine?.supportsCombinedMeasurement == true private val hrMeasureSeconds = HR_MEASURE_SECONDS.toLong() private val hrSettleSeconds = HR_SETTLE_SECONDS diff --git a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt index baac28d..2481fd4 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt @@ -56,8 +56,14 @@ fun VitalsScreen( // • combined (56ff/Jring): one 0x23 packet → BP + SpO₂ + stress + fatigue + blood sugar // • spot (Colmi): sequential live HR + SpO₂ via the real-time command (0x69) // Colmi has no BP/glucose hardware, so it never qualifies for the combined flow. - val combinedMode = state.supportsBP || state.supportsGlucose - val spotMode = !combinedMode && (state.supportsManualHr || state.supportsManualSpo2) + // Manual BP/glucose capability alone is not evidence of Jring's 0x23 combined packet. + // In particular YCBT/R10M advertises those vitals but requires sequential 03/2f modes; + // routing it to measureCombined() silently called a protocol no-op. + val combinedMode = coordinator?.supportsCombinedMeasurement == true && + (state.supportsBP || state.supportsGlucose) + val spotMode = !combinedMode && ( + state.supportsManualHr || state.supportsManualSpo2 || state.supportsBP + ) val measureSeconds = if (combinedMode) com.pulseloop.service.RingSyncCoordinator.COMBINED_MEASURE_SECONDS else From f6eb7ec26f76b9a485f70ea9b5ed844ee8b48c91 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 16:50:01 +0200 Subject: [PATCH 23/36] refactor: harden YCBT sync and measurement routing --- .../java/com/pulseloop/ring/PulseEventBus.kt | 1 + .../java/com/pulseloop/ring/RingBLEClient.kt | 122 ++++++++++++------ .../com/pulseloop/ring/RingDecodedEvent.kt | 2 + .../com/pulseloop/ring/RingEventBridge.kt | 9 +- .../pulseloop/ring/SubscriptionSetupGate.kt | 48 +++++++ .../java/com/pulseloop/ring/WearableDriver.kt | 24 +++- .../java/com/pulseloop/ring/YCBTDecoder.kt | 5 + .../java/com/pulseloop/ring/YCBTDriver.kt | 50 +++++-- .../java/com/pulseloop/ring/YCBTEncoder.kt | 7 +- .../java/com/pulseloop/ring/YCBTProtocol.kt | 11 +- .../java/com/pulseloop/ring/YCBTSyncEngine.kt | 22 +--- .../service/EventPersistenceSubscriber.kt | 16 +++ .../pulseloop/service/RingSyncCoordinator.kt | 79 +++++++----- .../ring/ExistingFamilyRefreshContractTest.kt | 48 +++++++ .../com/pulseloop/ring/RingEventBridgeTest.kt | 26 ++++ .../ring/SubscriptionSetupGateTest.kt | 62 +++++++++ .../com/pulseloop/ring/YCBTDecoderTest.kt | 40 ++++-- .../java/com/pulseloop/ring/YCBTDriverTest.kt | 36 +++++- .../com/pulseloop/ring/YCBTEncoderTest.kt | 18 ++- .../pulseloop/ring/YCBTHistoryTransferTest.kt | 2 +- .../com/pulseloop/ring/YCBTSyncEngineTest.kt | 20 ++- 21 files changed, 511 insertions(+), 137 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/ring/SubscriptionSetupGate.kt create mode 100644 app/src/test/java/com/pulseloop/ring/ExistingFamilyRefreshContractTest.kt create mode 100644 app/src/test/java/com/pulseloop/ring/SubscriptionSetupGateTest.kt diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index 62c8482..97207d7 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -39,6 +39,7 @@ sealed class PulseEvent { data class Spo2Complete(val timestamp: java.time.Instant) : PulseEvent() /** A live measurement command was refused (not worn, sensor busy, unsupported). */ data class MeasurementRejected(val mode: Int) : PulseEvent() + data class BloodPressureSample(val systolic: Int, val diastolic: Int, val timestamp: java.time.Instant) : PulseEvent() data class HistoryMeasurement(val kind: MeasurementKind, val value: Double, val timestamp: java.time.Instant) : PulseEvent() data class StressSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() data class HrvSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 65ae46d..cd3c258 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -101,7 +101,7 @@ class RingBLEClient(private val context: Context) { private var commandChar: BluetoothGattCharacteristic? = null private var notifyChars: MutableMap = mutableMapOf() private var batteryChar: BluetoothGattCharacteristic? = null - private val pendingRingNotifyDescriptors = java.util.Collections.synchronizedSet(mutableSetOf()) + private var subscriptionGate: SubscriptionSetupGate? = null // MARK: Active driver/engine @@ -434,6 +434,7 @@ class RingBLEClient(private val context: Context) { val gatt = bluetoothGatt bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null + subscriptionGate = null resetOpQueue() if (gatt != null) { try { gatt.disconnect() } catch (_: Exception) {} @@ -629,7 +630,7 @@ class RingBLEClient(private val context: Context) { } bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null - pendingRingNotifyDescriptors.clear() + subscriptionGate = null resetOpQueue() serviceDiscoveryStarted.set(false) val coordinator = coordinators.firstOrNull { it.deviceType == deviceType } ?: JringCoordinator @@ -748,6 +749,10 @@ class RingBLEClient(private val context: Context) { activeCoordinator = coordinator activeDriver = driver driver.connectionDidStart() + subscriptionGate = SubscriptionSetupGate( + notifyUUIDs = driver.notifyUUIDs, + requiredSubscriptions = driver.requiredSubscriptionsBeforeConnected, + ) activeSyncEngine = driver.makeSyncEngine() // Capability-gated bonding: the engine fires this only when the ring's device-support // reply advertises supportBlePair (Colmi R09 and newer). See docs/qring-ble-adoption.md. @@ -771,13 +776,29 @@ class RingBLEClient(private val context: Context) { * op was issued) retiring the WRONG op: only complete when the callback corresponds * to what is actually in flight. */ + private fun retireOp(matches: (GattOp) -> Boolean = { true }): Boolean { + return synchronized(opLock) { + val current = inFlightOp ?: return@synchronized false + if (!matches(current)) return@synchronized false + inFlightOp = null + true + } + } + private fun completeOp(matches: (GattOp) -> Boolean = { true }) { + if (retireOp(matches)) pumpOps() + } + + /** Place a protocol-ready handshake ahead of reads/optional CCCDs already in the queue. */ + private fun prependCommandWrites(commands: List) { + val driver = activeDriver ?: return + val ops = commands.map { command -> + val framed = driver.frame(command) + GattOp.CommandWrite(framed, driver.usesCommandChannel(framed)) + } synchronized(opLock) { - val current = inFlightOp ?: return - if (!matches(current)) return - inFlightOp = null + for (op in ops.asReversed()) opQueue.addFirst(op) } - pumpOps() } /** Drop everything queued or in flight (connection reset / teardown). Any pending @@ -1092,7 +1113,8 @@ class RingBLEClient(private val context: Context) { // ring becomes usable as soon as possible instead of waiting behind firmware/ // battery reads. (This is the R10 fix: its 0x180A DIS firmware read used to be // issued ahead of the CCCD write and silently dropped it, so it never connected.) - pendingRingNotifyDescriptors.clear() + val subscriptionGate = subscriptionGate ?: return + val ringDescriptorOps = mutableListOf>() for (service in gatt.services) { val svcUuid = service.uuid.toString() val isRingSvc = driver.serviceUUIDs.any { it == svcUuid } @@ -1108,25 +1130,34 @@ class RingBLEClient(private val context: Context) { if (uuid == driver.commandUUID) commandChar = ch if (driver.notifyUUIDs.any { it == uuid }) { notifyChars[ch.uuid] = ch - gatt.setCharacteristicNotification(ch, true) - ch.getDescriptor(CCCD_UUID)?.let { desc -> - pendingRingNotifyDescriptors.add(ch.uuid) - // Current SmartHealth enables both BE94-0001 and BE94-0003 as - // indications (CCCD 02 00), even though older ports commonly call - // them notifications. Writing 01 00 succeeds locally but leaves the - // R10M without its command-reply path and it closes with HCI 0x13. - val cccdValue = if (activeCoordinator?.deviceType == RingDeviceType.YCBT) { - BluetoothGattDescriptor.ENABLE_INDICATION_VALUE - } else { - BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + val localEnabled = gatt.setCharacteristicNotification(ch, true) + val descriptor = ch.getDescriptor(CCCD_UUID) + subscriptionGate.observeCharacteristic( + uuid = uuid, + localEnabled = localEnabled, + hasCccd = descriptor != null, + ) + if (localEnabled && descriptor != null) { + val cccdValue = when (subscriptionGate.modeFor(uuid)) { + SubscriptionMode.NOTIFICATION -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + SubscriptionMode.INDICATION -> BluetoothGattDescriptor.ENABLE_INDICATION_VALUE } - enqueueOp(GattOp.DescriptorWrite(desc, cccdValue)) + ringDescriptorOps += descriptor to cccdValue } } if (uuid == driver.batteryCharUUID) batteryChar = ch } } + subscriptionGate.topologyFailure()?.let { failure -> + recordDiagnostic(failure) + failConnectAttempt(failure) + return + } + for ((descriptor, value) in ringDescriptorOps) { + enqueueOp(GattOp.DescriptorWrite(descriptor, value)) + } + // Standard BLE health services — blood pressure (0x1810) + glucose (0x1808). val bpServiceUuid = java.util.UUID.fromString("00001810-0000-1000-8000-00805f9b34fb") val bpMeasureUuid = java.util.UUID.fromString("00002a35-0000-1000-8000-00805f9b34fb") @@ -1280,21 +1311,41 @@ class RingBLEClient(private val context: Context) { if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // descriptor ACK — link is alive resetOpFailures() - // Retire the in-flight op before any early return, so the queue drains. - completeOp { it is GattOp.DescriptorWrite && it.descriptor === descriptor } + // Retire without pumping: the final required CCCD may need to prepend an immediate + // vendor handshake ahead of reads and optional descriptors already in the queue. + if (!retireOp { it is GattOp.DescriptorWrite && it.descriptor === descriptor }) return + + val driver = activeDriver + val channelUuid = descriptor.characteristic.uuid.toString() + val isRingChannel = driver?.notifyUUIDs?.any { it == channelUuid } == true + val gate = subscriptionGate if (status != BluetoothGatt.GATT_SUCCESS) { - updateState { copy(lastError = "Could not enable ring notifications (GATT $status)") } + val failure = "Could not enable ring notifications (GATT $status, ${channelUuid.substringBefore('-')})" + if (isRingChannel && gate?.isRequired(channelUuid) == true) { + failConnectAttempt(failure) + } else { + updateState { copy(lastError = failure) } + pumpOps() + } return } - // Notification enabled — fire onConnected once at least one notify is live - val driver = activeDriver ?: return - val ch = descriptor.characteristic - if (!driver.notifyUUIDs.any { it == ch.uuid.toString() }) return - pendingRingNotifyDescriptors.remove(ch.uuid) - if (pendingRingNotifyDescriptors.isNotEmpty()) return - if (_state.value.connectionState == RingConnectionState.CONNECTED) return + if (!isRingChannel || driver == null || gate == null) { + pumpOps() + return + } + gate.descriptorWritten(channelUuid, successful = true) + if (!gate.isReady || _state.value.connectionState == RingConnectionState.CONNECTED) { + pumpOps() + return + } + + val immediateCommands = driver.immediatePostSubscriptionCommands() + if (immediateCommands.isNotEmpty()) { + recordDiagnostic("vendor handshake queued") + prependCommandWrites(immediateCommands) + } updateState { copy(connectionState = RingConnectionState.CONNECTED) } lastActivityAt = System.currentTimeMillis() // fresh link — start the staleness clock @@ -1333,18 +1384,11 @@ class RingBLEClient(private val context: Context) { ) ) } - if (activeCoordinator?.deviceType == RingDeviceType.YCBT) { - // R10M has a very short post-subscription watchdog. The normal startup callback - // loads Room/DataStore settings before its first write, which can leave the ring - // idle long enough to terminate an otherwise healthy link with HCI 0x13. Issue a - // Current SmartHealth calls GetDeviceName (02 03, payload 47 50) first and then - // queues SettingTime. This ordering differs from older public YCBT SDKs. - recordDiagnostic("YCBT vendor handshake") - (activeSyncEngine as? YCBTSyncEngine)?.beginVendorHandshake() - } else { + if (activeCoordinator?.deviceType != RingDeviceType.YCBT) { readBattery() } + pumpOps() scope.launch { onConnected?.invoke() } } @@ -1407,7 +1451,7 @@ class RingBLEClient(private val context: Context) { // and always starts from a fresh connectGatt. bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null - pendingRingNotifyDescriptors.clear() + subscriptionGate = null closeGattQuietly(gatt) PulseEventBus.publishBlocking( diff --git a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt index 0775382..beae824 100644 --- a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt +++ b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt @@ -84,6 +84,7 @@ sealed class RingDecodedEvent { is FirmwareVersion -> Instant.EPOCH is BindNotify -> Instant.EPOCH is MeasurementRejected -> Instant.EPOCH + is WearingStatus -> this._timestamp is SupportFunctions -> Instant.EPOCH is ChipScheme -> Instant.EPOCH @@ -283,6 +284,7 @@ sealed class RingDecodedEvent { override val debugJSON = """{"rejected_mode":$mode}""" } + data class WearingStatus( val worn: Boolean, val _timestamp: Instant, diff --git a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt index a5161e8..718d1ef 100644 --- a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt +++ b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt @@ -93,17 +93,16 @@ object RingEventBridge { is RingDecodedEvent.MeasurementRejected -> listOf(PulseEvent.MeasurementRejected(decoded.mode)) + is RingDecodedEvent.WearingStatus, is RingDecodedEvent.SupportFunctions, is RingDecodedEvent.ChipScheme -> emptyList() // Debug-feed only; no product surface yet is RingDecodedEvent.BloodPressureSample -> { - // Live BP has no dedicated PulseEvent; surface as upserting history rows so the reading lands. - val events = mutableListOf() - if (decoded.systolic > 0) events.add(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, decoded.systolic.toDouble(), decoded._timestamp)) - if (decoded.diastolic > 0) events.add(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, decoded.diastolic.toDouble(), decoded._timestamp)) - events + if (decoded.systolic > 0 && decoded.diastolic > 0) { + listOf(PulseEvent.BloodPressureSample(decoded.systolic, decoded.diastolic, decoded._timestamp)) + } else emptyList() } is RingDecodedEvent.BloodSugarSample -> { diff --git a/app/src/main/java/com/pulseloop/ring/SubscriptionSetupGate.kt b/app/src/main/java/com/pulseloop/ring/SubscriptionSetupGate.kt new file mode 100644 index 0000000..a39fca1 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/SubscriptionSetupGate.kt @@ -0,0 +1,48 @@ +package com.pulseloop.ring + +/** Pure readiness policy for GATT notification setup. Android objects stay in RingBLEClient. */ +internal class SubscriptionSetupGate( + notifyUUIDs: List, + requiredSubscriptions: List, +) { + private data class Observed(val localEnabled: Boolean, val hasCccd: Boolean) + + private val notifyUUIDs = notifyUUIDs.map(String::lowercase).toSet() + private val required = requiredSubscriptions.associateBy { it.uuid.lowercase() } + private val observed = mutableMapOf() + private val completed = mutableSetOf() + + fun observeCharacteristic(uuid: String, localEnabled: Boolean, hasCccd: Boolean) { + val key = uuid.lowercase() + if (key in notifyUUIDs) observed[key] = Observed(localEnabled, hasCccd) + } + + fun modeFor(uuid: String): SubscriptionMode = + required[uuid.lowercase()]?.mode ?: SubscriptionMode.NOTIFICATION + + fun isRequired(uuid: String): Boolean = uuid.lowercase() in required + + fun descriptorWritten(uuid: String, successful: Boolean) { + val key = uuid.lowercase() + if (successful && key in notifyUUIDs) completed += key + } + + val isReady: Boolean + get() = if (required.isEmpty()) { + completed.isNotEmpty() + } else { + required.keys.all { it in completed } + } + + /** Validate declared topology after service discovery, before any partial setup can connect. */ + fun topologyFailure(): String? { + if (required.isEmpty()) return null + val unavailable = required.keys.filter { uuid -> + val channel = observed[uuid] + channel == null || !channel.localEnabled || !channel.hasCccd + } + if (unavailable.isEmpty()) return null + val channels = unavailable.joinToString { it.substringBefore('-').uppercase() } + return "Required ring indication channel unavailable: $channels" + } +} diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index 99cc053..35aa82d 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -8,6 +8,13 @@ fun interface RingCommandWriter { fun enqueue(command: ByteArray) } +enum class SubscriptionMode { NOTIFICATION, INDICATION } + +data class RequiredSubscription( + val uuid: String, + val mode: SubscriptionMode, +) + /** * Ported from [WearableDriver] in WearableDriver.swift. * Connection + protocol handler for one wearable family. @@ -20,6 +27,13 @@ interface WearableDriver { val batteryServiceUUID: String? get() = null val batteryCharUUID: String? get() = null + /** Channels that must be subscribed successfully before this driver is usable. Empty keeps + * legacy first-notify readiness for devices whose additional channels are optional. */ + val requiredSubscriptionsBeforeConnected: List get() = emptyList() + + /** Commands that must be placed directly after the final required CCCD write. */ + fun immediatePostSubscriptionCommands(): List = emptyList() + /** Apply outbound framing. jring: identity. Colmi: pad to 15 + checksum. */ fun frame(command: ByteArray): ByteArray @@ -103,14 +117,16 @@ interface RingSyncEngine { fun runStartup() fun handle(event: RingDecodedEvent) - /** Re-run the full history sync without re-sending the connect handshake. No-op on devices - * that don't support staged history sync. */ - fun syncHistory() {} + /** User-requested refresh. Existing families historically replay their startup sync. */ + fun refresh() = runStartup() + + /** Legacy sleep query action. Existing families historically replay their startup sync. */ + fun querySleep() = runStartup() /** Refresh the recent vital series after a live workout without replaying all history. */ fun syncVitalsHistory() {} - /** On-demand, standalone sleep fetch — request just the sleep record without running the + /** On-screen, standalone sleep fetch — request just the sleep record without running the * whole history pipeline (which buries sleep behind activity/HR/stress/SpO₂ and can lose it * to a watchdog stage-skip). Mirrors the official QRing app, which fires a dedicated sleep * request when its sleep screen opens. No-op on devices whose history sync isn't staged this diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt index 5208cae..7eb8aca 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt @@ -138,6 +138,8 @@ class YCBTDecoder { if (events.isEmpty()) listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) else events } YCBTDevControl.MEASUREMENT_RESULT -> { + // SmartHealth acknowledges this push but its proprietary unpackParseData layout + // is not available in the decompile. Do not infer mode/result fields from bytes. listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) } else -> listOf(RingDecodedEvent.CommandAck(commandId = cmd.toUByte())) @@ -161,6 +163,9 @@ class YCBTDecoder { val tenths = value * 10 + fraction if (tenths > 0) listOf(RingDecodedEvent.BloodSugarSample(mgdl = YCBTHealthRecords.bloodSugarMgdl(tenths), _timestamp = now)) else emptyList() } + // Live HRV values are evidenced on Real 06/03. No captured 04/13 HRV value + // layout exists, so keep status payload tails diagnostic-only rather than guessing. + YCBTMeasurementMode.HRV -> emptyList() else -> emptyList() } } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt index 8489121..9b9d811 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt @@ -7,15 +7,10 @@ package com.pulseloop.ring class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { private val decoder = YCBTDecoder() + private val encoder = YCBTEncoder() private val assembler = YCBTFrameAssembler() private val transfer: YCBTHistoryTransfer = YCBTHistoryTransfer(writer = writer) - - /** Queue of pending measurement-reply modes: the mode for a start, null for a stop. */ - private val pendingMeasurementReplies = ArrayDeque(8) - - companion object { - private const val MAX_PENDING_MEASUREMENT_REPLIES = 8 - } + private val pendingMeasurementReplies = PendingMeasurementReplies() override val serviceUUIDs = listOf(YCBTUUIDs.SERVICE) override val writeUUID = YCBTUUIDs.COMMAND @@ -23,6 +18,13 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { override val commandUUID = YCBTUUIDs.COMMAND override val batteryServiceUUID: String? = null override val batteryCharUUID: String? = null + override val requiredSubscriptionsBeforeConnected = listOf( + RequiredSubscription(YCBTUUIDs.COMMAND, SubscriptionMode.INDICATION), + RequiredSubscription(YCBTUUIDs.STREAM, SubscriptionMode.INDICATION), + ) + + override fun immediatePostSubscriptionCommands(): List = + encoder.postSubscriptionHandshake() override fun frame(command: ByteArray): ByteArray { val logical = command @@ -36,10 +38,9 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { if (logical.size >= 4 && (logical[0].toInt() and 0xFF) == YCBTGroup.APP_CONTROL && (logical[1].toInt() and 0xFF) == YCBTCommand.LIVE_MEASUREMENT) { - pendingMeasurementReplies.add(if (logical[2].toInt() and 0xFF == 1) logical[3].toInt() and 0xFF else null) - if (pendingMeasurementReplies.size > MAX_PENDING_MEASUREMENT_REPLIES) { - pendingMeasurementReplies.removeFirst() - } + pendingMeasurementReplies.record( + if (logical[2].toInt() and 0xFF == 1) logical[3].toInt() and 0xFF else null + ) } } @@ -72,7 +73,7 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { events.addAll(decoder.decode(frame)) } YCBTGroup.APP_CONTROL -> if (frame.cmd == YCBTCommand.LIVE_MEASUREMENT) { - val startedMode = if (pendingMeasurementReplies.isEmpty()) null else pendingMeasurementReplies.removeFirst() + val startedMode = pendingMeasurementReplies.consume()?.startedMode events.addAll(decoder.decode(frame, startedMode = startedMode)) } else { events.addAll(decoder.decode(frame)) @@ -92,3 +93,28 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { return YCBTSyncEngine(writer = writer, transfer = transfer) } } + +/** GATT writes and replies arrive on different threads; keep FIFO pairing atomic. */ +internal class PendingMeasurementReplies { + data class Reply(val startedMode: Int?) + + private val replies = ArrayDeque() + + @Synchronized + fun record(startedMode: Int?) { + replies.addLast(Reply(startedMode)) + if (replies.size > MAX_PENDING) replies.removeFirst() + } + + @Synchronized + fun consume(): Reply? = if (replies.isEmpty()) null else replies.removeFirst() + + @Synchronized + fun clear() { + replies.clear() + } + + private companion object { + const val MAX_PENDING = 8 + } +} diff --git a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt index 9e22534..e7ece27 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt @@ -115,20 +115,17 @@ class YCBTEncoder { fun setTime(date: Instant = Instant.now()): ByteArray = settings.setTime(date) fun startupSequence( - date: Instant = Instant.now(), measurement: MeasurementSettings = MeasurementSettings.ALL_ON_DEFAULT, profile: UserProfileValues = UserProfileValues(metric = true, gender = 0x02u, age = 25u, heightCm = 175u, weightKg = 70u), languageCode: Int = 0, is24Hour: Boolean = true, ): List { val seq = mutableListOf() - seq.add(setTime(date)) seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, byteArrayOf(0x47, 0x43))) seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_SUPPORT_FUNCTION, byteArrayOf(0x47, 0x46))) // Do not query GetChipScheme (02 1B) during startup. The TK5 accepts it, but the // R10M closes an otherwise healthy connection with HCI 0x13 immediately on receipt. // Chip-scheme metadata is informational and no Android feature depends on it. - seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_NAME, byteArrayOf(0x47, 0x50))) seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_USER_CONFIG, byteArrayOf(0x43, 0x46))) seq.add(settings.language(languageCode)) seq.add(settings.units(metric = profile.metric, is24Hour = is24Hour)) @@ -138,6 +135,10 @@ class YCBTEncoder { return seq } + /** Exact current SmartHealth sequence immediately after both indication CCCDs complete. */ + fun postSubscriptionHandshake(date: Instant = Instant.now()): List = + listOf(deviceNameRequest(), setTime(date)) + fun monitorCommands(measurement: MeasurementSettings): List = settings.monitorCommands(measurement) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt index 0bb4e22..c3ba232 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt @@ -1,6 +1,7 @@ package com.pulseloop.ring import java.time.Instant +import java.time.ZoneOffset import java.util.TimeZone import java.util.UUID @@ -76,7 +77,7 @@ class YCBTFrame( } } -/** Little-endian + epoch helpers. YCBT timestamps are seconds since 2000-01-01 UTC. */ +/** Little-endian + epoch helpers. YCBT timestamps encode local wall-clock seconds since 2000. */ object YCBTBytes { const val EPOCH_OFFSET = 946_684_800L @@ -99,10 +100,10 @@ object YCBTBytes { /** Convert ring seconds (2000-epoch) to an Instant. The ring clock is local wall-clock; decoding * must un-apply the device's UTC offset to recover the true absolute instant. */ fun date(ringSeconds: Int, timeZone: TimeZone = TimeZone.getDefault()): Instant { - // The ring stores local wall-clock seconds. Match Foundation's - // secondsFromGMT() approximation and include the currently active DST offset. - val offset = timeZone.getOffset(System.currentTimeMillis()) / 1000 - return Instant.ofEpochSecond(ringSeconds.toLong() + EPOCH_OFFSET - offset) + val localDateTime = Instant.ofEpochSecond(ringSeconds.toLong() + EPOCH_OFFSET) + .atOffset(ZoneOffset.UTC) + .toLocalDateTime() + return localDateTime.atZone(timeZone.toZoneId()).toInstant() } /** Convert an Instant to ring seconds (2000-epoch), the inverse of date(). */ diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index 5b39c24..1c4918e 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -1,7 +1,5 @@ package com.pulseloop.ring -import java.time.Instant - /** * Ported from YCBTSyncEngine.swift. * YCBT sync engine. Connect is a parameterized handshake, followed by the history sync. @@ -36,10 +34,14 @@ class YCBTSyncEngine( // History is protocol-driven now — nothing here advances it. } - override fun syncHistory() { + override fun refresh() { transfer.start(types = HISTORY_TYPES) } + override fun querySleep() { + transfer.start(types = listOf(YCBTHistoryType.SLEEP)) + } + override fun syncVitalsHistory() { transfer.start(types = VITALS_TYPES) } @@ -68,20 +70,6 @@ class YCBTSyncEngine( writer?.enqueue(encoder.userInfo(profile)) } - fun resyncTime() { - writer?.enqueue(encoder.setTime()) - } - - /** Exact current SmartHealth/YCBT post-CCCD sequence: identify device, then set time. */ - fun beginVendorHandshake() { - writer?.enqueue(encoder.deviceNameRequest()) - writer?.enqueue(encoder.setTime()) - } - - fun requestBattery() { - writer?.enqueue(encoder.deviceInfoRequest()) - } - override fun startHeartRate() { writer?.enqueue(encoder.heartRateStart()) } diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 1cf894a..98d9c0c 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -48,6 +48,7 @@ class EventPersistenceSubscriber( is PulseEvent.HistoryMeasurement, is PulseEvent.StressSample, is PulseEvent.HrvSample, + is PulseEvent.BloodPressureSample, is PulseEvent.TemperatureSample, is PulseEvent.ActivityUpdate, is PulseEvent.ActivityBucket, @@ -173,6 +174,20 @@ class EventPersistenceSubscriber( sourceRaw = "colmi", )) } + is PulseEvent.BloodPressureSample -> { + db.measurementDao().insert(MeasurementEntity( + kindRaw = MeasurementKind.BLOOD_PRESSURE_SYSTOLIC.name, + value = event.systolic.toDouble(), unit = "mmHg", + timestamp = event.timestamp.toEpochMilli(), + sourceRaw = "live", + )) + db.measurementDao().insert(MeasurementEntity( + kindRaw = MeasurementKind.BLOOD_PRESSURE_DIASTOLIC.name, + value = event.diastolic.toDouble(), unit = "mmHg", + timestamp = event.timestamp.toEpochMilli(), + sourceRaw = "live", + )) + } is PulseEvent.TemperatureSample -> { db.measurementDao().insert(MeasurementEntity( kindRaw = MeasurementKind.TEMPERATURE.name, @@ -198,6 +213,7 @@ class EventPersistenceSubscriber( is PulseEvent.HeartRateComplete -> {} is PulseEvent.Spo2Complete -> {} is PulseEvent.MeasurementRejected -> {} // Product orchestration only; no persistence. + is PulseEvent.RawPacket -> { db.rawPacketDao().insert(RawPacketEntity( directionRaw = event.direction.name, diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index 94256ab..df54678 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -72,8 +72,6 @@ class RingSyncCoordinator( private var spo2NoReadingReported = false private var hrvNoReadingReported = false private var bloodPressureNoReadingReported = false - private var pendingSystolic: Int? = null - private var pendingDiastolic: Int? = null private var measurementReceivedReading = false val connectionState: RingConnectionState get() = client.state.value.connectionState @@ -109,6 +107,8 @@ class RingSyncCoordinator( private val engine: RingSyncEngine? get() = client.syncEngine private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var streamJob: Job? = null + private var startupEngine: RingSyncEngine? = null + private var startupJob: Job? = null fun start() { streamJob?.cancel() @@ -121,39 +121,49 @@ class RingSyncCoordinator( fun stop() { streamJob?.cancel() streamJob = null + startupJob?.cancel() + startupJob = null + startupEngine = null } // MARK: - Actions /** Canonical startup sequence run on connect. */ fun runStartupSequence() { + val targetEngine = engine ?: return + if (!isConnected || startupEngine === targetEngine) return + startupEngine = targetEngine // Begin progress here (a real sync request), NOT on DeviceStateChanged(CONNECTED): // the ring re-emits CONNECTED on every 0x0C status packet, which would otherwise // keep resetting the bar to 0%. beginSyncProgress() - scope.launch { + startupJob?.cancel() + startupJob = scope.launch { // Push the persisted measurement config + profile into the engine BEFORE // runStartup so the connect handshake reflects them (the engine emits the // commands itself, so we don't double-send here). iOS #19 parity. // No persisted config (null) ⇒ the engine seeds one from the ring's own // reported settings; persist that as the device's initial config. val persisted = loadMeasurementSettings() - engine?.setMeasurementSettings(persisted) + if (!isConnected || engine !== targetEngine) return@launch + targetEngine.setMeasurementSettings(persisted) if (persisted == null) { - engine?.setOnMeasurementConfigSeeded { seeded -> + targetEngine.setOnMeasurementConfigSeeded { seeded -> scope.launch { persistSeededMeasurementConfig(db, seeded) } } } - loadUserProfileValues()?.let { engine?.setUserProfile(it) } + val profile = loadUserProfileValues() + if (!isConnected || engine !== targetEngine) return@launch + profile?.let { targetEngine.setUserProfile(it) } // Claim the ring for this app FIRST (0x48). The ring binds to the connecting app's // id and otherwise can stay mute after another app (e.g. the official one) claimed it. - apiKeyStore?.ringAppId?.let { engine?.setAppId(it) } - engine?.runStartup() + apiKeyStore?.ringAppId?.let { targetEngine.setAppId(it) } + targetEngine.runStartup() // Push the user's profile so the ring's blood-sugar (profile-derived) and // calorie algorithms run on real inputs. BP is a direct sensor reading and // does not depend on user info. Matches the official app, which calls // setUserInfo on every connect anyway. - pushUserSettingsFromStore() + pushUserSettingsFromStore(targetEngine) lastSyncAt = System.currentTimeMillis() } } @@ -186,10 +196,12 @@ class RingSyncCoordinator( } /** Read the stored profile + BP calibration and push them to the ring. */ - private fun pushUserSettingsFromStore() { + private fun pushUserSettingsFromStore(targetEngine: RingSyncEngine) { scope.launch { val profile = try { db.userProfileDao().get() } catch (_: Exception) { null } - applyUserSettings( + if (!isConnected || engine !== targetEngine) return@launch + applyUserSettingsToEngine( + targetEngine, profile, apiKeyStore?.bpAdjustSystolic ?: 0, apiKeyStore?.bpAdjustDiastolic ?: 0, @@ -206,24 +218,34 @@ class RingSyncCoordinator( */ fun applyUserSettings(profile: UserProfileEntity?, bpSystolic: Int, bpDiastolic: Int) { if (!isConnected) return + val targetEngine = engine ?: return + applyUserSettingsToEngine(targetEngine, profile, bpSystolic, bpDiastolic) + } + + private fun applyUserSettingsToEngine( + targetEngine: RingSyncEngine, + profile: UserProfileEntity?, + bpSystolic: Int, + bpDiastolic: Int, + ) { profile?.let { p -> val age = p.age val heightCm = p.heightCm?.toInt() val weightKg = p.weightKg?.toInt() if (age != null && heightCm != null && weightKg != null) { val isMale = p.sex?.equals("male", ignoreCase = true) == true - engine?.setUserInfo(age, isMale, heightCm, weightKg) + targetEngine.setUserInfo(age, isMale, heightCm, weightKg) } } if (bpSystolic in 1..300 && bpDiastolic in 1..300) { - engine?.setBloodPressureAdjust(bpSystolic, bpDiastolic) + targetEngine.setBloodPressureAdjust(bpSystolic, bpDiastolic) } } fun syncNow() { if (!isConnected) return beginSyncProgress() - engine?.syncHistory() + engine?.refresh() lastSyncAt = System.currentTimeMillis() } @@ -268,7 +290,7 @@ class RingSyncCoordinator( fun querySleep() { if (!isConnected) return - engine?.syncSleepNow() + engine?.querySleep() } fun findRing() { @@ -306,7 +328,7 @@ class RingSyncCoordinator( scope.launch { if (isConnected) { onProgress("Syncing latest data…") - runStartupSequence() + syncNow() // Wait for the history sync to drain (progress reaches 100 or clears), capped // so a stale link can never hang the reset. kotlinx.coroutines.withTimeoutOrNull(SYNC_BEFORE_RESET_TIMEOUT_MS) { @@ -409,8 +431,6 @@ class RingSyncCoordinator( if (!isConnected) { bloodPressureState = MeasureState.FAILED; return null } bloodPressureState = MeasureState.MEASURING latestBloodPressure = null - pendingSystolic = null - pendingDiastolic = null bloodPressureNoReadingReported = false engine?.startBloodPressure() var result: Pair? = null @@ -493,7 +513,14 @@ class RingSyncCoordinator( is PulseEvent.Spo2Result -> { latestSpO2Value = event.value } - is PulseEvent.HrvSample -> latestHrvValue = event.value + is PulseEvent.HrvSample -> { + if (hrvState == MeasureState.MEASURING) latestHrvValue = event.value + } + is PulseEvent.BloodPressureSample -> { + if (bloodPressureState == MeasureState.MEASURING) { + latestBloodPressure = event.systolic to event.diastolic + } + } is PulseEvent.Spo2Complete -> { if (spo2State == MeasureState.MEASURING && latestSpO2Value == null) { spo2NoReadingReported = true @@ -507,6 +534,7 @@ class RingSyncCoordinator( YCBTMeasurementMode.HRV -> hrvNoReadingReported = true } } + is PulseEvent.DeviceStateChanged -> { when (event.state) { RingConnectionState.CONNECTED -> lastSyncAt = System.currentTimeMillis() @@ -522,19 +550,6 @@ class RingSyncCoordinator( is PulseEvent.ActivityUpdate -> advanceSyncProgress(event.timestamp.toEpochMilli()) is PulseEvent.SleepTimeline -> advanceSyncProgress(event.timestamp.toEpochMilli()) is PulseEvent.HistoryMeasurement -> { - when (event.kind) { - MeasurementKind.HRV -> latestHrvValue = event.value.toInt() - MeasurementKind.BLOOD_PRESSURE_SYSTOLIC -> pendingSystolic = event.value.toInt() - MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> pendingDiastolic = event.value.toInt() - else -> {} - } - val systolic = pendingSystolic - val diastolic = pendingDiastolic - if (systolic != null && diastolic != null) { - latestBloodPressure = systolic to diastolic - pendingSystolic = null - pendingDiastolic = null - } advanceSyncProgress(event.timestamp.toEpochMilli()) } is PulseEvent.SyncProgress -> if (event.stage == "done") finishSyncProgressSoon() diff --git a/app/src/test/java/com/pulseloop/ring/ExistingFamilyRefreshContractTest.kt b/app/src/test/java/com/pulseloop/ring/ExistingFamilyRefreshContractTest.kt new file mode 100644 index 0000000..9bf0a71 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/ExistingFamilyRefreshContractTest.kt @@ -0,0 +1,48 @@ +package com.pulseloop.ring + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExistingFamilyRefreshContractTest { + private class FakeWriter : RingCommandWriter { + val sent = mutableListOf() + override fun enqueue(command: ByteArray) { + sent += command.copyOf() + } + } + + @Test + fun `Jring refresh and query sleep retain startup behavior`() { + val startup = capture { JringSyncEngine(it).runStartup() } + val refresh = capture { JringSyncEngine(it).refresh() } + val sleep = capture { JringSyncEngine(it).querySleep() } + + assertEquals(startup, refresh) + assertEquals(startup, sleep) + } + + @Test + fun `Colmi refresh and query sleep retain startup behavior`() { + val startup = captureColmi { it.runStartup() } + val refresh = captureColmi { it.refresh() } + val sleep = captureColmi { it.querySleep() } + + assertEquals(startup, refresh) + assertEquals(startup, sleep) + } + + private fun capture(action: (FakeWriter) -> Unit): List> { + val writer = FakeWriter() + action(writer) + return writer.sent.map(ByteArray::toList) + } + + private fun captureColmi(action: (ColmiSyncEngine) -> Unit): List> { + val writer = FakeWriter() + val engine = ColmiSyncEngine(writer, ColmiDecoder) + action(engine) + val commands = writer.sent.map(ByteArray::toList) + engine.destroy() + return commands + } +} diff --git a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt index 9d48e52..acf3d9c 100644 --- a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt +++ b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt @@ -265,4 +265,30 @@ class RingEventBridgeTest { ) assertEquals(listOf(PulseEvent.MeasurementRejected(YCBTMeasurementMode.SPO2)), events) } + + @Test + fun `live blood pressure preserves live identity`() { + val events = RingEventBridge.eventsFor( + RingDecodedEvent.BloodPressureSample(118, 79, now), now + ) + + assertEquals(listOf(PulseEvent.BloodPressureSample(118, 79, now)), events) + assertFalse(events.any { it is PulseEvent.HistoryMeasurement }) + } + + @Test + fun `historical blood pressure and HRV retain history identity`() { + val systolic = RingEventBridge.eventsFor( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, 130.0, now), now + ).single() + val hrv = RingEventBridge.eventsFor( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.HRV, 55.0, now), now + ).single() + + assertTrue(systolic is PulseEvent.HistoryMeasurement) + assertTrue(hrv is PulseEvent.HistoryMeasurement) + assertFalse(systolic is PulseEvent.BloodPressureSample) + assertFalse(hrv is PulseEvent.HrvSample) + } + } diff --git a/app/src/test/java/com/pulseloop/ring/SubscriptionSetupGateTest.kt b/app/src/test/java/com/pulseloop/ring/SubscriptionSetupGateTest.kt new file mode 100644 index 0000000..bde79ad --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/SubscriptionSetupGateTest.kt @@ -0,0 +1,62 @@ +package com.pulseloop.ring + +import org.junit.Assert.* +import org.junit.Test + +class SubscriptionSetupGateTest { + private val command = YCBTUUIDs.COMMAND + private val stream = YCBTUUIDs.STREAM + private val required = listOf( + RequiredSubscription(command, SubscriptionMode.INDICATION), + RequiredSubscription(stream, SubscriptionMode.INDICATION), + ) + + @Test + fun `YCBT is ready only after both declared CCCDs succeed`() { + val gate = SubscriptionSetupGate(listOf(command, stream), required) + gate.observeCharacteristic(command, localEnabled = true, hasCccd = true) + gate.observeCharacteristic(stream, localEnabled = true, hasCccd = true) + assertNull(gate.topologyFailure()) + + gate.descriptorWritten(command, successful = true) + assertFalse(gate.isReady) + gate.descriptorWritten(stream, successful = true) + assertTrue(gate.isReady) + } + + @Test + fun `missing required channel gives a useful topology failure`() { + val gate = SubscriptionSetupGate(listOf(command, stream), required) + gate.observeCharacteristic(command, localEnabled = true, hasCccd = true) + + val failure = gate.topologyFailure() + assertNotNull(failure) + assertTrue(failure!!.contains("BE940003")) + } + + @Test + fun `missing CCCD or failed local enable cannot satisfy topology`() { + val missingCccd = SubscriptionSetupGate(listOf(command, stream), required) + missingCccd.observeCharacteristic(command, localEnabled = true, hasCccd = true) + missingCccd.observeCharacteristic(stream, localEnabled = true, hasCccd = false) + assertNotNull(missingCccd.topologyFailure()) + + val failedEnable = SubscriptionSetupGate(listOf(command, stream), required) + failedEnable.observeCharacteristic(command, localEnabled = true, hasCccd = true) + failedEnable.observeCharacteristic(stream, localEnabled = false, hasCccd = true) + assertNotNull(failedEnable.topologyFailure()) + } + + @Test + fun `legacy optional channels connect after first successful notify`() { + val first = "00000001-0000-1000-8000-00805f9b34fb" + val optional = "00000002-0000-1000-8000-00805f9b34fb" + val gate = SubscriptionSetupGate(listOf(first, optional), emptyList()) + gate.observeCharacteristic(first, localEnabled = true, hasCccd = true) + gate.descriptorWritten(first, successful = true) + + assertTrue(gate.isReady) + assertNull(gate.topologyFailure()) + assertEquals(SubscriptionMode.NOTIFICATION, gate.modeFor(optional)) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt index 870c3ab..4b4fe5a 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt @@ -3,6 +3,7 @@ package com.pulseloop.ring import org.junit.Assert.* import org.junit.Test import java.time.Instant +import java.time.LocalDateTime import java.util.TimeZone class YCBTDecoderTest { @@ -117,12 +118,22 @@ class YCBTDecoderTest { } @Test - fun `measurement result push acks`() { + fun `measurement result push remains an ack without an evidenced payload layout`() { val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x0e, 0x01, 0x02)))!! val events = decoder.decode(frame) - assertTrue(events.first() is RingDecodedEvent.CommandAck) + assertTrue(events.single() is RingDecodedEvent.CommandAck) } + @Test + fun `HRV status tail remains undecoded without a captured layout`() { + val frame = YCBTFrame.validating(YCBTFrame.frame(byteArrayOf(0x04, 0x13, 0x0a, 0x01, 0x2a)))!! + val events = decoder.decode(frame) + + assertTrue(events.single() is RingDecodedEvent.CommandAck) + assertFalse(events.any { it is RingDecodedEvent.HrvSample }) + } + + @Test fun `measurement start reply distinguishes acceptance from refusal`() { fun reply(status: Int, startedMode: Int?): RingDecodedEvent { @@ -155,15 +166,24 @@ class YCBTDecoderTest { } @Test - fun `date decode recovers true instant across time zone`() { + fun `date decode uses the record instant DST offset`() { val tz = TimeZone.getTimeZone("America/New_York") - val trueInstant = Instant.ofEpochSecond((System.currentTimeMillis() / 1000).toLong()) - val calendar = java.util.Calendar.getInstance(tz).apply { time = java.util.Date(trueInstant.toEpochMilli()) } - val utcCalendar = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) - utcCalendar.set(calendar.get(java.util.Calendar.YEAR), calendar.get(java.util.Calendar.MONTH), calendar.get(java.util.Calendar.DAY_OF_MONTH), calendar.get(java.util.Calendar.HOUR_OF_DAY), calendar.get(java.util.Calendar.MINUTE), calendar.get(java.util.Calendar.SECOND)) - val ringSeconds = (utcCalendar.timeInMillis / 1000 - YCBTBytes.EPOCH_OFFSET).toInt() - val decoded = YCBTBytes.date(ringSeconds, tz) - assertTrue(kotlin.math.abs(trueInstant.epochSecond - decoded.epochSecond) <= 1) + val winter = LocalDateTime.of(2026, 1, 15, 12, 0).atZone(tz.toZoneId()).toInstant() + val summer = LocalDateTime.of(2026, 7, 15, 12, 0).atZone(tz.toZoneId()).toInstant() + + assertEquals(winter, YCBTBytes.date(YCBTBytes.ringSeconds(winter, tz), tz)) + assertEquals(summer, YCBTBytes.date(YCBTBytes.ringSeconds(summer, tz), tz)) + assertEquals(3_600L, winter.epochSecond - summer.epochSecond + 181 * 86_400L) + } + + @Test + fun `date round trips across spring DST boundary`() { + val tz = TimeZone.getTimeZone("America/New_York") + val before = Instant.parse("2026-03-08T06:30:00Z") + val after = Instant.parse("2026-03-08T07:30:00Z") + + assertEquals(before, YCBTBytes.date(YCBTBytes.ringSeconds(before, tz), tz)) + assertEquals(after, YCBTBytes.date(YCBTBytes.ringSeconds(after, tz), tz)) } @Test diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt index b44812c..fb2dcf5 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt @@ -68,16 +68,16 @@ class YCBTDriverTest { val driver = YCBTDriver(writer) val engine = driver.makeSyncEngine() - engine.syncHistory() + engine.refresh() assertEquals(1, writer.sent.size) assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) writer.sent.clear() - engine.syncHistory() + engine.refresh() assertTrue(writer.sent.isEmpty()) driver.connectionDidEnd() - engine.syncHistory() + engine.refresh() assertEquals(1, writer.sent.size) assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) } @@ -146,4 +146,34 @@ class YCBTDriverTest { val events = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) assertTrue(events.first() is RingDecodedEvent.CommandAck) } + + @Test + fun `pending measurement replies preserve deterministic FIFO pairing`() { + val replies = PendingMeasurementReplies() + replies.record(YCBTMeasurementMode.HEART_RATE) + replies.record(null) + replies.record(YCBTMeasurementMode.HRV) + + assertEquals(YCBTMeasurementMode.HEART_RATE, replies.consume()?.startedMode) + assertNull(replies.consume()?.startedMode) + assertEquals(YCBTMeasurementMode.HRV, replies.consume()?.startedMode) + assertNull(replies.consume()) + } + + @Test + fun `pending measurement replies stay bounded when callbacks are lost`() { + val replies = PendingMeasurementReplies() + repeat(10) { replies.record(it) } + + assertEquals(2, replies.consume()?.startedMode) + repeat(7) { assertNotNull(replies.consume()) } + assertNull(replies.consume()) + } + + @Test + fun `YCBT requires both command and stream indications`() { + val required = YCBTDriver(RingCommandWriter { }).requiredSubscriptionsBeforeConnected + assertEquals(setOf(YCBTUUIDs.COMMAND, YCBTUUIDs.STREAM), required.map { it.uuid }.toSet()) + assertTrue(required.all { it.mode == SubscriptionMode.INDICATION }) + } } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt index cbb63c1..750ddaf 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -90,17 +90,16 @@ class YCBTEncoderTest { @Test fun `startup order mirrors SmartHealth handshake`() { val sequence = encoder.startupSequence().map { it.copyOfRange(0, 2) } - assertArrayEquals(byteArrayOf(0x01, 0x00), sequence.first()) + assertArrayEquals(byteArrayOf(0x02, 0x00), sequence.first()) assertArrayEquals(byteArrayOf(0x03, 0x09), sequence.last()) val expectedGets = listOf( byteArrayOf(0x02, 0x00), byteArrayOf(0x02, 0x01), - byteArrayOf(0x02, 0x03), byteArrayOf(0x02, 0x07), ) - assertEquals(expectedGets.map { it.toList() }, sequence.subList(1, 5).map { it.toList() }) + assertEquals(expectedGets.map { it.toList() }, sequence.subList(0, 3).map { it.toList() }) assertEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02).toList(), encoder.startupSequence().last().toList()) - val settings = sequence.subList(5, sequence.size) + val settings = sequence.subList(3, sequence.size) val expectedSettings = listOf( byteArrayOf(0x01, 0x12), byteArrayOf(0x01, 0x04), @@ -115,6 +114,17 @@ class YCBTEncoderTest { assertEquals(expectedSettings.map { it.toList() }, settings.map { it.toList() }) } + @Test + fun `post subscription handshake is exactly device name then time`() { + val date = Instant.parse("2026-07-06T12:34:14Z") + val sequence = encoder.postSubscriptionHandshake(date) + + assertEquals(2, sequence.size) + assertArrayEquals(byteArrayOf(0x02, 0x03, 0x47, 0x50), sequence[0]) + assertArrayEquals(byteArrayOf(0x01, 0x00), sequence[1].copyOfRange(0, 2)) + assertFalse(encoder.startupSequence().any { it.contentEquals(sequence[0]) }) + } + @Test fun `history request and block ack bytes`() { assertArrayEquals(byteArrayOf(0x05, 0x06), encoder.healthHistoryRequest(YCBTHistoryType.HEART)) diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt index 1b9b4b0..d48f0f3 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt @@ -176,7 +176,7 @@ class YCBTHistoryTransferTest { val writer = FakeWriter() val transfer = YCBTHistoryTransfer(writer = writer) val engine = YCBTSyncEngine(writer = writer, transfer = transfer) - engine.syncHistory() + engine.refresh() assertEquals(1, writer.sent.size) assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) diff --git a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt index b20b18a..fa6013f 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt @@ -1,6 +1,6 @@ package com.pulseloop.ring -import org.junit.Assert.assertArrayEquals +import org.junit.Assert.* import org.junit.Test class YCBTSyncEngineTest { @@ -19,7 +19,7 @@ class YCBTSyncEngineTest { @Test fun `full refresh begins with sport history`() { val writer = FakeWriter() - engine(writer).syncHistory() + engine(writer).refresh() assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent.single()) } @@ -36,4 +36,20 @@ class YCBTSyncEngineTest { engine(writer).syncSleepNow() assertArrayEquals(byteArrayOf(0x05, 0x04), writer.sent.single()) } + + @Test + fun `legacy query sleep action is history only for YCBT`() { + val writer = FakeWriter() + engine(writer).querySleep() + assertArrayEquals(byteArrayOf(0x05, 0x04), writer.sent.single()) + } + + @Test + fun `startup excludes the immediate name and time handshake`() { + val writer = FakeWriter() + engine(writer).runStartup() + val startup = writer.sent.filter { it.size >= 2 && it[0] != YCBTGroup.HEALTH.toByte() } + assertFalse(startup.any { it[0] == YCBTGroup.SETTING.toByte() && it[1] == YCBTSettingKey.SET_TIME.toByte() }) + assertFalse(startup.any { it[0] == YCBTGroup.GET.toByte() && it[1] == YCBTCommand.GET_DEVICE_NAME.toByte() }) + } } From 1acd016e97d24f48c5d66bb1361a0587ec488604 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 16:53:13 +0200 Subject: [PATCH 24/36] fix: label live blood pressure diagnostics --- app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt index 41e9c36..097c8e3 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt @@ -250,6 +250,7 @@ private fun labelFor(event: PulseEvent): String = when (event) { is PulseEvent.Spo2Result -> "SpO₂" is PulseEvent.Spo2Complete -> "SpO₂ Done" is PulseEvent.MeasurementRejected -> "Measure Rejected" + is PulseEvent.BloodPressureSample -> "Blood Pressure" is PulseEvent.HistoryMeasurement -> when (event.kind) { com.pulseloop.ring.MeasurementKind.HEART_RATE -> "HR History" com.pulseloop.ring.MeasurementKind.SPO2 -> "SpO₂ History" From be5c03761d0e90a9a3888ae8362bfccc1cd5ea14 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 16:59:39 +0200 Subject: [PATCH 25/36] chore: polish diagnostics and CI for upstream --- .github/workflows/ci.yml | 4 +-- .../java/com/pulseloop/ring/RingBLEClient.kt | 6 ++++- .../com/pulseloop/ui/screens/PairingScreen.kt | 27 +++++++++++-------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96de8a6..b88cb22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: - - 'feature/**' + - main pull_request: permissions: @@ -27,7 +27,7 @@ jobs: - name: Upload test APK uses: actions/upload-artifact@v4 with: - name: pulseloop-ycbt-r10m-debug + name: pulseloop-debug-apk path: app/build/outputs/apk/debug/*.apk if-no-files-found: error retention-days: 14 diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index cd3c258..7da164e 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1462,7 +1462,11 @@ class RingBLEClient(private val context: Context) { updateState { copy( connectionState = RingConnectionState.DISCONNECTED, - lastError = if (requestedByUser) lastError else "Ring disconnected (GATT $status)", + lastError = when { + requestedByUser -> lastError + status == BluetoothGatt.GATT_SUCCESS -> null + else -> "Ring disconnected (GATT $status)" + }, ) } } diff --git a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt index 104bf33..60e6635 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt @@ -75,6 +75,7 @@ fun PairingScreen( var isLooking by remember { mutableStateOf(false) } var didFireConnected by remember { mutableStateOf(false) } + var showDiagnostics by remember { mutableStateOf(false) } // The adapter flag isn't part of BLEState until a scan attempt, so poll it directly. var btReady by remember { mutableStateOf(bleClient.isBluetoothEnabled) } @@ -119,14 +120,9 @@ fun PairingScreen( previous != RingConnectionState.CONNECTED && !didFireConnected ) { - // Do not navigate away for a link that only survives a few callbacks. Keeping - // this screen visible makes the disconnect trace readable on real hardware. - delay(2_000) - if (bleClient.state.value.connectionState == RingConnectionState.CONNECTED) { - didFireConnected = true - isLooking = false - onConnected() - } + didFireConnected = true + isLooking = false + onConnected() } previous = s.connectionState } @@ -209,11 +205,20 @@ fun PairingScreen( modifier = Modifier.fillMaxWidth(), ) } + + if (!connected && (isLooking || state.diagnostics.isNotEmpty())) { + Text( + if (showDiagnostics) "Hide connection details" else "Show connection details", + fontSize = 12.sp, + color = PulseColors.textSecondary, + modifier = Modifier.clickable { showDiagnostics = !showDiagnostics }, + ) + } } - // A fixed-height trace avoids the connect/disconnect layout jump and remains visible - // while the scrollable pairing content changes underneath it. - if (isLooking || state.diagnostics.isNotEmpty()) { + // Raw transport detail remains available for device bring-up without occupying the + // normal pairing surface or delaying successful navigation. + if (showDiagnostics && !connected && (isLooking || state.diagnostics.isNotEmpty())) { val trace = buildList { addAll(state.diagnostics.takeLast(4)) state.lastError?.let { if (it !in this) add(it) } From fe484631e750fd71502ebc45078924e6f9ba04b9 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Fri, 17 Jul 2026 17:09:58 +0200 Subject: [PATCH 26/36] fix: harden GATT failure and live event handling --- .../java/com/pulseloop/ring/ColmiDecoder.kt | 6 +++- .../java/com/pulseloop/ring/RingBLEClient.kt | 33 +++++++++++++++++-- .../java/com/pulseloop/ring/YCBTProtocol.kt | 3 ++ .../com/pulseloop/ring/ColmiDecoderTest.kt | 17 ++++++++++ .../com/pulseloop/ring/YCBTDecoderTest.kt | 12 +++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt index e558c1b..f3a5f81 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt @@ -179,7 +179,11 @@ object ColmiDecoder { val hrv = v[i].toInt() if (hrv == 0) return@mapNotNull null val ts = base.plusMinutes((minutesInPrevious + (i - startIndex) * slotMin).toLong()).toInstant() - RingDecodedEvent.HrvSample(value = hrv, _timestamp = ts) + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.HRV, + value = hrv.toDouble(), + _timestamp = ts, + ) } } diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 7da164e..364fdb9 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -901,6 +901,14 @@ class RingBLEClient(private val context: Context) { delay(OP_TIMEOUT_MS) if (inFlightOp === op) { Log.w("RingBLEClient", "GATT op ACK timed out — unblocking queue: ${opLabel(op)}") + // Android does not identify a write callback beyond its characteristic. + // Two successive protocol writes use the same characteristic, so a late + // callback for a timed-out write could otherwise retire its successor. + // Reset the link instead of issuing another ambiguous command write. + if (op is GattOp.CommandWrite) { + recoverWedgedLink() + return@launch + } // A lost completion callback leaves the framework slot busy: don't just // free our slot and pump the next op into a still-busy stack (that is the // spin-and-drop loop). Escalate to a reconnect once enough pile up. @@ -927,6 +935,12 @@ class RingBLEClient(private val context: Context) { return } Log.w("RingBLEClient", "GATT op dropped after $MAX_OP_ATTEMPTS attempts: ${opLabel(op)}") + if (op is GattOp.DescriptorWrite && + subscriptionGate?.isRequired(op.descriptor.characteristic.uuid.toString()) == true + ) { + failConnectAttempt("Could not enable required ring indication") + return + } // The slot may be wedged — escalate to a reconnect rather than pump the next op into // the same busy stack (the tear-down aborts the loop). if (noteOpFailureAndMaybeRecover()) return @@ -1076,9 +1090,10 @@ class RingBLEClient(private val context: Context) { } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (gatt !== bluetoothGatt) return // stale callback from a superseded connection recordDiagnostic("services status=$status count=${gatt.services.size}") if (status != BluetoothGatt.GATT_SUCCESS) { - updateState { copy(lastError = "Service discovery failed (GATT $status)") } + failConnectAttempt("Service discovery failed (GATT $status)") return } @@ -1228,6 +1243,13 @@ class RingBLEClient(private val context: Context) { recordDiagnostic("write ${characteristic.uuid.toString().take(8)} status=$status") if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // GATT ACK — link is alive + if (status != BluetoothGatt.GATT_SUCCESS) { + updateState { copy(lastError = "Ring command write failed (GATT $status)") } + // Do not advance into the next command: the failed operation may be one of the + // required post-subscription handshake writes, and callbacks share a channel. + recoverWedgedLink() + return + } resetOpFailures() // a real completion — the stack is responsive again completeOp { it is GattOp.CommandWrite } } @@ -1245,8 +1267,13 @@ class RingBLEClient(private val context: Context) { if (value.size >= 7) { val systolic = decodeSFLOAT(value[1], value[2]) val diastolic = decodeSFLOAT(value[3], value[4]) - PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, systolic, java.time.Instant.now())) - PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, diastolic, java.time.Instant.now())) + PulseEventBus.publishBlocking( + PulseEvent.BloodPressureSample( + systolic = systolic.toInt(), + diastolic = diastolic.toInt(), + timestamp = java.time.Instant.now(), + ) + ) } return } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt index c3ba232..3b993e0 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt @@ -103,6 +103,9 @@ object YCBTBytes { val localDateTime = Instant.ofEpochSecond(ringSeconds.toLong() + EPOCH_OFFSET) .atOffset(ZoneOffset.UTC) .toLocalDateTime() + // A fall-back overlap is inherently ambiguous because the ring stores no UTC offset. + // java.time deliberately chooses the earlier valid offset, giving us a deterministic + // policy that matches normal LocalDateTime.atZone behavior rather than today's offset. return localDateTime.atZone(timeZone.toZoneId()).toInstant() } diff --git a/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt index 2fe8052..0486c85 100644 --- a/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt @@ -15,6 +15,23 @@ import java.time.ZoneOffset class ColmiDecoderTest { private val zone = ZoneOffset.UTC + @Test + fun `HRV history remains distinct from a live HRV sample`() { + val frame = ColmiPacket.frame(byteArrayOf( + ColmiCommandID.SYNC_HRV.toByte(), 0x01, 0x1e, 42, + )) + + val events = ColmiDecoder.decodeHistory( + frame, + day = LocalDate.of(2026, 7, 17), + zone = zone, + ) + + val sample = events.single() as RingDecodedEvent.HistoryMeasurement + assertEquals(MeasurementKind.HRV, sample.kind_field) + assertEquals(42.0, sample.value, 0.0) + } + // MARK: Framing / checksum @Test diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt index 4b4fe5a..02b5c47 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTDecoderTest.kt @@ -186,6 +186,18 @@ class YCBTDecoderTest { assertEquals(after, YCBTBytes.date(YCBTBytes.ringSeconds(after, tz), tz)) } + @Test + fun `fall DST overlap deterministically chooses the earlier offset`() { + val tz = TimeZone.getTimeZone("America/New_York") + val firstOccurrence = Instant.parse("2026-11-01T05:30:00Z") + val secondOccurrence = Instant.parse("2026-11-01T06:30:00Z") + val encoded = YCBTBytes.ringSeconds(firstOccurrence, tz) + + // The ring cannot represent which 01:30 occurrence it meant: both encode identically. + assertEquals(encoded, YCBTBytes.ringSeconds(secondOccurrence, tz)) + assertEquals(firstOccurrence, YCBTBytes.date(encoded, tz)) + } + @Test fun `u24 reads three bytes little endian`() { assertEquals(72000, YCBTBytes.u24(byteArrayOf(0x40, 0x19, 0x01), 0)) From aefa6468bf12bbe4a1ef76c9a4a9190c7a1e4e4d Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Sat, 18 Jul 2026 10:47:57 +0200 Subject: [PATCH 27/36] fix: stabilize YCBT sleep and activity refresh --- .../java/com/pulseloop/data/DataRepairs.kt | 7 ++----- .../com/pulseloop/ring/YCBTHealthRecords.kt | 10 ++++----- .../java/com/pulseloop/ring/YCBTSyncEngine.kt | 3 +++ .../service/EventPersistenceSubscriber.kt | 21 +++++++------------ .../pulseloop/ring/YCBTHealthRecordsTest.kt | 11 ++++------ .../com/pulseloop/ring/YCBTSyncEngineTest.kt | 6 ++++-- 6 files changed, 25 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/pulseloop/data/DataRepairs.kt b/app/src/main/java/com/pulseloop/data/DataRepairs.kt index 4d66300..e6e65fd 100644 --- a/app/src/main/java/com/pulseloop/data/DataRepairs.kt +++ b/app/src/main/java/com/pulseloop/data/DataRepairs.kt @@ -23,11 +23,8 @@ object DataRepairs { * window are additionally self-healed by `applyActivityBucket` on the next sync. Today's * row is out of scope — the live cumulative total re-ratchets on the next update. * - * No sleep counterpart is needed: sleep_sessions AND sleep_stage_blocks are cleared and - * rebuilt from the ring on every connect (see EventPersistenceSubscriber's CONNECTED - * handling), so rows keyed by the old start-of-day grouping — sessions split across - * midnight and stage blocks filed under what is now a different night's id — disappear - * on the first sync after this update. + * Sleep is repaired independently by idempotent waking-day upserts. Connection events must + * not clear real sleep: replacement history is asynchronous and may be empty or consumed. */ suspend fun runIfNeeded(context: Context, db: PulseLoopDatabase = PulseLoopDatabase.getInstance(context)) { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt index dc75623..670236a 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt @@ -78,12 +78,10 @@ object YCBTHealthRecords { val events = mutableListOf() for (r in records(buffer, 20)) { val ts = YCBTBytes.date(YCBTBytes.u32(r, 0)) - events.add(RingDecodedEvent.ActivityUpdate( - _timestamp = ts, - steps = YCBTBytes.u16(r, 4), - distanceMeters = 0, - calories = 0, - )) + // 0x09 is vitals history, not the activity source of truth. Its adjacent step field + // can lag/reset differently and arrives late in the refresh pipeline; routing it as + // a live cumulative ActivityUpdate made today's steps jump to stale values. Activity + // comes from 0x02 sport buckets plus 0x06/00 live status instead. events.addAll(bloodPressureEvents(systolic = r[7].toInt() and 0xFF, diastolic = r[8].toInt() and 0xFF, timestamp = ts)) if (r[9].toInt() and 0xFF > 0) { events.add(RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.SPO2, value = (r[9].toInt() and 0xFF).toDouble(), _timestamp = ts)) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index 1c4918e..51f3b41 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -35,6 +35,9 @@ class YCBTSyncEngine( } override fun refresh() { + // Ask for current cumulative activity before the slower multi-type history walk. Without + // this, pull-to-refresh can leave steps stale until an unsolicited live push arrives. + writer?.enqueue(encoder.enableLiveStatus()) transfer.start(types = HISTORY_TYPES) } diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 98d9c0c..3a14112 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -73,14 +73,13 @@ class EventPersistenceSubscriber( RingConnectionState.CONNECTED -> { db.measurementDao().clearDemo() db.activityDailyDao().clearDemo() - // Sessions rebuild from the ring on every connect. Blocks must be wiped - // WITH them (no FK cascade at runtime): stale blocks keyed under an id a - // rebuilt session reuses — e.g. rows from the pre-waking-day keying, which - // filed a night's pre-midnight packets under what is now a different - // night's id — would otherwise be merged into that night by - // upsertSleepSession and corrupt its span and score. - db.sleepStageBlockDao().clear() - db.sleepSessionDao().clear() + // Never clear real sleep on CONNECTED. YCBT emits Status/CONNECTED more + // than once during startup, and history arrives asynchronously; clearing + // here made a complete night flash, partially rebuild, then disappear. + // Sleep upserts are timestamp-idempotent, so preserve the last good local + // copy until replacement packets arrive. + db.sleepStageBlockDao().clearDemo() + db.sleepSessionDao().clearDemo() "CONNECTED" } RingConnectionState.DISCONNECTED -> "DISCONNECTED" @@ -327,11 +326,7 @@ class EventPersistenceSubscriber( val score = computeSleepScore(deepMin, totalMin) // Upsert the parent session BEFORE its stage blocks. sleep_stage_blocks has a foreign key - // to sleep_sessions(id), and both tables are cleared on every connect (see - // DeviceStateChanged), so the first packet of a night finds no parent row: inserting blocks - // first throws FOREIGN KEY constraint failed, persist() swallows it, and the whole night is - // silently dropped. Parent-first matches DemoDataSeeder — which is exactly why demo sleep - // persists while real ring sleep did not. + // to sleep_sessions(id), so a newly discovered night needs its parent first. val existing = db.sleepSessionDao().ringByDay(dayStart) db.sleepSessionDao().upsert( (existing ?: SleepSessionEntity(id = sessionId, date = dayStart, startAt = sessionStart, endAt = sessionEnd, totalMinutes = totalMin, score = score)).copy( diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt index c22d2ac..4955863 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt @@ -60,16 +60,14 @@ class YCBTHealthRecordsTest { } @Test - fun `combined vitals decodes steps BP SpO2 and HRV`() { + fun `combined vitals decodes BP SpO2 and HRV without activity`() { val events = YCBTHealthRecords.combinedVitals(capturedAllRecords) assertEquals(listOf(98.0, 97.0, 97.0, 96.0, 97.0, 96.0, 97.0, 95.0), values(MeasurementKind.SPO2, events)) assertEquals(listOf(52.0, 43.0, 177.0, 95.0, 33.0, 33.0, 61.0, 128.0), values(MeasurementKind.HRV, events)) assertEquals(listOf(115.0, 112.0, 112.0, 109.0, 111.0, 111.0, 110.0, 106.0), values(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, events)) assertEquals(70.0, values(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, events).last(), 0.001) assertFalse(events.any { it is RingDecodedEvent.BloodPressureSample }) - - val steps = events.mapNotNull { if (it is RingDecodedEvent.ActivityUpdate) it.steps else null } - assertEquals(3336, steps.max()) + assertFalse(events.any { it is RingDecodedEvent.ActivityUpdate }) } @Test @@ -93,10 +91,9 @@ class YCBTHealthRecordsTest { } @Test - fun `unworn combined vitals record yields steps only`() { + fun `unworn combined vitals record produces no activity`() { val events = YCBTHealthRecords.combinedVitals(bytes("1cf0de31080d4700000000000000000000000000")) - assertEquals(1, events.size) - assertTrue(events.first() is RingDecodedEvent.ActivityUpdate) + assertTrue(events.isEmpty()) } @Test diff --git a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt index fa6013f..c127913 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt @@ -17,10 +17,12 @@ class YCBTSyncEngineTest { } @Test - fun `full refresh begins with sport history`() { + fun `full refresh requests live status before sport history`() { val writer = FakeWriter() engine(writer).refresh() - assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent.single()) + assertEquals(2, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[1]) } @Test From f25cb462eed04f9d9366c5ded94140328b20c29a Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Sat, 18 Jul 2026 10:52:16 +0200 Subject: [PATCH 28/36] test: align YCBT refresh expectations --- .../test/java/com/pulseloop/ring/YCBTDriverTest.kt | 14 +++++++++----- .../com/pulseloop/ring/YCBTHealthRecordsTest.kt | 2 +- .../com/pulseloop/ring/YCBTHistoryTransferTest.kt | 6 +++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt index fb2dcf5..24aa963 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt @@ -69,17 +69,21 @@ class YCBTDriverTest { val engine = driver.makeSyncEngine() engine.refresh() - assertEquals(1, writer.sent.size) - assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) + assertEquals(2, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[1]) writer.sent.clear() engine.refresh() - assertTrue(writer.sent.isEmpty()) + assertEquals(1, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) driver.connectionDidEnd() + writer.sent.clear() engine.refresh() - assertEquals(1, writer.sent.size) - assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) + assertEquals(2, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[1]) } @Test diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt index 4955863..01fc8e9 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt @@ -252,7 +252,7 @@ class YCBTHealthRecordsTest { assertEquals(1, YCBTHealthRecords.decode(sport, YCBTHistoryType.SPORT).size) assertEquals(2, YCBTHealthRecords.decode(temperature, YCBTHistoryType.TEMPERATURE).size) assertEquals(4, YCBTHealthRecords.decode(capturedBodyRecord, YCBTHistoryType.BODY_DATA).size) - assertEquals(8 * 6, YCBTHealthRecords.decode(capturedAllRecords, YCBTHistoryType.ALL).size) + assertEquals(8 * 5, YCBTHealthRecords.decode(capturedAllRecords, YCBTHistoryType.ALL).size) assertFalse(YCBTHealthRecords.decode(capturedNight, YCBTHistoryType.SLEEP).isEmpty()) assertFalse(YCBTHealthRecords.decode(capturedHeartRecords, YCBTHistoryType.HEART).isEmpty()) } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt index d48f0f3..e04cc14 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt @@ -178,9 +178,9 @@ class YCBTHistoryTransferTest { val engine = YCBTSyncEngine(writer = writer, transfer = transfer) engine.refresh() - assertEquals(1, writer.sent.size) - assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[0]) - assertTrue(writer.sent.all { it[0] == 0x05.toByte() }) + assertEquals(2, writer.sent.size) + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent[0]) + assertArrayEquals(byteArrayOf(0x05, 0x02), writer.sent[1]) } @Test From 65fddc3354b6cfcbc2a9d5e9d67887ba6749a47a Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Sat, 18 Jul 2026 11:25:51 +0200 Subject: [PATCH 29/36] fix: persist sleep stages atomically --- .../java/com/pulseloop/service/EventPersistenceSubscriber.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 3a14112..31b2746 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -1,5 +1,6 @@ package com.pulseloop.service +import androidx.room.withTransaction import com.pulseloop.data.PulseLoopDatabase import com.pulseloop.data.entity.* import com.pulseloop.ring.* @@ -303,6 +304,10 @@ class EventPersistenceSubscriber( private suspend fun upsertSleepSession(ts: Long, stages: List) { if (stages.isEmpty()) return + db.withTransaction { upsertSleepSessionAtomic(ts, stages) } + } + + private suspend fun upsertSleepSessionAtomic(ts: Long, stages: List) { // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so // a night that starts before midnight lands under the morning of waking instead of being // split into two sessions at midnight. Matches the iOS reference From 3b648c99e9922316b65c582e492ffbe0acaf5f83 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Sat, 18 Jul 2026 21:04:41 +0200 Subject: [PATCH 30/36] fix: refresh YCBT activity after reconnect sync --- .../java/com/pulseloop/ring/YCBTSyncEngine.kt | 11 ++++++++++- .../com/pulseloop/ring/YCBTSyncEngineTest.kt | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index 51f3b41..7d6a6b4 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -13,6 +13,7 @@ class YCBTSyncEngine( private var measurementSettings = MeasurementSettings.ALL_ON_DEFAULT private var userProfile = UserProfileValues(metric = true, gender = 0x02u, age = 25u, heightCm = 175u, weightKg = 70u) + private var requestActivityAfterStartupHistory = false companion object { private val HISTORY_TYPES: List = listOf( @@ -24,6 +25,7 @@ class YCBTSyncEngine( } override fun runStartup() { + requestActivityAfterStartupHistory = true for (command in encoder.startupSequence(measurement = measurementSettings, profile = userProfile)) { writer?.enqueue(command) } @@ -31,7 +33,14 @@ class YCBTSyncEngine( } override fun handle(event: RingDecodedEvent) { - // History is protocol-driven now — nothing here advances it. + // The early startup command enables live status while the ring is still processing its + // connect handshake. Some R10M firmware acknowledges it without immediately publishing + // the current cumulative activity. Ask once more after the startup history walk, when the + // connection is settled, so reconnect updates steps without requiring pull-to-refresh. + if (event is RingDecodedEvent.HistorySyncFinished && requestActivityAfterStartupHistory) { + requestActivityAfterStartupHistory = false + writer?.enqueue(encoder.enableLiveStatus()) + } } override fun refresh() { diff --git a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt index c127913..e88bc34 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt @@ -54,4 +54,20 @@ class YCBTSyncEngineTest { assertFalse(startup.any { it[0] == YCBTGroup.SETTING.toByte() && it[1] == YCBTSettingKey.SET_TIME.toByte() }) assertFalse(startup.any { it[0] == YCBTGroup.GET.toByte() && it[1] == YCBTCommand.GET_DEVICE_NAME.toByte() }) } + + @Test + fun `startup requests current activity again when history finishes`() { + val writer = FakeWriter() + val engine = engine(writer) + + engine.runStartup() + writer.sent.clear() + engine.handle(RingDecodedEvent.HistorySyncFinished) + + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent.single()) + + writer.sent.clear() + engine.handle(RingDecodedEvent.HistorySyncFinished) + assertTrue(writer.sent.isEmpty()) + } } From 82d1ef906f3a6d6494c944b13c79fa051db0e0c6 Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Sat, 18 Jul 2026 21:17:30 +0200 Subject: [PATCH 31/36] fix: refresh vitals after committed measurements --- app/src/main/java/com/pulseloop/data/dao/Daos.kt | 4 ++++ .../main/java/com/pulseloop/ui/viewmodels/ViewModels.kt | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index f95ff50..d217240 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -41,6 +41,10 @@ interface DeviceDao { @Dao interface MeasurementDao { + /** Invalidates after committed measurement inserts; consumers can debounce bursty history. */ + @Query("SELECT COUNT(*) FROM measurements") + fun changeFlow(): Flow + @Query("SELECT * FROM measurements WHERE kindRaw = :kind AND timestamp BETWEEN :start AND :end ORDER BY timestamp ASC") suspend fun range(kind: String, start: Long, end: Long): List diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt index 8620afd..0f71a44 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -375,6 +375,7 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { * Ported from MetricsService.metricRange in PulseServices.swift. * Uses reactive polling so data appears as soon as the ring syncs. */ +@OptIn(kotlinx.coroutines.FlowPreview::class) class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore? = null) : ViewModel() { data class VitalsState( val hrSamples: List = emptyList(), @@ -429,6 +430,14 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore val state: StateFlow = _state.asStateFlow() init { + // Room is the display source of truth. Rebuild after inserts commit so a manual reading + // cannot lose a race between the coordinator seeing its event and the persistence + // subscriber writing it. Debounce coalesces large history batches into one rebuild. + viewModelScope.launch { + db.measurementDao().changeFlow().debounce(250).collect { + try { refresh(db) } catch (_: Exception) {} + } + } // Poll every 5 seconds so data appears as the ring syncs history viewModelScope.launch { while (true) { From 80082ae80a89331541bec461f5d7170de898b90a Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Mon, 20 Jul 2026 13:14:58 +0200 Subject: [PATCH 32/36] fix: harden YCBT sync persistence --- .../summaries/CoachSummaryCoordinator.kt | 1 + .../java/com/pulseloop/data/DataRepairs.kt | 5 +- .../com/pulseloop/data/PulseLoopDatabase.kt | 31 ++- .../main/java/com/pulseloop/data/dao/Daos.kt | 41 ++++ .../com/pulseloop/data/entity/CoreEntities.kt | 7 +- .../java/com/pulseloop/ring/PulseEventBus.kt | 31 ++- .../java/com/pulseloop/ring/RingBLEClient.kt | 184 ++++++++++-------- .../com/pulseloop/ring/RingDecodedEvent.kt | 5 +- .../com/pulseloop/ring/RingEventBridge.kt | 36 +++- .../com/pulseloop/ring/YCBTCoordinator.kt | 3 +- .../java/com/pulseloop/ring/YCBTDriver.kt | 67 ++++++- .../java/com/pulseloop/ring/YCBTEncoder.kt | 22 ++- .../com/pulseloop/ring/YCBTHealthRecords.kt | 21 +- .../com/pulseloop/ring/YCBTHistoryTransfer.kt | 40 +++- .../java/com/pulseloop/ring/YCBTSyncEngine.kt | 73 ++++++- .../service/EventPersistenceSubscriber.kt | 149 +++++++++++--- .../pulseloop/service/RingSyncCoordinator.kt | 3 +- .../java/com/pulseloop/ui/PulseLoopApp.kt | 15 ++ .../com/pulseloop/ui/screens/DebugScreen.kt | 3 + .../ui/screens/SettingsSubScreens.kt | 74 ++++--- .../ui/viewmodels/CurrentDayValues.kt | 11 ++ .../com/pulseloop/ui/viewmodels/ViewModels.kt | 28 ++- .../main/java/com/pulseloop/util/TimeUtil.kt | 11 ++ .../com/pulseloop/wearables/WearableModel.kt | 2 +- .../com/pulseloop/ring/PairingMatchingTest.kt | 7 + .../com/pulseloop/ring/PulseEventBusTest.kt | 28 +++ .../com/pulseloop/ring/RingEventBridgeTest.kt | 26 +++ .../java/com/pulseloop/ring/YCBTDriverTest.kt | 56 ++++++ .../com/pulseloop/ring/YCBTEncoderTest.kt | 47 +++-- .../pulseloop/ring/YCBTHealthRecordsTest.kt | 28 ++- .../pulseloop/ring/YCBTHistoryTransferTest.kt | 128 +++++++++++- .../com/pulseloop/ring/YCBTProtocolTest.kt | 22 +++ .../com/pulseloop/ring/YCBTSyncEngineTest.kt | 106 ++++++++++ .../service/EventPersistenceIdentityTest.kt | 105 ++++++++++ .../ui/viewmodels/CurrentDayValuesTest.kt | 41 ++++ .../java/com/pulseloop/util/TimeUtilTest.kt | 18 ++ 36 files changed, 1260 insertions(+), 215 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/ui/viewmodels/CurrentDayValues.kt create mode 100644 app/src/test/java/com/pulseloop/ring/PulseEventBusTest.kt create mode 100644 app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt create mode 100644 app/src/test/java/com/pulseloop/ui/viewmodels/CurrentDayValuesTest.kt diff --git a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt index d2dfe55..f295e6c 100644 --- a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt +++ b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt @@ -49,6 +49,7 @@ class CoachSummaryCoordinator( is PulseEvent.ActivityUpdate, is PulseEvent.HeartRateSample, is PulseEvent.Spo2Result, + is PulseEvent.BloodSugarSample, is PulseEvent.HistoryMeasurement -> { pendingToday = true scheduleRefresh() diff --git a/app/src/main/java/com/pulseloop/data/DataRepairs.kt b/app/src/main/java/com/pulseloop/data/DataRepairs.kt index e6e65fd..5601ea4 100644 --- a/app/src/main/java/com/pulseloop/data/DataRepairs.kt +++ b/app/src/main/java/com/pulseloop/data/DataRepairs.kt @@ -23,8 +23,9 @@ object DataRepairs { * window are additionally self-healed by `applyActivityBucket` on the next sync. Today's * row is out of scope — the live cumulative total re-ratchets on the next update. * - * Sleep is repaired independently by idempotent waking-day upserts. Connection events must - * not clear real sleep: replacement history is asynchronous and may be empty or consumed. + * Complete ring sleep records independently replace their waking-day session and remove any + * overlapping legacy midnight-split parents. Connection events must not clear real sleep: + * replacement history is asynchronous and may be empty or consumed. */ suspend fun runIfNeeded(context: Context, db: PulseLoopDatabase = PulseLoopDatabase.getInstance(context)) { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index e1414c3..f07ab83 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -38,7 +38,7 @@ import com.pulseloop.data.entity.* CoachSummaryEntity::class, WearableLogEntity::class, ], - version = 7, + version = 8, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -128,6 +128,26 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** v7 → v8: make sensor history reconnect-safe and remove already duplicated rows. */ + private val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + DELETE FROM `measurements` + WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM `measurements` + GROUP BY `kindRaw`, `timestamp`, `sourceRaw` + ) + """.trimIndent() + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_measurements_kindRaw_timestamp_sourceRaw` " + + "ON `measurements` (`kindRaw`, `timestamp`, `sourceRaw`)" + ) + } + } + fun getInstance(context: Context): PulseLoopDatabase = INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( @@ -135,7 +155,14 @@ abstract class PulseLoopDatabase : RoomDatabase() { PulseLoopDatabase::class.java, "pulseloop.db" ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7) + .addMigrations( + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8, + ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and // coach conversation on any future version bump that misses a diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index d217240..0809a9b 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -62,6 +62,41 @@ interface MeasurementDao { @Insert suspend fun insert(measurement: MeasurementEntity) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertByIdentity(measurement: MeasurementEntity): Long + + @Query(""" + UPDATE measurements + SET value = :value, unit = :unit, confidenceRaw = :confidenceRaw, + activitySessionId = :activitySessionId, rawPacketId = :rawPacketId + WHERE kindRaw = :kindRaw AND timestamp = :timestamp AND sourceRaw = :sourceRaw + """) + suspend fun updateByIdentity( + kindRaw: String, + timestamp: Long, + sourceRaw: String, + value: Double, + unit: String, + confidenceRaw: String, + activitySessionId: String?, + rawPacketId: String?, + ): Int + + @Transaction + suspend fun upsertByIdentity(measurement: MeasurementEntity) { + val updated = updateByIdentity( + kindRaw = measurement.kindRaw, + timestamp = measurement.timestamp, + sourceRaw = measurement.sourceRaw, + value = measurement.value, + unit = measurement.unit, + confidenceRaw = measurement.confidenceRaw, + activitySessionId = measurement.activitySessionId, + rawPacketId = measurement.rawPacketId, + ) + if (updated == 0) insertByIdentity(measurement) + } + @Query("DELETE FROM measurements WHERE sourceRaw = 'demo'") suspend fun clearDemo() @@ -183,6 +218,9 @@ interface SleepSessionDao { @Query("SELECT * FROM sleep_sessions WHERE date = :day AND sourceRaw != 'demo' LIMIT 1") suspend fun ringByDay(day: Long): SleepSessionEntity? + @Query("SELECT * FROM sleep_sessions WHERE sourceRaw != 'demo' AND startAt < :end AND endAt > :start") + suspend fun ringOverlapping(start: Long, end: Long): List + @Query("SELECT * FROM sleep_sessions ORDER BY date DESC LIMIT :limit") suspend fun recent(limit: Int = 7): List @@ -195,6 +233,9 @@ interface SleepSessionDao { @Upsert suspend fun upsert(session: SleepSessionEntity) + @Query("DELETE FROM sleep_sessions WHERE id = :id") + suspend fun deleteById(id: String) + @Query("DELETE FROM sleep_sessions") suspend fun clear() diff --git a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt index 0dbe1ad..95b88a5 100644 --- a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt @@ -41,7 +41,12 @@ data class DeviceEntity( */ @Entity( tableName = "measurements", - indices = [Index("timestamp"), Index("activitySessionId"), Index("kindRaw")], + indices = [ + Index("timestamp"), + Index("activitySessionId"), + Index("kindRaw"), + Index(value = ["kindRaw", "timestamp", "sourceRaw"], unique = true), + ], ) data class MeasurementEntity( @PrimaryKey val id: String = java.util.UUID.randomUUID().toString(), diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index 97207d7..243744c 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -1,8 +1,13 @@ package com.pulseloop.ring +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch /** * Ported from [PulseEvent] in PulseEventBus.swift. @@ -39,12 +44,22 @@ sealed class PulseEvent { data class Spo2Complete(val timestamp: java.time.Instant) : PulseEvent() /** A live measurement command was refused (not worn, sensor busy, unsupported). */ data class MeasurementRejected(val mode: Int) : PulseEvent() - data class BloodPressureSample(val systolic: Int, val diastolic: Int, val timestamp: java.time.Instant) : PulseEvent() + data class BloodPressureSample( + val systolic: Int, + val diastolic: Int, + val timestamp: java.time.Instant, + val isHistory: Boolean = false, + ) : PulseEvent() + data class BloodSugarSample(val mgdl: Double, val timestamp: java.time.Instant) : PulseEvent() data class HistoryMeasurement(val kind: MeasurementKind, val value: Double, val timestamp: java.time.Instant) : PulseEvent() data class StressSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() data class HrvSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() data class TemperatureSample(val celsius: Double, val timestamp: java.time.Instant) : PulseEvent() - data class SleepTimeline(val timestamp: java.time.Instant, val stages: List) : PulseEvent() + data class SleepTimeline( + val timestamp: java.time.Instant, + val stages: List, + val completeSession: Boolean = false, + ) : PulseEvent() data class SyncProgress(val stage: String) : PulseEvent() data class FirmwareVersion(val version: Int?) : PulseEvent() } @@ -60,13 +75,21 @@ enum class PacketDirection { INCOMING, OUTGOING } object PulseEventBus { private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 256) val events: SharedFlow = _events.asSharedFlow() + private val pending = Channel(capacity = Channel.UNLIMITED) + private val dispatchScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + init { + dispatchScope.launch { + for (event in pending) _events.emit(event) + } + } suspend fun publish(event: PulseEvent) { - _events.emit(event) + pending.send(event) } /** Non-suspending publish for use from non-coroutine contexts (callbacks). */ fun publishBlocking(event: PulseEvent) { - _events.tryEmit(event) + check(pending.trySend(event).isSuccess) { "Pulse event queue is closed" } } } diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 364fdb9..d41890c 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.util.UUID /** @@ -112,7 +113,9 @@ class RingBLEClient(private val context: Context) { private var activeAdvertisedName: String? = null // Set while a "Forget" is waiting for the ring's UNBOND_ACK (0x4B) before teardown. - private var forgetPending = false + private val forgetLock = Any() + @Volatile private var forgetPending = false + @Volatile private var forgetFinalizing = false private var forgetJob: Job? = null // MARK: GATT operation serialization @@ -433,6 +436,7 @@ class RingBLEClient(private val context: Context) { private fun failConnectAttempt(reason: String) { val gatt = bluetoothGatt bluetoothGatt = null + activeDriver?.connectionDidEnd() writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null subscriptionGate = null resetOpQueue() @@ -460,6 +464,7 @@ class RingBLEClient(private val context: Context) { scanner?.stopScan(scanCallback) val gatt = bluetoothGatt bluetoothGatt = null + activeDriver?.connectionDidEnd() writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null resetOpQueue() if (gatt != null) { @@ -534,12 +539,15 @@ class RingBLEClient(private val context: Context) { val gatt = bluetoothGatt if (gatt != null && writeChar != null && _state.value.connectionState == RingConnectionState.CONNECTED) { - forgetPending = true + synchronized(forgetLock) { + forgetPending = true + forgetFinalizing = false + } enqueueWrite(RingEncoder.makeUnbindCommand()) // 0x4B 05 00 01 forgetJob?.cancel() forgetJob = scope.launch { delay(UNBIND_ACK_TIMEOUT_MS) - if (forgetPending) { + if (synchronized(forgetLock) { forgetPending && !forgetFinalizing }) { Log.w("RingBLEClient", "Unbind ACK not received in ${UNBIND_ACK_TIMEOUT_MS}ms — forcing teardown") finalizeForget() } @@ -551,10 +559,14 @@ class RingBLEClient(private val context: Context) { /** Tear down the link: clear the GATT cache, remove any OS bond, close the GATT. */ private fun finalizeForget() { - forgetPending = false + synchronized(forgetLock) { + if (forgetFinalizing) return + forgetFinalizing = true + } forgetJob?.cancel(); forgetJob = null stopKeepalive() scanner?.stopScan(scanCallback) + activeDriver?.connectionDidEnd() bluetoothGatt?.let { gatt -> try { gatt::class.java.getMethod("refresh").invoke(gatt) } catch (_: Exception) {} try { gatt.device::class.java.getMethod("removeBond").invoke(gatt.device) } catch (_: Exception) {} @@ -580,6 +592,10 @@ class RingBLEClient(private val context: Context) { ) } PulseEventBus.publishBlocking(PulseEvent.DeviceForgotten) + synchronized(forgetLock) { + forgetPending = false + forgetFinalizing = false + } } fun enqueueWrite(data: ByteArray) { @@ -923,10 +939,22 @@ class RingBLEClient(private val context: Context) { // characteristic that went away). Retry a couple of times after a short // pause before giving up — dropping outright could lose a CCCD write that // gates CONNECTED, or a queued factory-reset/unbind command. - synchronized(opLock) { if (inFlightOp === op) inFlightOp = null } - if (op.attempts < MAX_OP_ATTEMPTS - 1) { - op.attempts++ - synchronized(opLock) { opQueue.addFirst(op) } + var retrying = false + val stillCurrent = synchronized(opLock) { + if (inFlightOp !== op || bluetoothGatt !== gatt) { + false + } else { + inFlightOp = null + if (op.attempts < MAX_OP_ATTEMPTS - 1) { + op.attempts++ + opQueue.addFirst(op) + retrying = true + } + true + } + } + if (!stillCurrent) return + if (retrying) { Log.w("RingBLEClient", "GATT op rejected at issue — retrying (${op.attempts}/$MAX_OP_ATTEMPTS): ${opLabel(op)}") scope.launch { delay(OP_RETRY_DELAY_MS) @@ -971,14 +999,15 @@ class RingBLEClient(private val context: Context) { } private inline fun updateState(crossinline update: BLEState.() -> BLEState) { - _state.value = _state.value.update() + _state.update { it.update() } } @Synchronized private fun recordDiagnostic(message: String) { Log.i("RingBLEClient", message) - val current = _state.value - _state.value = current.copy(diagnostics = (current.diagnostics + message).takeLast(6)) + _state.update { current -> + current.copy(diagnostics = (current.diagnostics + message).takeLast(6)) + } } // MARK: Scan callback @@ -1101,23 +1130,7 @@ class RingBLEClient(private val context: Context) { val serviceUuids = gatt.services.map { it.uuid.toString() } android.util.Log.i("RingBLEClient", "Services: ${serviceUuids.joinToString(", ")}") - // Store service list in a log event for export - scope.launch(Dispatchers.IO) { - try { - val db = com.pulseloop.data.PulseLoopDatabase.getInstance(context.applicationContext) - val device = db.deviceDao().current() - if (device != null) { - // Replace (never append) the diagnostic suffix: appending once per - // discovery grew the row by one "|services:…" per reconnect — a - // user export showed ~98 of them. - db.deviceDao().upsert(device.copy( - capabilitiesRaw = device.capabilitiesRaw.substringBefore("|services:") + - "|services:" + serviceUuids.joinToString(","), - updatedAt = System.currentTimeMillis() - )) - } - } catch (_: Exception) {} - } + recordDiagnostic("services ${serviceUuids.joinToString(",")}") val driver = activeDriver ?: return @@ -1257,31 +1270,38 @@ class RingBLEClient(private val context: Context) { override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { + if (gatt !== bluetoothGatt) return // late callback from a superseded connection lastActivityAt = System.currentTimeMillis() // inbound notify — link is alive val value = characteristic.value ?: return val uuid = characteristic.uuid.toString() // Standard BLE health services — read before the ring-service guard if (uuid.startsWith("00002a35")) { - // Blood Pressure Measurement — IEEE 11073 SFLOAT - if (value.size >= 7) { - val systolic = decodeSFLOAT(value[1], value[2]) - val diastolic = decodeSFLOAT(value[3], value[4]) - PulseEventBus.publishBlocking( - PulseEvent.BloodPressureSample( - systolic = systolic.toInt(), - diastolic = diastolic.toInt(), - timestamp = java.time.Instant.now(), + synchronized(forgetLock) { + if (forgetPending) return + // Blood Pressure Measurement — IEEE 11073 SFLOAT + if (value.size >= 7) { + val systolic = decodeSFLOAT(value[1], value[2]) + val diastolic = decodeSFLOAT(value[3], value[4]) + PulseEventBus.publishBlocking( + PulseEvent.BloodPressureSample( + systolic = systolic.toInt(), + diastolic = diastolic.toInt(), + timestamp = java.time.Instant.now(), + ) ) - ) + } } return } if (uuid.startsWith("00002a18")) { - // Glucose Measurement — IEEE 11073 SFLOAT in kg/L → mg/dL - if (value.size >= 12) { - val glucoseKgL = decodeSFLOAT(value[10], value[11]) - PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, glucoseKgL * 100000.0, java.time.Instant.now())) + synchronized(forgetLock) { + if (forgetPending) return + // Glucose Measurement — IEEE 11073 SFLOAT in kg/L → mg/dL + if (value.size >= 12) { + val glucoseKgL = decodeSFLOAT(value[10], value[11]) + PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, glucoseKgL * 100000.0, java.time.Instant.now())) + } } return } @@ -1289,45 +1309,55 @@ class RingBLEClient(private val context: Context) { val driver = activeDriver ?: return if (!driver.notifyUUIDs.any { it == characteristic.uuid.toString() }) return - // Raw seam: reply payloads the decoded-event stream doesn't carry - // (e.g. Colmi pref-read replies seeding the measurement config). - if (!forgetPending) activeSyncEngine?.handleRawNotify(value) - - val decodedEvents = driver.ingest(value, characteristic.uuid.toString()) - for (decoded in decodedEvents) { - // A forget is in flight: don't persist any more data or re-publish a - // "connected" device state (which would re-create the row we're clearing). - // Just watch for the ring's unbind ack (6 = UNBOND_ACK, 3 = ACK_CANCEL). - if (forgetPending) { - if (decoded is RingDecodedEvent.BindNotify && - (decoded.action == 6 || decoded.action == 3)) { - finalizeForget() - } - continue + var shouldFinalizeForget = false + synchronized(forgetLock) { + // Raw seam: reply payloads the decoded-event stream doesn't carry + // (e.g. Colmi pref-read replies seeding the measurement config). + if (!forgetPending) activeSyncEngine?.handleRawNotify(value) + + val decodedEvents = driver.ingest(value, characteristic.uuid.toString()) + if (!forgetPending) { + val diagnostic = decodedEvents.firstOrNull() ?: RingDecodedEvent.Unknown( + commandId = value.firstOrNull()?.toUByte() ?: 0u, + raw = value, + ) + PulseEventBus.publishBlocking( + PulseEvent.RawPacket(PacketDirection.INCOMING, value, diagnostic) + ) } - if (decoded is RingDecodedEvent.SupportFunctions) { - activeCoordinator?.let { coordinator -> - val dynamic = decoded.capabilities.intersect(coordinator.bitmapGatedCapabilities) - val merged = coordinator.capabilities + dynamic - updateState { copy(activeCapabilities = merged) } - PulseEventBus.publishBlocking( - PulseEvent.DeviceIdentified( - deviceType = coordinator.deviceType, - wearableModelID = _state.value.activeWearableModelID, - advertisedName = activeAdvertisedName ?: connectingName, - capabilities = merged, + for (decoded in decodedEvents) { + // A forget is in flight: don't persist any more data or re-publish a + // "connected" device state (which would re-create the row we're clearing). + // Just watch for the ring's unbind ack (6 = UNBOND_ACK, 3 = ACK_CANCEL). + if (forgetPending) { + if (decoded is RingDecodedEvent.BindNotify && + (decoded.action == 6 || decoded.action == 3)) { + shouldFinalizeForget = true + } + continue + } + if (decoded is RingDecodedEvent.SupportFunctions) { + activeCoordinator?.let { coordinator -> + val dynamic = decoded.capabilities.intersect(coordinator.bitmapGatedCapabilities) + val merged = coordinator.capabilities + dynamic + updateState { copy(activeCapabilities = merged) } + PulseEventBus.publishBlocking( + PulseEvent.DeviceIdentified( + deviceType = coordinator.deviceType, + wearableModelID = _state.value.activeWearableModelID, + advertisedName = activeAdvertisedName ?: connectingName, + capabilities = merged, + ) ) - ) + } } + for (event in RingEventBridge.eventsFor(decoded)) { + PulseEventBus.publishBlocking(event) + } + activeSyncEngine?.handle(decoded) } - PulseEventBus.publishBlocking( - PulseEvent.RawPacket(PacketDirection.INCOMING, value, decoded) - ) - for (event in RingEventBridge.eventsFor(decoded)) { - PulseEventBus.publishBlocking(event) - } - activeSyncEngine?.handle(decoded) } + if (shouldFinalizeForget) finalizeForget() } @@ -1502,10 +1532,10 @@ class RingBLEClient(private val context: Context) { watchdogJob?.cancel() stopKeepalive() scanner?.stopScan(scanCallback) + activeDriver?.connectionDidEnd() // The scope is about to die, so the graceful delayed close in disconnect() // would never run — tear the GATT down synchronously instead. bluetoothGatt?.let { gatt -> - activeDriver?.connectionDidEnd() try { gatt.disconnect() } catch (_: Exception) {} closeGattQuietly(gatt) } diff --git a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt index beae824..bb05c93 100644 --- a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt +++ b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt @@ -161,7 +161,9 @@ sealed class RingDecodedEvent { data class SleepTimeline( val _timestamp: Instant, - val stages: List + val stages: List, + /** True when this event is the ring's complete authoritative session, not one packet. */ + val completeSession: Boolean = false, ) : RingDecodedEvent() { override val kind = "sleep_timeline" override val confidence = DecodeConfidence.KNOWN @@ -314,6 +316,7 @@ sealed class RingDecodedEvent { val systolic: Int, val diastolic: Int, val _timestamp: Instant, + val isHistory: Boolean = false, ) : RingDecodedEvent() { override val kind = "blood_pressure_sample" override val confidence = DecodeConfidence.KNOWN diff --git a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt index 718d1ef..f5ffc58 100644 --- a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt +++ b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt @@ -13,6 +13,9 @@ object RingEventBridge { private val stressRange = 1..100 private val hrvRange = 1..300 private val temperatureRange = 30.0..45.0 + private val systolicRange = 60..250 + private val diastolicRange = 30..150 + private val bloodSugarRange = 20.0..600.0 private const val maxBucketSteps = 5000 private const val maxBucketDistance = 6000 @@ -37,7 +40,7 @@ object RingEventBridge { listOf(PulseEvent.Spo2Result(decoded.value, decoded._timestamp)) is RingDecodedEvent.HistoryMeasurement -> { - if (decoded.kind_field == MeasurementKind.HEART_RATE && decoded.value.toInt() !in hrRange) emptyList() + if (!isPlausibleHistoryMeasurement(decoded.kind_field, decoded.value)) emptyList() else listOf(PulseEvent.HistoryMeasurement(decoded.kind_field, decoded.value, decoded._timestamp)) } @@ -64,7 +67,7 @@ object RingEventBridge { is RingDecodedEvent.SleepTimeline -> { if (!isPlausibleSleepStart(decoded._timestamp, now) || decoded.stages.isEmpty()) emptyList() - else listOf(PulseEvent.SleepTimeline(decoded._timestamp, decoded.stages)) + else listOf(PulseEvent.SleepTimeline(decoded._timestamp, decoded.stages, decoded.completeSession)) } is RingDecodedEvent.Battery -> { @@ -100,17 +103,40 @@ object RingEventBridge { emptyList() // Debug-feed only; no product surface yet is RingDecodedEvent.BloodPressureSample -> { - if (decoded.systolic > 0 && decoded.diastolic > 0) { - listOf(PulseEvent.BloodPressureSample(decoded.systolic, decoded.diastolic, decoded._timestamp)) + if (decoded.systolic in systolicRange && decoded.diastolic in diastolicRange) { + listOf( + PulseEvent.BloodPressureSample( + decoded.systolic, + decoded.diastolic, + decoded._timestamp, + decoded.isHistory, + ) + ) } else emptyList() } is RingDecodedEvent.BloodSugarSample -> { - if (decoded.mgdl > 0) listOf(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, decoded.mgdl, decoded._timestamp)) + if (decoded.mgdl in bloodSugarRange) listOf(PulseEvent.BloodSugarSample(decoded.mgdl, decoded._timestamp)) else emptyList() } } + private fun isPlausibleHistoryMeasurement(kind: MeasurementKind, value: Double): Boolean { + if (!value.isFinite()) return false + return when (kind) { + MeasurementKind.HEART_RATE -> value.toInt() in hrRange + MeasurementKind.SPO2 -> value.toInt() in spo2Range + MeasurementKind.STRESS, MeasurementKind.FATIGUE -> value.toInt() in stressRange + MeasurementKind.HRV -> value.toInt() in hrvRange + MeasurementKind.TEMPERATURE -> value in temperatureRange + MeasurementKind.BLOOD_PRESSURE_SYSTOLIC -> value.toInt() in systolicRange + MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> value.toInt() in diastolicRange + MeasurementKind.BLOOD_SUGAR -> value in bloodSugarRange + MeasurementKind.RESPIRATORY_RATE -> value.toInt() in 5..60 + MeasurementKind.VO2MAX -> value.toInt() in 1..100 + } + } + private fun isPlausibleSleepStart(start: Instant, now: Instant): Boolean { val lower = now.minus(8, ChronoUnit.DAYS) val upper = now.plus(1, ChronoUnit.HOURS) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt index 6be5943..25e4435 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt @@ -37,7 +37,6 @@ object YCBTCoordinator : WearableCoordinator { override val capabilities = setOf( WearableCapability.HEART_RATE, WearableCapability.SPO2, - WearableCapability.SPO2_HISTORY, WearableCapability.STEPS, WearableCapability.SLEEP, WearableCapability.REM_SLEEP, @@ -46,7 +45,6 @@ object YCBTCoordinator : WearableCoordinator { WearableCapability.MANUAL_SPO2, WearableCapability.REALTIME_HEART_RATE, WearableCapability.REALTIME_STEPS, - WearableCapability.FIND_DEVICE, WearableCapability.MEASUREMENT_INTERVAL, ) @@ -59,6 +57,7 @@ object YCBTCoordinator : WearableCoordinator { WearableCapability.BLOOD_SUGAR, WearableCapability.HRV, WearableCapability.MANUAL_HRV, + WearableCapability.FIND_DEVICE, ) override val iconSystemName = "circle.circle.fill" diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt index 9b9d811..1199bb3 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt @@ -9,8 +9,13 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { private val decoder = YCBTDecoder() private val encoder = YCBTEncoder() private val assembler = YCBTFrameAssembler() - private val transfer: YCBTHistoryTransfer = YCBTHistoryTransfer(writer = writer) + private var syncEngine: RingSyncEngine? = null + private val transfer: YCBTHistoryTransfer = YCBTHistoryTransfer( + writer = writer, + onOutOfBandEvents = ::handleOutOfBandEvents, + ) private val pendingMeasurementReplies = PendingMeasurementReplies() + private var capabilities = YCBTCoordinator.capabilities override val serviceUUIDs = listOf(YCBTUUIDs.SERVICE) override val writeUUID = YCBTUUIDs.COMMAND @@ -48,12 +53,14 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { assembler.reset() transfer.cancel() pendingMeasurementReplies.clear() + capabilities = YCBTCoordinator.capabilities } override fun connectionDidEnd() { assembler.reset() transfer.cancel() pendingMeasurementReplies.clear() + capabilities = YCBTCoordinator.capabilities } override fun ingest(data: ByteArray, from: String): List { @@ -64,22 +71,31 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { events.add(RingDecodedEvent.Unknown(commandId = logical.firstOrNull()?.toUByte() ?: 0u, raw = logical)) continue } - when (frame.type) { + val decoded = when (frame.type) { YCBTGroup.HEALTH -> { - events.addAll(transfer.handle(cmd = frame.cmd, payload = frame.payload)) + transfer.handle(cmd = frame.cmd, payload = frame.payload) } YCBTGroup.DEV_CONTROL -> { acknowledgePush(frame) - events.addAll(decoder.decode(frame)) + decoder.decode(frame).also { decoded -> + // A real measurement value proves a start succeeded even if its command + // reply was lost. Drop stale FIFO correlation before the next command. + if (frame.cmd == YCBTDevControl.MEASUREMENT_STATUS && + decoded.any { it !is RingDecodedEvent.CommandAck }) { + pendingMeasurementReplies.clear() + } + } } YCBTGroup.APP_CONTROL -> if (frame.cmd == YCBTCommand.LIVE_MEASUREMENT) { val startedMode = pendingMeasurementReplies.consume()?.startedMode - events.addAll(decoder.decode(frame, startedMode = startedMode)) + decoder.decode(frame, startedMode = startedMode) } else { - events.addAll(decoder.decode(frame)) + decoder.decode(frame) } - else -> events.addAll(decoder.decode(frame)) + else -> decoder.decode(frame) } + updateCapabilities(decoded) + events.addAll(decoded.filter(::isSupported)) } return events } @@ -90,7 +106,42 @@ class YCBTDriver(private val writer: RingCommandWriter) : WearableDriver { } override fun makeSyncEngine(): RingSyncEngine { - return YCBTSyncEngine(writer = writer, transfer = transfer) + return YCBTSyncEngine(writer = writer, transfer = transfer).also { syncEngine = it } + } + + private fun updateCapabilities(events: List) { + val support = events.filterIsInstance().lastOrNull() ?: return + capabilities = YCBTCoordinator.capabilities + + support.capabilities.intersect(YCBTCoordinator.bitmapGatedCapabilities) + } + + private fun isSupported(event: RingDecodedEvent): Boolean = when (event) { + is RingDecodedEvent.BloodPressureSample -> WearableCapability.BLOOD_PRESSURE in capabilities + is RingDecodedEvent.BloodSugarSample -> WearableCapability.BLOOD_SUGAR in capabilities + is RingDecodedEvent.HrvSample -> WearableCapability.HRV in capabilities + is RingDecodedEvent.StressSample -> WearableCapability.STRESS in capabilities + is RingDecodedEvent.TemperatureSample -> WearableCapability.TEMPERATURE in capabilities + is RingDecodedEvent.HistoryMeasurement -> when (event.kind_field) { + MeasurementKind.HEART_RATE, MeasurementKind.SPO2 -> true + MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, + MeasurementKind.BLOOD_PRESSURE_DIASTOLIC -> WearableCapability.BLOOD_PRESSURE in capabilities + MeasurementKind.BLOOD_SUGAR -> WearableCapability.BLOOD_SUGAR in capabilities + MeasurementKind.HRV -> WearableCapability.HRV in capabilities + MeasurementKind.STRESS -> WearableCapability.STRESS in capabilities + MeasurementKind.FATIGUE -> WearableCapability.FATIGUE in capabilities + MeasurementKind.TEMPERATURE -> WearableCapability.TEMPERATURE in capabilities + MeasurementKind.RESPIRATORY_RATE, MeasurementKind.VO2MAX -> false + } + else -> true + } + + private fun handleOutOfBandEvents(events: List) { + for (event in events.filter(::isSupported)) { + syncEngine?.handle(event) + for (pulseEvent in RingEventBridge.eventsFor(event)) { + PulseEventBus.publishBlocking(pulseEvent) + } + } } } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt index e7ece27..1355455 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt @@ -112,13 +112,17 @@ class YCBTSettingsEncoder { class YCBTEncoder { private val settings = YCBTSettingsEncoder() - fun setTime(date: Instant = Instant.now()): ByteArray = settings.setTime(date) + fun setTime( + date: Instant = Instant.now(), + timeZone: TimeZone = TimeZone.getDefault(), + ): ByteArray = settings.setTime(date, timeZone) fun startupSequence( measurement: MeasurementSettings = MeasurementSettings.ALL_ON_DEFAULT, profile: UserProfileValues = UserProfileValues(metric = true, gender = 0x02u, age = 25u, heightCm = 175u, weightKg = 70u), languageCode: Int = 0, is24Hour: Boolean = true, + capabilities: Set = YCBTCoordinator.capabilities, ): List { val seq = mutableListOf() seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_DEVICE_INFO, byteArrayOf(0x47, 0x43))) @@ -129,7 +133,7 @@ class YCBTEncoder { seq.add(logical(YCBTGroup.GET, YCBTCommand.GET_USER_CONFIG, byteArrayOf(0x43, 0x46))) seq.add(settings.language(languageCode)) seq.add(settings.units(metric = profile.metric, is24Hour = is24Hour)) - seq.addAll(settings.monitorCommands(measurement)) + seq.addAll(monitorCommands(measurement, capabilities)) seq.add(settings.userInfo(profile)) seq.add(enableLiveStatus()) return seq @@ -139,8 +143,18 @@ class YCBTEncoder { fun postSubscriptionHandshake(date: Instant = Instant.now()): List = listOf(deviceNameRequest(), setTime(date)) - fun monitorCommands(measurement: MeasurementSettings): List = - settings.monitorCommands(measurement) + fun monitorCommands( + measurement: MeasurementSettings, + capabilities: Set = YCBTCoordinator.capabilities, + ): List = settings.monitorCommands(measurement).filter { command -> + when (command[1].toInt() and 0xFF) { + YCBTSettingKey.HEART_MONITOR -> WearableCapability.HEART_RATE in capabilities + YCBTSettingKey.TEMPERATURE_MONITOR -> WearableCapability.TEMPERATURE in capabilities + YCBTSettingKey.BLOOD_OXYGEN_MONITOR -> WearableCapability.SPO2 in capabilities + YCBTSettingKey.HRV_MONITOR -> WearableCapability.HRV in capabilities + else -> false + } + } fun userInfo(profile: UserProfileValues): ByteArray = settings.userInfo(profile) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt index 670236a..cabae83 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt @@ -9,6 +9,7 @@ import java.time.Instant object YCBTHealthRecords { private const val TEMPERATURE_FILLER: Int = 15 + private const val MAX_SLEEP_SESSION_MINUTES = 24 * 60 fun decode(buffer: ByteArray, type: YCBTHistoryType): List { return when (type) { @@ -185,11 +186,19 @@ object YCBTHealthRecords { if (!seenStarts.add(segmentStart)) continue val segmentSeconds = YCBTBytes.u24(buffer, offset + 5) if (sessionStart == null) sessionStart = YCBTBytes.date(segmentStart) - val minutes = maxOf(1, kotlin.math.round(segmentSeconds / 60.0).toInt()) + val remaining = MAX_SLEEP_SESSION_MINUTES - stages.size + if (remaining <= 0) break + val minutes = kotlin.math.round(segmentSeconds / 60.0).toInt().coerceIn(1, remaining) repeat(minutes) { stages.add(stage) } } if (sessionStart != null && stages.isNotEmpty()) { - events.add(RingDecodedEvent.SleepTimeline(_timestamp = sessionStart, stages = stages)) + events.add( + RingDecodedEvent.SleepTimeline( + _timestamp = sessionStart, + stages = stages, + completeSession = true, + ) + ) } cursor = segmentsStart + segmentCount * segmentLength } @@ -212,8 +221,12 @@ object YCBTHealthRecords { private fun bloodPressureEvents(systolic: Int, diastolic: Int, timestamp: Instant): List { if (systolic <= 0 || diastolic <= 0) return emptyList() return listOf( - RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, value = systolic.toDouble(), _timestamp = timestamp), - RingDecodedEvent.HistoryMeasurement(kind_field = MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, value = diastolic.toDouble(), _timestamp = timestamp), + RingDecodedEvent.BloodPressureSample( + systolic = systolic, + diastolic = diastolic, + _timestamp = timestamp, + isHistory = true, + ), ) } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt index a229f15..aa58ad4 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt @@ -12,6 +12,13 @@ class YCBTHistoryTransfer( private val writer: RingCommandWriter?, private val inactivitySeconds: Double = 10.0, private val absoluteCapSeconds: Double = 30.0, + private val onOutOfBandEvents: (List) -> Unit = { events -> + for (event in events) { + for (pulseEvent in RingEventBridge.eventsFor(event)) { + PulseEventBus.publishBlocking(pulseEvent) + } + } + }, ) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -26,6 +33,8 @@ class YCBTHistoryTransfer( private var retriedCurrentType = false private var unsupported: MutableSet = mutableSetOf() private var bufferCap = DEFAULT_BUFFER_CAP + private var expectedPackets: Int? = null + private var expectedBytes: Int? = null private var watchdogJob: Job? = null private var typeDeadline: Long? = null @@ -44,10 +53,26 @@ class YCBTHistoryTransfer( advance() } + /** Add newly discovered capability-gated types without disturbing an active block. */ + @Synchronized + fun append(types: List) { + val additions = types.distinct().filter { type -> + !unsupported.contains(type.queryKey) && type != currentType && type !in queue + } + if (additions.isEmpty()) return + if (state == State.IDLE) { + queue = additions.toMutableList() + advance() + } else { + queue.addAll(additions) + } + } + @Synchronized fun cancel() { cancelWatchdog() state = State.IDLE + currentType = null queue.clear() buffer = ByteArray(0) } @@ -55,10 +80,13 @@ class YCBTHistoryTransfer( private fun advance(): List { cancelWatchdog() buffer = ByteArray(0) + expectedPackets = null + expectedBytes = null bufferCap = DEFAULT_BUFFER_CAP retriedCurrentType = false if (queue.isEmpty()) { state = State.IDLE + currentType = null return listOf(RingDecodedEvent.HistorySyncFinished) } val next = queue.removeAt(0) @@ -77,6 +105,7 @@ class YCBTHistoryTransfer( /** Feed every validated Health-group (type == 0x05) frame here. */ @Synchronized fun handle(cmd: Int, payload: ByteArray): List { + if (state == State.IDLE) return emptyList() val type = currentType ?: return emptyList() YCBTFrameError.detect(payload)?.let { error -> @@ -98,7 +127,9 @@ class YCBTHistoryTransfer( private fun handleHeader(type: YCBTHistoryType, payload: ByteArray): List { if (payload.size < YCBTHealth.HEADER_PAYLOAD_LENGTH) return advance() + expectedPackets = YCBTBytes.u16(payload, 2) val totalBytes = YCBTBytes.u32(payload, 6) + expectedBytes = totalBytes buffer = ByteArray(0) bufferCap = totalBytes.coerceIn(0, MAX_BUFFER_CAP) state = State.RECEIVING @@ -115,6 +146,11 @@ class YCBTHistoryTransfer( private fun handleTerminal(type: YCBTHistoryType, payload: ByteArray): List { if (state == State.REQUEST_SENT && buffer.isEmpty()) return emptyList() if (payload.size < YCBTHealth.TERMINAL_PAYLOAD_LENGTH) return advance() + val packets = YCBTBytes.u16(payload, 0) + val bytes = YCBTBytes.u16(payload, 2) + if (packets != expectedPackets || bytes != expectedBytes || bytes != buffer.size) { + return emptyList() + } val expected = YCBTBytes.u16(payload, 4) val matches = YCBTFrame.crc16(buffer) == expected @@ -158,8 +194,6 @@ class YCBTHistoryTransfer( } private fun publishOutOfBand(events: List) { - if (events.any { it is RingDecodedEvent.HistorySyncFinished }) { - PulseEventBus.publishBlocking(PulseEvent.SyncProgress("done")) - } + onOutOfBandEvents(events) } } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index 7d6a6b4..f6ca251 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -14,25 +14,56 @@ class YCBTSyncEngine( private var measurementSettings = MeasurementSettings.ALL_ON_DEFAULT private var userProfile = UserProfileValues(metric = true, gender = 0x02u, age = 25u, heightCm = 175u, weightKg = 70u) private var requestActivityAfterStartupHistory = false + private var historyCapabilities = YCBTCoordinator.capabilities companion object { private val HISTORY_TYPES: List = listOf( YCBTHistoryType.SPORT, YCBTHistoryType.SLEEP, YCBTHistoryType.HEART, - YCBTHistoryType.BLOOD, YCBTHistoryType.ALL, YCBTHistoryType.SPO2, + YCBTHistoryType.BLOOD, YCBTHistoryType.ALL, YCBTHistoryType.TEMPERATURE, YCBTHistoryType.COMPREHENSIVE, YCBTHistoryType.BODY_DATA, ) - private val VITALS_TYPES: List = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL, YCBTHistoryType.SPO2) + private val VITALS_TYPES: List = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL) } + @Synchronized override fun runStartup() { requestActivityAfterStartupHistory = true - for (command in encoder.startupSequence(measurement = measurementSettings, profile = userProfile)) { + for (command in encoder.startupSequence( + measurement = measurementSettings, + profile = userProfile, + capabilities = historyCapabilities, + )) { writer?.enqueue(command) } - transfer.start(types = HISTORY_TYPES) + transfer.start(types = supportedHistoryTypes(HISTORY_TYPES)) } + @Synchronized override fun handle(event: RingDecodedEvent) { + if (event is RingDecodedEvent.SupportFunctions) { + val previousTypes = supportedHistoryTypes(HISTORY_TYPES).toSet() + val previousCapabilities = historyCapabilities + historyCapabilities = YCBTCoordinator.capabilities + + event.capabilities.intersect(YCBTCoordinator.bitmapGatedCapabilities) + for (command in encoder.monitorCommands(measurementSettings, historyCapabilities - previousCapabilities)) { + writer?.enqueue(command) + } + val addedCapabilities = historyCapabilities - previousCapabilities + val newlySupported = supportedHistoryTypes(HISTORY_TYPES) + .filterNot(previousTypes::contains) + .toMutableList() + // ALL carries optional fields as well as baseline SpO2. If capability discovery lands + // after its first pass, fetch it once more so newly accepted values are not lost. + if (addedCapabilities.any { + it == WearableCapability.BLOOD_PRESSURE || + it == WearableCapability.HRV || + it == WearableCapability.TEMPERATURE || + it == WearableCapability.BLOOD_SUGAR + }) { + newlySupported.add(YCBTHistoryType.ALL) + } + transfer.append(newlySupported) + } // The early startup command enables live status while the ring is still processing its // connect handshake. Some R10M firmware acknowledges it without immediately publishing // the current cumulative activity. Ask once more after the startup history walk, when the @@ -43,24 +74,46 @@ class YCBTSyncEngine( } } + @Synchronized override fun refresh() { // Ask for current cumulative activity before the slower multi-type history walk. Without // this, pull-to-refresh can leave steps stale until an unsolicited live push arrives. writer?.enqueue(encoder.enableLiveStatus()) - transfer.start(types = HISTORY_TYPES) + transfer.start(types = supportedHistoryTypes(HISTORY_TYPES)) } + @Synchronized override fun querySleep() { - transfer.start(types = listOf(YCBTHistoryType.SLEEP)) + transfer.start(types = supportedHistoryTypes(listOf(YCBTHistoryType.SLEEP))) } + @Synchronized override fun syncVitalsHistory() { - transfer.start(types = VITALS_TYPES) + transfer.start(types = supportedHistoryTypes(VITALS_TYPES)) } + @Synchronized override fun syncSleepNow() { - transfer.start(types = listOf(YCBTHistoryType.SLEEP)) - } + transfer.start(types = supportedHistoryTypes(listOf(YCBTHistoryType.SLEEP))) + } + + private fun supportedHistoryTypes(types: List): List = + types.filter { type -> + when (type) { + YCBTHistoryType.SPORT -> WearableCapability.STEPS in historyCapabilities + YCBTHistoryType.SLEEP -> WearableCapability.SLEEP in historyCapabilities + YCBTHistoryType.HEART -> WearableCapability.HEART_RATE in historyCapabilities + YCBTHistoryType.BLOOD -> WearableCapability.BLOOD_PRESSURE in historyCapabilities + YCBTHistoryType.ALL -> true + YCBTHistoryType.SPO2 -> WearableCapability.SPO2_HISTORY in historyCapabilities + YCBTHistoryType.TEMPERATURE -> WearableCapability.TEMPERATURE in historyCapabilities + YCBTHistoryType.COMPREHENSIVE -> WearableCapability.BLOOD_SUGAR in historyCapabilities + YCBTHistoryType.BODY_DATA -> + WearableCapability.STRESS in historyCapabilities || + WearableCapability.FATIGUE in historyCapabilities + else -> false + } + } override fun setMeasurementSettings(settings: MeasurementSettings?) { if (settings != null) measurementSettings = settings @@ -68,7 +121,7 @@ class YCBTSyncEngine( override fun applyMeasurementSettings(settings: MeasurementSettings) { measurementSettings = settings - for (command in encoder.monitorCommands(settings)) { + for (command in encoder.monitorCommands(settings, historyCapabilities)) { writer?.enqueue(command) } } diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 31b2746..e356120 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -50,6 +50,7 @@ class EventPersistenceSubscriber( is PulseEvent.StressSample, is PulseEvent.HrvSample, is PulseEvent.BloodPressureSample, + is PulseEvent.BloodSugarSample, is PulseEvent.TemperatureSample, is PulseEvent.ActivityUpdate, is PulseEvent.ActivityBucket, @@ -151,7 +152,8 @@ class EventPersistenceSubscriber( )) } is PulseEvent.HistoryMeasurement -> { - db.measurementDao().insert(MeasurementEntity( + db.measurementDao().upsertByIdentity(MeasurementEntity( + id = historyMeasurementId(event.kind, event.timestamp.toEpochMilli()), kindRaw = event.kind.name, value = event.value, unit = event.kind.unit, timestamp = event.timestamp.toEpochMilli(), @@ -171,19 +173,42 @@ class EventPersistenceSubscriber( kindRaw = MeasurementKind.HRV.name, value = event.value.toDouble(), unit = "ms", timestamp = event.timestamp.toEpochMilli(), - sourceRaw = "colmi", + sourceRaw = "live", )) } is PulseEvent.BloodPressureSample -> { - db.measurementDao().insert(MeasurementEntity( - kindRaw = MeasurementKind.BLOOD_PRESSURE_SYSTOLIC.name, - value = event.systolic.toDouble(), unit = "mmHg", - timestamp = event.timestamp.toEpochMilli(), - sourceRaw = "live", - )) - db.measurementDao().insert(MeasurementEntity( - kindRaw = MeasurementKind.BLOOD_PRESSURE_DIASTOLIC.name, - value = event.diastolic.toDouble(), unit = "mmHg", + db.withTransaction { + val systolic = MeasurementEntity( + id = if (event.isHistory) { + historyMeasurementId(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, event.timestamp.toEpochMilli()) + } else { + java.util.UUID.randomUUID().toString() + }, + kindRaw = MeasurementKind.BLOOD_PRESSURE_SYSTOLIC.name, + value = event.systolic.toDouble(), unit = "mmHg", + timestamp = event.timestamp.toEpochMilli(), + sourceRaw = if (event.isHistory) "history" else "live", + ) + val diastolic = MeasurementEntity( + id = if (event.isHistory) { + historyMeasurementId(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, event.timestamp.toEpochMilli()) + } else { + java.util.UUID.randomUUID().toString() + }, + kindRaw = MeasurementKind.BLOOD_PRESSURE_DIASTOLIC.name, + value = event.diastolic.toDouble(), unit = "mmHg", + timestamp = event.timestamp.toEpochMilli(), + sourceRaw = if (event.isHistory) "history" else "live", + ) + db.measurementDao().upsertByIdentity(systolic) + db.measurementDao().upsertByIdentity(diastolic) + } + } + is PulseEvent.BloodSugarSample -> { + db.measurementDao().upsertByIdentity(MeasurementEntity( + kindRaw = MeasurementKind.BLOOD_SUGAR.name, + value = event.mgdl, + unit = MeasurementKind.BLOOD_SUGAR.unit, timestamp = event.timestamp.toEpochMilli(), sourceRaw = "live", )) @@ -193,7 +218,7 @@ class EventPersistenceSubscriber( kindRaw = MeasurementKind.TEMPERATURE.name, value = event.celsius, unit = "°C", timestamp = event.timestamp.toEpochMilli(), - sourceRaw = "colmi", + sourceRaw = "live", )) } is PulseEvent.ActivityUpdate -> { @@ -207,7 +232,7 @@ class EventPersistenceSubscriber( applyActivityBucket(event.timestamp.toEpochMilli(), event.steps, event.distanceMeters) } is PulseEvent.SleepTimeline -> { - upsertSleepSession(event.timestamp.toEpochMilli(), event.stages) + upsertSleepSession(event.timestamp.toEpochMilli(), event.stages, event.completeSession) } is PulseEvent.SyncProgress -> {} // UI feedback, no persistence needed is PulseEvent.HeartRateComplete -> {} @@ -269,6 +294,10 @@ class EventPersistenceSubscriber( * iOS (the GadgetBridge model). Calories intentionally untouched (unverified ring field). */ private suspend fun applyActivityBucket(ts: Long, steps: Int, distanceM: Double) { + db.withTransaction { applyActivityBucketAtomic(ts, steps, distanceM) } + } + + private suspend fun applyActivityBucketAtomic(ts: Long, steps: Int, distanceM: Double) { val dayStart = com.pulseloop.util.TimeUtil.startOfDayLocal(ts) db.activityBucketDao().upsert(ActivityBucketEntity( startEpoch = ts, @@ -302,37 +331,59 @@ class EventPersistenceSubscriber( ) } - private suspend fun upsertSleepSession(ts: Long, stages: List) { - if (stages.isEmpty()) return - db.withTransaction { upsertSleepSessionAtomic(ts, stages) } + private suspend fun upsertSleepSession(ts: Long, stages: List, completeSession: Boolean) { + if (stages.isEmpty() || stages.size > MAX_SLEEP_TIMELINE_MINUTES) return + db.withTransaction { upsertSleepSessionAtomic(ts, stages, completeSession) } } - private suspend fun upsertSleepSessionAtomic(ts: Long, stages: List) { + private suspend fun upsertSleepSessionAtomic(ts: Long, stages: List, completeSession: Boolean) { // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so // a night that starts before midnight lands under the morning of waking instead of being // split into two sessions at midnight. Matches the iOS reference // (PulseEventBus.persistSleepTimeline + Calendar.wakingDay(forSleepStart:)). val dayStart = com.pulseloop.util.TimeUtil.wakingDayLocal(ts) val sessionId = "sleep-$dayStart" + val existingSession = db.sleepSessionDao().ringByDay(dayStart) + if (completeSession && existingSession != null && !shouldReplaceCompleteSleep( + existingStart = existingSession.startAt, + existingMinutes = existingSession.totalMinutes, + incomingStart = ts, + incomingMinutes = stages.size, + )) return - // The ring streams a night as many 15-minute 0x11 packets that must be STITCHED, - // not overwritten. Accumulate this packet's blocks with those already stored for the - // night, de-duplicating by absolute start time so re-syncs don't double-count. - // Matches the iOS reference (PulseEventBus.persistSleepTimeline). - val byStart = LinkedHashMap() - for (b in db.sleepStageBlockDao().forSession(sessionId)) byStart[b.startAt] = b - for (b in buildStageBlocks(sessionId, ts, stages)) byStart.putIfAbsent(b.startAt, b) - - val merged = byStart.values.sortedBy { it.startAt } + val packetEnd = ts + stages.size * 60_000L + val replacements = buildStageBlocks(sessionId, ts, stages) + val merged = if (completeSession) { + // YCBT sends an authoritative whole-session record. Remove legacy midnight-split + // parents that overlap it, then replace every block so shortened revisions cannot + // retain a stale tail. + for (legacy in db.sleepSessionDao().ringOverlapping(ts, packetEnd)) { + if (legacy.id != sessionId) { + db.sleepStageBlockDao().deleteBySession(legacy.id) + db.sleepSessionDao().deleteById(legacy.id) + } + } + replacements + } else { + // Other families stream a night as packets. Replace only the packet interval while + // preserving and splitting unaffected portions of existing stage runs. + replaceOverlappingSleepBlocks( + existing = db.sleepStageBlockDao().forSession(sessionId), + replacements = replacements, + replacementStart = ts, + replacementEnd = packetEnd, + ) + } val sessionStart = merged.first().startAt val sessionEnd = merged.maxOf { it.startAt + it.durationMinutes * 60_000L } + if (sessionEnd - sessionStart > MAX_SLEEP_TIMELINE_MINUTES * 60_000L) return val totalMin = ((sessionEnd - sessionStart) / 60_000L).toInt().coerceAtLeast(0) val deepMin = merged.filter { it.stageRaw == SleepStage.DEEP.name }.sumOf { it.durationMinutes } val score = computeSleepScore(deepMin, totalMin) // Upsert the parent session BEFORE its stage blocks. sleep_stage_blocks has a foreign key // to sleep_sessions(id), so a newly discovered night needs its parent first. - val existing = db.sleepSessionDao().ringByDay(dayStart) + val existing = existingSession db.sleepSessionDao().upsert( (existing ?: SleepSessionEntity(id = sessionId, date = dayStart, startAt = sessionStart, endAt = sessionEnd, totalMinutes = totalMin, score = score)).copy( startAt = sessionStart, @@ -407,6 +458,50 @@ class EventPersistenceSubscriber( else -> 40 } } + + private companion object { + const val MAX_SLEEP_TIMELINE_MINUTES = 24 * 60 + } +} + +internal fun historyMeasurementId(kind: MeasurementKind, timestamp: Long): String = + "history:${kind.key}:$timestamp" + +internal fun shouldReplaceCompleteSleep( + existingStart: Long, + existingMinutes: Int, + incomingStart: Long, + incomingMinutes: Int, +): Boolean = existingStart == incomingStart || incomingMinutes > existingMinutes + +internal fun replaceOverlappingSleepBlocks( + existing: List, + replacements: List, + replacementStart: Long, + replacementEnd: Long, +): List { + val byStart = LinkedHashMap() + for (block in existing) { + val blockEnd = block.startAt + block.durationMinutes * 60_000L + if (blockEnd <= replacementStart || block.startAt >= replacementEnd) { + byStart[block.startAt] = block + continue + } + if (block.startAt < replacementStart) { + byStart[block.startAt] = block.copy( + durationMinutes = ((replacementStart - block.startAt) / 60_000L).toInt(), + ) + } + if (blockEnd > replacementEnd) { + byStart[replacementEnd] = block.copy( + id = "${block.id}:$replacementEnd", + startAt = replacementEnd, + durationMinutes = ((blockEnd - replacementEnd) / 60_000L).toInt(), + ) + } + } + for (block in replacements) byStart[block.startAt] = block + return byStart.values.sortedBy { it.startAt } } // Extension for Set CSV from WearableCapability.kt diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index df54678..2a3b96a 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -295,6 +295,7 @@ class RingSyncCoordinator( fun findRing() { if (!isConnected) return + if (!client.state.value.activeCapabilities.contains(WearableCapability.FIND_DEVICE)) return engine?.findDevice() } @@ -312,7 +313,6 @@ class RingSyncCoordinator( // OS bond, and clears the stored peripheral. That is the whole forget. scope.launch { client.forget() - stop() onCleared() } } @@ -343,7 +343,6 @@ class RingSyncCoordinator( client.awaitOpsFlushed() } client.forget() - stop() onCleared() } } diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 787079d..970429f 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -39,6 +39,7 @@ import com.pulseloop.ui.components.AppHeader import com.pulseloop.ui.screens.* import com.pulseloop.ui.theme.PulseLoopTheme import com.pulseloop.ui.viewmodels.* +import com.pulseloop.util.TimeUtil import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -188,6 +189,9 @@ fun PulseLoopApp() { when (event) { Lifecycle.Event.ON_START -> { bgDisconnectJob?.cancel(); bgDisconnectJob = null + todayVM.refreshCurrentDay() + activityVM.refreshCurrentDay() + sleepVM.refreshReferenceNight() if (isFirstStart) isFirstStart = false else bleClient.reconnectIfNeeded() } Lifecycle.Event.ON_STOP -> { @@ -210,6 +214,17 @@ fun PulseLoopApp() { } } + // Keep an app left open overnight on the current local-day Room query. Rechecking at most + // once a minute also catches wall-clock or timezone changes without a lifecycle edge. + LaunchedEffect(todayVM, activityVM, sleepVM) { + while (true) { + delay(TimeUtil.millisUntilNextLocalDay().coerceAtMost(60_000L)) + todayVM.refreshCurrentDay() + activityVM.refreshCurrentDay() + sleepVM.refreshReferenceNight() + } + } + // ── Navigation ─────────────────────────────────────────────────── // Tab order + icons mirror iOS MainTab (AppTheme.swift): Today (circle.circle), // Vitals (heart), Activity (waveform.path.ecg), Sleep (moon), Coach (sparkles). diff --git a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt index 097c8e3..ee91f6a 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt @@ -251,6 +251,7 @@ private fun labelFor(event: PulseEvent): String = when (event) { is PulseEvent.Spo2Complete -> "SpO₂ Done" is PulseEvent.MeasurementRejected -> "Measure Rejected" is PulseEvent.BloodPressureSample -> "Blood Pressure" + is PulseEvent.BloodSugarSample -> "Glucose" is PulseEvent.HistoryMeasurement -> when (event.kind) { com.pulseloop.ring.MeasurementKind.HEART_RATE -> "HR History" com.pulseloop.ring.MeasurementKind.SPO2 -> "SpO₂ History" @@ -283,6 +284,7 @@ private fun labelFor(event: PulseEvent): String = when (event) { private fun detailFor(event: PulseEvent): String = when (event) { is PulseEvent.HeartRateSample -> "${event.bpm} bpm" is PulseEvent.Spo2Result -> "${event.value}%" + is PulseEvent.BloodSugarSample -> "${event.mgdl.toInt()} mg/dL" is PulseEvent.HistoryMeasurement -> "${event.value.toInt()} ${event.kind.unit}" is PulseEvent.BatteryLevel -> "${event.percent}%" is PulseEvent.ActivityUpdate -> "${event.steps} steps" @@ -309,6 +311,7 @@ private fun hexDump(data: ByteArray): String { private fun colorFor(event: PulseEvent): Color = when (event) { is PulseEvent.HeartRateSample, is PulseEvent.HeartRateComplete -> Color(0xFFE53935) is PulseEvent.Spo2Result -> Color(0xFF1E88E5) + is PulseEvent.BloodSugarSample -> Color(0xFF00BCD4) is PulseEvent.HistoryMeasurement -> when (event.kind) { com.pulseloop.ring.MeasurementKind.HEART_RATE -> Color(0xFFE53935) com.pulseloop.ring.MeasurementKind.SPO2 -> Color(0xFF1E88E5) diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index 6c62e9a..10bb8ea 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -43,6 +43,8 @@ import com.pulseloop.notifications.CoachNotifications import com.pulseloop.ring.MeasurementKind import com.pulseloop.ring.RingBLEClient import com.pulseloop.ring.RingConnectionState +import com.pulseloop.ring.RingDeviceType +import com.pulseloop.ring.WearableCapability import com.pulseloop.service.RingSyncCoordinator import com.pulseloop.service.RingSyncWorker import com.pulseloop.settings.ApiKeyStore @@ -917,6 +919,11 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U val db = remember { PulseLoopDatabase.getInstance(context) } val scope = rememberCoroutineScope() val device = db.deviceDao().currentFlow().collectAsState(initial = null).value + val capabilities = device?.capabilities.orEmpty() + val minimumInterval = if (device?.deviceType == RingDeviceType.YCBT) 30 else 5 + val intervalSteps = ((60 - minimumInterval) / 5 - 1).coerceAtLeast(0) + val supportsStressSetting = WearableCapability.STRESS in capabilities && + device?.deviceType != RingDeviceType.YCBT var cfgHrEnabled by remember { mutableStateOf(true) } var cfgHrInterval by remember { mutableStateOf(5) } @@ -927,13 +934,16 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U var cfgLoaded by remember { mutableStateOf(false) } var cfgSavedMsg by remember { mutableStateOf(null) } - LaunchedEffect(device?.id) { + LaunchedEffect(device?.id, device?.capabilitiesRaw) { val id = device?.id ?: return@LaunchedEffect - db.deviceMeasurementConfigDao().byDevice(id)?.let { c -> - cfgHrEnabled = c.hrEnabled; cfgHrInterval = c.hrIntervalMinutes - cfgSpo2 = c.spo2Enabled; cfgStress = c.stressEnabled - cfgHrv = c.hrvEnabled; cfgTemp = c.temperatureEnabled - } + cfgLoaded = false + val config = db.deviceMeasurementConfigDao().byDevice(id) + cfgHrEnabled = config?.hrEnabled ?: true + cfgHrInterval = (config?.hrIntervalMinutes ?: minimumInterval).coerceIn(minimumInterval, 60) + cfgSpo2 = (config?.spo2Enabled ?: true) && WearableCapability.SPO2 in capabilities + cfgStress = (config?.stressEnabled ?: true) && supportsStressSetting + cfgHrv = (config?.hrvEnabled ?: true) && WearableCapability.HRV in capabilities + cfgTemp = (config?.temperatureEnabled ?: true) && WearableCapability.TEMPERATURE in capabilities cfgLoaded = true } @@ -974,16 +984,24 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U Slider( enabled = cfgLoaded, value = cfgHrInterval.toFloat(), - onValueChange = { cfgHrInterval = ((it / 5).toInt() * 5).coerceIn(5, 60); cfgSavedMsg = null }, - valueRange = 5f..60f, - steps = 10, // 5-minute stops: 5, 10, …, 60 + onValueChange = { cfgHrInterval = ((it / 5).toInt() * 5).coerceIn(minimumInterval, 60); cfgSavedMsg = null }, + valueRange = minimumInterval.toFloat()..60f, + steps = intervalSteps, ) } HorizontalDivider(Modifier.padding(vertical = 8.dp)) - VitalToggle("Blood oxygen (SpO₂)", cfgSpo2) { cfgSpo2 = it } - VitalToggle("Stress", cfgStress) { cfgStress = it } - VitalToggle("HRV", cfgHrv) { cfgHrv = it } - VitalToggle("Temperature", cfgTemp) { cfgTemp = it } + if (WearableCapability.SPO2 in capabilities) { + VitalToggle("Blood oxygen (SpO₂)", cfgSpo2) { cfgSpo2 = it } + } + if (supportsStressSetting) { + VitalToggle("Stress", cfgStress) { cfgStress = it } + } + if (WearableCapability.HRV in capabilities) { + VitalToggle("HRV", cfgHrv) { cfgHrv = it } + } + if (WearableCapability.TEMPERATURE in capabilities) { + VitalToggle("Temperature", cfgTemp) { cfgTemp = it } + } Spacer(Modifier.height(12.dp)) Button( @@ -993,12 +1011,12 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U db.deviceMeasurementConfigDao().upsert( com.pulseloop.data.entity.DeviceMeasurementConfigEntity( deviceId = device.id, - hrIntervalMinutes = cfgHrInterval, + hrIntervalMinutes = cfgHrInterval.coerceIn(minimumInterval, 60), hrEnabled = cfgHrEnabled, - spo2Enabled = cfgSpo2, - stressEnabled = cfgStress, - hrvEnabled = cfgHrv, - temperatureEnabled = cfgTemp, + spo2Enabled = cfgSpo2 && WearableCapability.SPO2 in capabilities, + stressEnabled = cfgStress && supportsStressSetting, + hrvEnabled = cfgHrv && WearableCapability.HRV in capabilities, + temperatureEnabled = cfgTemp && WearableCapability.TEMPERATURE in capabilities, updatedAt = System.currentTimeMillis(), ) ) @@ -1044,6 +1062,8 @@ fun WearableSettingsScreen( val isConnected = bleState.connectionState == RingConnectionState.CONNECTED val supportsFactoryReset = bleState.activeCapabilities .contains(com.pulseloop.ring.WearableCapability.FACTORY_RESET) + val supportsFindDevice = bleState.activeCapabilities + .contains(com.pulseloop.ring.WearableCapability.FIND_DEVICE) var showFactoryReset by remember { mutableStateOf(false) } var resetting by remember { mutableStateOf(false) } var resetStatus by remember { mutableStateOf("") } @@ -1106,14 +1126,16 @@ fun WearableSettingsScreen( Spacer(Modifier.width(4.dp)) Text("Sync now") } - Spacer(Modifier.height(8.dp)) - OutlinedButton( - onClick = { coordinator?.findRing() }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.NotificationsActive, null, Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text("Find ring") + if (supportsFindDevice) { + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { coordinator?.findRing() }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.NotificationsActive, null, Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Find ring") + } } Spacer(Modifier.height(8.dp)) OutlinedButton( diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/CurrentDayValues.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/CurrentDayValues.kt new file mode 100644 index 0000000..dd324a6 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/CurrentDayValues.kt @@ -0,0 +1,11 @@ +package com.pulseloop.ui.viewmodels + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal fun currentDayValues( + dayKeys: StateFlow, + valuesForDay: (K) -> Flow, +): Flow = dayKeys.flatMapLatest(valuesForDay) diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt index 0f71a44..2dabe21 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -35,7 +35,8 @@ import java.time.ZoneId class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore? = null) : ViewModel() { // Local midnight, not UTC — the Today dashboard rolls over at the device's local // midnight so daily stats line up with how the rest of the app keys per-day rows. - private val todayStart = com.pulseloop.util.TimeUtil.startOfTodayLocal() + private val todayStart = MutableStateFlow(TimeUtil.startOfTodayLocal()) + private val todayDate = MutableStateFlow(java.time.LocalDate.now().toString()) data class TodayState( val steps: Int? = null, @@ -70,9 +71,14 @@ class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore private val _state = MutableStateFlow(TodayState()) val state: StateFlow = _state.asStateFlow() + fun refreshCurrentDay() { + todayStart.value = TimeUtil.startOfTodayLocal() + todayDate.value = java.time.LocalDate.now().toString() + } + init { viewModelScope.launch { - db.activityDailyDao().byDayFlow(todayStart).collect { activity -> + currentDayValues(todayStart, db.activityDailyDao()::byDayFlow).collect { activity -> _state.update { it.copy( steps = activity?.steps, calories = activity?.calories, @@ -102,9 +108,10 @@ class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore } // Today's coach summary card (kind="today", scopeKey=local date). viewModelScope.launch { - db.coachSummaryDao() - .getFlow(com.pulseloop.coach.summaries.CoachSummaryKind.TODAY.rawValue, java.time.LocalDate.now().toString()) - .collect { summary -> _state.update { it.copy(coachSummary = summary) } } + currentDayValues(todayDate) { date -> + db.coachSummaryDao() + .getFlow(com.pulseloop.coach.summaries.CoachSummaryKind.TODAY.rawValue, date) + }.collect { summary -> _state.update { it.copy(coachSummary = summary) } } } viewModelScope.launch { db.deviceDao().currentFlow().collect { device -> @@ -211,6 +218,10 @@ class SleepViewModel(private val db: PulseLoopDatabase) : ViewModel() { viewModelScope.launch { try { rebuild(range) } catch (_: Exception) {} } } + fun refreshReferenceNight() { + viewModelScope.launch { try { rebuild(_state.value.range) } catch (_: Exception) {} } + } + init { // Any sleep-table change retriggers a rebuild of the selected range. viewModelScope.launch { @@ -313,9 +324,14 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { private val _state = MutableStateFlow(ActivityState()) val state: StateFlow = _state.asStateFlow() + private val todayStart = MutableStateFlow(TimeUtil.startOfTodayLocal()) private val db = db + fun refreshCurrentDay() { + todayStart.value = TimeUtil.startOfTodayLocal() + } + init { viewModelScope.launch { db.activityDailyDao().recentFlow(7).collect { days -> @@ -323,7 +339,7 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { } } viewModelScope.launch { - db.activityDailyDao().byDayFlow(TimeUtil.startOfTodayLocal()).collect { day -> + currentDayValues(todayStart, db.activityDailyDao()::byDayFlow).collect { day -> _state.update { it.copy(today = day) } } } diff --git a/app/src/main/java/com/pulseloop/util/TimeUtil.kt b/app/src/main/java/com/pulseloop/util/TimeUtil.kt index 2f86327..c46dfdc 100644 --- a/app/src/main/java/com/pulseloop/util/TimeUtil.kt +++ b/app/src/main/java/com/pulseloop/util/TimeUtil.kt @@ -1,5 +1,6 @@ package com.pulseloop.util +import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.temporal.ChronoUnit @@ -22,6 +23,16 @@ object TimeUtil { fun startOfTodayLocal(zone: ZoneId = ZoneId.systemDefault()): Long = startOfDayLocal(System.currentTimeMillis(), zone) + /** Delay from [nowMs] until the next local midnight, accounting for 23/25-hour DST days. */ + fun millisUntilNextLocalDay( + nowMs: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + ): Long { + val now = Instant.ofEpochMilli(nowMs).atZone(zone) + val nextDay = now.toLocalDate().plusDays(1).atStartOfDay(zone) + return Duration.between(now, nextDay).toMillis().coerceAtLeast(1L) + } + /** * Hour-of-day boundary between "belongs to last night" and "belongs to the coming night." * Sleep starting at or after this hour rolls onto the *next* morning's waking day; anything diff --git a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt index a66bbc8..0e8348d 100644 --- a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt +++ b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt @@ -96,7 +96,7 @@ data class WearableModel( @DrawableRes imageRes: Int?, ) = WearableModel( id = id, displayName = name, brand = brand, family = RingDeviceType.YCBT, - tint = PulseColors.hrv, blurb = "HR · SpO₂ · BP · HRV · Temp · Sleep · Stress", + tint = PulseColors.hrv, blurb = "HR · SpO₂ · BP · Sleep", advertisedNamePatterns = listOf(pattern), imageRes = imageRes, ) diff --git a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt index f1ee7b8..91f7db4 100644 --- a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt +++ b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt @@ -81,6 +81,13 @@ class PairingMatchingTest { assertEquals("LittleMeatball R10M", WearableModel.R10M.displayName) } + @Test + fun `YCBT find device is enabled only by the support bitmap`() { + assertFalse(YCBTCoordinator.capabilities.contains(WearableCapability.FIND_DEVICE)) + assertTrue(YCBTCoordinator.bitmapGatedCapabilities.contains(WearableCapability.FIND_DEVICE)) + assertFalse(YCBTCoordinator.capabilities.contains(WearableCapability.SPO2_HISTORY)) + } + @Test fun `YCBT manufacturer marker does not override QRing service`() { val manufacturer = byteArrayOf(0x10, 0x78) diff --git a/app/src/test/java/com/pulseloop/ring/PulseEventBusTest.kt b/app/src/test/java/com/pulseloop/ring/PulseEventBusTest.kt new file mode 100644 index 0000000..63e11e9 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/PulseEventBusTest.kt @@ -0,0 +1,28 @@ +package com.pulseloop.ring + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Test + +class PulseEventBusTest { + @Test + fun `non-suspending publisher does not drop a history-sized burst`() = runBlocking { + val eventCount = 1_000 + val collected = async(start = CoroutineStart.UNDISPATCHED) { + PulseEventBus.events + .filterIsInstance() + .take(eventCount) + .toList() + } + + repeat(eventCount) { PulseEventBus.publishBlocking(PulseEvent.FirmwareVersion(it)) } + + assertEquals(eventCount, withTimeout(2_000) { collected.await() }.size) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt index acf3d9c..bb9195b 100644 --- a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt +++ b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt @@ -291,4 +291,30 @@ class RingEventBridgeTest { assertFalse(hrv is PulseEvent.HrvSample) } + @Test + fun `implausible historical measurements are dropped`() { + assertTrue( + RingEventBridge.eventsFor( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.SPO2, 255.0, now), now + ).isEmpty() + ) + assertTrue( + RingEventBridge.eventsFor( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.TEMPERATURE, 255.0, now), now + ).isEmpty() + ) + assertTrue( + RingEventBridge.eventsFor( + RingDecodedEvent.BloodPressureSample(255, 255, now, isHistory = true), now + ).isEmpty() + ) + } + + @Test + fun `live blood sugar keeps live event identity`() { + val events = RingEventBridge.eventsFor(RingDecodedEvent.BloodSugarSample(99.0, now), now) + + assertEquals(listOf(PulseEvent.BloodSugarSample(99.0, now)), events) + } + } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt index 24aa963..6ad4b90 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt @@ -151,6 +151,22 @@ class YCBTDriverTest { assertTrue(events.first() is RingDecodedEvent.CommandAck) } + @Test + fun `measurement data clears a lost start reply before the next command`() { + val driver = YCBTDriver(RingCommandWriter { }) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, YCBTMeasurementMode.HEART_RATE.toByte())) + driver.ingest( + YCBTFrame.frame(byteArrayOf(0x04, 0x13, YCBTMeasurementMode.HEART_RATE.toByte(), 0, 72)), + streamUUID, + ) + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, YCBTMeasurementMode.SPO2.toByte())) + + val refusal = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + + assertEquals(YCBTMeasurementMode.SPO2, (refusal.single() as RingDecodedEvent.MeasurementRejected).mode) + } + @Test fun `pending measurement replies preserve deterministic FIFO pairing`() { val replies = PendingMeasurementReplies() @@ -180,4 +196,44 @@ class YCBTDriverTest { assertEquals(setOf(YCBTUUIDs.COMMAND, YCBTUUIDs.STREAM), required.map { it.uuid }.toSet()) assertTrue(required.all { it.mode == SubscriptionMode.INDICATION }) } + + @Test + fun `optional live values are discarded until support function declares them`() { + val driver = YCBTDriver(RingCommandWriter { }) + val liveVitals = YCBTFrame.frame(byteArrayOf(0x06, 0x03, 120, 80, 70, 42, 98, 36, 5)) + + val beforeSupport = driver.ingest(liveVitals, streamUUID) + + assertTrue(beforeSupport.none { it is RingDecodedEvent.BloodPressureSample }) + assertTrue(beforeSupport.none { it is RingDecodedEvent.HrvSample }) + assertTrue(beforeSupport.none { it is RingDecodedEvent.TemperatureSample }) + assertTrue(beforeSupport.any { it is RingDecodedEvent.HeartRateSample }) + assertTrue(beforeSupport.any { it is RingDecodedEvent.Spo2Result }) + + val support = ByteArray(24).apply { + this[0] = 0x01 // blood pressure + this[1] = 0x02 // HRV + } + driver.ingest(YCBTFrame.frame(byteArrayOf(0x02, 0x01) + support), streamUUID) + + val afterSupport = driver.ingest(liveVitals, streamUUID) + assertTrue(afterSupport.any { it is RingDecodedEvent.BloodPressureSample }) + assertTrue(afterSupport.any { it is RingDecodedEvent.HrvSample }) + assertTrue(afterSupport.none { it is RingDecodedEvent.TemperatureSample }) + } + + @Test + fun `reconnect forgets optional capabilities learned on the old link`() { + val driver = YCBTDriver(RingCommandWriter { }) + val support = ByteArray(14).apply { this[0] = 0x01 } + val bloodPressure = YCBTFrame.frame(byteArrayOf(0x04, 0x13, 0x01, 0x00, 120, 80)) + + driver.ingest(YCBTFrame.frame(byteArrayOf(0x02, 0x01) + support), streamUUID) + assertTrue(driver.ingest(bloodPressure, streamUUID).any { it is RingDecodedEvent.BloodPressureSample }) + + driver.connectionDidEnd() + driver.connectionDidStart() + + assertTrue(driver.ingest(bloodPressure, streamUUID).none { it is RingDecodedEvent.BloodPressureSample }) + } } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt index 750ddaf..a20ea67 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -14,7 +14,7 @@ class YCBTEncoderTest { calendar.set(2026, java.util.Calendar.JULY, 6, 12, 34, 14) for (offset in 0..6) { val date = Instant.ofEpochMilli(calendar.timeInMillis + offset * 86_400_000L) - val command = encoder.setTime(date) + val command = encoder.setTime(date, timeZone = calendar.timeZone) assertEquals(offset.toByte(), command.last()) } } @@ -32,7 +32,7 @@ class YCBTEncoderTest { val calendar = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) calendar.set(2026, java.util.Calendar.JULY, 6, 12, 34, 14) val date = Instant.ofEpochMilli(calendar.timeInMillis) - val command = encoder.setTime(date) + val command = encoder.setTime(date, timeZone = calendar.timeZone) assertArrayEquals(byteArrayOf(0x01, 0x00, 0xea.toByte(), 0x07, 0x07, 0x06, 0x0c, 0x22, 0x0e, 0x00), command) } @@ -42,13 +42,12 @@ class YCBTEncoderTest { hrEnabled = true, hrIntervalMinutes = 5, spo2Enabled = true, stressEnabled = true, hrvEnabled = true, temperatureEnabled = true, ) - val commands = encoder.monitorCommands(settings) - assertEquals(5, commands.size) + val commands = encoder.monitorCommands(settings, allMonitorCapabilities) + assertEquals(4, commands.size) assertArrayEquals(byteArrayOf(0x01, 0x0c, 0x01, 30), commands[0]) - assertArrayEquals(byteArrayOf(0x01, 0x1c, 0x01, 30), commands[1]) - assertArrayEquals(byteArrayOf(0x01, 0x20, 0x01, 30), commands[2]) - assertArrayEquals(byteArrayOf(0x01, 0x26, 0x01, 30), commands[3]) - assertArrayEquals(byteArrayOf(0x01, 0x45, 0x01, 30, 0, 0, 0), commands[4]) + assertArrayEquals(byteArrayOf(0x01, 0x20, 0x01, 30), commands[1]) + assertArrayEquals(byteArrayOf(0x01, 0x26, 0x01, 30), commands[2]) + assertArrayEquals(byteArrayOf(0x01, 0x45, 0x01, 30, 0, 0, 0), commands[3]) } @Test @@ -57,12 +56,11 @@ class YCBTEncoderTest { hrEnabled = true, hrIntervalMinutes = 60, spo2Enabled = false, stressEnabled = false, hrvEnabled = false, temperatureEnabled = false, ) - val commands = encoder.monitorCommands(settings) + val commands = encoder.monitorCommands(settings, allMonitorCapabilities) assertArrayEquals(byteArrayOf(0x01, 0x0c, 0x01, 60), commands[0]) - assertArrayEquals(byteArrayOf(0x01, 0x1c, 0x01, 60), commands[1]) - assertArrayEquals(byteArrayOf(0x01, 0x20, 0x00, 60), commands[2]) - assertArrayEquals(byteArrayOf(0x01, 0x26, 0x00, 60), commands[3]) - assertArrayEquals(byteArrayOf(0x01, 0x45, 0x00, 60, 0, 0, 0), commands[4]) + assertArrayEquals(byteArrayOf(0x01, 0x20, 0x00, 60), commands[1]) + assertArrayEquals(byteArrayOf(0x01, 0x26, 0x00, 60), commands[2]) + assertArrayEquals(byteArrayOf(0x01, 0x45, 0x00, 60, 0, 0, 0), commands[3]) } @Test @@ -104,16 +102,23 @@ class YCBTEncoderTest { byteArrayOf(0x01, 0x12), byteArrayOf(0x01, 0x04), byteArrayOf(0x01, 0x0c), - byteArrayOf(0x01, 0x1c), - byteArrayOf(0x01, 0x20), byteArrayOf(0x01, 0x26), - byteArrayOf(0x01, 0x45), byteArrayOf(0x01, 0x03), byteArrayOf(0x03, 0x09), ) assertEquals(expectedSettings.map { it.toList() }, settings.map { it.toList() }) } + @Test + fun `monitor commands include only declared sensors`() { + val commands = encoder.monitorCommands( + MeasurementSettings.ALL_ON_DEFAULT, + setOf(WearableCapability.HEART_RATE, WearableCapability.SPO2, WearableCapability.BLOOD_PRESSURE), + ) + + assertEquals(listOf(0x0c, 0x26), commands.map { it[1].toInt() and 0xFF }) + } + @Test fun `post subscription handshake is exactly device name then time`() { val date = Instant.parse("2026-07-06T12:34:14Z") @@ -155,4 +160,14 @@ class YCBTEncoderTest { assertArrayEquals(byteArrayOf(0x03, 0x00, 0x01, 0x05, 0x02), encoder.findDevice()) assertNotEquals(0x04.toByte(), encoder.findDevice()[0]) } + + companion object { + private val allMonitorCapabilities = setOf( + WearableCapability.HEART_RATE, + WearableCapability.BLOOD_PRESSURE, + WearableCapability.TEMPERATURE, + WearableCapability.SPO2, + WearableCapability.HRV, + ) + } } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt index 01fc8e9..00417ef 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt @@ -64,9 +64,10 @@ class YCBTHealthRecordsTest { val events = YCBTHealthRecords.combinedVitals(capturedAllRecords) assertEquals(listOf(98.0, 97.0, 97.0, 96.0, 97.0, 96.0, 97.0, 95.0), values(MeasurementKind.SPO2, events)) assertEquals(listOf(52.0, 43.0, 177.0, 95.0, 33.0, 33.0, 61.0, 128.0), values(MeasurementKind.HRV, events)) - assertEquals(listOf(115.0, 112.0, 112.0, 109.0, 111.0, 111.0, 110.0, 106.0), values(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, events)) - assertEquals(70.0, values(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, events).last(), 0.001) - assertFalse(events.any { it is RingDecodedEvent.BloodPressureSample }) + val bloodPressure = events.filterIsInstance() + assertEquals(listOf(115, 112, 112, 109, 111, 111, 110, 106), bloodPressure.map { it.systolic }) + assertEquals(70, bloodPressure.last().diastolic) + assertTrue(bloodPressure.all { it.isHistory }) assertFalse(events.any { it is RingDecodedEvent.ActivityUpdate }) } @@ -112,6 +113,7 @@ class YCBTHealthRecordsTest { assertTrue(kotlin.math.abs(249 - light) <= 3) assertTrue(kotlin.math.abs(130 - rem) <= 3) assertFalse(event.stages.contains(SleepStage.AWAKE)) + assertTrue(event.completeSession) } @Test @@ -140,6 +142,15 @@ class YCBTHealthRecordsTest { assertEquals(1200, event.stages.size) } + @Test + fun `malformed sleep durations are bounded to one day`() { + val session = sleepSession(listOf(0xf2 to 0xFFFFFF, 0xf1 to 60 * 60)) + val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline + + assertEquals(24 * 60, event.stages.size) + assertTrue(event.stages.all { it == SleepStage.LIGHT }) + } + @Test fun `repeated segment is counted once`() { var session = sleepSession(listOf(0xf2 to 30 * 60, 0xf1 to 30 * 60, 0xf3 to 30 * 60)) @@ -170,10 +181,11 @@ class YCBTHealthRecordsTest { @Test fun `blood pressure records decode`() { val events = YCBTHealthRecords.bloodPressure(bytes("1cf0de3101764f401afede3100000000")) - assertEquals(listOf(118.0), values(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, events)) - assertEquals(listOf(79.0), values(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, events)) + val bloodPressure = events.filterIsInstance() + assertEquals(listOf(118), bloodPressure.map { it.systolic }) + assertEquals(listOf(79), bloodPressure.map { it.diastolic }) assertEquals(listOf(64.0), values(MeasurementKind.HEART_RATE, events)) - assertFalse(events.any { it is RingDecodedEvent.BloodPressureSample }) + assertTrue(bloodPressure.all { it.isHistory }) assertEquals(YCBTBytes.date(836_694_044), timestamps(events).first()) } @@ -248,11 +260,11 @@ class YCBTHealthRecordsTest { val temperature = bytes("1cf0de310024051afede31002419260cdf3100000f") assertEquals(2, YCBTHealthRecords.decode(spo2, YCBTHistoryType.SPO2).size) - assertEquals(3, YCBTHealthRecords.decode(blood, YCBTHistoryType.BLOOD).size) + assertEquals(2, YCBTHealthRecords.decode(blood, YCBTHistoryType.BLOOD).size) assertEquals(1, YCBTHealthRecords.decode(sport, YCBTHistoryType.SPORT).size) assertEquals(2, YCBTHealthRecords.decode(temperature, YCBTHistoryType.TEMPERATURE).size) assertEquals(4, YCBTHealthRecords.decode(capturedBodyRecord, YCBTHistoryType.BODY_DATA).size) - assertEquals(8 * 5, YCBTHealthRecords.decode(capturedAllRecords, YCBTHistoryType.ALL).size) + assertEquals(8 * 4, YCBTHealthRecords.decode(capturedAllRecords, YCBTHistoryType.ALL).size) assertFalse(YCBTHealthRecords.decode(capturedNight, YCBTHistoryType.SLEEP).isEmpty()) assertFalse(YCBTHealthRecords.decode(capturedHeartRecords, YCBTHistoryType.HEART).isEmpty()) } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt index e04cc14..ef751bf 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt @@ -45,6 +45,20 @@ class YCBTHistoryTransferTest { } } + private fun drainNoData(writer: FakeWriter, transfer: YCBTHistoryTransfer): List { + val requested = mutableListOf() + while (true) { + val query = writer.sent.firstOrNull { + it.size == 2 && it[0] == YCBTGroup.HEALTH.toByte() + } ?: break + writer.sent.clear() + val key = query[1].toInt() and 0xFF + requested += key + transfer.handle(cmd = key, payload = byteArrayOf(0x00)) + } + return requested + } + @Test fun `full cycle acks and advances to next type`() { val writer = FakeWriter() @@ -138,24 +152,43 @@ class YCBTHistoryTransferTest { } @Test - fun `engine requests every history type in order`() { + fun `late terminal from skipped type cannot corrupt the next transfer`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val allBuffer = ByteArray(20) { it.toByte() } + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + transfer.handle(cmd = 0x06, payload = byteArrayOf(0xfc.toByte())) + transfer.handle(cmd = 0x09, payload = header(records = 1, packets = 2, bytes = allBuffer.size)) + transfer.handle(cmd = 0x18, payload = allBuffer) + writer.sent.clear() + + val events = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) + + assertTrue(events.isEmpty()) + assertTrue(writer.sent.isEmpty()) + assertTrue(transfer.isActive) + } + + @Test + fun `startup requests only baseline history until capabilities arrive`() { val writer = FakeWriter() val transfer = YCBTHistoryTransfer(writer = writer) val engine = YCBTSyncEngine(writer = writer, transfer = transfer) engine.runStartup() val requested = mutableListOf() - for (i in 0 until YCBTHistoryType.CATALOG.size) { + repeat(4) { val query = writer.sent.lastOrNull { it.size == 2 && it[0] == 0x05.toByte() } - if (query == null) break + if (query == null) return@repeat requested.add(query[1].toInt() and 0xFF) transfer.handle(cmd = query[1].toInt() and 0xFF, payload = byteArrayOf(0x00)) } - assertEquals(listOf(0x02, 0x04, 0x06, 0x08, 0x09, 0x1a, 0x1e, 0x2f, 0x33), requested) + assertEquals(listOf(0x02, 0x04, 0x06, 0x09), requested) } @Test - fun `sync vitals history queues only the three vitals types`() { + fun `sync vitals history uses heart and combined records`() { val writer = FakeWriter() val transfer = YCBTHistoryTransfer(writer = writer) val engine = YCBTSyncEngine(writer = writer, transfer = transfer) @@ -168,7 +201,7 @@ class YCBTHistoryTransferTest { writer.sent.clear() transfer.handle(cmd = query[1].toInt() and 0xFF, payload = byteArrayOf(0x00)) } - assertEquals(listOf(0x06, 0x09, 0x1a), requested) + assertEquals(listOf(0x06, 0x09), requested) } @Test @@ -203,6 +236,38 @@ class YCBTHistoryTransferTest { assertArrayEquals(allQuery, writer.sent[1]) } + @Test + fun `append preserves the active transfer and adds new types once`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + writer.sent.clear() + transfer.append( + listOf( + YCBTHistoryType.HEART, + YCBTHistoryType.ALL, + YCBTHistoryType.SLEEP, + YCBTHistoryType.SLEEP, + ), + ) + assertTrue(writer.sent.isEmpty()) + + transfer.handle(cmd = 0x06, payload = byteArrayOf(0x00)) + assertEquals(listOf(0x09, 0x04), drainNoData(writer, transfer)) + } + + @Test + fun `append while idle starts the added history`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + + transfer.append(listOf(YCBTHistoryType.SLEEP)) + + assertArrayEquals(byteArrayOf(0x05, 0x04), writer.sent.single()) + assertTrue(transfer.isActive) + } + @Test fun `frames while idle are ignored`() { val writer = FakeWriter() @@ -212,6 +277,39 @@ class YCBTHistoryTransferTest { assertTrue(writer.sent.isEmpty()) } + @Test + fun `duplicate final terminal after completion is ignored`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val done = terminal(packets = 1, buffer = heartBuffer) + + transfer.start(types = listOf(YCBTHistoryType.HEART)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + transfer.handle(cmd = 0x80, payload = done) + + writer.sent.clear() + assertTrue(transfer.handle(cmd = 0x80, payload = done).isEmpty()) + assertTrue(writer.sent.isEmpty()) + assertFalse(transfer.isActive) + } + + @Test + fun `terminal after cancel is ignored`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + + transfer.start(types = listOf(YCBTHistoryType.HEART)) + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 1, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = heartBuffer) + transfer.cancel() + + writer.sent.clear() + assertTrue(transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)).isEmpty()) + assertTrue(writer.sent.isEmpty()) + assertFalse(transfer.isActive) + } + @Test fun `watchdog skips a stalled type and never acks`() = runBlocking { val writer = FakeWriter() @@ -257,4 +355,22 @@ class YCBTHistoryTransferTest { assertTrue(writer.sent.isEmpty()) assertFalse(transfer.isActive) } + + @Test + fun `final watchdog completion is emitted through the out of band event sink`() = runBlocking { + val writer = FakeWriter() + val emitted = mutableListOf() + val transfer = YCBTHistoryTransfer( + writer = writer, + inactivitySeconds = 0.05, + absoluteCapSeconds = 0.1, + onOutOfBandEvents = { emitted.addAll(it) }, + ) + + transfer.start(types = listOf(YCBTHistoryType.HEART)) + delay(200) + + assertEquals(listOf(RingDecodedEvent.HistorySyncFinished), emitted) + assertFalse(transfer.isActive) + } } diff --git a/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt index c1bd379..188c0f5 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTProtocolTest.kt @@ -114,6 +114,28 @@ class YCBTProtocolTest { assertTrue(assembler.append(whole.copyOfRange(10, whole.size), streamUUID).isEmpty()) } + @Test + fun `find device capability follows its support bit`() { + val withoutFind = ByteArray(14) + val withFind = withoutFind.copyOf().apply { + this[6] = (1 shl 4).toByte() + } + + assertFalse(YCBTSupportFunction.capabilities(withoutFind).contains(WearableCapability.FIND_DEVICE)) + assertTrue(YCBTSupportFunction.capabilities(withFind).contains(WearableCapability.FIND_DEVICE)) + } + + @Test + fun `pressure support bit enables both body-data scores`() { + val payload = ByteArray(23).apply { + this[22] = (1 shl 6).toByte() + } + val capabilities = YCBTSupportFunction.capabilities(payload) + + assertTrue(capabilities.contains(WearableCapability.STRESS)) + assertTrue(capabilities.contains(WearableCapability.FATIGUE)) + } + private fun hexToBytes(hex: String): ByteArray { val clean = hex.replace(" ", "") require(clean.length % 2 == 0) diff --git a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt index e88bc34..ae0477f 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTSyncEngineTest.kt @@ -16,6 +16,20 @@ class YCBTSyncEngineTest { return YCBTSyncEngine(writer, transfer) } + private fun drainNoData(writer: FakeWriter, transfer: YCBTHistoryTransfer): List { + val requested = mutableListOf() + while (true) { + val query = writer.sent.firstOrNull { + it.size == 2 && it[0] == YCBTGroup.HEALTH.toByte() + } ?: break + writer.sent.clear() + val key = query[1].toInt() and 0xFF + requested += key + transfer.handle(cmd = key, payload = byteArrayOf(0x00)) + } + return requested + } + @Test fun `full refresh requests live status before sport history`() { val writer = FakeWriter() @@ -53,6 +67,12 @@ class YCBTSyncEngineTest { val startup = writer.sent.filter { it.size >= 2 && it[0] != YCBTGroup.HEALTH.toByte() } assertFalse(startup.any { it[0] == YCBTGroup.SETTING.toByte() && it[1] == YCBTSettingKey.SET_TIME.toByte() }) assertFalse(startup.any { it[0] == YCBTGroup.GET.toByte() && it[1] == YCBTCommand.GET_DEVICE_NAME.toByte() }) + assertEquals( + listOf(YCBTSettingKey.HEART_MONITOR, YCBTSettingKey.BLOOD_OXYGEN_MONITOR), + startup.filter { it[0] == YCBTGroup.SETTING.toByte() } + .map { it[1].toInt() and 0xFF } + .filter { it in setOf(0x0c, 0x1c, 0x20, 0x26, 0x45) }, + ) } @Test @@ -70,4 +90,90 @@ class YCBTSyncEngineTest { engine.handle(RingDecodedEvent.HistorySyncFinished) assertTrue(writer.sent.isEmpty()) } + + @Test + fun `support bitmap appends only declared optional history`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer) + val engine = YCBTSyncEngine(writer, transfer) + + engine.runStartup() + engine.handle( + RingDecodedEvent.SupportFunctions( + setOf(WearableCapability.BLOOD_PRESSURE, WearableCapability.TEMPERATURE), + ), + ) + + val optionalMonitors = writer.sent.filter { + it.size >= 2 && it[0] == YCBTGroup.SETTING.toByte() + }.map { it[1].toInt() and 0xFF } + assertTrue(optionalMonitors.contains(YCBTSettingKey.TEMPERATURE_MONITOR)) + assertFalse(optionalMonitors.contains(YCBTSettingKey.BLOOD_PRESSURE_MONITOR)) + assertFalse(optionalMonitors.contains(YCBTSettingKey.HRV_MONITOR)) + + assertEquals( + listOf(0x02, 0x04, 0x06, 0x09, 0x08, 0x1e), + drainNoData(writer, transfer), + ) + } + + @Test + fun `repeated support bitmap does not duplicate optional history`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer) + val engine = YCBTSyncEngine(writer, transfer) + val support = RingDecodedEvent.SupportFunctions(setOf(WearableCapability.BLOOD_PRESSURE)) + + engine.runStartup() + engine.handle(support) + engine.handle(support) + + assertEquals( + listOf(0x02, 0x04, 0x06, 0x09, 0x08), + drainNoData(writer, transfer), + ) + } + + @Test + fun `late optional capability requeues the combined history source`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer) + val engine = YCBTSyncEngine(writer, transfer) + + engine.runStartup() + drainNoData(writer, transfer) + writer.sent.clear() + + engine.handle(RingDecodedEvent.SupportFunctions(setOf(WearableCapability.HRV))) + + assertEquals(listOf(0x09), drainNoData(writer, transfer)) + } + + @Test + fun `refresh uses the complete capability-filtered history catalog`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer) + val engine = YCBTSyncEngine(writer, transfer) + + engine.runStartup() + engine.handle( + RingDecodedEvent.SupportFunctions( + setOf( + WearableCapability.BLOOD_PRESSURE, + WearableCapability.TEMPERATURE, + WearableCapability.BLOOD_SUGAR, + WearableCapability.STRESS, + ), + ), + ) + drainNoData(writer, transfer) + + writer.sent.clear() + engine.refresh() + assertArrayEquals(byteArrayOf(0x03, 0x09, 0x01, 0x00, 0x02), writer.sent.first()) + assertEquals( + listOf(0x02, 0x04, 0x06, 0x08, 0x09, 0x1e, 0x2f, 0x33), + drainNoData(writer, transfer), + ) + } } diff --git a/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt b/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt new file mode 100644 index 0000000..c4da062 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt @@ -0,0 +1,105 @@ +package com.pulseloop.service + +import com.pulseloop.data.entity.SleepStageBlockEntity +import com.pulseloop.ring.MeasurementKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class EventPersistenceIdentityTest { + @Test + fun `history identity is stable across repeated syncs`() { + val timestamp = 1_721_234_567_000L + + assertEquals( + historyMeasurementId(MeasurementKind.HEART_RATE, timestamp), + historyMeasurementId(MeasurementKind.HEART_RATE, timestamp), + ) + assertNotEquals( + historyMeasurementId(MeasurementKind.HEART_RATE, timestamp), + historyMeasurementId(MeasurementKind.SPO2, timestamp), + ) + } + + @Test + fun `revised sleep packet replaces every overlapping stale block`() { + val start = 1_721_234_000_000L + val existing = listOf( + block("old-1", start, 5, "LIGHT"), + block("old-2", start + 5 * 60_000L, 10, "DEEP"), + block("later", start + 15 * 60_000L, 5, "REM"), + ) + val revised = listOf(block("new", start, 15, "LIGHT")) + + val merged = replaceOverlappingSleepBlocks( + existing = existing, + replacements = revised, + replacementStart = start, + replacementEnd = start + 15 * 60_000L, + ) + + assertEquals(listOf("new", "later"), merged.map { it.id }) + } + + @Test + fun `packet revision preserves portions outside its interval`() { + val start = 1_721_234_000_000L + val existing = listOf(block("old", start, 60, "LIGHT")) + val revisedStart = start + 15 * 60_000L + val revisedEnd = start + 30 * 60_000L + + val merged = replaceOverlappingSleepBlocks( + existing = existing, + replacements = listOf(block("new", revisedStart, 15, "DEEP")), + replacementStart = revisedStart, + replacementEnd = revisedEnd, + ) + + assertEquals(listOf(start, revisedStart, revisedEnd), merged.map { it.startAt }) + assertEquals(listOf(15, 15, 30), merged.map { it.durationMinutes }) + assertEquals(listOf("LIGHT", "DEEP", "LIGHT"), merged.map { it.stageRaw }) + } + + @Test + fun `short nap cannot replace a longer night on the same waking day`() { + val nightStart = 1_721_234_000_000L + + assertEquals( + false, + shouldReplaceCompleteSleep( + existingStart = nightStart, + existingMinutes = 480, + incomingStart = nightStart + 12 * 60 * 60_000L, + incomingMinutes = 60, + ), + ) + assertEquals( + true, + shouldReplaceCompleteSleep( + existingStart = nightStart + 12 * 60 * 60_000L, + existingMinutes = 60, + incomingStart = nightStart, + incomingMinutes = 480, + ), + ) + assertEquals( + true, + shouldReplaceCompleteSleep( + existingStart = nightStart, + existingMinutes = 480, + incomingStart = nightStart, + incomingMinutes = 420, + ), + ) + } + + private fun block(id: String, start: Long, duration: Int, stage: String) = + SleepStageBlockEntity( + id = id, + sessionId = "sleep-session", + startAt = start, + startMinute = 0, + durationMinutes = duration, + stageRaw = stage, + ) +} diff --git a/app/src/test/java/com/pulseloop/ui/viewmodels/CurrentDayValuesTest.kt b/app/src/test/java/com/pulseloop/ui/viewmodels/CurrentDayValuesTest.kt new file mode 100644 index 0000000..3432cbd --- /dev/null +++ b/app/src/test/java/com/pulseloop/ui/viewmodels/CurrentDayValuesTest.kt @@ -0,0 +1,41 @@ +package com.pulseloop.ui.viewmodels + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CurrentDayValuesTest { + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun retainedCollectorSwitchesFromYesterdayAtMidnight() = runTest { + val sunday = 1L + val monday = 2L + val currentDay = MutableStateFlow(sunday) + val rows = mapOf( + sunday to MutableStateFlow(5_397), + monday to MutableStateFlow(null), + ) + val displayedSteps = currentDayValues(currentDay, rows::getValue) + .stateIn(backgroundScope, SharingStarted.Eagerly, null) + + runCurrent() + assertEquals(5_397, displayedSteps.value) + + currentDay.value = monday + runCurrent() + assertNull("yesterday's steps must disappear after rollover", displayedSteps.value) + + rows.getValue(sunday).value = 6_000 + runCurrent() + assertNull("updates to yesterday must stay hidden", displayedSteps.value) + + rows.getValue(monday).value = 42 + runCurrent() + assertEquals(42, displayedSteps.value) + } +} diff --git a/app/src/test/java/com/pulseloop/util/TimeUtilTest.kt b/app/src/test/java/com/pulseloop/util/TimeUtilTest.kt index ae72968..ef801ad 100644 --- a/app/src/test/java/com/pulseloop/util/TimeUtilTest.kt +++ b/app/src/test/java/com/pulseloop/util/TimeUtilTest.kt @@ -84,4 +84,22 @@ class TimeUtilTest { TimeUtil.wakingDayLocal(morningPacket, zone), ) } + + @Test + fun `delay to midnight follows a normal local day`() { + val now = millis(LocalDateTime.of(2026, 7, 5, 23, 0)) + assertEquals(3_600_000L, TimeUtil.millisUntilNextLocalDay(now, zone)) + } + + @Test + fun `delay from midnight spans the spring-forward day`() { + val now = millis(LocalDateTime.of(2026, 3, 8, 0, 0)) + assertEquals(23 * 3_600_000L, TimeUtil.millisUntilNextLocalDay(now, zone)) + } + + @Test + fun `delay from midnight spans the fall-back day`() { + val now = millis(LocalDateTime.of(2026, 11, 1, 0, 0)) + assertEquals(25 * 3_600_000L, TimeUtil.millisUntilNextLocalDay(now, zone)) + } } From 849131d5cd05aa01783a87270a50afeed13e01ef Mon Sep 17 00:00:00 2001 From: Darko Bozhinovski Date: Wed, 22 Jul 2026 15:34:10 +0200 Subject: [PATCH 33/36] fix: address YCBT review feedback --- .../com/pulseloop/data/PulseLoopDatabase.kt | 80 ++++-- .../main/java/com/pulseloop/data/dao/Daos.kt | 36 +-- .../com/pulseloop/data/entity/CoreEntities.kt | 2 +- .../notifications/CoachNotifications.kt | 28 +- .../java/com/pulseloop/ring/ColmiDecoder.kt | 12 +- .../java/com/pulseloop/ring/PulseEventBus.kt | 17 +- .../java/com/pulseloop/ring/RingBLEClient.kt | 270 ++++++++++++------ .../com/pulseloop/ring/RingDecodedEvent.kt | 6 +- .../com/pulseloop/ring/RingEventBridge.kt | 4 +- .../java/com/pulseloop/ring/YCBTDecoder.kt | 3 - .../java/com/pulseloop/ring/YCBTDriver.kt | 26 +- .../com/pulseloop/ring/YCBTHealthRecords.kt | 3 +- .../com/pulseloop/ring/YCBTHistoryTransfer.kt | 7 +- .../java/com/pulseloop/ring/YCBTProtocol.kt | 2 - .../java/com/pulseloop/ring/YCBTSyncEngine.kt | 2 +- .../service/EventPersistenceSubscriber.kt | 74 +++-- .../pulseloop/service/RingSyncCoordinator.kt | 43 +-- .../com/pulseloop/service/RingSyncWorker.kt | 40 ++- .../com/pulseloop/ring/ColmiDecoderTest.kt | 1 + .../pulseloop/ring/ColmiQringParityTest.kt | 1 + .../java/com/pulseloop/ring/YCBTDriverTest.kt | 16 ++ .../pulseloop/ring/YCBTHistoryTransferTest.kt | 48 +++- .../service/EventPersistenceIdentityTest.kt | 15 + 23 files changed, 524 insertions(+), 212 deletions(-) diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index cf4ff81..fb53063 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -40,7 +40,7 @@ import com.pulseloop.data.entity.* BatterySampleEntity::class, CoachNotificationRecordEntity::class, ], - version = 13, + version = 14, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -205,7 +205,7 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } - /** v12 → v13: make replayed sensor history idempotent across reconnects. */ + /** v12 → v13: index replayed sensor-history identity without deleting valid collisions. */ private val MIGRATION_12_13 = object : Migration(12, 13) { override fun migrate(db: SupportSQLiteDatabase) { // Feature APKs briefly used version 8 for this index before main assigned v8 to @@ -222,21 +222,68 @@ abstract class PulseLoopDatabase : RoomDatabase() { """.trimIndent() ) db.execSQL("CREATE INDEX IF NOT EXISTS `index_battery_samples_timestamp` ON `battery_samples` (`timestamp`)") - db.execSQL( - """ - DELETE FROM `measurements` - WHERE rowid NOT IN ( - SELECT MAX(rowid) - FROM `measurements` - GROUP BY `kindRaw`, `timestamp`, `sourceRaw` - ) - """.trimIndent() - ) - db.execSQL( - "CREATE UNIQUE INDEX IF NOT EXISTS `index_measurements_kindRaw_timestamp_sourceRaw` " + - "ON `measurements` (`kindRaw`, `timestamp`, `sourceRaw`)" - ) + adoptStableMeasurementIdentities(db) + } + } + + /** v13 → v14: replace the pre-review unique identity index without dropping rows. */ + private val MIGRATION_13_14 = object : Migration(13, 14) { + override fun migrate(db: SupportSQLiteDatabase) { + adoptStableMeasurementIdentities(db) + } + } + + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { + db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") + db.execSQL( + "UPDATE `measurements` SET `sourceRaw` = 'live' " + + "WHERE `sourceRaw` = 'colmi' AND `kindRaw` IN ('HRV', 'TEMPERATURE')" + ) + listOf( + "HEART_RATE" to "hr", + "SPO2" to "spo2", + "STRESS" to "stress", + "FATIGUE" to "fatigue", + "HRV" to "hrv", + "TEMPERATURE" to "temp", + "BLOOD_PRESSURE_SYSTOLIC" to "bp_sys", + "BLOOD_PRESSURE_DIASTOLIC" to "bp_dia", + "BLOOD_SUGAR" to "glucose", + "RESPIRATORY_RATE" to "resp_rate", + "VO2MAX" to "vo2max", + ).forEach { (kind, key) -> + adoptStableMeasurementIdentity(db, kind = kind, source = "history", key = key) } + adoptStableMeasurementIdentity(db, kind = "STRESS", source = "colmi", key = "stress") + adoptStableMeasurementIdentity(db, kind = "TEMPERATURE", source = "live", key = "temp") + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_measurements_kindRaw_timestamp_sourceRaw` " + + "ON `measurements` (`kindRaw`, `timestamp`, `sourceRaw`)" + ) + } + + private fun adoptStableMeasurementIdentity( + db: SupportSQLiteDatabase, + kind: String, + source: String, + key: String, + ) { + db.execSQL( + """ + UPDATE `measurements` + SET `id` = 'history:$key:' || `timestamp` + WHERE `kindRaw` = '$kind' AND `sourceRaw` = '$source' + AND `rowid` IN ( + SELECT MIN(`rowid`) FROM `measurements` + WHERE `kindRaw` = '$kind' AND `sourceRaw` = '$source' + GROUP BY `timestamp` + ) + AND NOT EXISTS ( + SELECT 1 FROM `measurements` AS `existing` + WHERE `existing`.`id` = 'history:$key:' || `measurements`.`timestamp` + ) + """.trimIndent() + ) } fun getInstance(context: Context): PulseLoopDatabase = @@ -258,6 +305,7 @@ abstract class PulseLoopDatabase : RoomDatabase() { MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, + MIGRATION_13_14, ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index 5ef7395..e31f124 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -67,40 +67,8 @@ interface MeasurementDao { @Insert suspend fun insert(measurement: MeasurementEntity) - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertByIdentity(measurement: MeasurementEntity): Long - - @Query(""" - UPDATE measurements - SET value = :value, unit = :unit, confidenceRaw = :confidenceRaw, - activitySessionId = :activitySessionId, rawPacketId = :rawPacketId - WHERE kindRaw = :kindRaw AND timestamp = :timestamp AND sourceRaw = :sourceRaw - """) - suspend fun updateByIdentity( - kindRaw: String, - timestamp: Long, - sourceRaw: String, - value: Double, - unit: String, - confidenceRaw: String, - activitySessionId: String?, - rawPacketId: String?, - ): Int - - @Transaction - suspend fun upsertByIdentity(measurement: MeasurementEntity) { - val updated = updateByIdentity( - kindRaw = measurement.kindRaw, - timestamp = measurement.timestamp, - sourceRaw = measurement.sourceRaw, - value = measurement.value, - unit = measurement.unit, - confidenceRaw = measurement.confidenceRaw, - activitySessionId = measurement.activitySessionId, - rawPacketId = measurement.rawPacketId, - ) - if (updated == 0) insertByIdentity(measurement) - } + @Upsert + suspend fun upsert(measurement: MeasurementEntity) @Query("DELETE FROM measurements WHERE sourceRaw = 'demo'") suspend fun clearDemo() diff --git a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt index 2e013f4..f33a2b9 100644 --- a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt @@ -49,7 +49,7 @@ data class DeviceEntity( Index("timestamp"), Index("activitySessionId"), Index("kindRaw"), - Index(value = ["kindRaw", "timestamp", "sourceRaw"], unique = true), + Index(value = ["kindRaw", "timestamp", "sourceRaw"]), ], ) data class MeasurementEntity( diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt index 06c59e8..1ae6810 100644 --- a/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt +++ b/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt @@ -11,6 +11,8 @@ import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.* import com.pulseloop.MainActivity import com.pulseloop.coach.config.CoachSleepSyncGate @@ -21,12 +23,16 @@ import com.pulseloop.data.entity.CoachNotificationRecordEntity import com.pulseloop.ring.PulseEvent import com.pulseloop.ring.PulseEventBus import com.pulseloop.ring.RingBLEClient +import com.pulseloop.ring.RingConnectionState import com.pulseloop.service.loadPersistedMeasurementSettings import com.pulseloop.service.loadPersistedUserProfile import com.pulseloop.settings.ApiKeyStore import kotlinx.coroutines.async +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.* import java.time.LocalDateTime @@ -264,8 +270,9 @@ class CoachNotificationWorker( awaitSyncDone() return } + if (isAppForeground()) return - val bleClient = RingBLEClient(applicationContext) + val bleClient = RingBLEClient(applicationContext, transientOwner = true) if (!bleClient.hasPermissions()) { // destroy(), not just drop the reference: the client's init-started connection // watchdog would otherwise keep firing into permission-less connect attempts. @@ -288,16 +295,33 @@ class CoachNotificationWorker( engine?.runStartup() } bleClient.connectLastKnown() + while (!doneSignal.isCompleted && !isAppForeground()) delay(500) + if (isAppForeground()) { + doneSignal.cancel() + return@withTimeoutOrNull + } doneSignal.await() } } finally { // destroy(), not disconnect(): the client's connection watchdog (started in init) // survives disconnect() and re-attaches the ring ~15s after the worker exits — // re-firing onConnected → a full runStartup, then holding the ring with no UI. - bleClient.destroy() + val releasedConnection = bleClient.destroy() + if (releasedConnection && !isAppForeground()) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged( + RingConnectionState.DISCONNECTED, + null, + ) + ) + } } } + private suspend fun isAppForeground(): Boolean = withContext(Dispatchers.Main.immediate) { + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + /** Give an in-flight sync (driven by whoever owns the live link) a bounded chance to finish. */ private suspend fun awaitSyncDone() { withTimeoutOrNull(SYNC_WAIT_TIMEOUT_MS) { diff --git a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt index 86304e2..e23a4ba 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt @@ -174,7 +174,11 @@ object ColmiDecoder { val stress = v[i].toInt() if (stress == 0) return@mapNotNull null val ts = base.plusMinutes((minutesInPrevious + (i - startIndex) * slotMin).toLong()).toInstant() - RingDecodedEvent.StressSample(value = stress, _timestamp = ts) + RingDecodedEvent.StressSample( + value = stress, + _timestamp = ts, + isHistory = true, + ) } } @@ -329,7 +333,8 @@ object ColmiDecoder { if (raw > 0) { events.add(RingDecodedEvent.TemperatureSample( celsius = raw.toDouble() / 100.0, - _timestamp = dayStart.plusMinutes((slot * interval).toLong()).toInstant() + _timestamp = dayStart.plusMinutes((slot * interval).toLong()).toInstant(), + isHistory = true, )) } slot++ @@ -389,7 +394,8 @@ object ColmiDecoder { if (raw > 0) { events.add(RingDecodedEvent.TemperatureSample( celsius = raw.toDouble() / 10.0 + 20.0, - _timestamp = dayStart.plusMinutes((s * timeSpan).toLong()).toInstant() + _timestamp = dayStart.plusMinutes((s * timeSpan).toLong()).toInstant(), + isHistory = true, )) } } diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index 243744c..48ce444 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -14,7 +14,13 @@ import kotlinx.coroutines.launch * Typed events published on the bus for subscribers to consume. */ sealed class PulseEvent { - data class DeviceStateChanged(val state: RingConnectionState, val address: String?, val firmware: String? = null, val name: String? = null) : PulseEvent() + data class DeviceStateChanged( + val state: RingConnectionState, + val address: String?, + val firmware: String? = null, + val name: String? = null, + val deviceType: RingDeviceType? = null, + ) : PulseEvent() /** Emitted on connect once the active wearable's type + capabilities are known (iOS #49 adds * the exact catalog model + advertised name so persistence can stamp them on the device). */ data class DeviceIdentified( @@ -52,9 +58,9 @@ sealed class PulseEvent { ) : PulseEvent() data class BloodSugarSample(val mgdl: Double, val timestamp: java.time.Instant) : PulseEvent() data class HistoryMeasurement(val kind: MeasurementKind, val value: Double, val timestamp: java.time.Instant) : PulseEvent() - data class StressSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() + data class StressSample(val value: Int, val timestamp: java.time.Instant, val isHistory: Boolean = false) : PulseEvent() data class HrvSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent() - data class TemperatureSample(val celsius: Double, val timestamp: java.time.Instant) : PulseEvent() + data class TemperatureSample(val celsius: Double, val timestamp: java.time.Instant, val isHistory: Boolean = false) : PulseEvent() data class SleepTimeline( val timestamp: java.time.Instant, val stages: List, @@ -90,6 +96,9 @@ object PulseEventBus { /** Non-suspending publish for use from non-coroutine contexts (callbacks). */ fun publishBlocking(event: PulseEvent) { - check(pending.trySend(event).isSuccess) { "Pulse event queue is closed" } + val result = pending.trySend(event) + if (result.isFailure) { + android.util.Log.e("PulseEventBus", "Pulse event queue rejected ${event.javaClass.simpleName}", result.exceptionOrNull()) + } } } diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index a25cb22..5533bd2 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -30,7 +30,10 @@ import java.util.UUID * Adding a new wearable = append one entry. */ @SuppressLint("MissingPermission") -class RingBLEClient(private val context: Context) { +class RingBLEClient( + private val context: Context, + private val transientOwner: Boolean = false, +) { /** Registry of supported wearables. Adding a wearable = append one entry. * @@ -132,7 +135,9 @@ class RingBLEClient(private val context: Context) { private val forgetLock = Any() @Volatile private var forgetPending = false @Volatile private var forgetFinalizing = false + @Volatile private var forgetGeneration = 0L private var forgetJob: Job? = null + private var forgetCompletion: CompletableDeferred? = null // MARK: GATT operation serialization // @@ -192,6 +197,8 @@ class RingBLEClient(private val context: Context) { @Volatile private var lastActivityAt: Long = 0L private var connectingStartedAt: Long = 0L private var watchdogJob: Job? = null + private var ownershipRetryJob: Job? = null + @Volatile private var watchdogReconnectPaused = false // MARK: Service-discovery gate // @@ -266,6 +273,7 @@ class RingBLEClient(private val context: Context) { // Pairing/connecting a ring is an explicit user intent — clear any stay-off flag from a // prior Disconnect so [connectLastKnown] isn't suppressed for this or the next session. prefs.edit().remove(USER_DISCONNECTED_KEY).apply() + watchdogReconnectPaused = false resetReconnectBackoff() // Normally discovery's name-derived family wins over the carousel choice @@ -304,6 +312,11 @@ class RingBLEClient(private val context: Context) { * (official: maxReconnect = 10); the cap resets on user action or app foreground. */ fun connectLastKnown() { + watchdogReconnectPaused = false + connectLastKnownInternal() + } + + private fun connectLastKnownInternal() { if (!bluetoothAdapter.isEnabled) return // Honor a user-initiated Disconnect: stay off until the user reconnects ([userConnect]) // or pairs a new ring ([connectTo]). Every auto-reconnect path — foreground @@ -401,6 +414,7 @@ class RingBLEClient(private val context: Context) { private fun connectionWatchdogTick() { if (!bluetoothAdapter.isEnabled || !hasPermissions()) return + if (watchdogReconnectPaused) return // Time out a hung CONNECTING attempt FIRST — before the last-known-ring guard below — // so it also covers a first-ever pairing (no stored ring yet). The official QRing app // arms this timeout on every connect (mTimeoutRunnable). Without it, a first pair that @@ -447,13 +461,13 @@ class RingBLEClient(private val context: Context) { } RingConnectionState.DISCONNECTED, RingConnectionState.FAILED, - RingConnectionState.IDLE -> connectLastKnown() + RingConnectionState.IDLE -> connectLastKnownInternal() // CONNECTING is handled above (before the last-known-ring guard). else -> { // SCANNING: a pairing scan proceeds untouched, but a reconnect scan that // ran a full watchdog interval without sighting the ring moves on to the // next attempt (connectLastKnown counts the miss and alternates strategy). - if (reconnectScanPending) connectLastKnown() + if (reconnectScanPending) connectLastKnownInternal() } } } @@ -461,7 +475,7 @@ class RingBLEClient(private val context: Context) { /** Hard reset: drop the (possibly zombie) GATT and start a fresh connection. */ private fun forceReconnect() { // beginConnect (via connectLastKnown) closes the stale GATT before opening a new one. - connectLastKnown() + connectLastKnownInternal() } /** @@ -480,6 +494,7 @@ class RingBLEClient(private val context: Context) { try { gatt.disconnect() } catch (_: Exception) {} closeGattQuietly(gatt) } + releaseConnectionOwnership() connectingStartedAt = 0 updateState { copy(connectionState = RingConnectionState.FAILED, lastError = reason) } PulseEventBus.publishBlocking( @@ -496,6 +511,12 @@ class RingBLEClient(private val context: Context) { * toggled). Keeps the stored identity so [connectLastKnown] can reconnect later. */ fun disconnect() { + // Lifecycle/worker teardown is intentional. Leave genuine link-loss callbacks eligible + // for watchdog recovery, but do not let this client undo an explicit transient teardown. + watchdogReconnectPaused = true + ownershipRetryJob?.cancel(); ownershipRetryJob = null + val shouldPublishDisconnect = + _state.value.connectionState != RingConnectionState.DISCONNECTED stopKeepalive() scanner?.stopScan(scanCallback) val gatt = bluetoothGatt @@ -510,9 +531,17 @@ class RingBLEClient(private val context: Context) { scope.launch { delay(GATT_CLOSE_DELAY_MS) closeGattQuietly(gatt) + releaseConnectionOwnership() } + } else { + releaseConnectionOwnership() } updateState { copy(connectionState = RingConnectionState.DISCONNECTED) } + if (shouldPublishDisconnect) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) + ) + } } /** @@ -563,6 +592,27 @@ class RingBLEClient(private val context: Context) { * to an unconditional teardown if the ring is offline or never acks. */ fun forget() { + startForget() + } + + suspend fun forgetAndWait() { + startForget().await() + } + + private fun startForget(): CompletableDeferred { + ownershipRetryJob?.cancel(); ownershipRetryJob = null + val completion: CompletableDeferred + val forgetRequestGeneration: Long + synchronized(forgetLock) { + val inFlight = forgetCompletion + if ((forgetPending || forgetFinalizing) && inFlight != null) return inFlight + completion = CompletableDeferred() + forgetCompletion = completion + forgetGeneration++ + forgetPending = true + forgetFinalizing = false + forgetRequestGeneration = forgetGeneration + } // Clear the known-ring id up front so the watchdog/auto-reconnect can't grab // the ring back during or after the unbind window. prefs.edit() @@ -575,65 +625,75 @@ class RingBLEClient(private val context: Context) { val gatt = bluetoothGatt if (gatt != null && writeChar != null && _state.value.connectionState == RingConnectionState.CONNECTED) { - synchronized(forgetLock) { - forgetPending = true - forgetFinalizing = false - } enqueueWrite(RingEncoder.makeUnbindCommand()) // 0x4B 05 00 01 forgetJob?.cancel() forgetJob = scope.launch { delay(UNBIND_ACK_TIMEOUT_MS) if (synchronized(forgetLock) { forgetPending && !forgetFinalizing }) { Log.w("RingBLEClient", "Unbind ACK not received in ${UNBIND_ACK_TIMEOUT_MS}ms — forcing teardown") - finalizeForget() + finalizeForget(expectedGeneration = forgetRequestGeneration) } } } else { - finalizeForget() + finalizeForget(expectedGeneration = forgetRequestGeneration) } + return completion } /** Tear down the link: clear the GATT cache, remove any OS bond, close the GATT. */ - private fun finalizeForget() { - synchronized(forgetLock) { + private fun finalizeForget(expectedGeneration: Long? = null) { + val completion = synchronized(forgetLock) { + if (expectedGeneration != null && expectedGeneration != forgetGeneration) return if (forgetFinalizing) return forgetFinalizing = true + forgetGeneration++ + forgetJob?.cancel(); forgetJob = null + forgetCompletion } - forgetJob?.cancel(); forgetJob = null - stopKeepalive() - scanner?.stopScan(scanCallback) - activeDriver?.connectionDidEnd() - bluetoothGatt?.let { gatt -> - try { gatt::class.java.getMethod("refresh").invoke(gatt) } catch (_: Exception) {} - try { gatt.device::class.java.getMethod("removeBond").invoke(gatt.device) } catch (_: Exception) {} - gatt.disconnect() - gatt.close() - } - bluetoothGatt = null - writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null - resetOpQueue() - prefs.edit() - .remove(LAST_PERIPHERAL_KEY) - .remove(LAST_DEVICE_TYPE_KEY) - .remove(LAST_WEARABLE_MODEL_KEY) - .remove(USER_DISCONNECTED_KEY) - .apply() - activeAdvertisedName = null - updateState { - copy( - connectionState = RingConnectionState.IDLE, - activeDeviceType = null, - activeWearableModelID = null, - activeCapabilities = emptySet(), - ) - } - PulseEventBus.publishBlocking(PulseEvent.DeviceForgotten) - synchronized(forgetLock) { - forgetPending = false - forgetFinalizing = false + try { + stopKeepalive() + scanner?.stopScan(scanCallback) + activeDriver?.connectionDidEnd() + bluetoothGatt?.let { gatt -> + try { gatt::class.java.getMethod("refresh").invoke(gatt) } catch (_: Exception) {} + try { gatt.device::class.java.getMethod("removeBond").invoke(gatt.device) } catch (_: Exception) {} + try { gatt.disconnect() } catch (_: Exception) {} + try { gatt.close() } catch (_: Exception) {} + } + bluetoothGatt = null + releaseConnectionOwnership() + writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null + resetOpQueue() + prefs.edit() + .remove(LAST_PERIPHERAL_KEY) + .remove(LAST_DEVICE_TYPE_KEY) + .remove(LAST_WEARABLE_MODEL_KEY) + .remove(USER_DISCONNECTED_KEY) + .apply() + activeAdvertisedName = null + updateState { + copy( + connectionState = RingConnectionState.IDLE, + activeDeviceType = null, + activeWearableModelID = null, + activeCapabilities = emptySet(), + ) + } + PulseEventBus.publishBlocking(PulseEvent.DeviceForgotten) + } finally { + synchronized(forgetLock) { + if (forgetCompletion === completion) forgetCompletion = null + forgetPending = false + forgetFinalizing = false + } + completion?.complete(Unit) } } + private fun acceptsCallback(generation: Long): Boolean = synchronized(forgetLock) { + !forgetPending && generation == forgetGeneration + } + fun enqueueWrite(data: ByteArray) { val framed = activeDriver?.frame(data) ?: data val useCommand = activeDriver?.usesCommandChannel(framed) ?: false @@ -670,6 +730,22 @@ class RingBLEClient(private val context: Context) { selectedModelID: String? = null, advertisedName: String? = null, ) { + if (watchdogReconnectPaused) return + if (synchronized(forgetLock) { forgetPending || forgetFinalizing }) return + if (!claimConnectionOwnership()) { + Log.i("RingBLEClient", "Connection skipped: another PulseLoop BLE client owns GATT") + updateState { copy(connectionState = RingConnectionState.DISCONNECTED) } + if (!transientOwner) { + ownershipRetryJob?.cancel() + ownershipRetryJob = scope.launch { + delay(PROCESS_OWNER_RETRY_MS) + ownershipRetryJob = null + beginConnect(target, deviceType, selectedModelID, advertisedName) + } + } + return + } + ownershipRetryJob = null scanner?.stopScan(scanCallback) // Close any stale GATT from a previous (now-dead) connection before opening a new // one. Reconnect attempts after an idle drop would otherwise leak GATT clients and @@ -799,6 +875,11 @@ class RingBLEClient(private val context: Context) { } private fun installDriver(coordinator: WearableCoordinator) { + synchronized(forgetLock) { + forgetGeneration++ + forgetPending = false + forgetFinalizing = false + } val driver = coordinator.makeDriver { enqueueWrite(it) } activeCoordinator = coordinator activeDriver = driver @@ -1403,15 +1484,15 @@ class RingBLEClient(private val context: Context) { lastActivityAt = System.currentTimeMillis() // inbound notify — link is alive val value = characteristic.value ?: return val uuid = characteristic.uuid.toString() + val callbackForgetGeneration = forgetGeneration // Standard BLE health services — read before the ring-service guard if (uuid.startsWith("00002a35")) { - synchronized(forgetLock) { - if (forgetPending) return - // Blood Pressure Measurement — IEEE 11073 SFLOAT - if (value.size >= 7) { - val systolic = decodeSFLOAT(value[1], value[2]) - val diastolic = decodeSFLOAT(value[3], value[4]) + // Blood Pressure Measurement — IEEE 11073 SFLOAT + if (value.size >= 7) { + val systolic = decodeSFLOAT(value[1], value[2]) + val diastolic = decodeSFLOAT(value[3], value[4]) + if (acceptsCallback(callbackForgetGeneration)) { PulseEventBus.publishBlocking( PulseEvent.BloodPressureSample( systolic = systolic.toInt(), @@ -1424,11 +1505,10 @@ class RingBLEClient(private val context: Context) { return } if (uuid.startsWith("00002a18")) { - synchronized(forgetLock) { - if (forgetPending) return - // Glucose Measurement — IEEE 11073 SFLOAT in kg/L → mg/dL - if (value.size >= 12) { - val glucoseKgL = decodeSFLOAT(value[10], value[11]) + // Glucose Measurement — IEEE 11073 SFLOAT in kg/L → mg/dL + if (value.size >= 12) { + val glucoseKgL = decodeSFLOAT(value[10], value[11]) + if (acceptsCallback(callbackForgetGeneration)) { PulseEventBus.publishBlocking(PulseEvent.HistoryMeasurement(MeasurementKind.BLOOD_SUGAR, glucoseKgL * 100000.0, java.time.Instant.now())) } } @@ -1438,43 +1518,42 @@ class RingBLEClient(private val context: Context) { val driver = activeDriver ?: return if (!driver.notifyUUIDs.any { it == characteristic.uuid.toString() }) return - var shouldFinalizeForget = false - synchronized(forgetLock) { - // Raw seam: reply payloads the decoded-event stream doesn't carry - // (e.g. Colmi pref-read replies seeding the measurement config). - if (!forgetPending) activeSyncEngine?.handleRawNotify(value) - - val decodedEvents = driver.ingest(value, characteristic.uuid.toString()) - if (!forgetPending) { - val diagnostic = decodedEvents.firstOrNull() ?: RingDecodedEvent.Unknown( - commandId = value.firstOrNull()?.toUByte() ?: 0u, - raw = value, - ) - PulseEventBus.publishBlocking( - PulseEvent.RawPacket(PacketDirection.INCOMING, value, diagnostic) - ) - } + // Raw seam: reply payloads the decoded-event stream doesn't carry + // (e.g. Colmi pref-read replies seeding the measurement config). + if (acceptsCallback(callbackForgetGeneration)) { + activeSyncEngine?.handleRawNotify(value) + } + + val decodedEvents = driver.ingest(value, characteristic.uuid.toString()) + if (acceptsCallback(callbackForgetGeneration)) { + val diagnostic = decodedEvents.firstOrNull() ?: RingDecodedEvent.Unknown( + commandId = value.firstOrNull()?.toUByte() ?: 0u, + raw = value, + ) + PulseEventBus.publishBlocking( + PulseEvent.RawPacket(PacketDirection.INCOMING, value, diagnostic) + ) for (decoded in decodedEvents) { - // A forget is in flight: don't persist any more data or re-publish a - // "connected" device state (which would re-create the row we're clearing). - // Just watch for the ring's unbind ack (6 = UNBOND_ACK, 3 = ACK_CANCEL). - if (forgetPending) { - if (decoded is RingDecodedEvent.BindNotify && - (decoded.action == 6 || decoded.action == 3)) { - shouldFinalizeForget = true - } - continue - } + if (!acceptsCallback(callbackForgetGeneration)) break if (decoded is RingDecodedEvent.SupportFunctions) { refineActiveCapabilities(decoded.capabilities) } for (event in RingEventBridge.eventsFor(decoded)) { + if (!acceptsCallback(callbackForgetGeneration)) break PulseEventBus.publishBlocking(event) } - activeSyncEngine?.handle(decoded) + if (acceptsCallback(callbackForgetGeneration)) activeSyncEngine?.handle(decoded) } + return } - if (shouldFinalizeForget) finalizeForget() + val shouldFinalizeForget = synchronized(forgetLock) { + forgetPending && callbackForgetGeneration == forgetGeneration && + decodedEvents.any { decoded -> + decoded is RingDecodedEvent.BindNotify && + (decoded.action == 6 || decoded.action == 3) + } + } + if (shouldFinalizeForget) finalizeForget(expectedGeneration = callbackForgetGeneration) } @@ -1545,7 +1624,10 @@ class RingBLEClient(private val context: Context) { PulseEventBus.publishBlocking( PulseEvent.DeviceStateChanged( - RingConnectionState.CONNECTED, device.address, name = device.name ?: connectingName + RingConnectionState.CONNECTED, + device.address, + name = device.name ?: connectingName, + deviceType = activeCoordinator?.deviceType, ) ) activeCoordinator?.let { coord -> @@ -1627,6 +1709,7 @@ class RingBLEClient(private val context: Context) { writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null subscriptionGate = null closeGattQuietly(gatt) + releaseConnectionOwnership() PulseEventBus.publishBlocking( PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) @@ -1645,8 +1728,10 @@ class RingBLEClient(private val context: Context) { } } - fun destroy() { + /** Tear down this client. Returns true only if it owned PulseLoop's process-wide GATT slot. */ + fun destroy(): Boolean { watchdogJob?.cancel() + ownershipRetryJob?.cancel(); ownershipRetryJob = null stopKeepalive() scanner?.stopScan(scanCallback) activeDriver?.connectionDidEnd() @@ -1657,10 +1742,20 @@ class RingBLEClient(private val context: Context) { closeGattQuietly(gatt) } bluetoothGatt = null + val releasedConnection = releaseConnectionOwnership() scope.cancel() updateState { copy(connectionState = RingConnectionState.DISCONNECTED) } + return releasedConnection + } + + private fun claimConnectionOwnership(): Boolean { + val owner = processConnectionOwner.get() + return owner === this || (owner == null && processConnectionOwner.compareAndSet(null, this)) } + private fun releaseConnectionOwnership(): Boolean = + processConnectionOwner.compareAndSet(this, null) + companion object { private const val LAST_PERIPHERAL_KEY = "ring.lastPeripheralIdentifier" private const val LAST_DEVICE_TYPE_KEY = "ring.lastDeviceType" @@ -1668,6 +1763,11 @@ class RingBLEClient(private val context: Context) { /** Set when the user taps Disconnect; suppresses every auto-reconnect path * (foreground, watchdog, background worker) until the user reconnects or re-pairs. */ private const val USER_DISCONNECTED_KEY = "ring.userDisconnected" + /** A ring has one command stream; never let this process open competing GATT clients. */ + private val processConnectionOwner = + java.util.concurrent.atomic.AtomicReference(null) + /** Foreground retry delay while a short-lived background client yields ownership. */ + private const val PROCESS_OWNER_RETRY_MS = 1_000L /** How often the liveness watchdog runs. */ private const val WATCHDOG_INTERVAL_MS = 15_000L /** No GATT activity for this long while CONNECTED ⇒ zombie link ⇒ reconnect. diff --git a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt index 72bbbf4..f9dbd8d 100644 --- a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt +++ b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt @@ -261,7 +261,8 @@ sealed class RingDecodedEvent { data class StressSample( val value: Int, - val _timestamp: Instant + val _timestamp: Instant, + val isHistory: Boolean = false, ) : RingDecodedEvent() { override val kind = "stress_sample" override val confidence = DecodeConfidence.KNOWN @@ -279,7 +280,8 @@ sealed class RingDecodedEvent { data class TemperatureSample( val celsius: Double, - val _timestamp: Instant + val _timestamp: Instant, + val isHistory: Boolean = false, ) : RingDecodedEvent() { override val kind = "temperature_sample" override val confidence = DecodeConfidence.KNOWN diff --git a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt index 4aea3fc..73bb1af 100644 --- a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt +++ b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt @@ -51,7 +51,7 @@ object RingEventBridge { is RingDecodedEvent.StressSample -> { if (decoded.value !in stressRange) emptyList() - else listOf(PulseEvent.StressSample(decoded.value, decoded._timestamp)) + else listOf(PulseEvent.StressSample(decoded.value, decoded._timestamp, decoded.isHistory)) } is RingDecodedEvent.HrvSample -> { @@ -61,7 +61,7 @@ object RingEventBridge { is RingDecodedEvent.TemperatureSample -> { if (decoded.celsius !in temperatureRange) emptyList() - else listOf(PulseEvent.TemperatureSample(decoded.celsius, decoded._timestamp)) + else listOf(PulseEvent.TemperatureSample(decoded.celsius, decoded._timestamp, decoded.isHistory)) } is RingDecodedEvent.HistorySyncProgress -> diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt index 7eb8aca..e373ace 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDecoder.kt @@ -112,9 +112,6 @@ class YCBTDecoder { val minor = p.getOrNull(2)?.toInt()?.and(0xFF) val firmware = if (major != null && minor != null) String.format("%d.%02d", major, minor) else null val events = mutableListOf(RingDecodedEvent.Status(address = null, firmware = firmware)) - if (major != null && minor != null) { - events.add(RingDecodedEvent.FirmwareVersion(version = major * 100 + minor)) - } if (p.size >= 6) { events.add(RingDecodedEvent.Battery(percent = p[5].toInt() and 0xFF)) } diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt index 4d7f7fa..23b5090 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt @@ -91,11 +91,13 @@ class YCBTDriver( YCBTGroup.DEV_CONTROL -> { acknowledgePush(frame) decoder.decode(frame).also { decoded -> - // A real measurement value proves a start succeeded even if its command - // reply was lost. Drop stale FIFO correlation before the next command. + // A real measurement value proves that mode's start succeeded even if its + // command reply was lost. Preserve unrelated pending correlations. if (frame.cmd == YCBTDevControl.MEASUREMENT_STATUS && decoded.any { it !is RingDecodedEvent.CommandAck }) { - pendingMeasurementReplies.clear() + frame.payload.firstOrNull()?.let { + pendingMeasurementReplies.discardStartedMode(it.toInt() and 0xFF) + } } } } @@ -143,7 +145,8 @@ class YCBTDriver( MeasurementKind.STRESS -> WearableCapability.STRESS in capabilities MeasurementKind.FATIGUE -> WearableCapability.FATIGUE in capabilities MeasurementKind.TEMPERATURE -> WearableCapability.TEMPERATURE in capabilities - MeasurementKind.RESPIRATORY_RATE, MeasurementKind.VO2MAX -> false + // These have no support-function bits. Valid history values are data-gated in the UI. + MeasurementKind.RESPIRATORY_RATE, MeasurementKind.VO2MAX -> true } else -> true } @@ -173,6 +176,21 @@ internal class PendingMeasurementReplies { @Synchronized fun consume(): Reply? = if (replies.isEmpty()) null else replies.removeFirst() + @Synchronized + fun discardStartedMode(mode: Int) { + val retained = ArrayDeque() + var discarded = false + while (replies.isNotEmpty()) { + val reply = replies.removeFirst() + if (!discarded && reply.startedMode == mode) { + discarded = true + } else { + retained.addLast(reply) + } + } + replies.addAll(retained) + } + @Synchronized fun clear() { replies.clear() diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt index cabae83..28943cb 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt @@ -240,11 +240,12 @@ object YCBTHealthRecords { return "$integer.$fraction".toDoubleOrNull() ?: integer.toDouble() } - /** Digit-concatenated score (used for stress/fatigue on a 1…100 scale). */ + /** UNVERIFIED: digit-concatenated score inferred for stress/fatigue on a 1…100 scale. */ fun score(integer: Int, fraction: Int): Double { return "$integer$fraction".toDoubleOrNull() ?: integer.toDouble() } + // UNVERIFIED: hardware payloads look like tenths of mmol/L; no vendor ground truth yet. const val MGDL_PER_MMOL = 18.016 fun bloodSugarMgdl(tenthsOfMmol: Int): Double { diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt index aa58ad4..2987633 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt @@ -148,8 +148,11 @@ class YCBTHistoryTransfer( if (payload.size < YCBTHealth.TERMINAL_PAYLOAD_LENGTH) return advance() val packets = YCBTBytes.u16(payload, 0) val bytes = YCBTBytes.u16(payload, 2) - if (packets != expectedPackets || bytes != expectedBytes || bytes != buffer.size) { - return emptyList() + val terminalMatchesHeader = packets == expectedPackets && bytes == expectedBytes + val terminalMatchesBuffer = bytes == buffer.size + if (!terminalMatchesHeader || !terminalMatchesBuffer) { + writer?.enqueue(YCBTHealthCommand.historyBlockAck(status = YCBTHealth.ACK_CRC_FAILURE)) + return retryOrSkip(type) } val expected = YCBTBytes.u16(payload, 4) val matches = YCBTFrame.crc16(buffer) == expected diff --git a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt index 3b993e0..0581ce2 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTProtocol.kt @@ -178,11 +178,9 @@ object YCBTMeasurementMode { const val HEART_RATE: Int = 0x00 const val BLOOD_PRESSURE: Int = 0x01 const val SPO2: Int = 0x02 - const val RESPIRATORY_RATE: Int = 0x03 const val TEMPERATURE: Int = 0x04 const val BLOOD_SUGAR: Int = 0x05 const val URIC_ACID: Int = 0x06 - const val BLOOD_KETONE: Int = 0x07 const val BLOOD_FAT: Int = 0x09 const val HRV: Int = 0x0a const val STRESS: Int = 0x0c diff --git a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt index 96bde63..1c7debf 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTSyncEngine.kt @@ -6,7 +6,7 @@ package com.pulseloop.ring */ class YCBTSyncEngine( - private var writer: RingCommandWriter?, + private val writer: RingCommandWriter?, private val transfer: YCBTHistoryTransfer, private val profile: YCBTFamilyProfile = YCBTFamilyProfile( baselineCapabilities = YCBTCoordinator.capabilities, diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 10cbf99..175de78 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -82,13 +82,16 @@ class EventPersistenceSubscriber( RingConnectionState.CONNECTED -> { db.measurementDao().clearDemo() db.activityDailyDao().clearDemo() - // Never clear real sleep on CONNECTED. YCBT emits Status/CONNECTED more - // than once during startup, and history arrives asynchronously; clearing - // here made a complete night flash, partially rebuild, then disappear. - // Sleep upserts are timestamp-idempotent, so preserve the last good local - // copy until replacement packets arrive. - db.sleepStageBlockDao().clearDemo() - db.sleepSessionDao().clearDemo() + if (preservesSleepOnConnect(event.deviceType, existing?.deviceType)) { + // YCBT status packets re-emit CONNECTED while history is still arriving. + db.sleepStageBlockDao().clearDemo() + db.sleepSessionDao().clearDemo() + } else { + // Packet-based families rebuild sleep on connect. Clear blocks with + // sessions so legacy midnight-keyed blocks cannot contaminate a rebuild. + db.sleepStageBlockDao().clear() + db.sleepSessionDao().clear() + } "CONNECTED" } RingConnectionState.DISCONNECTED -> "DISCONNECTED" @@ -126,15 +129,9 @@ class EventPersistenceSubscriber( )) } is PulseEvent.DeviceForgotten -> { - // Mirror iOS: forgetting clears the stored model identity (the row itself is - // cleared by the Forget flow; this covers a row that survives, e.g. offline forget). - db.deviceDao().currentReal()?.let { device -> - db.deviceDao().upsert(device.copy( - wearableModelID = null, - advertisedName = null, - updatedAt = System.currentTimeMillis(), - )) - } + // This event is queued after all earlier ring events, so deleting here prevents a + // pre-forget CONNECTED event still in the bus from recreating the cleared row. + db.deviceDao().currentReal()?.let { db.deviceDao().deleteById(it.id) } } is PulseEvent.BatteryLevel -> { val device = db.deviceDao().currentReal() ?: DeviceEntity() @@ -162,7 +159,7 @@ class EventPersistenceSubscriber( )) } is PulseEvent.HistoryMeasurement -> { - db.measurementDao().upsertByIdentity(MeasurementEntity( + db.measurementDao().upsert(MeasurementEntity( id = historyMeasurementId(event.kind, event.timestamp.toEpochMilli()), kindRaw = event.kind.name, value = event.value, unit = event.kind.unit, @@ -171,12 +168,19 @@ class EventPersistenceSubscriber( )) } is PulseEvent.StressSample -> { - db.measurementDao().insert(MeasurementEntity( + val measurement = MeasurementEntity( + id = if (event.isHistory) { + historyMeasurementId(MeasurementKind.STRESS, event.timestamp.toEpochMilli()) + } else { + java.util.UUID.randomUUID().toString() + }, kindRaw = MeasurementKind.STRESS.name, value = event.value.toDouble(), unit = "", timestamp = event.timestamp.toEpochMilli(), sourceRaw = "colmi", - )) + ) + if (event.isHistory) db.measurementDao().upsert(measurement) + else db.measurementDao().insert(measurement) } is PulseEvent.HrvSample -> { db.measurementDao().insert(MeasurementEntity( @@ -210,12 +214,17 @@ class EventPersistenceSubscriber( timestamp = event.timestamp.toEpochMilli(), sourceRaw = if (event.isHistory) "history" else "live", ) - db.measurementDao().upsertByIdentity(systolic) - db.measurementDao().upsertByIdentity(diastolic) + if (event.isHistory) { + db.measurementDao().upsert(systolic) + db.measurementDao().upsert(diastolic) + } else { + db.measurementDao().insert(systolic) + db.measurementDao().insert(diastolic) + } } } is PulseEvent.BloodSugarSample -> { - db.measurementDao().upsertByIdentity(MeasurementEntity( + db.measurementDao().insert(MeasurementEntity( kindRaw = MeasurementKind.BLOOD_SUGAR.name, value = event.mgdl, unit = MeasurementKind.BLOOD_SUGAR.unit, @@ -224,12 +233,19 @@ class EventPersistenceSubscriber( )) } is PulseEvent.TemperatureSample -> { - db.measurementDao().insert(MeasurementEntity( + val measurement = MeasurementEntity( + id = if (event.isHistory) { + historyMeasurementId(MeasurementKind.TEMPERATURE, event.timestamp.toEpochMilli()) + } else { + java.util.UUID.randomUUID().toString() + }, kindRaw = MeasurementKind.TEMPERATURE.name, value = event.celsius, unit = "°C", timestamp = event.timestamp.toEpochMilli(), sourceRaw = "live", - )) + ) + if (event.isHistory) db.measurementDao().upsert(measurement) + else db.measurementDao().insert(measurement) } is PulseEvent.ActivityUpdate -> { upsertActivityDaily(event.timestamp.toEpochMilli(), event.steps, event.calories, event.distanceMeters) @@ -585,6 +601,16 @@ class EventPersistenceSubscriber( internal fun historyMeasurementId(kind: MeasurementKind, timestamp: Long): String = "history:${kind.key}:$timestamp" +internal fun preservesSleepOnConnect( + eventDeviceType: RingDeviceType?, + persistedDeviceType: RingDeviceType? = null, +): Boolean = when (eventDeviceType ?: persistedDeviceType) { + // All three identifiers use YCBTDriver and share its repeated status packets plus async + // history transfer. Packet-based Colmi/Jring/CRP families still clear and rebuild on connect. + RingDeviceType.YCBT, RingDeviceType.TK5, RingDeviceType.COLMI_SMART_HEALTH -> true + else -> false +} + internal fun shouldReplaceCompleteSleep( existingStart: Long, existingMinutes: Int, diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index 6de09a3..21c5525 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -102,10 +102,9 @@ class RingSyncCoordinator( * with no result — at 40s the outcome is a coin toss where the user watches the ring's * red LED work and gets an error anyway. */ const val SPO2_MEASURE_SECONDS = 60 - /** Upper-bound for a sequential HR+SpO₂ spot measurement; drives the UI countdown. + /** Intentional UX upper bound for sequential HR + SpO₂ + BP + HRV; drives the countdown. * Derived from the legs so the countdown can't desync when one is tuned. Post-#66 the - * HR leg samples its full window by design (no early exit), so this is a real bound, - * not slack. */ + * HR leg samples its full window by design, so this is a real bound, not slack. */ const val BP_MEASURE_SECONDS = 40 const val HRV_MEASURE_SECONDS = 40 const val SPOT_MEASURE_SECONDS = @@ -334,11 +333,18 @@ class RingSyncCoordinator( * cleanup (e.g. clearing the device row) without tying it to a screen's lifecycle. */ fun forgetRing(onCleared: suspend () -> Unit) { - // client.forget() already sends the protocol unbind, waits for the ack, removes any + // client.forgetAndWait() sends the protocol unbind, waits for the ack, removes any // OS bond, and clears the stored peripheral. That is the whole forget. scope.launch { - client.forget() - onCleared() + stop() + client.forgetAndWait() + try { + onCleared() + } finally { + // The coordinator is process-long; resume its event collector so a ring paired + // without restarting the app still receives sync and measurement events. + start() + } } } @@ -367,8 +373,13 @@ class RingSyncCoordinator( // otherwise a slow queue silently swallows the wipe the user confirmed. client.awaitOpsFlushed() } - client.forget() - onCleared() + stop() + client.forgetAndWait() + try { + onCleared() + } finally { + start() + } } } @@ -477,11 +488,11 @@ class RingSyncCoordinator( engine?.startBloodPressure() var result: Pair? = null try { - for (step in 0 until BP_MEASURE_SECONDS * 2) { - result = latestBloodPressure - if (result != null || bloodPressureNoReadingReported || spot.isRejected(spotToken)) break - delay(500) - } + result = pollForValue( + BP_MEASURE_SECONDS.toLong(), + { latestBloodPressure }, + { bloodPressureNoReadingReported || spot.isRejected(spotToken) }, + ) } finally { spot.end(spotToken) engine?.stopBloodPressure() @@ -535,11 +546,11 @@ class RingSyncCoordinator( } } - private suspend fun pollForValue( + private suspend fun pollForValue( windowSec: Long, - value: () -> Int?, + value: () -> T?, abort: () -> Boolean, - ): Int? { + ): T? { val steps = (windowSec * 2).toInt() repeat(steps) { value()?.let { return it } diff --git a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt index 779a743..7cb68bb 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt @@ -1,11 +1,18 @@ package com.pulseloop.service import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.* import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.ring.PulseEvent +import com.pulseloop.ring.PulseEventBus import com.pulseloop.ring.RingBLEClient +import com.pulseloop.ring.RingConnectionState import com.pulseloop.settings.ApiKeyStore import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.util.concurrent.TimeUnit @@ -55,6 +62,11 @@ class RingSyncWorker( } override suspend fun doWork(): Result { + // The foreground app owns the live GATT. An overdue periodic job can start at the same + // moment as MainActivity after process launch; opening a second client interleaves both + // command queues and lets this worker's cleanup disconnect the UI-owned session. + if (isAppForeground()) return Result.success() + val keyStore = ApiKeyStore(applicationContext) val db = PulseLoopDatabase.getInstance(applicationContext) @@ -63,7 +75,7 @@ class RingSyncWorker( val device = db.deviceDao().currentReal() if (device == null) return Result.success() - val bleClient = RingBLEClient(applicationContext) + val bleClient = RingBLEClient(applicationContext, transientOwner = true) // Load the persisted measurement config + profile up front so the connect // handshake pushes the user's saved settings. Null (never saved) makes the @@ -94,22 +106,38 @@ class RingSyncWorker( while (!connected && waited < 20_000L) { delay(1000) waited += 1000 + if (isAppForeground()) return@withTimeout Result.success() } if (!connected) { - bleClient.disconnect() return@withTimeout Result.success() } - // Let data stream in for a few seconds after connect - delay(15_000L) + // Let data stream in after connect, but yield ownership promptly if the app opens. + repeat(15) { + delay(1000L) + if (isAppForeground()) return@withTimeout Result.success() + } - // Disconnect cleanly - bleClient.disconnect() Result.success() } } catch (e: Exception) { Result.retry() + } finally { + // This client is worker-owned. Cancel its watchdog as well as closing GATT so it + // cannot reconnect after doWork() has returned. + val releasedConnection = bleClient.destroy() + // A private client must not overwrite a foreground client's state during an ownership + // race. If the app remains backgrounded, persist the worker's actual teardown. + if (releasedConnection && !isAppForeground()) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) + ) + } } } + + private suspend fun isAppForeground(): Boolean = withContext(Dispatchers.Main.immediate) { + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } } diff --git a/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt index cd610c2..fe500a0 100644 --- a/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/ColmiDecoderTest.kt @@ -195,6 +195,7 @@ class ColmiDecoderTest { val events = ColmiDecoder.decodeBigData(frame, zone = zone) val temps = events.filterIsInstance() assertEquals(35.0, temps.first().celsius, 0.01) + assertTrue(temps.first().isHistory) } @Test diff --git a/app/src/test/java/com/pulseloop/ring/ColmiQringParityTest.kt b/app/src/test/java/com/pulseloop/ring/ColmiQringParityTest.kt index a672cef..fe9c812 100644 --- a/app/src/test/java/com/pulseloop/ring/ColmiQringParityTest.kt +++ b/app/src/test/java/com/pulseloop/ring/ColmiQringParityTest.kt @@ -268,6 +268,7 @@ class ColmiQringParityTest { assertEquals(1, events.size) val sample = events.single() as RingDecodedEvent.TemperatureSample assertEquals(36.26, sample.celsius, 0.001) + assertTrue(sample.isHistory) val expectedDay = LocalDate.now(zone).minusDays(1).atStartOfDay(zone).toInstant() assertEquals(expectedDay, sample._timestamp) diff --git a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt index 6ad4b90..a9399f1 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTDriverTest.kt @@ -167,6 +167,22 @@ class YCBTDriverTest { assertEquals(YCBTMeasurementMode.SPO2, (refusal.single() as RingDecodedEvent.MeasurementRejected).mode) } + @Test + fun `measurement data preserves pending replies for other modes`() { + val driver = YCBTDriver(RingCommandWriter { }) + + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, YCBTMeasurementMode.HEART_RATE.toByte())) + driver.frame(byteArrayOf(0x03, 0x2f, 0x01, YCBTMeasurementMode.SPO2.toByte())) + driver.ingest( + YCBTFrame.frame(byteArrayOf(0x04, 0x13, YCBTMeasurementMode.HEART_RATE.toByte(), 0, 72)), + streamUUID, + ) + + val refusal = driver.ingest(YCBTFrame.frame(byteArrayOf(0x03, 0x2f, 0x01)), streamUUID) + + assertEquals(YCBTMeasurementMode.SPO2, (refusal.single() as RingDecodedEvent.MeasurementRejected).mode) + } + @Test fun `pending measurement replies preserve deterministic FIFO pairing`() { val replies = PendingMeasurementReplies() diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt index ef751bf..bd457a1 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHistoryTransferTest.kt @@ -25,11 +25,16 @@ class YCBTHistoryTransferTest { ) } - private fun terminal(packets: Int, buffer: ByteArray, crc: Int? = null): ByteArray { + private fun terminal( + packets: Int, + buffer: ByteArray, + crc: Int? = null, + reportedBytes: Int = buffer.size, + ): ByteArray { val checksum = crc ?: YCBTFrame.crc16(buffer) return byteArrayOf( (packets and 0xFF).toByte(), (packets shr 8).toByte(), - (buffer.size and 0xFF).toByte(), ((buffer.size shr 8) and 0xFF).toByte(), + (reportedBytes and 0xFF).toByte(), ((reportedBytes shr 8) and 0xFF).toByte(), (checksum and 0xFF).toByte(), ((checksum shr 8) and 0xFF).toByte(), ) } @@ -122,6 +127,39 @@ class YCBTHistoryTransferTest { assertArrayEquals(allQuery, writer.sent[1]) } + @Test + fun `missing data frame nacks and retries the type once`() { + val writer = FakeWriter() + val transfer = YCBTHistoryTransfer(writer = writer) + val firstFrame = heartBuffer.copyOfRange(0, 6) + transfer.start(types = listOf(YCBTHistoryType.HEART, YCBTHistoryType.ALL)) + + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 2, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = firstFrame) + writer.sent.clear() + + transfer.handle( + cmd = 0x80, + payload = terminal(packets = 2, buffer = firstFrame, reportedBytes = heartBuffer.size), + ) + + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(heartQuery, writer.sent[1]) + + transfer.handle(cmd = 0x06, payload = header(records = 2, packets = 2, bytes = heartBuffer.size)) + transfer.handle(cmd = 0x15, payload = firstFrame) + writer.sent.clear() + transfer.handle( + cmd = 0x80, + payload = terminal(packets = 2, buffer = firstFrame, reportedBytes = heartBuffer.size), + ) + + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) + } + @Test fun `no data header advances without acking`() { val writer = FakeWriter() @@ -152,7 +190,7 @@ class YCBTHistoryTransferTest { } @Test - fun `late terminal from skipped type cannot corrupt the next transfer`() { + fun `late terminal from skipped type causes only a bounded retry`() { val writer = FakeWriter() val transfer = YCBTHistoryTransfer(writer = writer) val allBuffer = ByteArray(20) { it.toByte() } @@ -166,7 +204,9 @@ class YCBTHistoryTransferTest { val events = transfer.handle(cmd = 0x80, payload = terminal(packets = 1, buffer = heartBuffer)) assertTrue(events.isEmpty()) - assertTrue(writer.sent.isEmpty()) + assertEquals(2, writer.sent.size) + assertArrayEquals(ackCrcFailure, writer.sent[0]) + assertArrayEquals(allQuery, writer.sent[1]) assertTrue(transfer.isActive) } diff --git a/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt b/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt index c4da062..d35124b 100644 --- a/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt +++ b/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt @@ -2,11 +2,26 @@ package com.pulseloop.service import com.pulseloop.data.entity.SleepStageBlockEntity import com.pulseloop.ring.MeasurementKind +import com.pulseloop.ring.RingDeviceType +import org.junit.Assert.assertFalse import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Test class EventPersistenceIdentityTest { + @Test + fun `only YCBT protocol families preserve sleep on connect`() { + assertTrue(preservesSleepOnConnect(RingDeviceType.YCBT)) + assertTrue(preservesSleepOnConnect(RingDeviceType.TK5)) + assertTrue(preservesSleepOnConnect(RingDeviceType.COLMI_SMART_HEALTH)) + assertTrue(preservesSleepOnConnect(null, RingDeviceType.YCBT)) + assertTrue(preservesSleepOnConnect(RingDeviceType.YCBT, RingDeviceType.JRING)) + assertFalse(preservesSleepOnConnect(RingDeviceType.JRING, RingDeviceType.YCBT)) + assertFalse(preservesSleepOnConnect(RingDeviceType.COLMI_R02)) + assertFalse(preservesSleepOnConnect(null)) + } + @Test fun `history identity is stable across repeated syncs`() { val timestamp = 1_721_234_567_000L From a2286ef9566cb27c97dea9f283dda7212ebd5297 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Wed, 22 Jul 2026 09:08:02 -0700 Subject: [PATCH 34/36] =?UTF-8?q?fix(ycbt):=20resolve=20review=20blockers?= =?UTF-8?q?=20=E2=80=94=20transfer=20deadlock=20and=20coordinator=20shadow?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the confirmed issues from the PR #31 review: - YCBTHistoryTransfer: publish watchdog-driven events OUTSIDE the transfer monitor. Holding it while `onOutOfBandEvents` re-enters the sync engine (which calls back into `append`) inverted the engine->transfer lock order the GATT-callback path uses — a classic AB/BA deadlock that could wedge all BLE notification processing during history sync. - YCBTCoordinator: narrow name matching to R10M only. It sits ahead of TK5Coordinator/ColmiSmartHealthCoordinator in the registry, so matching the TK5/T50/SR0x/R0x prefixes by name shadowed uncataloged units of those families and mis-bound them to RingDeviceType.YCBT. The be940000 service stays a positive signal (R10M-exclusive; not advertised by TK5/SmartHealth). - PulseEventBus: isolate each emit so one throwing event can't kill the single, non-restarting dispatcher and silently stop every subscriber. - RingBLEClient: read `inFlightOp` under `opLock` in the op-timeout path so a completion landing at the timeout can't trigger a spurious reconnect. - YCBTDriver: mark the cross-thread `capabilities` field @Volatile. - RingDecodedEvent: correct the stale MeasurementRejected doc (it now maps to PulseEvent.MeasurementRejected via RingEventBridge). Add regression tests locking in the R10M-only coordinator scope. Left as-is by design: onCharacteristicWrite's immediate recoverWedgedLink on a write failure (it can't tell a failed required-handshake write from a normal one, and advancing risks retiring a successor on the shared callback channel). --- .../java/com/pulseloop/ring/PulseEventBus.kt | 14 +++++++++++++- .../java/com/pulseloop/ring/RingBLEClient.kt | 6 +++++- .../com/pulseloop/ring/RingDecodedEvent.kt | 6 +++--- .../java/com/pulseloop/ring/YCBTCoordinator.kt | 10 +++++++--- .../main/java/com/pulseloop/ring/YCBTDriver.kt | 4 ++++ .../com/pulseloop/ring/YCBTHistoryTransfer.kt | 13 ++++++++++--- .../com/pulseloop/ring/PairingMatchingTest.kt | 18 ++++++++++++++++++ 7 files changed, 60 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index 48ce444..e3449c8 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -1,5 +1,6 @@ package com.pulseloop.ring +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -86,7 +87,18 @@ object PulseEventBus { init { dispatchScope.launch { - for (event in pending) _events.emit(event) + // The bus is process-long and single-drained: an uncaught throw here would kill the + // dispatcher and silently stop every subscriber for the rest of the process (the + // SupervisorJob does not restart it). Isolate each emit so one bad event can't do that. + for (event in pending) { + try { + _events.emit(event) + } catch (ce: CancellationException) { + throw ce + } catch (t: Throwable) { + android.util.Log.e("PulseEventBus", "Dropped ${event.javaClass.simpleName} on emit failure", t) + } + } } } diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 5533bd2..effe7d9 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -1067,7 +1067,11 @@ class RingBLEClient( // command then silence" failure. Time the op out and unblock the queue. scope.launch { delay(OP_TIMEOUT_MS) - if (inFlightOp === op) { + // Read under opLock: inFlightOp is written on the binder thread under the same + // lock, and without it a completion landing right at the timeout could be + // invisible here and trigger a spurious reconnect for an op that already retired. + val stillInFlight = synchronized(opLock) { inFlightOp === op } + if (stillInFlight) { Log.w("RingBLEClient", "GATT op ACK timed out — unblocking queue: ${opLabel(op)}") // Android does not identify a write callback beyond its characteristic. // Two successive protocol writes use the same characteristic, so a late diff --git a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt index f9dbd8d..6071b89 100644 --- a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt +++ b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt @@ -247,9 +247,9 @@ sealed class RingDecodedEvent { /** * The ring **refused** to start the spot measurement we asked for (YCBT `03 2f` answered with a * non-zero status). `mode` is the measurement mode we started — the reply itself carries only a - * status byte, so the mode comes from the start `YCBTDriver` remembers sending. Produces no - * `PulseEvent`: it is a verdict on a command, not data — `RingSyncCoordinator` reads it off the - * raw-packet feed and aborts the matching in-flight measurement. + * status byte, so the mode comes from the start `YCBTDriver` remembers sending. `RingEventBridge` + * maps it to `PulseEvent.MeasurementRejected`, which `RingSyncCoordinator` consumes to abort the + * matching in-flight measurement. */ data class MeasurementRejected( val mode: Int diff --git a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt index 3775d92..7ad101c 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTCoordinator.kt @@ -21,17 +21,21 @@ object YCBTCoordinator : WearableCoordinator { return model.family == deviceType && isSmartHealthName(name) } + // Only the R10M is claimed on name alone. This coordinator sits ahead of + // ColmiSmartHealthCoordinator/TK5Coordinator in the registry, so it must NOT match the + // other YCBT-family prefixes (TK5/T50/SR0x/R0x) by name — an uncataloged `TK5_xxxx` unit + // still relies on TK5Coordinator's own name/manufacturer fallback and must fall through. + // `YCBTUUIDs.SERVICE` (be940000) is the R10M's proprietary service and is not advertised by + // TK5 or the SmartHealth-Colmi rings, so it stays a safe positive signal here. val hasYcbtService = advertisement.serviceUUIDs.contains(YCBTUUIDs.SERVICE) val hasKnownName = isSmartHealthName(name) - // The 0x7810 manufacturer marker is common enough that it is corroborating - // evidence only, never sufficient to route an unknown peripheral. return hasYcbtService || hasKnownName } private fun isSmartHealthName(name: String?): Boolean { if (name == null) return false val normalized = name.trim().uppercase() - return Regex("^(?:R10M|TK5|T50|SR09|SR08|R08|R09)(?:[ _-][0-9A-Z]+)?$").matches(normalized) + return Regex("^R10M(?:[ _-][0-9A-Z]+)?$").matches(normalized) } override val capabilities = setOf( diff --git a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt index 23b5090..91dda64 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTDriver.kt @@ -28,6 +28,10 @@ class YCBTDriver( onOutOfBandEvents = ::handleOutOfBandEvents, ) private val pendingMeasurementReplies = PendingMeasurementReplies() + // Written on the GATT thread (updateCapabilities / connectionDidStart/End) and read on the + // watchdog thread via handleOutOfBandEvents → isSupported. Single-reference assignment, so + // @Volatile is enough to publish it across threads without a lock. + @Volatile private var capabilities = profile.baselineCapabilities override val serviceUUIDs = listOf(YCBTUUIDs.SERVICE) diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt index 2987633..2af2ea9 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHistoryTransfer.kt @@ -184,10 +184,17 @@ class YCBTHistoryTransfer( } } - @Synchronized private fun watchdogFired(type: YCBTHistoryType) { - if (state == State.IDLE || currentType != type) return - publishOutOfBand(advance()) + // Advance the state machine under the lock, then publish OUTSIDE it. `onOutOfBandEvents` + // re-enters the sync engine (which in turn calls back into this transfer's `append`), so + // holding the transfer monitor while publishing inverts the engine→transfer lock order the + // GATT-callback path uses and can deadlock. The `handle()` path already returns its events + // to the caller for exactly this reason; the watchdog path must do the same. + val events = synchronized(this) { + if (state == State.IDLE || currentType != type) return + advance() + } + publishOutOfBand(events) } private fun cancelWatchdog() { diff --git a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt index d9ec063..2e4f8eb 100644 --- a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt +++ b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt @@ -78,6 +78,24 @@ class PairingMatchingTest { } } + @Test + fun `YCBT does not shadow uncataloged TK5 or SmartHealth-family names`() { + // YCBTCoordinator is registered ahead of TK5Coordinator/ColmiSmartHealthCoordinator, so it + // must claim only the R10M by name — never the other YCBT-family prefixes. An uncataloged + // TK5/SR0x/R0x unit has to fall through to its own coordinator's name/manufacturer fallback. + for (name in listOf("TK5_1234", "TK5 1234", "T50_1234", "SR09_1234", "SR08_1234", "R08 1234", "R09 1234")) { + assertFalse("YCBT should not claim $name by name", YCBTCoordinator.matches(name, noAdv)) + } + // TK5's own fallback still recognizes the uncataloged unit. + assertTrue(TK5Coordinator.matches("TK5_1234", noAdv)) + } + + @Test + fun `YCBT still claims an R10M advertising only its proprietary service`() { + val adv = AdvertisementInfo(listOf(YCBTUUIDs.SERVICE), null) + assertTrue(YCBTCoordinator.matches("Unlabeled", adv)) + } + @Test fun `R10M catalog entry uses the discoverable retail name`() { assertTrue(WearableModel.R10M in WearableModel.CATALOG) From 27700cfe06f940a3e341d1c3f6da7a3a692d85fb Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Fri, 24 Jul 2026 18:01:00 -0700 Subject: [PATCH 35/36] fix(ycbt): re-key legacy HRV rows; correct the R10M card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colmi HRV *history* used to persist as an `HrvSample` — random id, sourceRaw 'live' — and now decodes to a `HistoryMeasurement`, keyed on `history:hrv:`. `adoptStableMeasurementIdentities` re-keyed the equivalent TEMPERATURE rows but not HRV, so the old rows never collided with the new ones: a re-sync wrote a second row at every timestamp already stored, and `range()` filters on kindRaw + timestamp (never sourceRaw), so both came back and the HRV series doubled. Adds the missing 'live' pass for HRV, mirroring TEMPERATURE, plus a v14 → v15 migration so a test APK already on v14 re-runs the (idempotent) adoption instead of keeping its un-keyed rows. Also fixes the R10M catalog entry: it no longer borrows Colmi R10 product art (it is a YCBT-protocol ring, not a Colmi one — it now falls back to the generic silhouette like TK5/TK18), and leads with the model number the ring actually advertises, "R10M (LittleMeatball)", since that is what every reseller of this white-label ODM shares while the brand is what a buyer recognises. --- .../com/pulseloop/data/PulseLoopDatabase.kt | 21 ++++++++++++++++++- .../com/pulseloop/wearables/WearableModel.kt | 19 ++++++++++++++--- .../com/pulseloop/ring/PairingMatchingTest.kt | 6 +++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index fb53063..dd3ba25 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -40,7 +40,7 @@ import com.pulseloop.data.entity.* BatterySampleEntity::class, CoachNotificationRecordEntity::class, ], - version = 14, + version = 15, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -233,6 +233,17 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** v14 → v15: re-run identity adoption now that it also covers HRV rows stored as 'live'. + * Same reasoning as v13 → v14: a test APK that already reached v14 ran the pre-fix version + * of [adoptStableMeasurementIdentities] and would otherwise keep its un-keyed HRV rows, + * which double the HRV series on the next re-sync. The function is idempotent, so re-running + * it is free for anyone whose rows are already adopted. */ + private val MIGRATION_14_15 = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + adoptStableMeasurementIdentities(db) + } + } + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") db.execSQL( @@ -256,6 +267,13 @@ abstract class PulseLoopDatabase : RoomDatabase() { } adoptStableMeasurementIdentity(db, kind = "STRESS", source = "colmi", key = "stress") adoptStableMeasurementIdentity(db, kind = "TEMPERATURE", source = "live", key = "temp") + // HRV needs the same 'live' pass as TEMPERATURE above, for the same reason: Colmi's + // HRV *history* used to persist as an `HrvSample` — random id, sourceRaw 'live' — and + // now decodes to a `HistoryMeasurement`, which keys on `history:hrv:`. + // Without re-keying the old rows they don't collide with the new ones, so a re-sync + // writes a second row at every timestamp already stored, and `range()` (which filters + // on kindRaw + timestamp, never sourceRaw) returns both — doubling the HRV series. + adoptStableMeasurementIdentity(db, kind = "HRV", source = "live", key = "hrv") db.execSQL( "CREATE INDEX IF NOT EXISTS `index_measurements_kindRaw_timestamp_sourceRaw` " + "ON `measurements` (`kindRaw`, `timestamp`, `sourceRaw`)" @@ -306,6 +324,7 @@ abstract class PulseLoopDatabase : RoomDatabase() { MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, + MIGRATION_14_15, ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and diff --git a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt index 151f4bd..05f9482 100644 --- a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt +++ b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt @@ -65,10 +65,23 @@ data class WearableModel( requiresOsBond = true) val COLMI_R12 = colmi("colmi-r12", "Colmi R12", "Colmi", "^COLMI R12_.*", R.drawable.ring_colmi_r12) - // YCBT / SmartHealth family (distinct protocol from QRing Colmi) + /** + * YCBT / SmartHealth family — a distinct protocol from the QRing Colmi rings above, so this + * runs [RingDeviceType.YCBT]'s driver, not the Colmi one, despite the R10-ish model number. + * + * "R10M" is a white-label ODM model sold under several reseller brands (LittleMeatball, + * Anarow, JTLlink, MOMOTECH). LittleMeatball is the one hardware-validated unit and the name + * a buyer is most likely to recognise, so it leads the display name. The pattern accepts + * both separators: `R10M FCF4` (the tested unit) is also caught by the deliberately broad + * [COLMI_SMARTHEALTH] entry — which this precedes in [CATALOG], so it wins — while + * `R10M_FCF4` matches nothing else in the catalog at all. + * + * No dedicated product art: it is not a Colmi ring, so it must not borrow Colmi art. Falls + * back to the generic ring silhouette, same as [TK5] and [LUCK_RING_TK18]. + */ val R10M = ycbt( - "r10m", "LittleMeatball R10M", "LittleMeatball", - "^R10M[ _][0-9A-F]{4}$", R.drawable.ring_colmi_r10, + "r10m", "R10M (LittleMeatball)", "LittleMeatball", + "^R10M[ _][0-9A-F]{4}$", imageRes = null, ) /** diff --git a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt index 2e4f8eb..4e06bdf 100644 --- a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt +++ b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt @@ -100,7 +100,11 @@ class PairingMatchingTest { fun `R10M catalog entry uses the discoverable retail name`() { assertTrue(WearableModel.R10M in WearableModel.CATALOG) assertEquals("LittleMeatball", WearableModel.R10M.brand) - assertEquals("LittleMeatball R10M", WearableModel.R10M.displayName) + // Model number first, reseller brand in parentheses: the model is what the ring advertises + // and what every reseller shares, the brand is what the buyer recognises. + assertEquals("R10M (LittleMeatball)", WearableModel.R10M.displayName) + // Never Colmi art — the R10M is a YCBT-protocol ring, not a Colmi one. + assertNull(WearableModel.R10M.imageRes) } @Test From 9b1fcd818892c0b2dea508c4e8e525ab13b820f9 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Fri, 24 Jul 2026 18:37:02 -0700 Subject: [PATCH 36/36] fix(data): delete the duplicate measurement rows earlier syncs accumulated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before measurements had stable ids, a ring's history replay was persisted with a fresh random id on every sync, so each re-sync appended another row at a slot already stored. On a real Colmi R10 that meant HRV, stress and temperature growing by one row per slot per sync, permanently — nothing prunes this table. A device pulled for this change held 1197 measurement rows covering only 414 distinct slots: 783 rows, 65% of the table, were replay copies. They are not merely wasted space. `dailyAggregates`/`hourlyAggregates` compute AVG(value) over raw rows with no sourceRaw filter, so a slot the ring replayed more often than its neighbours drags the daily average toward its value. `adoptStableMeasurementIdentities` already stops the growth by giving one row per slot the canonical `history::` id that later syncs upsert onto, but it deliberately leaves the accumulated copies behind. This removes them, restricted to rows that are provably redundant: a non-canonical row goes only when a canonical row exists for the same (kindRaw, timestamp) AND holds the same value. A differing value is a distinct reading and is always kept, so no information can be destroyed. Interruption safety — one statement, so SQLite's journal makes it all-or- nothing; a kill can never leave the table half-deleted. Room additionally runs migrations inside SQLiteOpenHelper's onUpgrade transaction, so the version bump and the delete commit together and an interrupted upgrade rolls back to v15 and re-runs next launch. The statement is idempotent, so that retry is safe. Verified against the real device database: 1197 -> 414 rows with every distinct (kind, timestamp, value) triple preserved, a second run a no-op, a rolled-back run leaving all 1197 intact and its retry completing cleanly, and PRAGMA integrity_check ok. Then confirmed on-device: every kind now holds exactly one row per slot. --- .../com/pulseloop/data/PulseLoopDatabase.kt | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index dd3ba25..7ded19a 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -40,7 +40,7 @@ import com.pulseloop.data.entity.* BatterySampleEntity::class, CoachNotificationRecordEntity::class, ], - version = 15, + version = 16, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -244,6 +244,51 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** + * v15 → v16: delete the duplicate rows the pre-identity code accumulated. + * + * Before measurements had stable ids, a ring's history *replay* was persisted with a fresh + * random id every time, so each re-sync appended another row at a slot already stored — on + * a real Colmi that meant HRV, stress and temperature growing by one row per slot per sync, + * forever (nothing prunes this table). [adoptStableMeasurementIdentities] stops the growth + * by giving one row per slot the canonical `history::` id that later syncs + * upsert onto, but it deliberately leaves the already-accumulated copies in place. They are + * not harmless: `dailyAggregates`/`hourlyAggregates` compute `AVG(value)` over raw rows with + * no `sourceRaw` filter, so a slot replayed more often than its neighbours drags the average + * toward its value. + * + * Deleting is restricted to rows that are **provably redundant**: a non-canonical row is + * removed only when a canonical row exists for the same `(kindRaw, timestamp)` *and* holds + * the same `value`. A row whose value differs is a distinct reading and is always kept, so + * this can never destroy information — every deleted row's (kind, timestamp, value) is still + * represented by the canonical row that survives. + * + * **Interruption safety.** This is deliberately one statement. SQLite applies a single + * `DELETE` atomically via its journal, so a process kill mid-migration can only leave the + * table fully cleaned or wholly untouched — never half-deleted. Room additionally runs + * migrations inside the transaction `SQLiteOpenHelper` opens around `onUpgrade`, so the + * schema-version bump and this delete commit together: an interrupted upgrade rolls back to + * v15 and simply re-runs on the next launch. The statement is also idempotent — once the + * redundant rows are gone it matches nothing — so re-running after a rollback is a no-op. + */ + private val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + DELETE FROM `measurements` + WHERE `id` NOT LIKE 'history:%' + AND EXISTS ( + SELECT 1 FROM `measurements` AS `canonical` + WHERE `canonical`.`id` LIKE 'history:%' + AND `canonical`.`kindRaw` = `measurements`.`kindRaw` + AND `canonical`.`timestamp` = `measurements`.`timestamp` + AND `canonical`.`value` = `measurements`.`value` + ) + """.trimIndent() + ) + } + } + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") db.execSQL( @@ -325,6 +370,7 @@ abstract class PulseLoopDatabase : RoomDatabase() { MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_15_16, ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and