diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ebef82d..b0621e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,16 @@ android { buildConfig = true } + testOptions { + unitTests { + // Let JVM unit tests exercise code that logs. Without this, `android.util.Log` throws + // "not mocked" and any class with a Log call becomes untestable off-device — which + // would mean choosing between covering the ring drivers and being able to diagnose + // them in the field. + isReturnDefaultValues = true + } + } + splits { abi { isEnable = true diff --git a/app/src/main/java/com/pulseloop/ring/RWfitCoordinator.kt b/app/src/main/java/com/pulseloop/ring/RWfitCoordinator.kt new file mode 100644 index 0000000..4e2ddc1 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitCoordinator.kt @@ -0,0 +1,77 @@ +package com.pulseloop.ring + +/** + * Coordinator for the RWfit family (vendor app `com.rw.revivalfit`) — rings sold under assorted + * brands, including "Colmi"-badged units that share nothing with the Colmi protocol. + * + * Recognition uses **only** the strong, family-exclusive signals the vendor's own scanner keys on + * (`r5/d.java c()`, lines 76-120): the advertised `A00A` service, or manufacturer data opening with + * company `0x05D6` / `0x06D6`. + * + * **No name matching, deliberately.** The vendor requires a non-empty name but never looks at its + * content, because the name is the one field every rebrander changes — the unit iOS was built + * against was sold as a "Colmi". The first Android attempt matched `name.startsWith("RW")`, which + * both misses genuinely-rebranded rings and would hijack any unrelated device whose name happens to + * begin with those two letters. + */ +object RWfitCoordinator : WearableCoordinator { + + override val deviceType: RingDeviceType = RingDeviceType.RWFIT + + /** + * The floor: what every RWfit ring's firmware serves regardless of framing — the history + * streams both wire protocols define unconditionally, plus in-band battery. REM is in: both + * sleep formats carry a REM stage (legacy type 3). + */ + override val capabilities: Set = setOf( + WearableCapability.HEART_RATE, + WearableCapability.SPO2, + WearableCapability.STEPS, + WearableCapability.SLEEP, + WearableCapability.REM_SLEEP, + WearableCapability.BATTERY, + ) + + /** + * Per-unit extras, granted only when the connected ring claims them. + * + * The manual/realtime set is here rather than in [capabilities] because the vendor app has **no + * legacy on-demand measurement command at all** — on a `0x7E` link a Measure button could only + * ever time out. The sensor streams (temperature, BP, HRV, stress, blood sugar) are per-SKU and + * come from the legacy `0x03` feature bitmap / the JieLi bind reply. + * + * Note: the bitmap's own layout (`x5/b.java i()` → `SupportMenuBean`) has not been extracted + * yet, so nothing currently *grants* these — the set is declared so the gating exists the + * moment that decode lands, and so they are never granted unconditionally in the meantime. + */ + override val bitmapGatedCapabilities: Set = setOf( + WearableCapability.TEMPERATURE, + WearableCapability.BLOOD_PRESSURE, + WearableCapability.MANUAL_BLOOD_PRESSURE, + WearableCapability.HRV, + WearableCapability.MANUAL_HRV, + WearableCapability.STRESS, + WearableCapability.BLOOD_SUGAR, + WearableCapability.REALTIME_HEART_RATE, + WearableCapability.MANUAL_HEART_RATE, + WearableCapability.MANUAL_SPO2, + ) + + override val iconSystemName: String = "circle.fill" + + override fun matches(name: String?, advertisement: AdvertisementInfo): Boolean { + if (advertisesService(advertisement)) return true + val mfg = advertisement.manufacturerData ?: return false + val hex = mfg.joinToString("") { "%02x".format(it) } + return RWfitProtocol.MANUFACTURER_HEX_PREFIXES.any { hex.startsWith(it) } + } + + /** True when the advertisement carries `A00A`, in either the 16-bit or 128-bit form. */ + private fun advertisesService(advertisement: AdvertisementInfo): Boolean = + advertisement.serviceUUIDs.any { + val uuid = it.lowercase() + uuid == "a00a" || uuid == "0000a00a" || uuid == RWfitProtocol.SERVICE_UUID + } + + override fun makeDriver(writer: RingCommandWriter): WearableDriver = RWfitDriver(writer) +} diff --git a/app/src/main/java/com/pulseloop/ring/RWfitDecoder.kt b/app/src/main/java/com/pulseloop/ring/RWfitDecoder.kt new file mode 100644 index 0000000..b0c4c1a --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitDecoder.kt @@ -0,0 +1,271 @@ +package com.pulseloop.ring + +import java.time.Instant +import java.util.TimeZone + +/** + * Payload decoders for the RWfit legacy (`0x7E`) replies, ported from `x5/b.java` in + * `decompiled-rwfit-official/sources/`. Framing is handled upstream by [RWfitLegacyCodec]; this + * turns one deframed `(cmd, payload)` into PulseLoop events. + * + * Each decoder cites the vendor method it came from. The dispatch itself is `x5/b.java a()`. + */ +object RWfitDecoder { + + /** + * The vendor stamps history timestamps as **local wall-clock seconds pretending to be UTC**, and + * corrects them on the way in by subtracting the zone's raw offset (plus a flat hour when the + * zone observes DST at all). Replicated exactly, quirk included — `useDaylightTime()` asks + * whether the zone *ever* uses DST, not whether the timestamp falls inside it, so the vendor is + * an hour out for half the year in DST zones. Matching that keeps our decode aligned with what + * the ring and the vendor app agree on; "fixing" it here would put us an hour off theirs. + */ + private fun tzCorrectionSeconds(): Long { + val tz = TimeZone.getDefault() + return (tz.rawOffset + if (tz.useDaylightTime()) 3_600_000L else 0L) / 1000 + } + + private fun u16(p: ByteArray, i: Int) = ((p[i].toInt() and 0xFF) shl 8) or (p[i + 1].toInt() and 0xFF) + + private fun u24(p: ByteArray, i: Int) = + ((p[i].toInt() and 0xFF) shl 16) or ((p[i + 1].toInt() and 0xFF) shl 8) or (p[i + 2].toInt() and 0xFF) + + private fun u32(p: ByteArray, i: Int) = + ((p[i].toLong() and 0xFF) shl 24) or ((p[i + 1].toLong() and 0xFF) shl 16) or + ((p[i + 2].toLong() and 0xFF) shl 8) or (p[i + 3].toLong() and 0xFF) + + private fun instantAt(p: ByteArray, i: Int): Instant = + Instant.ofEpochSecond(u32(p, i) - tzCorrectionSeconds()) + + /** What the ring says it currently holds (`x5/b.java v0()`, cmd `0xA0`). */ + data class SyncManifest( + val totalDataCount: Int, + val hasSteps: Boolean, + val hasSleep: Boolean, + val hasHeartRate: Boolean, + val hasBloodPressure: Boolean, + val hasSpo2: Boolean, + val hasTemperature: Boolean, + val hasBreathe: Boolean, + val hasEcg: Boolean, + val hasSport: Boolean, + ) { + /** The streams worth requesting, in the vendor's own cascade order. */ + fun pendingStreams(): List = buildList { + if (hasSteps) add(RWfitProtocol.HistoryType.STEPS) + if (hasSleep) add(RWfitProtocol.HistoryType.SLEEP) + if (hasHeartRate) add(RWfitProtocol.HistoryType.HEART_RATE) + if (hasBloodPressure) add(RWfitProtocol.HistoryType.BLOOD_PRESSURE) + if (hasSpo2) add(RWfitProtocol.HistoryType.SPO2) + if (hasTemperature) add(RWfitProtocol.HistoryType.TEMPERATURE) + if (hasBreathe) add(RWfitProtocol.HistoryType.BREATHE) + } + } + + /** Decoded manifest, or null when the frame is too short. `x5/b.java v0()`. */ + fun decodeSyncManifest(p: ByteArray): SyncManifest? { + if (p.size < 4) return null + fun bit(b: Byte, n: Int) = ((b.toInt() shr n) and 1) == 1 + return SyncManifest( + totalDataCount = u16(p, 0), + hasSteps = bit(p[2], 0), + hasSleep = bit(p[2], 1), + hasHeartRate = bit(p[2], 2), + hasBloodPressure = bit(p[2], 3), + hasSpo2 = bit(p[2], 4), + hasTemperature = bit(p[2], 5), + hasBreathe = bit(p[2], 6), + hasEcg = bit(p[2], 7), + hasSport = bit(p[3], 0), + ) + } + + /** + * `PowerBean` (`x5/b.java a()`, case `b3 == 1 || b3 == 96`): + * `[lowPowerFlag, powerStatus, percent]`. The percentage is **byte 2** — reading byte 0 gets a + * boolean flag, which is how the reverted driver reported every ring as 0 % or 1 %. + * `powerStatus` 1 = charging, per the vendor's charge UI. + */ + fun decodeBattery(p: ByteArray): List { + if (p.size < 3) return emptyList() + return listOf( + RingDecodedEvent.Battery( + percent = (p[2].toInt() and 0xFF).coerceIn(0, 100), + charging = (p[1].toInt() and 0xFF) == 1, + ) + ) + } + + /** + * Heart-rate history (`x5/b.java w0()`, cmd `0xA3`). Repeating day records: + * `[dayTs u32][itemCount u16]` then `itemCount × [sampleTs u32][bpm u8]`. + */ + fun decodeHeartRateHistory(p: ByteArray) = + decodeDayRecords(p, itemSize = 5) { payload, at -> + val bpm = payload[at + 4].toInt() and 0xFF + if (bpm in 25..250) { + listOf( + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.HEART_RATE, + value = bpm.toDouble(), + _timestamp = instantAt(payload, at), + ) + ) + } else emptyList() + } + + /** SpO2 history (`x5/b.java r0()`, cmd `0xA5`). Same shape as HR. */ + fun decodeSpo2History(p: ByteArray) = + decodeDayRecords(p, itemSize = 5) { payload, at -> + val spo2 = payload[at + 4].toInt() and 0xFF + if (spo2 in 50..100) { + listOf( + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.SPO2, + value = spo2.toDouble(), + _timestamp = instantAt(payload, at), + ) + ) + } else emptyList() + } + + /** + * Blood-pressure history (`x5/b.java s0()`, cmd `0xA4`). Item is 6 bytes: + * `[ts u32][systolic u8][diastolic u8]`. + */ + fun decodeBloodPressureHistory(p: ByteArray) = + decodeDayRecords(p, itemSize = 6) { payload, at -> + val sys = payload[at + 4].toInt() and 0xFF + val dia = payload[at + 5].toInt() and 0xFF + if (sys in 60..250 && dia in 30..200) { + val ts = instantAt(payload, at) + listOf( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, sys.toDouble(), ts), + RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, dia.toDouble(), ts), + ) + } else emptyList() + } + + /** + * Body-temperature history (`x5/b.java u0()`, cmd `0xA6`). Item is 5 bytes, and the value is + * offset-encoded: **°C = (raw + 200) / 10**, i.e. raw 160 → 36.0 °C. + */ + fun decodeTemperatureHistory(p: ByteArray) = + decodeDayRecords(p, itemSize = 5) { payload, at -> + val celsius = ((payload[at + 4].toInt() and 0xFF) + 200) / 10.0 + if (celsius in 30.0..45.0) { + listOf( + RingDecodedEvent.HistoryMeasurement( + kind_field = MeasurementKind.TEMPERATURE, + value = celsius, + _timestamp = instantAt(payload, at), + ) + ) + } else emptyList() + } + + /** + * Step history (`x5/b.java C0()`, cmd `0xA1`). Day record is 15 bytes — + * `[dayTs u32][totalSteps u24][totalCalorie u24][totalDistance u24][itemCount u16]` — followed + * by `itemCount × 8`: `[index u8][steps u16][calorie u24][distance u16]`. + * + * Only the day totals are emitted: PulseLoop's intraday buckets are keyed by wall-clock start, + * and the per-item `index` alone doesn't pin one down without knowing the ring's bucket width, + * which the vendor never states. Left for a hardware capture rather than assumed. + */ + fun decodeStepHistory(p: ByteArray): List { + val events = mutableListOf() + var i = 0 + while (i + 15 <= p.size) { + val dayTs = instantAt(p, i) + val totalSteps = u24(p, i + 4) + val totalCalorie = u24(p, i + 7) + val totalDistance = u24(p, i + 10) + val itemCount = u16(p, i + 13) + i += 15 + val itemsEnd = i + itemCount * 8 + if (itemsEnd > p.size) break // truncated record — stop rather than read past it + i = itemsEnd + + events.add( + RingDecodedEvent.ActivityUpdate( + _timestamp = dayTs, + steps = totalSteps, + distanceMeters = totalDistance, + calories = totalCalorie, + ) + ) + } + return events + } + + /** + * Sleep history (`x5/b.java A0()`, cmd `0xA2`). Day record is 16 bytes — + * `[dayTs u32][totalSleepMinutes u16][asleepTs u32][awakeTs u32][itemCount u16]` — followed by + * `itemCount × 2`: `[lengthMinutes u8][stageType u8]`. + * + * Stage types are confirmed from the vendor's own aggregation (`s1.java:1636-1645`, which sums + * them into wakeupCount / light / deep / REM): `0` awake, `1` light, `2` deep, `3` REM. + */ + fun decodeSleepHistory(p: ByteArray): List { + val events = mutableListOf() + var i = 0 + while (i + 16 <= p.size) { + val asleepAt = instantAt(p, i + 6) + val itemCount = u16(p, i + 14) + i += 16 + val itemsEnd = i + itemCount * 2 + if (itemsEnd > p.size) break + + val stages = mutableListOf() + for (n in 0 until itemCount) { + val at = i + n * 2 + val minutes = p[at].toInt() and 0xFF + val stage = when (p[at + 1].toInt() and 0xFF) { + 0 -> SleepStage.AWAKE + 1 -> SleepStage.LIGHT + 2 -> SleepStage.DEEP + 3 -> SleepStage.REM + else -> SleepStage.UNKNOWN + } + repeat(minutes) { stages.add(stage) } + } + i = itemsEnd + + // An all-awake record carries no sleep; the persistence layer treats it as a session. + if (stages.any { it != SleepStage.AWAKE }) { + events.add( + RingDecodedEvent.SleepTimeline( + _timestamp = asleepAt, + stages = stages, + completeSession = true, + ) + ) + } + } + return events + } + + /** + * The five history streams that share `[dayTs u32][itemCount u16]` + fixed-size items + * (`w0`/`r0`/`s0`/`u0` are byte-for-byte the same loop in the vendor, differing only in the + * item body). + */ + private inline fun decodeDayRecords( + p: ByteArray, + itemSize: Int, + decodeItem: (ByteArray, Int) -> List, + ): List { + val events = mutableListOf() + var i = 0 + while (i + 6 <= p.size) { + val itemCount = u16(p, i + 4) + i += 6 + val itemsEnd = i + itemCount * itemSize + if (itemsEnd > p.size) break // truncated record + for (n in 0 until itemCount) events.addAll(decodeItem(p, i + n * itemSize)) + i = itemsEnd + } + return events + } +} diff --git a/app/src/main/java/com/pulseloop/ring/RWfitDriver.kt b/app/src/main/java/com/pulseloop/ring/RWfitDriver.kt new file mode 100644 index 0000000..a859911 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitDriver.kt @@ -0,0 +1,188 @@ +package com.pulseloop.ring + +import android.util.Log + +/** + * RWfit driver. Owns the one thing that makes this family awkward: **the wire framing isn't known + * until after connect.** + * + * The advertisement carries no signal for it, so the vendor decides from the sibling services the + * ring exposes at discovery (`r5/b.java onServicesDiscovered`, lines 700-727): JieLi `AE00`, Telink + * OTA or PixArt `FF00` present ⇒ `0xAB` framing; none of them ⇒ legacy `0x7E`. Both framings serve + * the same `A00A`/`B002`/`B003` GATT, so the driver is shared and only the codec swaps. + * + * Legacy is the default until [servicesDiscovered] says otherwise: it's the more common firmware, + * and a frame in the wrong format is ignored by the ring rather than misinterpreted (the magic + * byte differs), so a wrong guess is inert rather than dangerous. + */ +class RWfitDriver(private val writer: RingCommandWriter?) : WearableDriver { + + private val legacyCodec = RWfitLegacyCodec() + private val jieliCodec = RWfitJLCodec() + private val encoder = RWfitEncoder(legacyCodec, jieliCodec) + private val syncEngine = RWfitSyncEngine(writer, encoder) + + override val serviceUUIDs: List = listOf(RWfitProtocol.SERVICE_UUID) + override val writeUUID: String = RWfitProtocol.WRITE_UUID + override val notifyUUIDs: List = listOf(RWfitProtocol.NOTIFY_UUID) + + /** Commands arrive pre-framed from [RWfitEncoder]; there's no second envelope. */ + override fun frame(command: ByteArray): ByteArray = command + + override fun makeSyncEngine(): RingSyncEngine = syncEngine + + override fun connectionDidStart() { + legacyCodec.reset() + jieliCodec.reset() + syncEngine.reset() + encoder.framing = RWfitFraming.LEGACY + } + + override fun connectionDidEnd() { + legacyCodec.reset() + jieliCodec.reset() + syncEngine.reset() + } + + override fun servicesDiscovered(serviceUUIDs: Collection) { + val present = serviceUUIDs.map { it.lowercase() }.toSet() + val isJieLi = RWfitProtocol.FRAMING_DISCRIMINATOR_UUIDS.any { it in present } + encoder.framing = if (isJieLi) RWfitFraming.JIELI else RWfitFraming.LEGACY + syncEngine.framing = encoder.framing + Log.i(TAG, "framing = ${encoder.framing} (discriminators present: " + + RWfitProtocol.FRAMING_DISCRIMINATOR_UUIDS.filter { it in present } + ")") + } + + override fun ingest(data: ByteArray, from: String): List = + when (encoder.framing) { + RWfitFraming.LEGACY -> ingestLegacy(data) + RWfitFraming.JIELI -> ingestJieLi(data) + } + + // ── Legacy 0x7E ───────────────────────────────────────────────────────────── + + private fun ingestLegacy(data: ByteArray): List { + val events = mutableListOf() + for (inbound in legacyCodec.decode(data)) { + when (inbound) { + // The vendor ACKs before it parses, and the ring retransmits until it sees one. + is RWfitLegacyInbound.AckNeeded -> + writer?.enqueue(legacyCodec.ack(inbound.cmd, inbound.serial, status = 0)) + + // Status 2 asks for a retransmit rather than dropping the frame silently. + is RWfitLegacyInbound.ChecksumFailed -> { + Log.w(TAG, "checksum failed on cmd 0x${"%02X".format(inbound.cmd)} — NACKing") + writer?.enqueue(legacyCodec.ack(inbound.cmd, inbound.serial, status = 2)) + } + + is RWfitLegacyInbound.DeviceAck -> + syncEngine.onDeviceAck(inbound.cmd, inbound.status) + + is RWfitLegacyInbound.Frame -> events.addAll(decodeLegacyFrame(inbound)) + } + } + return events + } + + private fun decodeLegacyFrame(frame: RWfitLegacyInbound.Frame): List { + val p = frame.payload + return when (frame.cmd) { + RWfitProtocol.Legacy.DEVICE_INFO -> { + syncEngine.onDeviceInfo() + listOf(RingDecodedEvent.Status(address = null)) + } + + RWfitProtocol.Legacy.BATTERY, RWfitProtocol.Legacy.BATTERY_ALT -> + RWfitDecoder.decodeBattery(p) + + RWfitProtocol.Legacy.SYNC_MANIFEST -> { + RWfitDecoder.decodeSyncManifest(p)?.let { syncEngine.onManifest(it) } + emptyList() + } + + RWfitProtocol.Legacy.STEPS_HISTORY -> + RWfitDecoder.decodeStepHistory(p).also { syncEngine.onHistoryReply(RWfitProtocol.HistoryType.STEPS) } + RWfitProtocol.Legacy.SLEEP_HISTORY -> + RWfitDecoder.decodeSleepHistory(p).also { syncEngine.onHistoryReply(RWfitProtocol.HistoryType.SLEEP) } + RWfitProtocol.Legacy.HEART_RATE_HISTORY -> + RWfitDecoder.decodeHeartRateHistory(p).also { syncEngine.onHistoryReply(RWfitProtocol.HistoryType.HEART_RATE) } + RWfitProtocol.Legacy.BLOOD_PRESSURE_HISTORY -> + RWfitDecoder.decodeBloodPressureHistory(p).also { syncEngine.onHistoryReply(RWfitProtocol.HistoryType.BLOOD_PRESSURE) } + RWfitProtocol.Legacy.SPO2_HISTORY -> + RWfitDecoder.decodeSpo2History(p).also { syncEngine.onHistoryReply(RWfitProtocol.HistoryType.SPO2) } + RWfitProtocol.Legacy.TEMPERATURE_HISTORY -> + RWfitDecoder.decodeTemperatureHistory(p).also { syncEngine.onHistoryReply(RWfitProtocol.HistoryType.TEMPERATURE) } + + // Breathe history decodes to nothing PulseLoop stores, but the reply still has to + // advance the cascade or the sync stalls on it. + RWfitProtocol.Legacy.BREATHE_HISTORY -> { + syncEngine.onHistoryReply(RWfitProtocol.HistoryType.BREATHE) + emptyList() + } + + // Feature bitmap (x5/b.java i() → SupportMenuBean). Its layout hasn't been extracted + // yet, so the capability gating it would drive isn't wired — see RWfitCoordinator. + RWfitProtocol.Legacy.FEATURES, RWfitProtocol.Legacy.BIND_STATUS -> emptyList() + + else -> { + Log.d(TAG, "unhandled legacy cmd 0x${"%02X".format(frame.cmd)} (${p.size}B)") + emptyList() + } + } + } + + // ── JieLi 0xAB ────────────────────────────────────────────────────────────── + + private fun ingestJieLi(data: ByteArray): List { + val events = mutableListOf() + for (inbound in jieliCodec.decode(data)) { + when (inbound) { + is RWfitJLInbound.ChecksumFailed -> + Log.w(TAG, "JieLi CRC failed for ${inbound.triple}") + + is RWfitJLInbound.Frame -> { + // The vendor app→device ACKs every non-ACK frame it receives (r5/b.java:429). + if (!inbound.isAck) writer?.enqueue(jieliCodec.ack(inbound.triple)) + events.addAll(decodeJieLiFrame(inbound)) + } + } + } + return events + } + + private fun decodeJieLiFrame(frame: RWfitJLInbound.Frame): List { + val t = frame.triple + return when { + t.cmd == RWfitProtocol.JieLi.BATTERY.cmd && t.key == RWfitProtocol.JieLi.BATTERY.key -> { + // The JieLi battery body is a bare percentage after the triple, unlike legacy's + // three-byte PowerBean. + frame.payload.firstOrNull() + ?.let { listOf(RingDecodedEvent.Battery(percent = (it.toInt() and 0xFF).coerceIn(0, 100))) } + ?: emptyList() + } + + t.cmd == RWfitProtocol.JieLi.DEVICE_INFO.cmd && t.key == RWfitProtocol.JieLi.DEVICE_INFO.key -> { + syncEngine.onDeviceInfo() + listOf(RingDecodedEvent.Status(address = null)) + } + + // The 05-group history bodies have their own per-type layouts which have NOT been + // extracted from the vendor yet. Logged, not guessed — this is exactly the gap that + // made the first version of this driver worthless. RWfitSyncEngine does not request + // history on a JieLi link for the same reason. + t.cmd == 0x05.toByte() -> { + Log.i(TAG, "JieLi history frame ${t.key} (${frame.payload.size}B) — decoder not yet ported") + emptyList() + } + + else -> { + Log.d(TAG, "unhandled JieLi triple $t (${frame.payload.size}B)") + emptyList() + } + } + } + + private companion object { + const val TAG = "RWfitDriver" + } +} diff --git a/app/src/main/java/com/pulseloop/ring/RWfitEncoder.kt b/app/src/main/java/com/pulseloop/ring/RWfitEncoder.kt new file mode 100644 index 0000000..5945382 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitEncoder.kt @@ -0,0 +1,104 @@ +package com.pulseloop.ring + +import java.util.Calendar + +/** + * Builds RWfit commands for whichever framing the link turned out to be, ported from the vendor's + * senders in `p.java` (`CmdHelper`) and `blesdk/service/l.java`. + * + * The two framings are **not** interchangeable at the payload level either — `p.java` has a + * separate builder per framing for the same logical command, and they differ in more than the + * envelope (see [timeSync]). Anything only one side implements is exposed as nullable rather than + * faked on the other. + */ +class RWfitEncoder( + private val legacy: RWfitLegacyCodec, + private val jieli: RWfitJLCodec, +) { + var framing: RWfitFraming = RWfitFraming.LEGACY + + /** Device info — the first thing the vendor asks for after connect. */ + fun deviceInfo(): ByteArray = when (framing) { + RWfitFraming.LEGACY -> legacy.encode(RWfitProtocol.Legacy.DEVICE_INFO).frame + RWfitFraming.JIELI -> jieli.encode(RWfitProtocol.JieLi.DEVICE_INFO) + } + + fun battery(): ByteArray = when (framing) { + RWfitFraming.LEGACY -> legacy.encode(RWfitProtocol.Legacy.BATTERY).frame + RWfitFraming.JIELI -> jieli.encode(RWfitProtocol.JieLi.BATTERY) + } + + /** + * Clock sync. The two framings disagree on the year encoding — legacy sends the full four-digit + * year as a big-endian u16 (`p.java u(Date)`), JieLi sends `year - 2000` in one byte + * (`p.java v(Date)`). Getting this wrong sets the ring's clock ~2000 years out and every + * history timestamp with it. + */ + fun timeSync(nowMillis: Long = System.currentTimeMillis()): ByteArray { + val cal = Calendar.getInstance().apply { timeInMillis = nowMillis } + val year = cal.get(Calendar.YEAR) + val month = (cal.get(Calendar.MONTH) + 1).toByte() + val day = cal.get(Calendar.DAY_OF_MONTH).toByte() + val hour = cal.get(Calendar.HOUR_OF_DAY).toByte() + val minute = cal.get(Calendar.MINUTE).toByte() + val second = cal.get(Calendar.SECOND).toByte() + + return when (framing) { + RWfitFraming.LEGACY -> { + val y = RWfitProtocol.u16BE(year) + legacy.encode( + RWfitProtocol.Legacy.SET_TIME, + byteArrayOf(y[0], y[1], month, day, hour, minute, second), + ).frame + } + RWfitFraming.JIELI -> jieli.encode( + RWfitProtocol.JieLi.SET_TIME, + byteArrayOf((year - 2000).toByte(), month, day, hour, minute, second), + ) + } + } + + /** The manifest of what history the ring is holding — legacy only (`x5/b.java v0()`). */ + fun syncManifest(): ByteArray? = when (framing) { + RWfitFraming.LEGACY -> legacy.encode(RWfitProtocol.Legacy.SYNC_MANIFEST).frame + RWfitFraming.JIELI -> null // JieLi has no equivalent; each stream is requested directly + } + + /** + * One history stream. Legacy requests carry an **empty payload** — the ring replies with + * everything it holds for that stream (`blesdk/service/l.java`). + * + * Returns null when the stream doesn't exist on this framing (HRV/stress/blood sugar are + * JieLi-only; breathe is legacy-only). + */ + fun history(type: RWfitProtocol.HistoryType): ByteArray? = when (framing) { + RWfitFraming.LEGACY -> type.legacyCommand?.let { legacy.encode(it).frame } + RWfitFraming.JIELI -> type.jlType?.let { jieli.encode(RWfitProtocol.JieLi.historySync(it)) } + } + + /** + * Realtime measurement toggle — `06 09 00 05 ` (`u0.java n()`), **JieLi only**. + * The vendor app has no legacy on-demand measurement command at all, which is why + * [RWfitCoordinator] gates the manual/realtime capabilities behind the framing rather than + * granting them to the whole family. + */ + fun realtimeMeasure(dataType: Byte, enable: Boolean): ByteArray? = when (framing) { + RWfitFraming.JIELI -> jieli.encode( + RWfitProtocol.JieLi.REALTIME_MEASURE, + byteArrayOf(dataType, 0x05, if (enable) 0x01 else 0x00), + ) + RWfitFraming.LEGACY -> null + } + + /** Unbind on Forget. Legacy `0x44` with an empty payload (`h0.java n()`, line 319). */ + fun unbind(): ByteArray? = when (framing) { + RWfitFraming.LEGACY -> legacy.encode(RWfitProtocol.Legacy.UNBIND).frame + // iOS records a JieLi unbind triple of {03,01,30}, but the vendor's table (y5/c.java) has + // no such entry — see RWfitProtocol.JieLi.UNBIND_UNCONFIRMED. Not sent until confirmed: + // guessing a bind-group triple risks re-binding or factory-resetting someone's ring. + RWfitFraming.JIELI -> null + } +} + +/** Which wire format the connected ring speaks. Decided post-connect — see [RWfitDriver]. */ +enum class RWfitFraming { LEGACY, JIELI } diff --git a/app/src/main/java/com/pulseloop/ring/RWfitJLCodec.kt b/app/src/main/java/com/pulseloop/ring/RWfitJLCodec.kt new file mode 100644 index 0000000..2345dec --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitJLCodec.kt @@ -0,0 +1,134 @@ +package com.pulseloop.ring + +/** One deframed JieLi (`0xAB`) event. */ +sealed interface RWfitJLInbound { + /** + * A complete frame. [body] still carries its `{cmd, key, keyFlag}` triple in bytes 0..2 — + * the length field and the CRC both cover the triple, so stripping it here would desync them. + */ + data class Frame(val flag: Byte, val triple: RWfitProtocol.JLTriple, val body: ByteArray) : RWfitJLInbound { + /** The payload after the addressing triple. */ + val payload: ByteArray get() = if (body.size > 3) body.copyOfRange(3, body.size) else ByteArray(0) + + /** Flag `0x11` marks this as the device ACKing one of our commands. */ + val isAck: Boolean get() = flag == RWfitJLCodec.FLAG_ACK + + override fun equals(other: Any?): Boolean = + other is Frame && flag == other.flag && triple == other.triple && body.contentEquals(other.body) + override fun hashCode(): Int = 31 * (31 * flag.toInt() + triple.hashCode()) + body.contentHashCode() + } + + /** The body failed its CRC-16/ARC check. The vendor drops these silently and waits. */ + data class ChecksumFailed(val triple: RWfitProtocol.JLTriple) : RWfitJLInbound +} + +/** + * JieLi (`0xAB`) wire codec. Ported from `x5/c.java g()` (encode) and `r5/b.java:386-476` (decode, + * which the vendor inlines into its GATT callback) in `decompiled-rwfit-official/sources/`. + * + * **Frame layout:** + * ``` + * [0] 0xAB [1] flag (0x01 normal, 0x11 ACK) + * [2..3] bodyLen BE u16 [4..5] CRC-16/ARC of body, BE + * [6] cmd [7] key [8] keyFlag <- the triple is the first 3 bytes of the body + * [9..] payload + * ``` + * `bodyLen` counts from index 6, i.e. it **includes** the triple, and the CRC covers the same span + * (`r5/b.java`: `arraycopy(value, 6, body, 0, dataLen)` then `crc.equals(y5.d.a(body))`). + * + * Long bodies arrive as a header packet followed by raw continuation packets with no header of + * their own; [decode] reassembles them using [chunkSize], which the driver sets from the negotiated + * MTU (`r5/b.java j()` returns `mtu - 3 - 3` on JieLi links). + */ +class RWfitJLCodec(private var chunkSize: Int = DEFAULT_CHUNK_SIZE) { + + private var pending: Pending? = null + + private class Pending(val flag: Byte, val triple: RWfitProtocol.JLTriple, val crc: Int, val expected: Int) { + val body = ByteArray(expected) + var filled = 0 + } + + fun setChunkSize(size: Int) { + if (size > 0) chunkSize = size + } + + /** Drop cross-frame reassembly state. Call on connect and disconnect. */ + fun reset() { + pending = null + } + + // ── Encode ─────────────────────────────────────────────────────────────────── + + fun encode(triple: RWfitProtocol.JLTriple, payload: ByteArray = ByteArray(0), isAck: Boolean = false): ByteArray { + val body = triple.bytes + payload + val len = RWfitProtocol.u16BE(body.size) + val crc = RWfitProtocol.u16BE(RWfitProtocol.crc16Arc(body)) + return byteArrayOf(FRAME_HEADER, if (isAck) FLAG_ACK else FLAG_NORMAL, len[0], len[1], crc[0], crc[1]) + body + } + + /** + * The app→device ACK the vendor sends for every non-ACK inbound frame: flag `0x11`, body = the + * inbound triple (`r5/b.java:429-444`). The realtime-measure triple `06 09 xx` is the one + * exception — its ACK body carries a trailing `0x00`. + */ + fun ack(triple: RWfitProtocol.JLTriple): ByteArray { + val isRealtimeMeasure = triple.cmd == RWfitProtocol.JieLi.REALTIME_MEASURE.cmd && + triple.key == RWfitProtocol.JieLi.REALTIME_MEASURE.key + val payload = if (isRealtimeMeasure) byteArrayOf(0x00) else ByteArray(0) + return encode(triple, payload, isAck = true) + } + + // ── Decode ─────────────────────────────────────────────────────────────────── + + fun decode(data: ByteArray): List { + val inFlight = pending + if (inFlight != null && (data.isEmpty() || data[0] != FRAME_HEADER)) { + return appendContinuation(inFlight, data) + } + if (data.size < HEADER_SIZE || data[0] != FRAME_HEADER) return emptyList() + + val flag = data[1] + val bodyLen = RWfitProtocol.readU16BE(data, 2) + val crc = RWfitProtocol.readU16BE(data, 4) + if (data.size < HEADER_SIZE + 3) return emptyList() + val triple = RWfitProtocol.JLTriple(data[6], data[7], data[8]) + + val available = data.size - HEADER_SIZE + if (bodyLen > available) { + // Header packet of a multi-packet body — keep what arrived and wait for continuations. + val p = Pending(flag, triple, crc, bodyLen) + data.copyInto(p.body, 0, HEADER_SIZE, data.size) + p.filled = available + pending = p + return emptyList() + } + + val body = data.copyOfRange(HEADER_SIZE, HEADER_SIZE + bodyLen) + return finish(flag, triple, crc, body) + } + + private fun appendContinuation(p: Pending, data: ByteArray): List { + val room = p.expected - p.filled + val take = minOf(room, data.size) + data.copyInto(p.body, p.filled, 0, take) + p.filled += take + if (p.filled < p.expected) return emptyList() + pending = null + return finish(p.flag, p.triple, p.crc, p.body) + } + + private fun finish(flag: Byte, triple: RWfitProtocol.JLTriple, crc: Int, body: ByteArray): List { + if (RWfitProtocol.crc16Arc(body) != crc) return listOf(RWfitJLInbound.ChecksumFailed(triple)) + return listOf(RWfitJLInbound.Frame(flag, triple, body)) + } + + companion object { + const val FRAME_HEADER: Byte = 0xAB.toByte() + const val FLAG_NORMAL: Byte = 0x01 + const val FLAG_ACK: Byte = 0x11 + const val HEADER_SIZE = 6 + /** Conservative pre-MTU-negotiation default (23-byte ATT MTU → 20-byte payload, minus 3). */ + const val DEFAULT_CHUNK_SIZE = 17 + } +} diff --git a/app/src/main/java/com/pulseloop/ring/RWfitLegacyCodec.kt b/app/src/main/java/com/pulseloop/ring/RWfitLegacyCodec.kt new file mode 100644 index 0000000..37cc7e0 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitLegacyCodec.kt @@ -0,0 +1,179 @@ +package com.pulseloop.ring + +/** + * One deframed legacy (`0x7E`) event, as surfaced to [RWfitDriver]. + * + * The vendor ACKs **before** it parses (`x5/d.java h()` calls `b(...)` on the way through), so + * [AckNeeded] is emitted ahead of the [Frame] it belongs to and the driver must preserve that order. + */ +sealed interface RWfitLegacyInbound { + /** A complete data frame — single-packet, or a fully reassembled multi-packet payload. */ + data class Frame(val cmd: Byte, val payload: ByteArray) : RWfitLegacyInbound { + override fun equals(other: Any?): Boolean = + other is Frame && cmd == other.cmd && payload.contentEquals(other.payload) + override fun hashCode(): Int = 31 * cmd.toInt() + payload.contentHashCode() + } + + /** The device ACKed one of our commands: `0xFE` with `[serHi, serLo, cmd, status]`. */ + data class DeviceAck(val cmd: Byte, val serial: Int, val status: Byte) : RWfitLegacyInbound + + /** This inbound frame must be app-ACKed with the given serial and status `0x00`. */ + data class AckNeeded(val cmd: Byte, val serial: Int) : RWfitLegacyInbound + + /** The frame failed its XOR check; NACK it with status `0x02` so the device retransmits. */ + data class ChecksumFailed(val cmd: Byte, val serial: Int) : RWfitLegacyInbound +} + +/** + * Legacy (`0x7E`, "Realtek") wire codec — framing, serials, XOR checksums, the ACK handshake and + * inbound multi-packet reassembly. Ported from `x5/d.java` (`CmdHandlerUtils`) in + * `decompiled-rwfit-official/sources/`. + * + * **Frame layout** (`x5/d.java j()` encodes, `h()` decodes): + * ``` + * single packet (8-byte header): + * [0] 0x7E [1] 0x01 version [2] cmd [3] flags [4] dataLen + * [5..6] serial BE u16 [7] XOR of payload (0x00 when empty) + * [8..] payload + * + * multi-packet (12-byte header, flags bit 3 set): + * [0..7] as above, with [4]/[7] describing *this chunk* + * [8..9] total chunk count BE u16 + * [10..11] this chunk index BE u16, 1-based + * [12..] chunk payload + * ``` + * Serials run 1…65535 and wrap (`x5/d.java a()`). They are an echo token, not a sequence check, so + * [reset] deliberately keeps counting across links — exactly as the vendor does. + * + * Not ported: outbound multi-packet splitting. The vendor only chunks file transfers + * (`x5/d.java j()`'s `length > 1` branch), which PulseLoop never sends — our largest payload is the + * bind userId, well under one packet. [encode] rejects anything that wouldn't fit rather than + * silently truncating. + */ +class RWfitLegacyCodec { + + /** Outbound serial counter (`x5/d.java` field `e`, starts at 1). */ + private var serial: Int = 0 + + /** In-flight inbound reassembly, keyed by cmd id (`x5/d.java` field `f19809c`). */ + private val partials = mutableMapOf>>() + + /** + * Drop cross-frame state. Call on connect and disconnect — a chunk left over from a dropped + * link must never complete a frame on the next one (`x5/d.java v()` clears the same map). + */ + fun reset() { + partials.clear() + } + + private fun nextSerial(): Int { + serial = if (serial >= MAX_SERIAL) 1 else serial + 1 + return serial + } + + // ── Encode ─────────────────────────────────────────────────────────────────── + + /** A framed command plus the serial it was stamped with, so the gate can match its `0xFE`. */ + data class Outbound(val frame: ByteArray, val serial: Int) { + override fun equals(other: Any?): Boolean = + other is Outbound && serial == other.serial && frame.contentEquals(other.frame) + override fun hashCode(): Int = 31 * serial + frame.contentHashCode() + } + + fun encode(cmd: Byte, payload: ByteArray = ByteArray(0)): Outbound { + require(payload.size <= MAX_SINGLE_PACKET_PAYLOAD) { + "legacy payload ${payload.size} exceeds single-frame capacity $MAX_SINGLE_PACKET_PAYLOAD" + } + val serial = nextSerial() + val serialBytes = RWfitProtocol.u16BE(serial) + val frame = ByteArray(HEADER_SIZE + payload.size) + frame[0] = FRAME_HEADER + frame[1] = PROTOCOL_VERSION + frame[2] = cmd + frame[3] = 0 // single packet: no flags + frame[4] = (payload.size and 0xFF).toByte() + frame[5] = serialBytes[0] + frame[6] = serialBytes[1] + frame[7] = if (payload.isEmpty()) 0 else RWfitProtocol.xorChecksum(payload) + payload.copyInto(frame, HEADER_SIZE) + return Outbound(frame, serial) + } + + /** + * The app→device ACK for an inbound frame: cmd `0xFF`, payload `[serHi, serLo, cmd, status]` + * carrying the *inbound* frame's serial, while the ACK frame's own header serial is freshly + * assigned (`x5/d.java b()` → `j((byte) -1, …)`). Status `0` = accepted, `2` = checksum failure. + */ + fun ack(cmd: Byte, inboundSerial: Int, status: Byte): ByteArray { + val ser = RWfitProtocol.u16BE(inboundSerial) + return encode(RWfitProtocol.Legacy.APP_ACK, byteArrayOf(ser[0], ser[1], cmd, status)).frame + } + + // ── Decode ─────────────────────────────────────────────────────────────────── + + /** + * Deframe one notification. Returns an empty list for anything that isn't a `0x7E` frame — the + * vendor logs and drops those too ("接收到其他指令,不处理"). + * + * Unlike the reverted implementation, this does not keep a rolling byte buffer: the vendor + * treats each BLE notification as one whole frame (`r5/b.java onCharacteristicChanged` hands + * `value` straight to `h()`), and *fragmentation is expressed in the protocol* via the + * multi-packet header rather than by splitting frames across notifications. + */ + fun decode(data: ByteArray): List { + if (data.size < HEADER_SIZE || data[0] != FRAME_HEADER) return emptyList() + + val cmd = data[2] + val flags = data[3].toInt() + val isMultiPacket = (flags shr 3) and 1 == 1 + val dataLen = data[4].toInt() and 0xFF + val serial = RWfitProtocol.readU16BE(data, 5) + val checksum = data[7] + + // `x5/d.java h()` takes the single-packet path when the multi-packet flag is clear *or* the + // frame is too short to hold the 12-byte header. + val bodyOffset = if (isMultiPacket && data.size > MULTI_HEADER_SIZE - 3) MULTI_HEADER_SIZE else HEADER_SIZE + if (data.size < bodyOffset + dataLen) return emptyList() + val chunk = data.copyOfRange(bodyOffset, bodyOffset + dataLen) + + if (dataLen > 0 && checksum != RWfitProtocol.xorChecksum(chunk)) { + return listOf(RWfitLegacyInbound.ChecksumFailed(cmd, serial)) + } + + // A device ACK is never itself ACKed (`x5/d.java h()`: `if (b10 != -2) b(...)`). + if (cmd == RWfitProtocol.Legacy.DEVICE_ACK) { + if (chunk.size < 4) return emptyList() + val ackedSerial = RWfitProtocol.readU16BE(chunk, 0) + return listOf(RWfitLegacyInbound.DeviceAck(cmd = chunk[2], serial = ackedSerial, status = chunk[3])) + } + + val events = mutableListOf(RWfitLegacyInbound.AckNeeded(cmd, serial)) + + if (bodyOffset == HEADER_SIZE) { + events.add(RWfitLegacyInbound.Frame(cmd, chunk)) + return events + } + + // Multi-packet: accumulate by cmd id, emit once the last (1-based) index has landed. + val total = RWfitProtocol.readU16BE(data, 8) + val index = RWfitProtocol.readU16BE(data, 10) + val bucket = partials.getOrPut(cmd) { mutableListOf() } + bucket.add(index to chunk) + if (index == total && bucket.size == total) { + val assembled = bucket.sortedBy { it.first }.fold(ByteArray(0)) { acc, p -> acc + p.second } + partials.remove(cmd) + events.add(RWfitLegacyInbound.Frame(cmd, assembled)) + } + return events + } + + private companion object { + const val FRAME_HEADER: Byte = 0x7E + const val PROTOCOL_VERSION: Byte = 0x01 + const val HEADER_SIZE = 8 + const val MULTI_HEADER_SIZE = 12 + const val MAX_SERIAL = 65535 + /** `dataLen` is a single byte (`x5/d.java j()`: `bArr2[4] = (byte) (bArr.length & 255)`). */ + const val MAX_SINGLE_PACKET_PAYLOAD = 0xFF + } +} diff --git a/app/src/main/java/com/pulseloop/ring/RWfitProtocol.kt b/app/src/main/java/com/pulseloop/ring/RWfitProtocol.kt new file mode 100644 index 0000000..8533904 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitProtocol.kt @@ -0,0 +1,276 @@ +package com.pulseloop.ring + +/** + * Shared vocabulary for the RWfit ring family (vendor app `com.rw.revivalfit`). + * + * **Every constant here was read out of `decompiled-rwfit-official/sources/` and carries the file + * it came from.** Paths below are relative to that directory. This is the rule in the root + * `AGENTS.md`: match the vendor app, not iOS, and never guess a wire constant. The first attempt at + * this driver (PulseLoopAndroid PR #45) invented the whole table — wrong characteristics, wrong + * frame layout, wrong command ids — and had to be reverted; see `docs/ios-sync.md` + * § "RWfit (#130) — backed out". + * + * One GATT service, two wire framings: + * - **Legacy `0x7E`** ("Realtek" in the vendor's own logging): 8-byte header, XOR checksum over + * the payload, per-frame serials, mandatory `0xFE`/`0xFF` ACK handshake (`x5/d.java`). + * - **JieLi `0xAB`**: 6-byte header, CRC-16/ARC over the body, `{cmd, key, keyFlag}` triple as the + * first three body bytes, flag-`0x11` ACKs (`x5/c.java` encode, `r5/b.java:386-476` decode). + * + * Which one a ring speaks is **not** in the advertisement — it is decided after connect from the + * sibling services the ring exposes (`r5/b.java onServicesDiscovered`, lines 700-727). That is why + * the whole family is one [RingDeviceType] and [RWfitDriver] owns the decision. + */ +object RWfitProtocol { + + // ── GATT (`y5/a.java`, static initialiser) ─────────────────────────────────── + + /** Primary data service, both framings (`y5/a.java f19994a`). */ + const val SERVICE_UUID = "0000a00a-0000-1000-8000-00805f9b34fb" + + /** Command write characteristic (`y5/a.java f19995b`). */ + const val WRITE_UUID = "0000b002-0000-1000-8000-00805f9b34fb" + + /** Notify characteristic — command replies and device-initiated pushes (`y5/a.java f19996c`). */ + const val NOTIFY_UUID = "0000b003-0000-1000-8000-00805f9b34fb" + + /** + * Framing discriminators. Never subscribed — only *seen* at service discovery. Any one of them + * present ⇒ JieLi framing; none present ⇒ legacy (`r5/b.java:700-727`, where the vendor logs + * "获取杰里蓝牙服务" / "获取瑞昱蓝牙服务" — got JieLi / got Realtek). + */ + const val JIELI_SERVICE_UUID = "0000ae00-0000-1000-8000-00805f9b34fb" + const val PIXART_OTA_SERVICE_UUID = "0000ff00-0000-1000-8000-00805f9b34fb" + const val TELINK_OTA_SERVICE_UUID = "00010203-0405-0607-0809-0a0b0c0d1912" + + val FRAMING_DISCRIMINATOR_UUIDS = listOf( + JIELI_SERVICE_UUID, TELINK_OTA_SERVICE_UUID, PIXART_OTA_SERVICE_UUID, + ) + + // ── Advertisement recognition (`r5/d.java:76`) ─────────────────────────────── + + /** + * The vendor's scanner matches on the **raw advertising bytes**, hex-formatted with spaces, and + * accepts a device when any of these four substrings appears (`r5/d.java c()`): + * + * - `02 01 06 03 03 0a a0` — Flags + a complete 16-bit service list holding `0xA00A` + * (little-endian on the wire), the vendor's `pidType 1`. + * - `d6 05 02 00` — manufacturer data, company `0x05D6`, `pidType 2`. + * - `15 ff d6 05 41 54` — a 0x15-long manufacturer AD, company `0x05D6`, then ASCII "AT" + * (`pidType 239`). + * - `d6 06 02 00` — company `0x06D6`, the "T-Ring" line (`pidType 4`). + * + * It requires a non-empty device name but **never matches on the name's content** — these rings + * are rebranded constantly (the unit iOS tested was sold as a "Colmi"), so the name is the one + * field that carries no family signal. + */ + val ADVERTISEMENT_HEX_PATTERNS = listOf( + "02010603030aa0", // 02 01 06 03 03 0a a0 — Flags + 16-bit service list with 0xA00A + "d6050200", + "15ffd6054154", + "d6060200", + ) + + /** Manufacturer-data prefixes, for the parsed-`manufacturerData` path (same source). */ + val MANUFACTURER_HEX_PREFIXES = listOf("d6050200", "d6054154", "d6060200") + + // ── Legacy `0x7E` commands (`x5/b.java a()` dispatch + the senders cited per line) ── + + object Legacy { + /** Reply decoded by `x5/b.java o()` (`a()` case `b3 == 0`). */ + const val DEVICE_INFO: Byte = 0x00 + + /** + * `PowerBean`: payload `[lowPowerFlag, powerStatus, percent]` — the battery percentage is + * **byte 2, not byte 0** (`x5/b.java a()`, case `b3 == 1 || b3 == 96`). `0x60` is an alias + * the firmware also uses. + */ + const val BATTERY: Byte = 0x01 + const val BATTERY_ALT: Byte = 0x60 + + /** Bind state (`x5/b.java c()`). */ + const val BIND_STATUS: Byte = 0x02 + + /** Supported-features bitmap → `SupportMenuBean` (`x5/b.java i()`). Capability discovery. */ + const val FEATURES: Byte = 0x03 + + /** `[bindType, userId as UTF-16LE]` (`p.java s(BindInfoBean)`, line 763). */ + const val BIND: Byte = 0x20 + + /** + * `[yearHi, yearLo, month, day, hour, min, sec]` — the legacy year is the **full four-digit + * year** as a big-endian u16 (`p.java u(Date)`, line 815). The JieLi variant sends + * `year - 2000` in one byte instead (`p.java v(Date)`); do not share the encoder. + */ + const val SET_TIME: Byte = 0x21 + + /** `[lang, measureUnit, tempUnit, timeFont]` (`p.java P(UnitBean)`, line 316). */ + const val UNITS: Byte = 0x24 + + /** + * `[gender, age, heightBE u16, (weight×10) BE u16, goalBE u16, nickname UTF-16LE…]` + * (`p.java x(PersonBean)`, line 876). + */ + const val PROFILE: Byte = 0x2E + + /** Unbind on Forget — empty payload (`h0.java n()`, line 319). */ + const val UNBIND: Byte = 0x44 + + /** + * Health-sync manifest: which history streams the ring currently holds, decoded into + * `HealthSyncBean` (`u1.java g()` sends it, `x5/b.java v0()` decodes). Every per-stream + * request below is gated on the matching `isHasXData()` flag from this reply + * (`blesdk/service/l.java`). + */ + const val SYNC_MANIFEST: Byte = 0xA0.toByte() + + /** Per-stream history requests — all sent with an **empty payload** (`blesdk/service/l.java`). */ + const val STEPS_HISTORY: Byte = 0xA1.toByte() // l.java, via f.java e() + const val SLEEP_HISTORY: Byte = 0xA2.toByte() // l.java i(), line 332 + const val HEART_RATE_HISTORY: Byte = 0xA3.toByte() // l.java h(), line 307 + const val BLOOD_PRESSURE_HISTORY: Byte = 0xA4.toByte() // l.java e(), line 232 + const val SPO2_HISTORY: Byte = 0xA5.toByte() // l.java d(), line 207 + const val TEMPERATURE_HISTORY: Byte = 0xA6.toByte() // l.java g(), line 282 + const val BREATHE_HISTORY: Byte = 0xA7.toByte() // l.java f(), line 257 + + /** + * Device→app ACK of one of our commands: payload `[serHi, serLo, cmd, status]` + * (`x5/d.java i()`). Releases the command gate for the matching serial. + */ + const val DEVICE_ACK: Byte = 0xFE.toByte() + + /** + * App→device ACK of a device frame, sent as its own framed command with the *inbound* + * frame's serial in the payload (`x5/d.java b()` → `j((byte) -1, …)`). + */ + const val APP_ACK: Byte = 0xFF.toByte() + + /** + * File-transfer command ids, excluded from the ACK handshake by `x5/d.java d()`: + * `{0x84, 0x80, 0x82, 0x86, 0x85}`. PulseLoop never sends these — listed so + * [isFileTransfer] can mirror the vendor's gate rather than re-deriving it. + */ + private val FILE_TRANSFER_CMDS = setOf( + 0x84.toByte(), 0x80.toByte(), 0x82.toByte(), 0x86.toByte(), 0x85.toByte(), + ) + + fun isFileTransfer(cmd: Byte): Boolean = cmd in FILE_TRANSFER_CMDS + } + + // ── JieLi `0xAB` addressing (`y5/c.java a()`, the full triple→msgId table) ─── + + /** + * A JieLi `{cmd, key, keyFlag}` triple — the first three bytes of every `0xAB` frame body, in + * both directions. `keyFlag` convention as used by the table: `0x00` set, `0x10` get/sync, + * `0x20` bind variants, `0x30` a second sync variant. + */ + data class JLTriple(val cmd: Byte, val key: Byte, val keyFlag: Byte) { + val bytes: ByteArray get() = byteArrayOf(cmd, key, keyFlag) + } + + object JieLi { + // Every triple below appears verbatim in `y5/c.java a()`'s map. + val SET_TIME = JLTriple(0x02, 0x01, 0x00) // y5/c.java:17 + val BATTERY = JLTriple(0x02, 0x03, 0x10) // y5/c.java:19 + val DEVICE_INFO = JLTriple(0x02, 0x04, 0x10) // y5/c.java:20 + val PROFILE = JLTriple(0x02, 0x06, 0x00) // y5/c.java:26 + val GOAL = JLTriple(0x02, 0x07, 0x00) // y5/c.java:25 + val UNITS = JLTriple(0x02, 0x11, 0x00) // y5/c.java:23 + val BIND_STATUS = JLTriple(0x03, 0x01, 0x00) // y5/c.java:14 + val BIND = JLTriple(0x03, 0x01, 0x20) // y5/c.java:15 + /** + * NOTE — iOS `RWfitProtocol.swift` records unbind as `{0x03, 0x01, 0x30}`. The vendor's + * table has no such entry; the only other group-3 `0x20` triple is `{0x03, 0x02, 0x20}` + * (`y5/c.java:16`). Left unresolved rather than guessed: [RWfitEncoder] does not send a + * JieLi unbind until this is confirmed from the sender, and Forget falls back to dropping + * the link. See the open question in `docs/ios-sync.md`. + */ + val UNBIND_UNCONFIRMED = JLTriple(0x03, 0x02, 0x20) + + /** + * `06 09 00` — the unified realtime-measurement toggle. Its inbound ACK is the one special + * case in the vendor's app→device ACK builder: for `cmd == 6 && key == 9` the ACK body is + * four bytes `[cmd, key, keyFlag, 0x00]` rather than the usual three + * (`r5/b.java:436-443`). + */ + val REALTIME_MEASURE = JLTriple(0x06, 0x09, 0x00) + + /** History sync for one stream: `05 10` (`y5/c.java:74-91`). */ + fun historySync(type: Byte) = JLTriple(0x05, type, 0x10) + } + + /** + * JieLi `05`-group data-type bytes — used both as the `` in [JieLi.historySync] and as the + * type byte of the realtime-measure command (`y5/c.java:74-91`). + */ + object JLDataType { + const val STEPS: Byte = 0x02 + const val HEART_RATE: Byte = 0x03 + const val BLOOD_PRESSURE: Byte = 0x04 + const val SLEEP: Byte = 0x05 + const val TEMPERATURE: Byte = 0x08 + const val SPO2: Byte = 0x09 + const val HRV: Byte = 0x0A + const val STRESS: Byte = 0x0D + const val BLOOD_SUGAR: Byte = 0x10 + } + + /** + * One history stream, unified across the two framings so the sync engine doesn't care which + * wire it rides. `legacyCommand == null` marks a stream the legacy protocol has no request for + * — the vendor's legacy sync cascade (`blesdk/service/l.java`) covers exactly seven streams, so + * HRV, stress and blood sugar are JieLi-only. `jlType == null` is the mirror case: breathe has + * no `05`-group type. + */ + enum class HistoryType(val legacyCommand: Byte?, val jlType: Byte?, val label: String) { + STEPS(Legacy.STEPS_HISTORY, JLDataType.STEPS, "activity"), + SLEEP(Legacy.SLEEP_HISTORY, JLDataType.SLEEP, "sleep"), + HEART_RATE(Legacy.HEART_RATE_HISTORY, JLDataType.HEART_RATE, "heart rate"), + BLOOD_PRESSURE(Legacy.BLOOD_PRESSURE_HISTORY, JLDataType.BLOOD_PRESSURE, "blood pressure"), + SPO2(Legacy.SPO2_HISTORY, JLDataType.SPO2, "blood oxygen"), + TEMPERATURE(Legacy.TEMPERATURE_HISTORY, JLDataType.TEMPERATURE, "temperature"), + BREATHE(Legacy.BREATHE_HISTORY, null, "breathing"), + HRV(null, JLDataType.HRV, "HRV"), + STRESS(null, JLDataType.STRESS, "stress"), + BLOOD_SUGAR(null, JLDataType.BLOOD_SUGAR, "blood sugar"), + } + + // ── Checksums ──────────────────────────────────────────────────────────────── + + /** + * XOR fold over the whole array, legacy framing's payload checksum (`y5/b.java o()`). + * Undefined for an empty array in the vendor (it indexes `[0]`); the encoder writes `0x00` + * for an empty payload instead of calling this (`x5/d.java j()`). + */ + fun xorChecksum(data: ByteArray, offset: Int = 0, length: Int = data.size - offset): Byte { + var cs = 0 + for (i in offset until offset + length) cs = cs xor (data[i].toInt() and 0xFF) + return cs.toByte() + } + + /** + * CRC-16/ARC (init `0x0000`, reflected poly `0xA001`, no final xor) — the JieLi body checksum. + * The vendor ships it as a 256-entry table (`y5/d.java f20005a`, whose `[1] = 0xC0C1`, + * `[2] = 0xC181`, `[3] = 0x0140` identify it as ARC) and formats the result `"%04x"`, so the + * two frame bytes are **big-endian**: high byte first (`x5/c.java g()`, lines 213-225). + */ + fun crc16Arc(data: ByteArray, offset: Int = 0, length: Int = data.size - offset): Int { + var crc = 0x0000 + for (i in offset until offset + length) { + crc = crc xor (data[i].toInt() and 0xFF) + repeat(8) { + crc = if ((crc and 0x0001) != 0) (crc ushr 1) xor 0xA001 else crc ushr 1 + } + } + return crc and 0xFFFF + } + + // ── Small byte helpers, mirroring `y5/b.java` ──────────────────────────────── + + /** `y5/b.java a(value, 2)` — big-endian u16. */ + fun u16BE(value: Int): ByteArray = + byteArrayOf(((value shr 8) and 0xFF).toByte(), (value and 0xFF).toByte()) + + /** `y5/b.java j(byte[])` over two bytes — big-endian u16. */ + fun readU16BE(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) +} diff --git a/app/src/main/java/com/pulseloop/ring/RWfitSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/RWfitSyncEngine.kt new file mode 100644 index 0000000..71481ba --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitSyncEngine.kt @@ -0,0 +1,162 @@ +package com.pulseloop.ring + +import android.util.Log + +/** + * Drives the RWfit connect handshake and history sync. + * + * Mirrors the vendor's own order (`u1.java g()` → `blesdk/service/l.java`'s cascade): ask for device + * info, set the clock, ask what history the ring is holding, then pull **only** the streams the + * manifest claims — one at a time, each request fired when the previous stream's reply lands. + * + * The manifest gate matters. Every per-stream method in `l.java` opens with an + * `isHasXData()` check and falls through to the next stream when it's false, so a ring with no + * temperature sensor is never asked for temperature history. + */ +class RWfitSyncEngine( + private val writer: RingCommandWriter?, + private val encoder: RWfitEncoder, +) : RingSyncEngine { + + var framing: RWfitFraming = RWfitFraming.LEGACY + + /** Streams still to request this pass, in the vendor's cascade order. */ + private var pending = ArrayDeque() + private var handshakeDone = false + + fun reset() { + pending.clear() + handshakeDone = false + } + + private fun send(command: ByteArray?) { + if (command == null) return + writer?.enqueue(command) + } + + // ── Startup ───────────────────────────────────────────────────────────────── + + /** + * Also the ~30-minute background sync pass, so it stays lean: device info + clock + manifest. + * The manifest reply is what starts the history cascade. + */ + override fun runStartup() { + send(encoder.deviceInfo()) + send(encoder.timeSync()) + send(encoder.battery()) + requestManifest() + } + + private fun requestManifest() { + if (framing != RWfitFraming.LEGACY) { + // JieLi has no manifest command, and its 05-group history bodies aren't decodable yet + // (see RWfitDriver.decodeJieLiFrame). Requesting them would spend the link on frames we + // would only log — so the JieLi path is live/battery only until those layouts are read + // out of the vendor app. + Log.i(TAG, "JieLi link — history sync not enabled yet") + return + } + send(encoder.syncManifest()) + } + + // ── Driver callbacks ──────────────────────────────────────────────────────── + + fun onDeviceInfo() { + handshakeDone = true + } + + /** The device ACKed one of our commands. Status != 0 means it refused it. */ + fun onDeviceAck(cmd: Byte, status: Byte) { + if (status.toInt() != 0) { + Log.w(TAG, "ring rejected cmd 0x${"%02X".format(cmd)} with status $status") + } + } + + /** The ring told us what it's holding — queue exactly those streams and start the cascade. */ + fun onManifest(manifest: RWfitDecoder.SyncManifest) { + pending = ArrayDeque(manifest.pendingStreams()) + Log.i(TAG, "manifest: ${manifest.totalDataCount} records, streams ${pending.toList()}") + requestNextStream() + } + + /** A stream's reply landed; move on to the next one. */ + fun onHistoryReply(type: RWfitProtocol.HistoryType) { + pending.remove(type) + requestNextStream() + } + + private fun requestNextStream() { + while (pending.isNotEmpty()) { + val next = pending.first() + val command = encoder.history(next) + if (command == null) { + // Not available on this framing — drop it rather than stalling the cascade. + pending.removeFirst() + continue + } + send(command) + return + } + } + + // ── Live measurement ──────────────────────────────────────────────────────── + // + // JieLi only: the vendor app has no legacy on-demand measurement command at all, so on a legacy + // link these are genuinely unavailable rather than unimplemented. RWfitCoordinator keeps the + // matching capabilities out of the baseline set so the UI never offers a button that could only + // time out. + + override fun startHeartRate() { + send(encoder.realtimeMeasure(RWfitProtocol.JLDataType.HEART_RATE, enable = true)) + } + + override fun stopHeartRate() { + send(encoder.realtimeMeasure(RWfitProtocol.JLDataType.HEART_RATE, enable = false)) + } + + override fun startSpO2() { + send(encoder.realtimeMeasure(RWfitProtocol.JLDataType.SPO2, enable = true)) + } + + override fun stopSpO2() { + send(encoder.realtimeMeasure(RWfitProtocol.JLDataType.SPO2, enable = false)) + } + + override fun startBloodPressure() { + send(encoder.realtimeMeasure(RWfitProtocol.JLDataType.BLOOD_PRESSURE, enable = true)) + } + + override fun stopBloodPressure() { + send(encoder.realtimeMeasure(RWfitProtocol.JLDataType.BLOOD_PRESSURE, enable = false)) + } + + override fun startHRV() { + send(encoder.realtimeMeasure(RWfitProtocol.JLDataType.HRV, enable = true)) + } + + override fun stopHRV() { + send(encoder.realtimeMeasure(RWfitProtocol.JLDataType.HRV, enable = false)) + } + + override fun handle(event: RingDecodedEvent) { + // The cascade is driven from RWfitDriver's decode path, which sees the raw command ids; + // by the time an event reaches here the stream it came from is no longer identifiable. + } + + /** + * Unbind so the ring releases this phone (`h0.java n()`). Legacy only — the JieLi unbind triple + * is unconfirmed, see [RWfitEncoder.unbind]. + */ + override fun factoryReset() { + send(encoder.unbind()) + } + + // Not present in the vendor's command set for this family. + override fun findDevice() {} + override fun setGoal(steps: Int) {} + override fun powerOff() {} + + private companion object { + const val TAG = "RWfitSyncEngine" + } +} diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 36877b7..5d72eba 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -52,6 +52,9 @@ class RingBLEClient( ColmiSmartHealthCoordinator, LuckRingCoordinator, TK5Coordinator, + // RWfit matches only the `A00A` advertisement or its two manufacturer IDs — no name + // matching at all — so it can't shadow anything above it. + RWfitCoordinator, // CRP matches only its family-exclusive `fdda` service (or an explicit CRP carousel pick), // so its position is not load-bearing. Like the CRP R11, it's usually reached by the // post-connect re-route below rather than by a scan match. @@ -1386,6 +1389,11 @@ class RingBLEClient( val driver = activeDriver ?: return + // Hand the driver the service table before any GATT work: RWfit decides which of its + // two wire framings to speak from the sibling services present here, and must know + // before its first write. + driver.servicesDiscovered(serviceUuids) + // Bind the ring's own service first and enable its notifications BEFORE any // other GATT work. The CONNECTED transition is gated on a notify-CCCD descriptor // write completing (see onDescriptorWrite), and every GATT op now runs strictly diff --git a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt index 10849dd..2bb0053 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt @@ -75,5 +75,8 @@ enum class RingDeviceType(val displayName: String) { // reveals its `fdda` service post-connect (issue #29, zaggash's ring). See CRPCoordinator. CRP("Colmi / Moyoung ring (CRP)"), // Hardware-validated SmartHealth R10M path, kept separate from the broader YCBT families. - YCBT("YCBT / SmartHealth ring"); + YCBT("YCBT / SmartHealth ring"), + // RWfit family (`com.rw.revivalfit`) — one A00A GATT, two wire framings chosen post-connect. + // Sold under many badges including "Colmi", which is why recognition is advertisement-only. + RWFIT("RWfit ring"); } diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index 6511129..91d0501 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -49,6 +49,16 @@ interface WearableDriver { /** Reset connection-scoped protocol state. */ fun connectionDidStart() {} fun connectionDidEnd() {} + + /** + * The connected GATT's full service table, delivered once after discovery. + * + * For families whose wire format isn't decidable from the advertisement: RWfit serves one data + * service (`A00A`) over two incompatible framings, and the vendor app picks between them purely + * from which *sibling* services the ring exposes (`r5/b.java onServicesDiscovered`). No-op for + * everyone else. + */ + fun servicesDiscovered(serviceUUIDs: Collection) {} } /** 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 ee72b86..7c4e19f 100644 --- a/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt +++ b/app/src/main/java/com/pulseloop/ui/components/DeviceHeroCard.kt @@ -186,5 +186,5 @@ private fun fallbackRingImage(type: RingDeviceType?): Int? = when (type) { RingDeviceType.YCBT -> R.drawable.ring_colmi_r10 // No dedicated product art yet for either YCBT family or LuckRing — falls back to the generic ring. RingDeviceType.COLMI_R02, RingDeviceType.TK5, RingDeviceType.COLMI_SMART_HEALTH, - RingDeviceType.LUCK_RING, RingDeviceType.CRP, null -> null + RingDeviceType.LUCK_RING, RingDeviceType.CRP, RingDeviceType.RWFIT, null -> null } diff --git a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt index 05f9482..af4c89b 100644 --- a/app/src/main/java/com/pulseloop/wearables/WearableModel.kt +++ b/app/src/main/java/com/pulseloop/wearables/WearableModel.kt @@ -143,6 +143,19 @@ data class WearableModel( advertisedNamePatterns = listOf("^TK18([ _-].*)?$"), ) + /** + * The catalog card for the family. `advertisedNamePatterns` is deliberately **empty**: the + * vendor's own scanner never looks at the name, and these rings ship under whatever badge + * the reseller picked (the reference unit was sold as a "Colmi"). Recognition lives + * entirely in [com.pulseloop.ring.RWfitCoordinator]'s advertisement match. + */ + val RWFIT = WearableModel( + id = "rwfit", displayName = "RWfit Ring", brand = "RWfit", family = RingDeviceType.RWFIT, + tint = PulseColors.bloodPressure, + blurb = "HR · SpO₂ · Sleep · Steps", + advertisedNamePatterns = emptyList(), + ) + private fun colmi( id: String, name: String, @@ -175,7 +188,7 @@ data class WearableModel( val CATALOG: List = listOf( COLMI_R02, COLMI_R06, COLMI_R10, YAWELL_R11, JRING, COLMI_R03, COLMI_R07, COLMI_R08, COLMI_R09, COLMI_R11, COLMI_R12, - YAWELL_R05, YAWELL_R10, H59, R10M, TK5, LUCK_RING_TK18, COLMI_R11_CRP, + YAWELL_R05, YAWELL_R10, H59, R10M, TK5, LUCK_RING_TK18, COLMI_R11_CRP, RWFIT, // Broadest pattern last: every narrower QRing-Colmi/TK5 entry above gets first shot // in modelForAdvertisedName's scan, so this can only match a name nothing else claims. COLMI_SMARTHEALTH, diff --git a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt index 4e06bdf..e8d64b2 100644 --- a/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt +++ b/app/src/test/java/com/pulseloop/ring/PairingMatchingTest.kt @@ -141,7 +141,7 @@ class PairingMatchingTest { val registeredTypes = setOf( JringCoordinator.deviceType, ColmiCoordinator.deviceType, YCBTCoordinator.deviceType, TK5Coordinator.deviceType, ColmiSmartHealthCoordinator.deviceType, - LuckRingCoordinator.deviceType, CRPCoordinator.deviceType, + LuckRingCoordinator.deviceType, CRPCoordinator.deviceType, RWfitCoordinator.deviceType, ) for (model in WearableModel.CATALOG) { assertTrue("no coordinator for ${model.displayName}", registeredTypes.contains(model.family)) diff --git a/app/src/test/java/com/pulseloop/ring/RWfitCodecTest.kt b/app/src/test/java/com/pulseloop/ring/RWfitCodecTest.kt new file mode 100644 index 0000000..f9e65eb --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/RWfitCodecTest.kt @@ -0,0 +1,257 @@ +package com.pulseloop.ring + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Oracles for the RWfit wire codecs, taken from the vendor app + * (`decompiled-rwfit-official/sources/`) rather than from iOS or from the implementation itself. + * + * These exist because the first RWfit driver (PR #45) invented its framing and nothing caught it: + * every assertion below fails against that implementation. + */ +class RWfitCodecTest { + + // ── Checksums ──────────────────────────────────────────────────────────────── + + @Test + fun `xor checksum folds the whole payload`() { + // y5/b.java o(): seed with [0], xor the rest. + assertEquals(0x00.toByte(), RWfitProtocol.xorChecksum(byteArrayOf(0x0F, 0x0F))) + assertEquals(0x01.toByte(), RWfitProtocol.xorChecksum(byteArrayOf(0x01))) + assertEquals(0x07.toByte(), RWfitProtocol.xorChecksum(byteArrayOf(0x01, 0x02, 0x04))) + } + + @Test + fun `crc16 matches the CRC-16 ARC published vectors`() { + // The vendor ships the table form (y5/d.java f20005a); these are the standard ARC vectors + // that identify it — table[1] = 0xC0C1 etc. + assertEquals(0xBB3D, RWfitProtocol.crc16Arc("123456789".toByteArray())) + assertEquals(0x0000, RWfitProtocol.crc16Arc(ByteArray(0))) + assertEquals(0xC0C1, RWfitProtocol.crc16Arc(byteArrayOf(0x01))) + assertEquals(0xC181, RWfitProtocol.crc16Arc(byteArrayOf(0x02))) + assertEquals(0x0140, RWfitProtocol.crc16Arc(byteArrayOf(0x03))) + } + + // ── Legacy 0x7E ────────────────────────────────────────────────────────────── + + @Test + fun `legacy encode matches the vendor's 8-byte header`() { + // x5/d.java j(): 7E 01 cmd flags dataLen serHi serLo xor + val codec = RWfitLegacyCodec() + val out = codec.encode(RWfitProtocol.Legacy.SET_TIME, byteArrayOf(0x07, 0xE9.toByte(), 0x08, 0x09)) + assertEquals(1, out.serial) // the vendor's counter starts at 1 + assertArrayEquals( + byteArrayOf( + 0x7E, 0x01, 0x21, 0x00, 0x04, 0x00, 0x01, + (0x07 xor 0xE9 xor 0x08 xor 0x09).toByte(), + 0x07, 0xE9.toByte(), 0x08, 0x09, + ), + out.frame, + ) + } + + @Test + fun `legacy encode writes zero checksum for an empty payload`() { + // x5/d.java j() only sets bArr2[7] when payload.length > 0 — history requests are empty. + val out = RWfitLegacyCodec().encode(RWfitProtocol.Legacy.HEART_RATE_HISTORY) + assertArrayEquals(byteArrayOf(0x7E, 0x01, 0xA3.toByte(), 0x00, 0x00, 0x00, 0x01, 0x00), out.frame) + } + + @Test + fun `legacy serials increment per frame and wrap at 65535`() { + val codec = RWfitLegacyCodec() + assertEquals(1, codec.encode(0x00).serial) + assertEquals(2, codec.encode(0x00).serial) + assertEquals(3, codec.encode(0x00).serial) + } + + @Test + fun `legacy decode yields ack-before-frame in the vendor's order`() { + // x5/d.java h() calls b(...) (the app ACK) before handing the body onward. + val codec = RWfitLegacyCodec() + val payload = byteArrayOf(0x00, 0x01, 0x55) + val frame = byteArrayOf(0x7E, 0x01, 0x01, 0x00, 0x03, 0x12, 0x34, RWfitProtocol.xorChecksum(payload)) + payload + + val events = codec.decode(frame) + + assertEquals(2, events.size) + assertEquals(RWfitLegacyInbound.AckNeeded(0x01, 0x1234), events[0]) + assertEquals(RWfitLegacyInbound.Frame(0x01, payload), events[1]) + } + + @Test + fun `legacy decode reports a checksum failure instead of the frame`() { + val codec = RWfitLegacyCodec() + val frame = byteArrayOf(0x7E, 0x01, 0x01, 0x00, 0x02, 0x00, 0x09, 0x7F, 0x11, 0x22) + assertEquals(listOf(RWfitLegacyInbound.ChecksumFailed(0x01, 9)), codec.decode(frame)) + } + + @Test + fun `legacy device ack is parsed and never itself acked`() { + // x5/d.java i(): 0xFE payload is [serHi, serLo, cmd, status]; h() skips b(...) for 0xFE. + val codec = RWfitLegacyCodec() + val body = byteArrayOf(0x00, 0x2A, 0x21, 0x00) + val frame = byteArrayOf(0x7E, 0x01, 0xFE.toByte(), 0x00, 0x04, 0x00, 0x05, RWfitProtocol.xorChecksum(body)) + body + + val events = codec.decode(frame) + + assertEquals(listOf(RWfitLegacyInbound.DeviceAck(cmd = 0x21, serial = 42, status = 0x00)), events) + } + + @Test + fun `legacy app ack carries the inbound serial and a fresh header serial`() { + // x5/d.java b(): j(0xFF, [serHi, serLo, cmd, status]). + val codec = RWfitLegacyCodec() + val ack = codec.ack(cmd = 0x01, inboundSerial = 0x1234, status = 0) + + assertEquals(0xFF.toByte(), ack[2]) // cmd + assertEquals(0x00.toByte(), ack[5]) // this frame's own serial, BE hi + assertEquals(0x01.toByte(), ack[6]) // ... lo — first frame of the session + assertArrayEquals(byteArrayOf(0x12, 0x34, 0x01, 0x00), ack.copyOfRange(8, 12)) + } + + @Test + fun `legacy multi-packet frames reassemble in index order`() { + val codec = RWfitLegacyCodec() + fun chunk(index: Int, total: Int, body: ByteArray) = byteArrayOf( + 0x7E, 0x01, 0xA3.toByte(), 0x08, (body.size and 0xFF).toByte(), 0x00, index.toByte(), + RWfitProtocol.xorChecksum(body), + 0x00, total.toByte(), 0x00, index.toByte(), + ) + body + + val first = codec.decode(chunk(1, 2, byteArrayOf(0x11, 0x22))) + assertEquals(listOf(RWfitLegacyInbound.AckNeeded(0xA3.toByte(), 1)), first) + + val second = codec.decode(chunk(2, 2, byteArrayOf(0x33))) + assertEquals(2, second.size) + assertEquals( + RWfitLegacyInbound.Frame(0xA3.toByte(), byteArrayOf(0x11, 0x22, 0x33)), + second[1], + ) + } + + @Test + fun `legacy reset drops a half-assembled frame`() { + val codec = RWfitLegacyCodec() + val body = byteArrayOf(0x11) + codec.decode( + byteArrayOf(0x7E, 0x01, 0xA3.toByte(), 0x08, 0x01, 0x00, 0x01, RWfitProtocol.xorChecksum(body), 0x00, 0x02, 0x00, 0x01) + body + ) + codec.reset() + + val body2 = byteArrayOf(0x22) + val events = codec.decode( + byteArrayOf(0x7E, 0x01, 0xA3.toByte(), 0x08, 0x01, 0x00, 0x02, RWfitProtocol.xorChecksum(body2), 0x00, 0x02, 0x00, 0x02) + body2 + ) + + // Only the ACK — the stale chunk from the previous link must not complete this frame. + assertEquals(1, events.size) + assertTrue(events.single() is RWfitLegacyInbound.AckNeeded) + } + + @Test + fun `legacy decode ignores non-7E notifications`() { + assertTrue(RWfitLegacyCodec().decode(byteArrayOf(0xAB.toByte(), 0x01, 0x00, 0x03)).isEmpty()) + } + + // ── JieLi 0xAB ─────────────────────────────────────────────────────────────── + + @Test + fun `jieli encode matches the vendor's 6-byte header with big-endian crc`() { + // x5/c.java g(): AB flag lenHi lenLo crcHi crcLo , len and crc both over the body + // *including* the {cmd,key,keyFlag} triple. + val codec = RWfitJLCodec() + val frame = codec.encode(RWfitProtocol.JieLi.BATTERY) + + val body = byteArrayOf(0x02, 0x03, 0x10) + val crc = RWfitProtocol.crc16Arc(body) + assertArrayEquals( + byteArrayOf( + 0xAB.toByte(), 0x01, 0x00, 0x03, + ((crc shr 8) and 0xFF).toByte(), (crc and 0xFF).toByte(), + 0x02, 0x03, 0x10, + ), + frame, + ) + } + + @Test + fun `jieli length and crc cover the triple as part of the body`() { + val frame = RWfitJLCodec().encode(RWfitProtocol.JieLi.historySync(RWfitProtocol.JLDataType.SLEEP), byteArrayOf(0x07)) + assertEquals(4, RWfitProtocol.readU16BE(frame, 2)) // 3 triple + 1 payload + assertEquals( + RWfitProtocol.crc16Arc(byteArrayOf(0x05, 0x05, 0x10, 0x07)), + RWfitProtocol.readU16BE(frame, 4), + ) + } + + @Test + fun `jieli round-trips through decode`() { + val codec = RWfitJLCodec() + val frame = codec.encode(RWfitProtocol.JieLi.DEVICE_INFO, byteArrayOf(0x01, 0x02)) + + val decoded = codec.decode(frame).single() as RWfitJLInbound.Frame + + assertEquals(RWfitProtocol.JieLi.DEVICE_INFO, decoded.triple) + assertArrayEquals(byteArrayOf(0x01, 0x02), decoded.payload) + assertTrue(!decoded.isAck) + } + + @Test + fun `jieli ack uses flag 0x11 and echoes the triple`() { + val codec = RWfitJLCodec() + val ack = codec.ack(RWfitProtocol.JieLi.BATTERY) + + assertEquals(RWfitJLCodec.FLAG_ACK, ack[1]) + assertArrayEquals(byteArrayOf(0x02, 0x03, 0x10), ack.copyOfRange(6, 9)) + assertEquals(3, RWfitProtocol.readU16BE(ack, 2)) + } + + @Test + fun `jieli ack for the realtime-measure triple carries the extra zero byte`() { + // r5/b.java:436-443 — the one special case in the vendor's ACK builder. + val codec = RWfitJLCodec() + val ack = codec.ack(RWfitProtocol.JieLi.REALTIME_MEASURE) + + assertEquals(4, RWfitProtocol.readU16BE(ack, 2)) + assertArrayEquals(byteArrayOf(0x06, 0x09, 0x00, 0x00), ack.copyOfRange(6, 10)) + } + + @Test + fun `jieli reports a crc failure`() { + val codec = RWfitJLCodec() + val frame = codec.encode(RWfitProtocol.JieLi.BATTERY).copyOf() + frame[4] = (frame[4] + 1).toByte() + + assertTrue(codec.decode(frame).single() is RWfitJLInbound.ChecksumFailed) + } + + @Test + fun `jieli reassembles a body split across continuation packets`() { + // r5/b.java: the header packet carries the first chunk; later packets are raw body bytes. + val codec = RWfitJLCodec() + val payload = ByteArray(30) { (it + 1).toByte() } + val whole = codec.encode(RWfitProtocol.JieLi.historySync(RWfitProtocol.JLDataType.HEART_RATE), payload) + + val head = whole.copyOfRange(0, 20) + val tail = whole.copyOfRange(20, whole.size) + + assertTrue(codec.decode(head).isEmpty()) + val decoded = codec.decode(tail).single() as RWfitJLInbound.Frame + assertArrayEquals(payload, decoded.payload) + } + + // ── Advertisement recognition ──────────────────────────────────────────────── + + @Test + fun `advertisement patterns are the vendor's four scan signatures`() { + // r5/d.java c() — and deliberately no name pattern among them. + assertEquals( + listOf("02010603030aa0", "d6050200", "15ffd6054154", "d6060200"), + RWfitProtocol.ADVERTISEMENT_HEX_PATTERNS, + ) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/RWfitDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/RWfitDecoderTest.kt new file mode 100644 index 0000000..85200cc --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/RWfitDecoderTest.kt @@ -0,0 +1,225 @@ +package com.pulseloop.ring + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.TimeZone + +/** + * Payload-layout oracles taken from `x5/b.java` in `decompiled-rwfit-official/sources/`. + * + * Timestamps are asserted through the same tz correction the decoder applies, rather than against a + * hard-coded epoch — the vendor's own correction is timezone-dependent (and knowingly quirky), so + * pinning an absolute value here would just make the suite fail outside one zone. + */ +class RWfitDecoderTest { + + private fun tzCorrection(): Long { + val tz = TimeZone.getDefault() + return (tz.rawOffset + if (tz.useDaylightTime()) 3_600_000L else 0L) / 1000 + } + + private fun be32(v: Long) = byteArrayOf( + ((v shr 24) and 0xFF).toByte(), ((v shr 16) and 0xFF).toByte(), + ((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte(), + ) + + private fun be16(v: Int) = byteArrayOf(((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte()) + private fun be24(v: Int) = byteArrayOf( + ((v shr 16) and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte(), + ) + + private val dayTs = 1_723_000_000L + + // ── Battery ───────────────────────────────────────────────────────────────── + + @Test + fun `battery percent is payload byte 2, not byte 0`() { + // PowerBean = [lowPowerFlag, powerStatus, percent]. Reading byte 0 yields the low-power + // boolean, which is how the reverted driver reported every ring as 0% or 1%. + val event = RWfitDecoder.decodeBattery(byteArrayOf(0x00, 0x00, 0x53)).single() + assertEquals(RingDecodedEvent.Battery(percent = 83, charging = false), event) + } + + @Test + fun `battery reports charging from powerStatus`() { + val event = RWfitDecoder.decodeBattery(byteArrayOf(0x01, 0x01, 0x2A)).single() + assertEquals(RingDecodedEvent.Battery(percent = 42, charging = true), event) + } + + @Test + fun `battery ignores a short frame`() { + assertTrue(RWfitDecoder.decodeBattery(byteArrayOf(0x00)).isEmpty()) + } + + // ── Sync manifest (0xA0) ──────────────────────────────────────────────────── + + @Test + fun `sync manifest decodes its bit flags`() { + // v0(): [count u16][flagsA][flagsB]; flagsA bits 0..7 = step/sleep/hr/bp/spo2/temp/breathe/ecg + val manifest = RWfitDecoder.decodeSyncManifest(byteArrayOf(0x00, 0x2A, 0b0001_0101, 0x01))!! + + assertEquals(42, manifest.totalDataCount) + assertTrue(manifest.hasSteps) // bit 0 + assertTrue(!manifest.hasSleep) // bit 1 + assertTrue(manifest.hasHeartRate) // bit 2 + assertTrue(!manifest.hasBloodPressure) + assertTrue(manifest.hasSpo2) // bit 4 + assertTrue(!manifest.hasTemperature) + assertTrue(manifest.hasSport) // flagsB bit 0 + } + + @Test + fun `pending streams follow the vendor cascade order`() { + val manifest = RWfitDecoder.decodeSyncManifest(byteArrayOf(0x00, 0x05, 0xFF.toByte(), 0x00))!! + assertEquals( + listOf( + RWfitProtocol.HistoryType.STEPS, + RWfitProtocol.HistoryType.SLEEP, + RWfitProtocol.HistoryType.HEART_RATE, + RWfitProtocol.HistoryType.BLOOD_PRESSURE, + RWfitProtocol.HistoryType.SPO2, + RWfitProtocol.HistoryType.TEMPERATURE, + RWfitProtocol.HistoryType.BREATHE, + ), + manifest.pendingStreams(), + ) + } + + @Test + fun `sync manifest rejects a short frame`() { + assertNull(RWfitDecoder.decodeSyncManifest(byteArrayOf(0x00, 0x01))) + } + + // ── HR / SpO2 / BP / temperature history ──────────────────────────────────── + + @Test + fun `heart rate history decodes day header plus 5-byte items`() { + val p = be32(dayTs) + be16(2) + + be32(dayTs + 60) + byteArrayOf(72) + + be32(dayTs + 120) + byteArrayOf(88.toByte()) + + val events = RWfitDecoder.decodeHeartRateHistory(p) + .filterIsInstance() + + assertEquals(2, events.size) + assertEquals(MeasurementKind.HEART_RATE, events[0].kind_field) + assertEquals(72.0, events[0].value, 0.0) + assertEquals(dayTs + 60 - tzCorrection(), events[0]._timestamp.epochSecond) + assertEquals(88.0, events[1].value, 0.0) + } + + @Test + fun `heart rate history drops out-of-range samples`() { + val p = be32(dayTs) + be16(2) + + be32(dayTs) + byteArrayOf(0) + // 0 bpm = no reading + be32(dayTs + 60) + byteArrayOf(65) + assertEquals(1, RWfitDecoder.decodeHeartRateHistory(p).size) + } + + @Test + fun `spo2 history decodes and clamps`() { + val p = be32(dayTs) + be16(2) + + be32(dayTs) + byteArrayOf(97.toByte()) + + be32(dayTs + 60) + byteArrayOf(0) // dropped + val events = RWfitDecoder.decodeSpo2History(p).filterIsInstance() + + assertEquals(1, events.size) + assertEquals(MeasurementKind.SPO2, events[0].kind_field) + assertEquals(97.0, events[0].value, 0.0) + } + + @Test + fun `blood pressure history emits systolic and diastolic from a 6-byte item`() { + val p = be32(dayTs) + be16(1) + be32(dayTs + 30) + byteArrayOf(120.toByte(), 78) + val events = RWfitDecoder.decodeBloodPressureHistory(p) + .filterIsInstance() + + assertEquals(2, events.size) + assertEquals(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, events[0].kind_field) + assertEquals(120.0, events[0].value, 0.0) + assertEquals(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, events[1].kind_field) + assertEquals(78.0, events[1].value, 0.0) + assertEquals(events[0]._timestamp, events[1]._timestamp) + } + + @Test + fun `temperature is offset-encoded as raw plus 200 over 10`() { + // u0(): temp = ((raw & 255) + 200) / 10.0 — raw 165 → 36.5 °C. + val p = be32(dayTs) + be16(1) + be32(dayTs) + byteArrayOf(165.toByte()) + val events = RWfitDecoder.decodeTemperatureHistory(p) + .filterIsInstance() + + assertEquals(1, events.size) + assertEquals(36.5, events[0].value, 1e-9) + } + + @Test + fun `multiple day records in one payload all decode`() { + val p = be32(dayTs) + be16(1) + be32(dayTs) + byteArrayOf(60) + + be32(dayTs + 86_400) + be16(1) + be32(dayTs + 86_400) + byteArrayOf(62) + assertEquals(2, RWfitDecoder.decodeHeartRateHistory(p).size) + } + + @Test + fun `a truncated record stops decoding instead of reading past the end`() { + val p = be32(dayTs) + be16(5) + be32(dayTs) + byteArrayOf(60) // claims 5, carries 1 + assertTrue(RWfitDecoder.decodeHeartRateHistory(p).isEmpty()) + } + + // ── Steps (0xA1) ──────────────────────────────────────────────────────────── + + @Test + fun `step history decodes the 15-byte day header and skips its items`() { + val p = be32(dayTs) + be24(8421) + be24(310) + be24(6200) + be16(2) + + byteArrayOf(0) + be16(100) + be24(4) + be16(70) + + byteArrayOf(1) + be16(250) + be24(9) + be16(180) + + val event = RWfitDecoder.decodeStepHistory(p).single() as RingDecodedEvent.ActivityUpdate + + assertEquals(8421, event.steps) + assertEquals(310, event.calories) + assertEquals(6200, event.distanceMeters) + assertEquals(dayTs - tzCorrection(), event._timestamp.epochSecond) + } + + // ── Sleep (0xA2) ──────────────────────────────────────────────────────────── + + @Test + fun `sleep history expands runs into per-minute stages`() { + val asleep = dayTs + 3600 + val p = be32(dayTs) + be16(240) + be32(asleep) + be32(asleep + 28_800) + be16(3) + + byteArrayOf(30, 1) + // 30 min light + byteArrayOf(20, 2) + // 20 min deep + byteArrayOf(10, 3) // 10 min REM + + val timeline = RWfitDecoder.decodeSleepHistory(p).single() as RingDecodedEvent.SleepTimeline + + assertEquals(60, timeline.stages.size) + assertEquals(SleepStage.LIGHT, timeline.stages[0]) + assertEquals(SleepStage.DEEP, timeline.stages[30]) + assertEquals(SleepStage.REM, timeline.stages[50]) + assertEquals(asleep - tzCorrection(), timeline._timestamp.epochSecond) + assertTrue(timeline.completeSession) + } + + @Test + fun `stage type 0 is awake`() { + val asleep = dayTs + 3600 + val p = be32(dayTs) + be16(60) + be32(asleep) + be32(asleep + 3600) + be16(2) + + byteArrayOf(5, 0) + byteArrayOf(10, 1) + + val timeline = RWfitDecoder.decodeSleepHistory(p).single() as RingDecodedEvent.SleepTimeline + + assertEquals(SleepStage.AWAKE, timeline.stages[0]) + assertEquals(SleepStage.LIGHT, timeline.stages[5]) + } + + @Test + fun `an all-awake record is not a sleep session`() { + val asleep = dayTs + 3600 + val p = be32(dayTs) + be16(0) + be32(asleep) + be32(asleep) + be16(1) + byteArrayOf(20, 0) + assertTrue(RWfitDecoder.decodeSleepHistory(p).isEmpty()) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/RWfitDriverTest.kt b/app/src/test/java/com/pulseloop/ring/RWfitDriverTest.kt new file mode 100644 index 0000000..ef5e874 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/RWfitDriverTest.kt @@ -0,0 +1,266 @@ +package com.pulseloop.ring + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Driver-level behaviour: framing selection from the service table, the mandatory ACK handshake, + * and the manifest-gated history cascade. + */ +class RWfitDriverTest { + + /** Captures everything the driver would write to the ring. */ + private class RecordingWriter : RingCommandWriter { + val frames = mutableListOf() + override fun enqueue(command: ByteArray) { frames.add(command) } + fun cmds(): List = frames.mapNotNull { if (it.size > 2 && it[0] == 0x7E.toByte()) it[2].toInt() and 0xFF else null } + fun clear() = frames.clear() + } + + private fun legacyFrame(cmd: Byte, payload: ByteArray, serial: Int = 1): ByteArray { + val ser = RWfitProtocol.u16BE(serial) + val xor = if (payload.isEmpty()) 0 else RWfitProtocol.xorChecksum(payload) + return byteArrayOf(0x7E, 0x01, cmd, 0x00, payload.size.toByte(), ser[0], ser[1], xor) + payload + } + + private fun advert(services: List = emptyList(), mfg: ByteArray? = null) = + AdvertisementInfo(serviceUUIDs = services, manufacturerData = mfg) + + // ── Framing selection (r5/b.java onServicesDiscovered) ────────────────────── + + @Test + fun `a bare A00A ring speaks legacy`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + + driver.makeSyncEngine().runStartup() + + assertTrue("expected 0x7E frames", writer.frames.all { it[0] == 0x7E.toByte() }) + } + + @Test + fun `the JieLi AE00 service switches framing`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID, RWfitProtocol.JIELI_SERVICE_UUID)) + + driver.makeSyncEngine().runStartup() + + assertTrue("expected 0xAB frames", writer.frames.all { it[0] == 0xAB.toByte() }) + } + + @Test + fun `Telink and PixArt OTA services also mean JieLi`() { + for (discriminator in listOf(RWfitProtocol.TELINK_OTA_SERVICE_UUID, RWfitProtocol.PIXART_OTA_SERVICE_UUID)) { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID, discriminator)) + driver.makeSyncEngine().runStartup() + assertTrue("$discriminator should select JieLi", writer.frames.first()[0] == 0xAB.toByte()) + } + } + + @Test + fun `service matching is case-insensitive`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.JIELI_SERVICE_UUID.uppercase())) + driver.makeSyncEngine().runStartup() + assertTrue(writer.frames.first()[0] == 0xAB.toByte()) + } + + // ── ACK handshake (x5/d.java) ─────────────────────────────────────────────── + + @Test + fun `every inbound legacy frame is app-ACKed`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + + driver.ingest(legacyFrame(RWfitProtocol.Legacy.BATTERY, byteArrayOf(0, 0, 77), serial = 9), "n") + + val ack = writer.frames.single() + assertEquals(0xFF.toByte(), ack[2]) // app-ACK command + assertArrayEquals(byteArrayOf(0x00, 0x09, 0x01, 0x00), ack.copyOfRange(8, 12)) + } + + @Test + fun `a bad checksum is NACKed with status 2`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + + val corrupt = legacyFrame(RWfitProtocol.Legacy.BATTERY, byteArrayOf(0, 0, 77), serial = 3) + corrupt[7] = (corrupt[7] + 1).toByte() + val events = driver.ingest(corrupt, "n") + + assertTrue("a corrupt frame must not decode", events.isEmpty()) + assertEquals(0x02.toByte(), writer.frames.single()[11]) // status byte of the ACK payload + } + + @Test + fun `a device ACK is not itself ACKed`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + + driver.ingest( + legacyFrame(RWfitProtocol.Legacy.DEVICE_ACK, byteArrayOf(0x00, 0x05, 0x21, 0x00)), + "n", + ) + + assertTrue("0xFE must not be echoed back", writer.frames.isEmpty()) + } + + @Test + fun `battery decodes through the driver`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + + val events = driver.ingest(legacyFrame(RWfitProtocol.Legacy.BATTERY, byteArrayOf(0, 0, 77)), "n") + + assertEquals(RingDecodedEvent.Battery(percent = 77), events.single()) + } + + // ── Manifest-gated cascade (blesdk/service/l.java) ────────────────────────── + + @Test + fun `startup asks for device info, time, battery and the manifest`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + + driver.makeSyncEngine().runStartup() + + assertEquals(listOf(0x00, 0x21, 0x01, 0xA0), writer.cmds()) + } + + @Test + fun `only the streams the manifest claims are requested`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + driver.makeSyncEngine().runStartup() + writer.clear() + + // steps (bit 0) + HR (bit 2) only. + driver.ingest( + legacyFrame(RWfitProtocol.Legacy.SYNC_MANIFEST, byteArrayOf(0x00, 0x02, 0b0000_0101, 0x00)), + "n", + ) + + // First stream requested immediately; the app-ACK for the manifest is also in the queue. + assertEquals(listOf(0xFF, 0xA1), writer.cmds()) + assertFalse("must not ask for sleep", writer.cmds().contains(0xA2)) + } + + @Test + fun `each history reply advances the cascade one stream at a time`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + driver.makeSyncEngine().runStartup() + driver.ingest( + legacyFrame(RWfitProtocol.Legacy.SYNC_MANIFEST, byteArrayOf(0x00, 0x02, 0b0000_0101, 0x00)), + "n", + ) + writer.clear() + + // Steps reply → HR is next, and nothing else. + driver.ingest(legacyFrame(RWfitProtocol.Legacy.STEPS_HISTORY, ByteArray(0)), "n") + assertEquals(listOf(0xFF, 0xA3), writer.cmds()) + writer.clear() + + // HR reply → the queue is empty, so no further history requests. + driver.ingest(legacyFrame(RWfitProtocol.Legacy.HEART_RATE_HISTORY, ByteArray(0)), "n") + assertEquals(listOf(0xFF), writer.cmds()) + } + + @Test + fun `a JieLi link does not request legacy history`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID, RWfitProtocol.JIELI_SERVICE_UUID)) + + driver.makeSyncEngine().runStartup() + + // Device info, time and battery only — the 05-group history bodies aren't decodable yet, so + // requesting them would spend the link on frames we could only log. + assertEquals(3, writer.frames.size) + } + + @Test + fun `reconnect clears the cascade`() { + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID)) + driver.makeSyncEngine().runStartup() + driver.ingest( + legacyFrame(RWfitProtocol.Legacy.SYNC_MANIFEST, byteArrayOf(0x00, 0x02, 0b0000_0101, 0x00)), + "n", + ) + + driver.connectionDidEnd() + driver.connectionDidStart() + writer.clear() + + // A history reply left over from the dead link must not resume anything. + driver.ingest(legacyFrame(RWfitProtocol.Legacy.STEPS_HISTORY, ByteArray(0)), "n") + assertEquals(listOf(0xFF), writer.cmds()) + } + + // ── Coordinator matching (r5/d.java) ──────────────────────────────────────── + + @Test + fun `matches the A00A advertisement in either form`() { + assertTrue(RWfitCoordinator.matches(null, advert(services = listOf("a00a")))) + assertTrue(RWfitCoordinator.matches(null, advert(services = listOf(RWfitProtocol.SERVICE_UUID)))) + assertTrue(RWfitCoordinator.matches(null, advert(services = listOf(RWfitProtocol.SERVICE_UUID.uppercase())))) + } + + @Test + fun `matches the vendor manufacturer prefixes`() { + assertTrue(RWfitCoordinator.matches(null, advert(mfg = byteArrayOf(0xD6.toByte(), 0x05, 0x02, 0x00)))) + assertTrue(RWfitCoordinator.matches(null, advert(mfg = byteArrayOf(0xD6.toByte(), 0x05, 0x41, 0x54)))) + assertTrue(RWfitCoordinator.matches(null, advert(mfg = byteArrayOf(0xD6.toByte(), 0x06, 0x02, 0x00)))) + } + + @Test + fun `never matches on the device name`() { + // The vendor requires a non-empty name but never reads it — these rings are rebranded + // constantly, and the first Android attempt's `startsWith("RW")` both missed rebrands and + // would hijack unrelated devices. + assertFalse(RWfitCoordinator.matches("RWfit Ring", advert())) + assertFalse(RWfitCoordinator.matches("RW-01", advert())) + assertFalse(RWfitCoordinator.matches("RWXYZ", advert())) + assertFalse(RWfitCoordinator.matches(null, advert(mfg = byteArrayOf(0x01, 0x02)))) + } + + @Test + fun `manual measurement is gated behind the feature bitmap, not granted to the family`() { + // The vendor app has no legacy on-demand measurement command, so a Measure button on a + // legacy link could only ever time out. + assertFalse(WearableCapability.MANUAL_HEART_RATE in RWfitCoordinator.capabilities) + assertTrue(WearableCapability.MANUAL_HEART_RATE in RWfitCoordinator.bitmapGatedCapabilities) + assertFalse(WearableCapability.BLOOD_PRESSURE in RWfitCoordinator.capabilities) + assertTrue(WearableCapability.BLOOD_PRESSURE in RWfitCoordinator.bitmapGatedCapabilities) + } +} diff --git a/docs/ios-sync.md b/docs/ios-sync.md index d5713b7..dc85511 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -111,7 +111,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | ☑ | [#100](https://github.com/saksham2001/PulseLoopiOS/pull/100) `4947628` | ~07-26 | Strava OAuth connect + TCX upload (GPS-HR merge, auto-dedup, token refresh) + shareable PNG stat cards | **ADAPT** | L | `4ce34dc` + `c4aab74` (CR fix: mobile endpoint, intent-filter, redirect handler, pollUntilDone, BuildConfig secrets, shared OkHttpClient) | | ☑ | — `160c775` | ~07-26 | Set version to 2.5.0 + read About version from bundle | **ALREADY-HAVE** | — | `68c9788` (versionName → 2.5.0 to match iOS MARKETING_VERSION) | | ☑ | [#98](https://github.com/saksham2001/PulseLoopiOS/pull/98) `ac01555` | ~07-27 | On-device daily calorie estimation (Mifflin-St Jeor BMR + Keytel/MET active energy, HR-gated) for rings that don't report calories | **PORT** | M | `0ca53a1` | -| ☐ | [#130](https://github.com/saksham2001/PulseLoopiOS/pull/130) `cf5c0f4` | ~08-04 | RWfit ring family (dual 0x7E/0xAB protocol, full metric set, service-UUID recognition) | **ADAPT** | L–XL | **BACKED OUT of PR #45** — see "RWfit (#130) — backed out" below. Work preserved on `feat/rwfit-ring-family`; redo against `decompiled-rwfit-official/`. | +| ☑ | [#130](https://github.com/saksham2001/PulseLoopiOS/pull/130) `cf5c0f4` | ~08-04 | RWfit ring family (dual 0x7E/0xAB protocol, full metric set, service-UUID recognition) | **ADAPT** | L–XL | Backed out of PR #45, then **rebuilt from `decompiled-rwfit-official/`** on `feat/rwfit-vendor-rebuild`. Legacy `0x7E` path complete; JieLi `0xAB` framing complete but its history bodies are not decoded yet. **No hardware validation.** See below. | | ☑ | [#131](https://github.com/saksham2001/PulseLoopiOS/pull/131) `88c0f6b` | ~08-08 | Sleep hypnogram label alignment + press-and-hold stage scrubber (+ sync spinner rewrite, iOS-only) | **ADAPT** | S–M | `802789d` | ## Port priority — open items (as of 2026-08-08) @@ -1438,8 +1438,70 @@ Logic bugs found in the same pass, independent of the vendor mismatch: the `DeviceHeroCard` fallback arm, and the `PairingMatchingTest` registered-type entry. The gratuitously-deleted CRP ordering comment in `RingBLEClient` was restored. -**Where the work lives:** `feat/rwfit-ring-family` (bookmarked at `b073dad`). Rebuild the protocol -layer there from `decompiled-rwfit-official/`, then recombine. +**Where the work lives:** `feat/rwfit-ring-family` (bookmarked at `b073dad`) is the archive of the +backed-out code. The rebuild is `feat/rwfit-vendor-rebuild`, described next. + +--- + +## RWfit (#130) — rebuilt from the vendor app, 2026-08-09 + +Branch `feat/rwfit-vendor-rebuild`, on top of the remediated `ios_sync_2026-08-08`. Every constant +is cited to the vendor file it came from. **Every claim iOS's `RWfitProtocol.swift` makes was +re-derived from the decompile rather than trusted** — and it checks out, with one exception noted +below. + +### What was verified, and what iOS's summary didn't carry + +| Source | Fact | +|---|---| +| `y5/a.java` | service `a00a`, write `b002`, notify `b003`; discriminators `ae00` / `ff00` / Telink `…1912` | +| `r5/b.java:700-727` | framing chosen **post-connect** from sibling services — never from the advertisement | +| `x5/d.java` | legacy header `7E 01 cmd flags dataLen serHi serLo xor`; XOR over payload only; serials 1…65535 wrapping; 12-byte multi-packet variant; **ack-before-parse** ordering | +| `x5/c.java`, `r5/b.java:386-476` | JieLi header `AB flag lenHi lenLo crcHi crcLo`; CRC-16/ARC **big-endian**; length *and* CRC cover the `{cmd,key,keyFlag}` triple opening the body; flag `0x11` = ACK; the `06/09` ACK carries a trailing `0x00` | +| `y5/c.java` | the full triple table and `05`-group data types | +| `x5/b.java` + senders | command ids — and **battery percent is payload byte 2**, not byte 0 | +| `p.java u()` vs `v()` | legacy `setTime` sends the full 4-digit year BE; JieLi sends `year − 2000`. Not a shared encoder. | +| `x5/b.java w0/r0/s0/u0` | HR/SpO2/BP/temp share `[dayTs u32][itemCount u16]` + fixed items; temperature is offset-encoded, **°C = (raw + 200) / 10** | +| `x5/b.java C0/A0` | steps has a 15-byte day header, sleep a 16-byte one with 2-byte `[minutes][stage]` runs | +| `s1.java:1636-1645` | sleep stage types **0 awake, 1 light, 2 deep, 3 REM**, from the vendor's own aggregation | +| `r5/d.java c()` | four raw-advertisement signatures; a non-empty name is required but **never matched on** | + +### One iOS error found + +iOS records the JieLi unbind triple as `{0x03, 0x01, 0x30}`. The vendor's table (`y5/c.java`) has no +such entry — the only other group-3 `0x20` triple is `{0x03, 0x02, 0x20}`. Left as +`JieLi.UNBIND_UNCONFIRMED` and **not sent**: guessing inside the bind group risks re-binding or +factory-resetting someone's ring. Worth an upstream issue on PulseLoopiOS. + +### Deliberate quirk replication + +The ring stamps history timestamps as local wall-clock pretending to be UTC, and the vendor corrects +them by subtracting the zone's raw offset **plus a flat hour whenever the zone observes DST at all** +— `useDaylightTime()`, not `inDaylightTime(date)`. That is an hour out for half the year, and it is +replicated exactly: matching it keeps our decode aligned with what the ring and the vendor app agree +on, whereas "fixing" it would put us an hour off theirs. + +### Scope + +- **Legacy `0x7E`: complete.** Framing, serials, XOR, the `0xFE`/`0xFF` handshake, multi-packet + reassembly, all six history streams, battery, manifest-gated cascade. +- **JieLi `0xAB`: framing complete, payloads not.** Handshake, battery, time sync and the ACK + discipline work; the `05`-group history bodies have their own per-type layouts that have **not** + been extracted. `RWfitSyncEngine` therefore does not request history on a JieLi link, and the + driver logs those frames rather than guessing at them. +- **Feature bitmap not decoded** (`x5/b.java i()` → `SupportMenuBean`), so `bitmapGatedCapabilities` + is declared but nothing grants from it yet. Manual/realtime measurement and the per-SKU sensors + stay ungranted rather than being handed out unconditionally — the vendor has no legacy on-demand + measurement command at all, so a Measure button on a `0x7E` link could only time out. +- **Step intraday buckets not emitted**: the per-item `index` doesn't pin a wall-clock bucket + without the ring's bucket width, which the vendor never states. Day totals only. + +### Testing + +49 unit tests across `RWfitCodecTest` (20), `RWfitDecoderTest` (16) and `RWfitDriverTest` (13), +asserting vendor byte layouts rather than the implementation. Suite: 812 → 866. + +**No hardware validation.** Nothing here has talked to a real RWfit ring. Say so on the PR. ---