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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,46 @@ to need an OS bond, add it to `WearableModel.requiresOsBond`'s allowlist by name
the condition to "whenever `supportBlePair` is set" to match the vendor app — that is exactly
the change that caused the regression, and it will cause it again for the R10.

**A driver re-route can silently revoke a bond, too.** `DriverReroute.shouldRerouteToJring` moves a
ring off its selected driver post-connect, and re-resolving the model against the JRING family lands
on the generic `JRING` entry, whose `requiresOsBond` is `false`. Root `AGENTS.md` records one hedged
suspicion — that the **R11**'s full Colmi UART profile (`6e40fff0`/`de5bf728`) *appears* to be gated
behind an OS bond. Unproven, and about one model, but if it holds anywhere then a re-route fired on
a table missing that profile is self-sealing: it prevents the very bond that would reveal it, and
CONNECTED persists the jring family to `LAST_WEARABLE_MODEL_KEY`, so every later reconnect starts
there and the carousel can't undo it. That is why the re-route is scoped to
`scanDetectedType == JRING`: only connections where a generic-"SMART_RING" guess was actually
overridden. Don't widen it to "any driver whose services are missing."

See `docs/qring-ble-adoption.md` §5a for the full history and the decompiled source references.

## Only the client's own connect may rebuild stored data

**Read this before touching `EventPersistenceSubscriber`'s `DeviceStateChanged` branch, or before
adding a family to `preservesSleepOnConnect`.**

`RingConnectionState.CONNECTED` arrives from two unrelated places, and only one of them means a
connection was established:

- `RingBLEClient`'s own connect event — always carries `deviceType` (`activeCoordinator` is set by
`installDriver`, which runs before the CCCD write that gates CONNECTED).
- `RingEventBridge`, which maps **every** decoder's `RingDecodedEvent.Status` to CONNECTED and never
sets `deviceType`. These are ordinary device-info replies: jring `0x0C`, LuckRing dev-info, YCBT
status packets. `runStartup` re-sends them, and `runStartup` is also the ~30-minute background
sync — so they recur for the whole life of a connection.

The CONNECTED branch clears and rebuilds (unscoped `DELETE FROM sleep_sessions` /
`sleep_stage_blocks` for families outside `preservesSleepOnConnect`). Ungated, every background sync
pass on jring or LuckRing wiped all stored sleep and depended on that same pass re-pulling it —
losing anything past the ring's retention when the pass was interrupted or came back empty.
`isConnectTransition(event.deviceType)` is the gate. **Don't remove it, and don't try to fix this
family-by-family** — `preservesSleepOnConnect` was an attempt at that, and the set of families that
re-assert CONNECTED turned out to be most of them.

Corollary for new protocol work: a reply that merely reports something about the device (firmware,
serial, capabilities) is not a connection event. Give it its own `RingDecodedEvent` — as
`FirmwareRevision` does — rather than hanging it off `Status`.

## Colmi R11 (CRP "Da Rings") — diagnose from the capture, and decode wear state before blaming code

**Read this before changing anything in `CRP*` startup, sync, all-day-monitoring, or history code —
Expand Down Expand Up @@ -89,8 +127,25 @@ supporting evidence as the cause.
NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN, and the monitor-state queries `2/6` HR, `2/7` HRV,
`2/8` SpO2, `2/45` stress, `2/21` temp each report the configured interval (`0` = off). These are
how you tell "the monitor is switched off" apart from "this ring lacks the sensor" — the open
question for stress (`2/47`), temperature and firmware (`7/1`), all 23-sent/0-answered. Send them
question for stress (`2/47`) and temperature, both 23-sent/0-answered. Send them
**once per connection**, not per poll pass: `runStartup` is also the ~30-minute background sync.
- **Group 7 is Gomore, not device info — an opcode read off a decompiled builder is a guess until
you check its caller.** Firmware was queried on `7/1` and never answered (23 sends, 0 replies),
which read like ring firmware ignoring a valid vendor command. It wasn't: every builder in `b1/r`
resolves to a Gomore call in `d1/b.java` (`7/0` querySupportGomore, `7/1` **querySavedGomoreKey**,
`7/2` queryGomoreEUID, `7/3` sendGomoreKey, `7/13` queryGomoreVersion). The constants had been
built by pairing `b1/r`'s methods with opcodes *positionally* (a→0, b→1, c→13) — but jadx
alphabetises method names, so letter order carries no meaning. The same slip mislabelled `3/1`
(`shutDown`) as `CMD_RESTART`; restart is `3/14`. **Resolve every opcode through its `d1/b.java`
caller, never by position in the builder class.**
- **Firmware version is `3/3`**, replying with a bare UTF-8 string (`g1/a.i1`:
`onVersion(new String(payload, UTF_8))`) — `MOY-R1K3-2.1.6` on zaggash's R11, matching the vendor
app's Firmware-information screen. Decoded into `RingDecodedEvent.FirmwareRevision`, which exists
because neither older event fits: `FirmwareVersion` carries an `Int` (the jring `0xF6` build), and
`Status` bridges to `DeviceStateChanged(CONNECTED, …)` — a connection-state event, which a
firmware string is not. Sibling group-3 queries confirmed from their callers: `3/0` reset,
`3/1` shutDown, `3/4` firmware hash, `3/6` real-time battery, `3/7` wear state, `3/14` restart,
`3/22` binding reminder.
- **Temperature history is `2/22`, not `2/48`.** `q.b(2,48)` is the vendor's `querySleepState`
(`d1/b.java` line 650); real temp history is `i0.b(day, frameIndex)` = `q.c(2,22,[day,idx])`, the
same shape as the other timing histories. Its sample layout is still unconfirmed — no non-empty
Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ android {
// versionCode/versionName are overridable from Gradle properties so the release CI
// can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0).
// Local builds fall back to the literals below.
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 31
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 32
versionName = (project.findProperty("appVersionName") as String?) ?: "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
46 changes: 33 additions & 13 deletions app/src/main/java/com/pulseloop/ring/CRPDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ object CRPDecoder {

/**
* Framed `fdd3` reply: `FD DA 10 <len> <group> <cmd> <payload>`.
* Real-time vital results come on group 1; history queries on group 7; device info on group 7.
* Real-time vital results come on group 1; sleep/all-day history on group 2; device identity
* and state pushes on group 3. Group 7 is the vendor's Gomore module, not device info.
*/
private fun decodeFramedReply(frame: ByteArray, now: Instant, zone: ZoneId): List<RingDecodedEvent> {
if (frame.size < CRPProtocol.HEADER_SIZE) return emptyList()
Expand All @@ -99,9 +100,10 @@ object CRPDecoder {
return decodeVitalResult(cmd, payload, now)
}

// Group 7: history queries + device info (decompiled `b1/e0` + `b1/r`).
if (group == CRPCommands.GROUP_DEVICE_INFO) {
return decodeHistoryOrDeviceInfoResponse(cmd, payload, now)
// Group 7: the vendor's Gomore module (`b1/r`). Nothing we send lands here any more; kept so
// an unsolicited Gomore frame in a capture is still recorded rather than dropped.
if (group == CRPCommands.GROUP_GOMORE) {
return decodeGomoreResponse(cmd)
}

// Group 2: sleep + the all-day "timing" vital timelines + temperature history.
Expand All @@ -120,13 +122,18 @@ object CRPDecoder {
return listOf(RingDecodedEvent.CommandAck(commandId = ((group shl 4) or (cmd and 0x0F)).toUByte()))
}

// Group 3: power control + the autonomous wear-state push (vendor `g1/a.java` case 3→7,
// `onWearStateChange(payload[0] > 0)`). Confirmed against zaggash's R11 (issue #29): a spot
// measure returns nothing while `payload[0] == 0` (ring off the finger).
// Group 3: device control, the firmware-version string (cmd 3), and the autonomous
// wear-state push (vendor `g1/a.java` case 3→7, `onWearStateChange(payload[0] > 0)`).
// Confirmed against zaggash's R11 (issue #29): a spot measure returns nothing while
// `payload[0] == 0` (ring off the finger).
if (group == CRPCommands.GROUP_POWER) {
if (cmd == CRPCommands.CMD_WEAR_STATE && payload.isNotEmpty()) {
return listOf(RingDecodedEvent.WearingStatus(worn = (payload[0].toInt() and 0xFF) != 0, _timestamp = now))
}
if (cmd == CRPCommands.CMD_QUERY_FIRMWARE_VERSION) {
// null ⇒ nothing readable in the payload; fall through to the ack below.
decodeFirmwareVersion(payload)?.let { return it }
}
return listOf(RingDecodedEvent.CommandAck(commandId = ((group shl 4) or (cmd and 0x0F)).toUByte()))
}

Expand Down Expand Up @@ -173,14 +180,27 @@ object CRPDecoder {
}
}

/** Group-7 (Gomore) replies. No layout is decoded — acked so the raw-packet feed records them. */
private fun decodeGomoreResponse(cmd: Int): List<RingDecodedEvent> {
return listOf(RingDecodedEvent.CommandAck(commandId = ((CRPCommands.GROUP_GOMORE shl 4) or (cmd and 0x0F)).toUByte()))
}

/**
* Decode group-7 responses: history queries (cmd 4–7, 14, 48) and device info (cmd 0, 1, 13).
* History layouts are unconfirmed against hardware — emit as CommandAck so the raw-packet feed
* records them without inventing metric values. Extend [decodeHistoryOrDeviceInfoResponse]
* as more layouts are confirmed.
* The firmware version string (`group 3 / cmd 3`). Vendor `g1/a.i1`:
* `onVersion(new String(payload, StandardCharsets.UTF_8))` — a bare UTF-8 string with no
* length prefix or terminator, e.g. `MOY-R1K3-2.1.6` on zaggash's R11 (issue #29).
*
* Surfaced as [RingDecodedEvent.FirmwareRevision], not [RingDecodedEvent.FirmwareVersion]
* (which carries an `Int` — the jring 0xF6 numeric build — and can't hold this) and not
* [RingDecodedEvent.Status] (which bridges to `DeviceStateChanged(CONNECTED, …)`; persistence
* rebuilds the sleep tables on every one of those, and [CRPSyncEngine.runStartup] re-queries
* firmware on every sync pass).
*/
private fun decodeHistoryOrDeviceInfoResponse(cmd: Int, payload: ByteArray, now: Instant): List<RingDecodedEvent> {
return listOf(RingDecodedEvent.CommandAck(commandId = ((CRPCommands.GROUP_DEVICE_INFO shl 4) or (cmd and 0x0F)).toUByte()))
private fun decodeFirmwareVersion(payload: ByteArray): List<RingDecodedEvent>? {
// Trims NUL padding as well as whitespace: some firmwares pad the frame to a fixed width.
val version = String(payload, Charsets.UTF_8).trim { it <= ' ' }
if (version.isEmpty()) return null // empty or all-padding — the caller acks it instead
return listOf(RingDecodedEvent.FirmwareRevision(version))
}

/**
Expand Down
47 changes: 29 additions & 18 deletions app/src/main/java/com/pulseloop/ring/CRPProtocol.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,19 @@ object CRPCommands {
const val CMD_ENABLE_TIMING_TEMP = 13 // b1/i0.c: q.c(1,13, [enable]) — all-day temp timing on/off
// NOTE: temp's spot-measure toggle is a DIFFERENT opcode (cmd 32, b1/i0.d) — see CMD_MEASURE_TEMP.

// Group 7 — history queries + device info (decompiled b1/e0 + b1/r).
// NOTE: most history queries are group 7 (b1/e0 builders use q.b(7,…)/q.c(7,…)), but sleep
// and temp are the exception — they live on group 2 (see GROUP_HISTORY below).
const val GROUP_DEVICE_INFO = 7
const val CMD_QUERY_DEVICE_INFO = 0 // b1/r.a: q.b(7,0)
const val CMD_QUERY_FIRMWARE_VERSION = 1 // b1/r.b: q.b(7,1)
const val CMD_QUERY_DEVICE_SN = 13 // b1/r.c: q.b(7,13)
// Group 7 is the vendor's **Gomore** group (the licensed activity-analytics module), NOT device
// info — every builder in `b1/r` resolves to a Gomore call in `d1/b.java`:
// q.b(7,0)=querySupportGomore q.b(7,1)=querySavedGomoreKey q.b(7,2)=queryGomoreEUID
// q.c(7,3,str)=sendGomoreKey q.b(7,13)=queryGomoreVersion
// The earlier constants here paired `b1/r`'s methods with opcodes positionally (a→0, b→1, c→13)
// and mislabelled all three as device info; `queryFirmwareVersion` was really
// `querySavedGomoreKey`, which is why the R11 answered none of the 23 sends (issue #29).
// Real device queries live on group 3 — see GROUP_POWER below. Kept only so a capture
// containing these frames is still identifiable; nothing sends them.
const val GROUP_GOMORE = 7
const val CMD_QUERY_SUPPORT_GOMORE = 0 // b1/r.e: q.b(7,0) → d1/b.querySupportGomore
const val CMD_QUERY_SAVED_GOMORE_KEY = 1 // b1/r.d: q.b(7,1) → d1/b.querySavedGomoreKey
const val CMD_QUERY_GOMORE_VERSION = 13 // b1/r.c: q.b(7,13) → d1/b.queryGomoreVersion

// Group 2 — the day's stored vital timelines. The all-day "timing" histories the vendor's sync
// pass actually pulls (`u3/g1.java`) live here with a [day, 0] payload, NOT on group 7: the ring
Expand Down Expand Up @@ -121,14 +127,23 @@ object CRPCommands {
const val CMD_QUERY_TIMING_TEMP_STATE = 21 // b1/i0.a: q.b(2,21) → onTimingState(type, state)
const val CMD_QUERY_TIMING_STRESS_STATE = 45 // b1/h0.e: q.b(2,45)

// Group 3 — power control + device-state pushes.
// Group 3 — device control, identity queries, and device-state pushes. (Named GROUP_POWER from
// when only the two power opcodes were known; the group is broader than the name.) Opcodes read
// off the `b1/l` builders via their `d1/b.java` callers (the method letters are alphabetised by
// the decompiler and carry no ordering, so each one is resolved by its caller, not by position).
const val GROUP_POWER = 3
const val CMD_FACTORY_RESET = 0 // b1/l.v: q.b(3,0)
const val CMD_RESTART = 1 // b1/l.w: q.b(3,1)
const val CMD_FACTORY_RESET = 0 // b1/l.v: q.b(3,0) → d1/b.reset
const val CMD_SHUT_DOWN = 1 // b1/l.y: q.b(3,1) → d1/b.shutDown (was mislabelled RESTART)
// Firmware identity — the pair the vendor's own "Firmware information" screen shows
// (`FirmwareInformationActivity`): version is a bare UTF-8 string, hash a hex code.
const val CMD_QUERY_FIRMWARE_VERSION = 3 // b1/l.k: q.b(3,3) → d1/b.queryFirmwareVersion
const val CMD_QUERY_FIRMWARE_HASH = 4 // b1/l.j: q.b(3,4) → d1/b.queryFirmwareHash
const val CMD_QUERY_REALTIME_BATTERY = 6 // b1/l.f: q.b(3,6) → d1/b.queryRealTimeBattery
// Autonomous wear-state push: vendor `g1/a.java` case 3→7 → onWearStateChange(payload[0] > 0).
// payload[0] == 0 ⇒ ring not on finger / no skin contact (issue #29: an optical spot measure
// returns nothing while this is 0; we surface it instead of spinning the full window).
const val CMD_WEAR_STATE = 7
const val CMD_RESTART = 14 // b1/l.w: q.b(3,14) → d1/b.restart

// Group 9 — device actions.
const val GROUP_ACTION = 9
Expand Down Expand Up @@ -309,14 +324,10 @@ object CRPProtocol {
fun queryTimingTempState(): ByteArray =
frame(CRPCommands.GROUP_HISTORY, CRPCommands.CMD_QUERY_TIMING_TEMP_STATE)

// ---- Device info queries (group 7) ----

fun queryDeviceInfo(): ByteArray =
frame(CRPCommands.GROUP_DEVICE_INFO, CRPCommands.CMD_QUERY_DEVICE_INFO)
// ---- Device identity queries (group 3) ----
// `queryDeviceInfo`/`queryDeviceSN` are gone: they framed group-7 Gomore opcodes, which the ring
// never answers. Firmware version is the one the vendor's Firmware-information screen reads.

fun queryFirmwareVersion(): ByteArray =
frame(CRPCommands.GROUP_DEVICE_INFO, CRPCommands.CMD_QUERY_FIRMWARE_VERSION)

fun queryDeviceSN(): ByteArray =
frame(CRPCommands.GROUP_DEVICE_INFO, CRPCommands.CMD_QUERY_DEVICE_SN)
frame(CRPCommands.GROUP_POWER, CRPCommands.CMD_QUERY_FIRMWARE_VERSION)
}
6 changes: 4 additions & 2 deletions app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine {
// the ring's step/calorie algorithm has real inputs.
send(CRPProtocol.setTime())
// Query firmware version so the UI doesn't show "Firmware: reading" (zaggash's report).
// NOTE: still unanswered on his R11 — 23 sends, 0 replies in the 2026-07-25 capture — so the
// panel keeps showing "?". The group-7 opcode is the vendor's, but this ring ignores it.
// The 23-sends/0-replies in the 2026-07-25 capture were our fault, not the ring's: the old
// opcode was group 7 cmd 1, which the vendor SDK uses for `querySavedGomoreKey`, not
// firmware. The real query is group 3 cmd 3 (`b1/l.k` → `d1/b.queryFirmwareVersion`), and
// it answers with a UTF-8 string — `MOY-R1K3-2.1.6` on zaggash's R11.
send(CRPProtocol.queryFirmwareVersion())
profile?.let { send(userInfoFrame(it)) }
sendConnectionReadBacks()
Expand Down
Loading
Loading