From 79ec72f84af54db183eb7a743697e1d090fec220 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 8 Aug 2026 19:25:06 -0700 Subject: [PATCH 1/3] feat(rwfit): protocol constants and both wire codecs, from the vendor app Rebuild of #130 from decompiled-rwfit-official/ instead of guesswork. Every constant carries the vendor file it was read from. Verified against the decompile (and confirming iOS RWfitProtocol.swift's reading, which the backed-out port had ignored): - y5/a.java: service a00a, write b002, notify b003; framing discriminators ae00 (JieLi), ff00 (PixArt OTA), 00010203-...-1912 (Telink OTA) - r5/b.java:700-727: framing is chosen post-connect from those sibling services, never from the advertisement - x5/d.java: legacy 0x7E header is 8 bytes `7E 01 cmd flags dataLen serHi serLo xor`, XOR over the payload only, serials 1..65535 wrapping, 12-byte multi-packet variant with totalBE/ currentBE at [8..11], mandatory 0xFE device-ACK / 0xFF app-ACK handshake with ack-before-parse ordering - x5/c.java + r5/b.java:386-476: JieLi 0xAB header is 6 bytes `AB flag lenHi lenLo crcHi crcLo`, CRC-16/ARC big-endian, length and CRC both covering the {cmd,key,keyFlag} triple that opens the body; flag 0x11 marks ACKs, and the 06/09 realtime-measure ACK carries a trailing 0x00 - y5/c.java: the full triple table (setTime 2/1/0, battery 2/3/10, deviceInfo 2/4/10, profile 2/6/0, goal 2/7/0, units 2/17/0, bind 3/1/20, history 5//10) and the 05-group data types - x5/b.java a() + the senders: deviceInfo 0x00, battery 0x01 (percent is payload byte 2, not byte 0), bindStatus 0x02, features 0x03, bind 0x20, setTime 0x21 (full 4-digit year BE, unlike the JieLi variant), units 0x24, profile 0x2E, unbind 0x44, manifest 0xA0, histories 0xA1-0xA7 with empty payloads - r5/d.java c(): recognition is four raw-advertisement signatures, with a non-empty name required but never matched on iOS records the JieLi unbind as 3/1/30; the vendor table has no such entry, so it is left flagged as unconfirmed rather than guessed. 20 unit tests pin the frame layouts, checksums, ACK shapes and reassembly to the vendor's bytes. Every one of them fails against the reverted driver. Decoders, driver, sync engine and coordinator still to come; RWFIT is not yet re-registered in RingDeviceType/WearableModel/RingBLEClient. --- .../java/com/pulseloop/ring/RWfitJLCodec.kt | 134 +++++++++ .../com/pulseloop/ring/RWfitLegacyCodec.kt | 179 ++++++++++++ .../java/com/pulseloop/ring/RWfitProtocol.kt | 276 ++++++++++++++++++ .../java/com/pulseloop/ring/RWfitCodecTest.kt | 257 ++++++++++++++++ 4 files changed, 846 insertions(+) create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitJLCodec.kt create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitLegacyCodec.kt create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitProtocol.kt create mode 100644 app/src/test/java/com/pulseloop/ring/RWfitCodecTest.kt 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/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, + ) + } +} From fa58db04622564c62c9d3d8f5992a79122d5a03e Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 8 Aug 2026 20:05:54 -0700 Subject: [PATCH 2/3] feat(rwfit): legacy payload decoders, from x5/b.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six history streams plus battery and the sync manifest, each read out of the vendor decoder it mirrors. - 0xA0 manifest (v0): [count u16][flagsA][flagsB]; flagsA bits 0..7 are step/sleep/hr/bp/spo2/temp/breathe/ecg, flagsB bit 0 is sport. Every per-stream request is gated on these, exactly as blesdk/service/l.java does. - 0x01 battery (a(), case 1||96): PowerBean is [lowPowerFlag, powerStatus, percent] — the percentage is byte 2. The reverted driver read byte 0, so every ring reported 0% or 1%. - 0xA3 HR (w0), 0xA5 SpO2 (r0), 0xA4 BP (s0), 0xA6 temperature (u0) share one loop: [dayTs u32][itemCount u16] then fixed-size items. HR/SpO2/temp items are 5 bytes, BP is 6 (systolic + diastolic). Temperature is offset-encoded — °C = (raw + 200) / 10. - 0xA1 steps (C0): 15-byte day header [dayTs u32][steps u24][calories u24][distance u24][itemCount u16] then 8-byte items. Only day totals are emitted: the per-item `index` doesn't pin a wall-clock bucket without knowing the ring's bucket width, which the vendor never states. Flagged rather than assumed. - 0xA2 sleep (A0): 16-byte day header [dayTs u32][totalMinutes u16][asleepTs u32][awakeTs u32][itemCount u16] then 2-byte [minutes][stageType] runs, expanded per-minute. Stage types confirmed from the vendor's own aggregation in s1.java:1636-1645 (0 awake, 1 light, 2 deep, 3 REM) rather than guessed from the byte values. Timestamps: the ring stamps local wall-clock as if it were UTC and the vendor subtracts 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 this timestamp is inside it, so the vendor is an hour out for half the year. Matching it keeps us aligned with what the ring and the vendor app agree on. Every decoder stops on a truncated record rather than reading past the buffer. 16 tests. Suite 812 → 849. RWFIT is still not registered — driver, sync engine and coordinator are next. --- .../java/com/pulseloop/ring/RWfitDecoder.kt | 271 ++++++++++++++++++ .../com/pulseloop/ring/RWfitDecoderTest.kt | 225 +++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitDecoder.kt create mode 100644 app/src/test/java/com/pulseloop/ring/RWfitDecoderTest.kt 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/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()) + } +} From 8d16513798b3d5167149db1ab9c30f6857e3812d Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 8 Aug 2026 20:15:22 -0700 Subject: [PATCH 3/3] feat(rwfit): driver, sync engine and coordinator; re-register the family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the #130 rebuild. RWFIT is registered again — RingDeviceType, WearableModel catalog, RingBLEClient coordinator list, DeviceHeroCard. RWfitDriver owns the awkward part: the wire framing isn't in the advertisement. Adds an optional WearableDriver.servicesDiscovered() hook (no-op for every other family) so RingBLEClient can hand over the GATT service table before the first write, and picks JieLi when AE00 / Telink OTA / PixArt FF00 is present, legacy otherwise — exactly r5/b.java:700-727. Legacy is the default because a frame in the wrong format is ignored rather than misread, so a wrong guess is inert. The legacy path is complete: the driver app-ACKs every inbound frame before parsing it (x5/d.java's ack-before-parse order, which the ring retransmits until it sees), NACKs a bad checksum with status 2 so the ring resends, and never echoes a 0xFE. RWfitSyncEngine follows the vendor's own cascade (u1.java g() → blesdk/service/l.java): device info, clock, battery, then the 0xA0 manifest, and pulls only the streams the manifest claims — one at a time, each request fired by the previous stream's reply. Every per-stream method in l.java opens with an isHasXData() check, so a ring without a temperature sensor is never asked for temperature history. RWfitCoordinator matches only the A00A advertisement or the d6050200/d6054154/ d6060200 manufacturer prefixes. No name matching, deliberately: the vendor requires a non-empty name but never reads it, because the name is the one field rebranders change. Manual/realtime measurement sits in bitmapGatedCapabilities, not the baseline — the vendor app has no legacy on-demand measurement command, so a Measure button on a 0x7E link could only ever time out. Not done, and marked as such rather than faked: - JieLi 05-group history bodies aren't decoded, so the engine doesn't request history on a JieLi link and the driver logs those frames. - The feature bitmap (x5/b.java i()) isn't decoded, so nothing grants from bitmapGatedCapabilities yet. - Step intraday buckets: the per-item index can't be placed on a clock without the ring's bucket width, which the vendor never states. Day totals only. - JieLi unbind: iOS records {03,01,30}; the vendor table has no such triple, so it is not sent. testOptions.unitTests.isReturnDefaultValues so JVM tests can cover code that logs — the alternative was choosing between test coverage and being able to diagnose a driver we have no hardware for. 13 driver tests (framing selection, ACK handshake, manifest cascade, coordinator matching). Suite 849 → 866, all passing. Still no hardware validation. --- app/build.gradle.kts | 10 + .../com/pulseloop/ring/RWfitCoordinator.kt | 77 +++++ .../java/com/pulseloop/ring/RWfitDriver.kt | 188 +++++++++++++ .../java/com/pulseloop/ring/RWfitEncoder.kt | 104 +++++++ .../com/pulseloop/ring/RWfitSyncEngine.kt | 162 +++++++++++ .../java/com/pulseloop/ring/RingBLEClient.kt | 8 + .../com/pulseloop/ring/WearableCapability.kt | 5 +- .../java/com/pulseloop/ring/WearableDriver.kt | 10 + .../pulseloop/ui/components/DeviceHeroCard.kt | 2 +- .../com/pulseloop/wearables/WearableModel.kt | 15 +- .../com/pulseloop/ring/PairingMatchingTest.kt | 2 +- .../com/pulseloop/ring/RWfitDriverTest.kt | 266 ++++++++++++++++++ docs/ios-sync.md | 68 ++++- 13 files changed, 910 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitCoordinator.kt create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitDriver.kt create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitEncoder.kt create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitSyncEngine.kt create mode 100644 app/src/test/java/com/pulseloop/ring/RWfitDriverTest.kt 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/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/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/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. ---