From 718b77f6884ae819136bc804fcaff978dc9df356 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Mon, 21 Sep 2026 12:28:42 +0200 Subject: [PATCH] fix(oura): publish the ring's history drain to LiveState.backfilling so the sync indicators light under a ring Every sync indicator - the Today header capsule and status light, the sync chip, the Sleep, Live and Health "Syncing..." states, the macOS menu-bar row and the #1164 "Pending sync" caption on today's Rest - reads ONE flag, `LiveState.backfilling`, with `syncChunksThisSession` beside it. Only `BLEManager` (the WHOOP offload) ever raised it. The Oura drain stamped `lastSyncedAt` at completion and nothing else, so under a ring all of those surfaces stayed at rest through every drain: a wearer whose ring was handing over a night saw the same header as one whose ring was idle, and the "handing over history now" beat #245 added to the header never fired for them. - Swift `OuraLiveSource`: `enterBackfilling()` at fetch start (chunk tally reset, mirroring `BLEManager.startBackfilling`), one chunk per `0x11` batch summary, `exitBackfilling()` in `finishDrain` on every end (caught up, stalled, deadline, no cursor progress) BEFORE the completed-offload stamp, so the re-score that stamp triggers sees the flag at rest and does not defer Today's history-wide reads (#755). A `publishedBackfilling` latch means the ring only ever lowers a flag it raised - `LiveState` is one object every source writes into. - The five duplicated `live.connected = false; live.streamingLiveHR = false` link-down writes fold into one `markLinkDown()` that also closes a drain cut by the drop, so a flag cannot be left up holding the header in "Syncing" until the next drain. - Kotlin twin: `WhoopBleClient.publishExternalBackfilling(active, chunks)` beside `publishExternalBattery`, a `syncSink` on `SourceCoordinator` wired in `NoopApplication`, and an `onBackfilling` callback on `OuraLiveSource` raised/ticked/lowered at the same four points (fetch start, batch summary, `finishDrain`, `stop()` + `STATE_DISCONNECTED`). Behaviour to expect: a caught-up ring's periodic drain is one GetEvents round trip at `bytes_left 0`, so the capsule expands and settles within seconds; a drain with a night behind it holds it up for the ~1-2 min a full pull takes, chunk tally ticking. The chained pass after a deadline stop lowers and re-raises it 5 s apart; `ChargeSyncIndicator` already handles a restart mid wind-down. Every action gated on the flag (`onAbortSync`, Health's "Sync now") is additionally WHOOP-gated, so nothing strap-only is exposed under a ring. Verification: `Strand` (macOS) builds; `compileFullDebugKotlin` clean; `doc_comment_lint` green. The drain is BLE-driven end to end, so the flag's timing is only checkable on hardware: to be confirmed on a ring against the strap log's "fetching history from cursor" / "history caught up" bracket with the Today header watched during it. Not yet run on hardware. Refs #245, #2208. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QrsZczdGcUyQSJVuYZuwUF --- Strand/BLE/OuraLiveSource.swift | 54 +++++++++++++++++-- .../src/main/java/com/noop/NoopApplication.kt | 2 + .../main/java/com/noop/ble/OuraLiveSource.kt | 36 +++++++++++++ .../java/com/noop/ble/SourceCoordinator.kt | 6 +++ .../main/java/com/noop/ble/WhoopBleClient.kt | 14 +++++ 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index c089011816..e012be805d 100644 --- a/Strand/BLE/OuraLiveSource.swift +++ b/Strand/BLE/OuraLiveSource.swift @@ -730,9 +730,47 @@ public final class OuraLiveSource: NSObject, ObservableObject { pendingContinuation = false stopBatchQuietTimer() log("Oura: fetching history from cursor \(historyCursor) (\(describeCursor(historyCursor))) [cursor-fix]") + enterBackfilling() advance(.startHistoryFetch(cursor: historyCursor)) } + /// True while THIS source holds `LiveState.backfilling` up. `LiveState` is one object every source + /// writes into, so the ring only ever clears a flag it raised itself: a `false` written on a ring + /// disconnect must not cancel a strap offload that some other source is publishing. + private var publishedBackfilling = false + + /// Publish the drain to `LiveState.backfilling`, the ONE flag every sync indicator reads — the Today + /// header capsule/light, the sync chip, the Live and Health "Syncing…" states, the menu-bar row and + /// the #1164 "Pending sync" caption on today's Rest. Only `BLEManager` ever raised it, so under a ring + /// all of them stayed at rest through every drain: a wearer whose ring was handing over a night saw + /// the same header as one whose ring was idle. Mirrors `BLEManager.startBackfilling`, chunk tally + /// reset included; the chunk here is one `0x11` batch summary (up to 255 events), counted in + /// `handleHistorySummary`. Gated on `feedsLive` like every other `LiveState` write in this type. + private func enterBackfilling() { + guard feedsLive else { return } + publishedBackfilling = true + live.backfilling = true + live.syncChunksThisSession = 0 + } + + /// The counterpart, on EVERY path a drain can end by: `finishDrain` (complete, stalled, deadline, or + /// no cursor progress) and any link loss while `.fetchingHistory` (`markLinkDown`). A flag left up + /// after the link dropped would hold the header in "Syncing" until the next drain cleared it. + private func exitBackfilling() { + guard publishedBackfilling else { return } + publishedBackfilling = false + live.backfilling = false + } + + /// The link is no longer live: clear the flags a live link publishes. One helper so a drain in flight + /// at the moment of loss is closed out on every path, not only the ones someone remembered. + private func markLinkDown() { + guard feedsLive else { return } + exitBackfilling() + live.connected = false + live.streamingLiveHR = false + } + private func startHistoryFetchTimer() { stopHistoryFetchTimer() let t = Timer.scheduledTimer(withTimeInterval: historyFetchInterval, repeats: true) { [weak self] _ in @@ -761,6 +799,8 @@ public final class OuraLiveSource: NSObject, ObservableObject { let elapsed = drainStartedAt.map { Date().timeIntervalSince($0) } ?? 0 let continueDrain = drain.onSummary(bytesLeft: summary.bytesLeft, moreData: summary.moreData, elapsedSeconds: elapsed) + // One GetEvents batch answered = one chunk, the unit the header capsule and VoiceOver count. + if publishedBackfilling { live.syncChunksThisSession += 1 } if summary.moreData, !continueDrain { let reason = elapsed > OuraHistoryDrain.maxDrainSeconds ? "exceeded \(Int(OuraHistoryDrain.maxDrainSeconds))s deadline" @@ -821,6 +861,10 @@ public final class OuraLiveSource: NSObject, ObservableObject { let rebootFullPullPending = commitResumeCursor(drainCompleted: completed) logActivityEstimateSummary() advance(.historyCursorAdvanced(cursor: historyCursor, moreData: false)) + // Down BEFORE `noteCompletedOffload` stamps `lastSyncedAt`: the re-score that stamp triggers reads + // the flag to decide whether to defer Today's history-wide reads (#755), and must see it at rest. + // A chained pass (below) raises it again 5 s later; the indicator handles a restart mid wind-down. + exitBackfilling() if rebootFullPullPending || resumeBacklog { guard chainedDrainPasses < Self.maxChainedDrainPasses else { log("Oura: drain pass cap (\(Self.maxChainedDrainPasses)) reached with work remaining - next periodic fetch / reconnect continues from the banked cursor") @@ -1638,7 +1682,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { batteryPct = nil needsPairing = nil flush() // persist anything still buffered - if feedsLive { live.connected = false; live.streamingLiveHR = false } + markLinkDown() } // MARK: - Driver wiring @@ -2653,7 +2697,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { log("Oura: \(msg)") stopReengageTimer() stopHistoryFetchTimer() - if feedsLive { live.connected = false; live.streamingLiveHR = false } + markLinkDown() } // CB delegate callbacks live in the @preconcurrency extensions below. The queue-less central delivers @@ -2708,7 +2752,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { } default: // Radio off / unauthorized / resetting -> the link is not live. - if feedsLive { live.connected = false; live.streamingLiveHR = false } + markLinkDown() } } @@ -2829,7 +2873,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { didFailToConnect peripheral: CBPeripheral, error: Error?) { log("Oura: WARNING failed to connect - \(error?.localizedDescription ?? "unknown error")") linkPhase = .disconnected - if feedsLive { live.connected = false; live.streamingLiveHR = false } + markLinkDown() // The ring wiped its bond (re-paired in the Oura app, or a firmware reset). CoreBluetooth surfaces // this as a stable CBError, and re-issuing connect just loops the same stale-pairing failure and // drains the ring, so DON'T auto-reconnect: route to the honest needs-pairing path instead, exactly @@ -2900,7 +2944,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { if adoptPhase == .installingKey { adoptPhase = .failed } batteryPct = nil flush() - if feedsLive { live.connected = false; live.streamingLiveHR = false } + markLinkDown() if self.peripheral?.identifier == peripheral.identifier { self.peripheral = nil } linkPhase = .disconnected // A user reconnect (#2305) cancelled this link on purpose: connect again now, not on the backoff. diff --git a/android/app/src/main/java/com/noop/NoopApplication.kt b/android/app/src/main/java/com/noop/NoopApplication.kt index 2b7561eb48..85a4f5eaa8 100644 --- a/android/app/src/main/java/com/noop/NoopApplication.kt +++ b/android/app/src/main/java/com/noop/NoopApplication.kt @@ -184,6 +184,8 @@ class NoopApplication : Application() { straplog = { ble.externalLog(it) }, // A generic strap's standard battery (0x180F) → the same live battery field the WHOOP uses. batterySink = { pct -> ble.publishExternalBattery(pct) }, + // A ring's history drain → the same backfilling flag + chunk tally the WHOOP offload publishes. + syncSink = { active, chunks -> ble.publishExternalBackfilling(active, chunks) }, initialActiveDeviceId = activeDeviceId, ) } diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index 12f739782a..4448cba965 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -146,6 +146,14 @@ class OuraLiveSource( private val log: (String) -> Unit = {}, /** Fired with the ring's battery percent (0-100) when decoded. */ private val onBattery: (Int) -> Unit = {}, + /** Fired as the history drain starts `(true, 0)`, per `0x11` batch summary `(true, n)` and on EVERY path + * the drain can end by `(false, n)` — [finishDrain], [stop] and a link drop mid-drain. Wired to the + * live state's `backfilling` / `syncChunksThisSession`, the one pair every sync indicator reads (the + * Today header capsule and chip, the Sleep and Live "Syncing…" states, the #1164 "Pending sync" Rest + * caption); before this only the WHOOP offload ever raised them, so under a ring all of them stayed at + * rest through every drain. Default no-op keeps the discovery-only scanner + tests inert. Twin of + * Swift's `enterBackfilling` / `exitBackfilling`. */ + private val onBackfilling: (Boolean, Int) -> Unit = { _, _ -> }, /** Fired with the ring's TRUE model label ("Oura Ring 3/4/5") once the GetProductInfo hardware id * resolves a generation on connect, so the app can correct a registry row mis-stamped from the * advertised name (#772). Default no-op. Twin of Swift's `onModel`. */ @@ -663,9 +671,30 @@ class OuraLiveSource( pendingContinuation = false handler.removeCallbacks(batchQuietRunnable) log("Oura: fetching history from cursor $historyCursor [cursor-fix]") + enterBackfilling() advance(OuraTransition.StartHistoryFetch(cursor = historyCursor)) } + /** True while THIS source holds the live `backfilling` flag up, so it only ever lowers a flag it raised: + * a `false` published on a ring disconnect must not cancel an offload some other source is publishing. */ + private var publishedBackfilling = false + /** Batches answered this drain — the chunk tally the header capsule and its VoiceOver label count. */ + private var drainChunks = 0 + + private fun enterBackfilling() { + publishedBackfilling = true + drainChunks = 0 + onBackfilling(true, 0) + } + + /** The counterpart, on EVERY path a drain can end by; a flag left up after the link dropped would hold + * the header in "Syncing" until the next drain lowered it. */ + private fun exitBackfilling() { + if (!publishedBackfilling) return + publishedBackfilling = false + onBackfilling(false, drainChunks) + } + private fun scheduleHistoryFetch() { if (historyFetchScheduled) return historyFetchScheduled = true @@ -687,6 +716,8 @@ class OuraLiveSource( private fun handleHistorySummary(summary: com.noop.oura.GetEventsSummary): Unit = guardedCallback("history-summary") { val elapsed = drainStartedAtMs?.let { (System.currentTimeMillis() - it) / 1000.0 } ?: 0.0 val continueDrain = drain.onSummary(summary.bytesLeft, summary.moreData, elapsed) + // One GetEvents batch answered = one chunk, the unit the header capsule and VoiceOver count. + if (publishedBackfilling) { drainChunks += 1; onBackfilling(true, drainChunks) } if (summary.moreData && !continueDrain) { val reason = if (elapsed > OuraHistoryDrain.MAX_DRAIN_SECONDS) { "exceeded ${OuraHistoryDrain.MAX_DRAIN_SECONDS.toInt()}s deadline" @@ -751,6 +782,9 @@ class OuraLiveSource( // two lines after "not a reboot (#2097)" and burn a chained pass that only re-read the kept cursor. val rebootFullPullPending = commitResumeCursor(completed) advance(OuraTransition.HistoryCursorAdvanced(cursor = historyCursor, moreData = false)) + // Down here, before the chain / completion branch: a chained pass (below) raises it again 5 s + // later, and the Swift twin lowers it before its completed-offload stamp for the same reason. + exitBackfilling() if (rebootFullPullPending || resumeBacklog) { if (chainedDrainPasses >= MAX_CHAINED_DRAIN_PASSES) { log("Oura: drain pass cap ($MAX_CHAINED_DRAIN_PASSES) reached with work remaining - " + @@ -1275,6 +1309,7 @@ class OuraLiveSource( handler.removeCallbacks(chainedDrainRunnable) pendingContinuation = false chainedDrainPasses = 0 + exitBackfilling() // a drain cut by the teardown must not leave the header "Syncing" // Drain BEFORE driver.stop() clears its anchor, so a pending event still gets a real anchored time // if one exists rather than always falling back to wall-clock at teardown (mirrors Swift's stop()). // Same for a hypnogram burst still accumulating (e.g. the session ended mid-drain); one that never @@ -1445,6 +1480,7 @@ class OuraLiveSource( handler.removeCallbacks(batchQuietRunnable) handler.removeCallbacks(chainedDrainRunnable) pendingContinuation = false + exitBackfilling() // a drain cut by the drop must not leave the header "Syncing" // Drain BEFORE the driver's anchor is gone (same reasoning as stop()): a pending event // still gets a real anchored time if the current session set one, else an honest // wall-clock fallback rather than being silently dropped. A hypnogram burst still diff --git a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt index 8155d51ae9..31f09a6e18 100644 --- a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt +++ b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt @@ -96,6 +96,11 @@ class SourceCoordinator( * generic strap / FTMS machine surfaces its charge where the WHOOP strap battery does. Default no-op * keeps existing call sites + JVM tests compiling unchanged. */ private val batterySink: (Int) -> Unit = {}, + /** Push a non-WHOOP source's history-offload state into the live state (`ble::publishExternalBackfilling`), + * so a ring drain lights the same sync indicators a WHOOP offload does. `(active, chunks)`: raised with + * 0 at drain start, ticked per batch, lowered at drain end. Default no-op keeps existing call sites + + * JVM tests compiling unchanged. */ + private val syncSink: (Boolean, Int) -> Unit = { _, _ -> }, /** Push the latest instantaneous speed/cadence/power from a connected standard fitness sensor * (RSC/CSC/CPS), read ADDITIVELY alongside HR by [StandardHrSource], into the live state the in-workout * UI observes (wired at the composition root to `ble::publishExternalSensorMetrics`). PURE ADDITIVE — it @@ -583,6 +588,7 @@ class SourceCoordinator( notifyMaskFull = { NoopPrefs.ouraNotifyMaskFull(ctx) }, // packed-notification A/B log = straplog, // Oura connect/auth/stream lifecycle → the SAME exported strap log (#421) onBattery = batterySink, // ring battery → the same live state the WHOOP strap battery uses + onBackfilling = syncSink, // ring history drain → the same sync indicators a WHOOP offload lights onModel = { model -> scope.launch { runCatching { registry.setModel(id, model) } } }, // #772: correct a name-guessed gen onSerial = { serial -> adoptOuraSerial(currentId = id, serial = serial) }, // #771 ) diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index b3d9fbf2eb..eff8a6f1ed 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -2275,6 +2275,20 @@ class WhoopBleClient( if (pct in 0..100) _state.update { it.copy(batteryPct = pct.toDouble()) } } + /** + * Surface a non-WHOOP source's history offload in the SAME [backfilling] / [syncChunksThisSession] the + * UI reads — the Today header capsule and sync chip, the Sleep and Live "Syncing…" states and the + * #1164 "Pending sync" caption on today's Rest. Only the WHOOP offload ever set them, so under a ring + * every one of those stayed at rest through every drain. Additive twin of [publishExternalBattery]: + * called by [SourceCoordinator] ONLY while WHOOP's own BLE is paused, so it never races + * [startBackfilling] / [exitBackfilling]. [chunks] is the source's own tally (one `0x11` batch summary + * for the Oura ring); the caller resets it to 0 at drain start, matching the WHOOP path. Mirrors the + * Swift OuraLiveSource → LiveState.backfilling wiring. + */ + fun publishExternalBackfilling(active: Boolean, chunks: Int) { + _state.update { it.copy(backfilling = active, syncChunksThisSession = chunks) } + } + // MARK: Android Bluetooth handles. private val bluetoothManager: BluetoothManager? = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager