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
73 changes: 59 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,48 @@ 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:
## Connecting must never delete stored history

**Read this before touching `EventPersistenceSubscriber`'s `DeviceStateChanged` branch.**

**No ring re-supplies more history than its own buffer holds, so the app's copy is the only durable
one.** A connect may retire *demo* rows and nothing else. This is not a style preference — it was a
data-loss bug twice, in two different shapes (issue #43, and the sync-pass variant before it).

The original design deleted all sleep on connect and re-pulled it, carving YCBT out via
`preservesSleepOnConnect` because YCBT re-asserts CONNECTED mid-history. That premise was false for
everyone: CRP asks `queryHistorySleep(daysAgo = 0)`, and jring calls `makeHistoryQueryCommand()` with
its default of 1 day (`JringDriver.kt:105`), so "delete
everything and ask again" capped stored sleep at a single night — a new night replaced the previous
one instead of joining it. Both the carve-out and the rebuild are gone. What protects a re-synced
day now is `upsertSleepSessionAtomic`, which reconciles one waking day at a time, idempotently, and
re-points legacy mis-keyed blocks itself — the blanket clear's own stated justification.

**Stopping the deletion only stops further loss; it recovers nothing.** A ring holds days the app
has never asked for, and asking is cheap because every reply is self-describing — CRP's sleep frame
carries its own day index in `payload[0]`, which `CRPDecoder.decodeSleep` accepts up to 14, so a
night is dated from the reply rather than from the request, and a day the ring has no record of
simply produces no reply. `CRPSyncEngine.sendSleepBackfill` therefore pulls the prior week **once
per connection** (not per pass — `runStartup` is also the ~30-minute background sync, and this ring
funnels everything through one `fdd2` channel).

jring has the same gap, for different reasons. Its depth is `makeHistoryQueryCommand()`'s default of
1, called with no argument at `JringDriver.kt:105`, against a command that accepts up to 27.
**`RingSyncCoordinator.syncWindowDays` is not that control** — despite its "must match
makeHistoryQueryCommand's default" comment, it has exactly one use, sizing the sync-progress window
in `beginSyncProgress`, and it applies to every family. Don't cite it as a per-family request depth;
that mistake is what deferred this fix once already. Two things do make jring harder than CRP:
`JringSyncEngine.runStartup` has no once-per-connection gate, so a wider `days` re-pulls the whole
span on every ~30-minute background pass rather than once; and `0x10` returns activity *and* sleep
together — there is no sleep-only request — so each extra day costs ~96 activity packets
(15× 1-minute buckets per packet) on top of the night.

Consequence to keep in mind: nothing bulk-deletes real sleep any more, so a Forget followed by
pairing a different ring carries the previous ring's history over. If that ever needs to change,
it belongs on `DeviceForgotten` as a deliberate choice, not as a side effect of connecting.

`RingConnectionState.CONNECTED` also arrives from two unrelated places, and only one is a real
transition:

- `RingBLEClient`'s own connect event — always carries `deviceType` (`activeCoordinator` is set by
`installDriver`, which runs before the CCCD write that gates CONNECTED).
Expand All @@ -65,13 +100,23 @@ connection was established:
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.
`isConnectTransition(event.deviceType)` is that gate, and `connectPurge` is what it feeds. Be precise
about its scope, because it is narrower than it looks: it decides only what a CONNECTED event may
*delete*. The row write below it — `stateRaw = "CONNECTED"`, `lastConnectedAt`, `lastSyncAt` — is
**outside** the gate and still runs for every decoder `Status`, so a jring `0x0C` reply does still
restamp the device row as freshly connected on each sync pass. That is harmless today; it is not
something the gate prevents, so don't cite it as if it were.

Two related things worth knowing before changing this area:

- **Nothing bulk-deletes sleep any more, anywhere in the app.** That connect path was the only
caller, so there is no retention or pruning mechanism at all now — the tables grow without bound
and a Forget doesn't reclaim them. Fine at current row sizes; a deliberate retention policy is a
separate piece of work, not something to bolt back onto connect.
- **One narrow delete path survives**, in `reconcileWakingDay`: `if (groups.isEmpty())` drops that
day's rows. It should be unreachable — `upsertSleepSession` returns early on empty stages, so the
replacements reaching it are never empty — but it is the one place a *re-sync* can still remove a
stored night, so check it first if history goes missing again.

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
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() ?: 32
versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 33
versionName = (project.findProperty("appVersionName") as String?) ?: "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.pulseloop.ring

/** Nights before today to pull once per connection. See [CRPSyncEngine.sendSleepBackfill]. */
private const val SLEEP_BACKFILL_DAYS = 6

/**
* Per-connection orchestration for a CRP ("crrepa") ring. Ported in spirit from the Moyoung
* "Da Rings" connect flow (`d1/b.java` + `b1` package builders): after the link is up the app sets the
Expand Down Expand Up @@ -116,6 +119,36 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine {
send(CRPProtocol.queryTimingStressHistory())
send(CRPProtocol.queryHistoryTemp())
send(CRPProtocol.queryHistorySleep())
sendSleepBackfill()
}

/** Whether this connection has already backfilled older nights. Same "fresh engine per
* connection" trick as [readBacksSent]. */
private var sleepBackfillSent = false

/**
* Pull the nights *before* today, once per connection.
*
* The poll pass above only ever asks for `daysAgo = 0`, so the app's stored history could only
* ever grow one night at a time from whenever the user installed — and before issue #43 it
* couldn't grow at all, because each connect deleted the older nights first. Asking for the
* ring's own back-catalogue is what actually restores a user's history rather than merely
* stopping further loss.
*
* Safe to send blind. Each reply is self-describing: `payload[0]` is the ring's own day index,
* so [CRPDecoder.decodeSleep] dates a night from the reply rather than from what we asked for,
* and a day the ring has no record of simply produces no reply — the same nothing we get today.
*
* Once per connection, and deliberately short of the decoder's 14-day ceiling: [runStartup] is
* also the ~30-minute background sync, and this ring funnels the handshake, timing config,
* history pull *and* on-demand measures through one `fdd2` channel (a spot SpO2 needs ~48 s of
* it). A week is the useful-recovery/quiet-channel trade; raise it once hardware shows the ring
* answers deeper.
*/
private fun sendSleepBackfill() {
if (sleepBackfillSent) return
sleepBackfillSent = true
for (daysAgo in 1..SLEEP_BACKFILL_DAYS) send(CRPProtocol.queryHistorySleep(daysAgo))
}

/** The last frame index each timing vital emits before its day is complete (vendor terminal
Expand Down
37 changes: 36 additions & 1 deletion app/src/main/java/com/pulseloop/ring/JringDriver.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.pulseloop.ring

/** Days of history pulled on the first pass of a jring connection; every later pass asks for one.
* See [JringSyncEngine.historyDaysForThisPass] for why this is shorter than CRP's week. */
private const val JRING_BACKFILL_DAYS = 3

@OptIn(ExperimentalStdlibApi::class)

/**
Expand Down Expand Up @@ -102,10 +106,41 @@ class JringSyncEngine(
// had to initialise with the vendor app first.
writer?.enqueue(encoder.makeAutomaticHeartRateCommand(enabled = true, cadenceMinutes = 30))
writer?.enqueue(encoder.makeBandFunctionCommand())
writer?.enqueue(encoder.makeHistoryQueryCommand())
writer?.enqueue(encoder.makeHistoryQueryCommand(days = historyDaysForThisPass()))
writer?.enqueue(encoder.makeHistoryMeasurementQueryCommand())
}

/** Whether this connection has already pulled the deep history window. A fresh engine is built
* per connection ([JringDriver.makeSyncEngine] runs on connect), so instance state gives
* "once per connection" for free — the same trick `CRPSyncEngine` uses for its read-backs. */
private var historyBackfilled = false

/**
* How many days of history to ask for on this pass: the deep window once per connection, one
* day on every pass after it.
*
* The ring holds days the app has never asked for. Before issue #43 that didn't matter, because
* connecting deleted the stored copy anyway; now that it doesn't, a single-day request means a
* user's history can only ever grow one night at a time from install, and never recovers what
* the ring already has. `0x10` takes a day count (`triggerActivityReportByDays`, capped at 27)
* and the ring replies with the days it actually has, so asking for more is safe.
*
* **Why the gate matters more here than on CRP.** [runStartup] is also the ~30-minute background
* sync (and `refresh()`/`querySleep()` route through it), so an unconditional wider window would
* re-pull the whole span every half hour forever. And `0x10` returns activity *and* sleep — there
* is no sleep-only request — so each extra day is roughly 96 more packets (activity arrives as
* 15× 1-minute buckets each), against the nights we actually came for. That volume, not the
* nights, is why this window is deliberately shorter than the CRP backfill's week.
*
* Re-syncing the same days is harmless: activity buckets upsert by timestamp with the day total
* recomputed from distinct buckets, and sleep reconciles one waking day at a time.
*/
private fun historyDaysForThisPass(): Int {
if (historyBackfilled) return 1
historyBackfilled = true
return JRING_BACKFILL_DAYS
}

override fun handle(event: RingDecodedEvent) {
when (event) {
// Ring-side bind handshake (0x4B), mirroring the official app's
Expand Down
Loading
Loading