Skip to content
Open
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
54 changes: 49 additions & 5 deletions Strand/BLE/OuraLiveSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/java/com/noop/NoopApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
Expand Down
36 changes: 36 additions & 0 deletions android/app/src/main/java/com/noop/ble/OuraLiveSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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 - " +
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/main/java/com/noop/ble/SourceCoordinator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
14 changes: 14 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading