Skip to content

[oura][ui] Today screen still draws the WHOOP's charge under an active Oura ring — #2076 covered the Live Console, Devices and the widget, not Today #2208

Open

Description

@pipiche38

NOOP version

11.7.0

Platform

iOS

OS version

26.6

WHOOP strap model

Not sure

Where the data is coming from

Live Bluetooth (strap connected)

Test Centre profile

Not a test-mode bug

What happens

NOOP version

11.7.0 (main at 8818477dc)

Platform

iOS, macOS and Android — the same shape on each

Where the data is coming from

Oura ring active beside a paired WHOOP

What happens

#2076 fixed the WHOOP-battery-under-a-ring leak on the Live Console, the Devices list and the home-screen widget by routing them through LiveConsoleReadout.batteryPercent(activeIsWhoop:whoopPct:ringPct:). The Today screen was not in that audit and still reads the raw field, so with a ring active and streaming it draws the WHOOP's last charge under the ring — on the screen most people look at first.

Readers still on the raw field, all gated on live.connected alone:

Surface Reader
Liquid Today header battery ring (LiquidBatteryButton, tap → Devices) Strand/Liquid/LiquidTodayView.swift:2658-2662 → StrapBatteryDisplay.resolve(connected: live.connected, batteryPct: live.batteryPct, charging: live.charging)
Liquid Today "Strap battery" row in the Data Sources card (LiquidStrapBatteryRow) Strand/Liquid/LiquidTodayView.swift:2874 if live.connected, let pct = live.batteryPct
Classic Today StrapBatteryRow Strand/Screens/TodayView.swift:5790, same if
Android Liquid Today header ring android/app/src/main/java/com/noop/ui/TodayScreen.kt:1394 batteryPct = if (liveSnap.connected) liveSnap.batteryPct else null
Android Today sources section battery pill TodayScreen.kt:1904, same shape

Why connected is not enough, same mechanism #2076 describes: LiveState is one object every source writes into. OuraLiveSource sets live.connected = true on the first live HR from the ring (Strand/BLE/OuraLiveSource.swift:1970; Android WhoopBleClient.publishExternalLiveHr, WhoopBleClient.kt:2220), and LiveState.batteryPct is the WHOOP's and is never cleared — clearBiometrics() leaves it on purpose (LiveState.swift:647, and it now nils ouraBatteryPct but not batteryPct), WHOOP disconnect only clears charging, and there is no batteryPct = null anywhere under android/…/ble. So the strap's stale % satisfies both halves of the gate the moment the ring streams.

The header ring's charging bolt and the #713 "~X days left" estimate are WHOOP-only too (live.charging is the strap's BATTERY_LEVEL event; batteryEstimate is banked from strap SoC samples), so under a ring they can also show the strap's charger state and runtime.

Liquid Today is the default Today on iOS (noop.liquidTodayEnabled ships true), so this is very likely "the UI" #2072 meant — that report never named the console.

What you expected instead

The same rule #2076 set for the other three surfaces: a non-WHOOP active device never falls back to the strap's number. Today shows the ring's charge (live.ouraBatteryPct / SourceCoordinator.ouraBatteryPct) when it has reported, and nothing when it hasn't — an em dash is honest, the strap's % under a ring is the confident lie the PR named.

Suggested fix

Same seam, no new spelling:

  • Apple: resolve activeIsWhoop once per Today view from model.deviceRegistry exactly as StrandiOS/Widgets/WidgetPublish.swift:16-23 does (LiveConsoleReadout.activeIsWhoop(devices:activeId:)), pass it into StrapBatteryDisplay.resolve, LiquidStrapBatteryRow and StrapBatteryRow, and take the % from LiveConsoleReadout.batteryPercent. Gate charging and the runtime estimate on the same flag. LiquidBatteryDisplayTests already pins StrapBatteryDisplay.resolve's truth table, so an activeIsWhoop: false row there is the regression test.
  • Android: AppViewModel.activeIsWhoop (AppViewModel.kt:197) already exists for Compose; collect it in TodayScreen and route the two liveSnap sites through LiveConsoleReadout.batteryPercent(activeIsWhoop, whoopPct, ringPct) with viewModel.ouraBatteryPct, the way DevicesScreen.kt:263 does. Mirror the test vector in LiveConsoleReadoutTest.

Not in scope, checked and left: the Settings → Strap card pill (SettingsView.swift:1462) and the onboarding battery line sit inside explicitly WHOOP-labelled sections, the same call #2076 made for the Data Sources pill. The macOS sidebar (RootView.swift:548) and menu-bar stat (MenuBarContent.swift:109, :215) read the raw field with no connected gate at all and say "Battery NN%" with no brand — arguably the same bug one screen over, flagging rather than folding in.

Steps to reproduce

  1. Register a WHOOP 5.0 and an Oura ring; let the WHOOP connect once so batteryPct is populated.
  2. Make the ring the active device and let it stream live HR (log: Oura: receiving live data - first HR …).
  3. Open Today. The header battery ring and the "Strap battery" row show the strap's last % — compare with Oura: battery NN% in the log.

Read from the code on main, not reproduced on hardware yet; the mechanism is the one #2076 already confirmed with the reporter's 93 / 72.40 / 72 numbers.

What you expected instead

see above

Steps to reproduce

  1. see above

Strap log


Before you submit

  • I attached my exported NOOP .zip (not a screenshot of the Live screen). If my bug is a clearly reproducible UI issue I described exactly how to see it instead.
  • I attached or pasted a strap log above — or my bug is a clearly reproducible UI issue that doesn't need one (e.g. a typo, a button that does nothing) and I've described exactly how to see it.
  • I checked the Troubleshooting guide — especially "history syncs but no data" (most often the strap isn't banking history to flash; charge it to 100% and reconnect).
  • I checked the FAQ and — if I'm on a WHOOP 5.0 / MG — the 5.0/MG status page, and this isn't an already-known limit (e.g. SpO₂, or recovery/sleep on the 5/MG).
  • I've left out anything personal (strap serial, account email, raw export).

Activity

  1. ryanbr commented on Sep 14, 2026

    @ryanbr
    Owner

    Thanks @pipiche38. Checked every claim against the code rather than reading it, and all of them hold, at the line numbers you gave.

    Confirmed

    • The Read the Live Console out of the active device, not whichever field is populated #2076 seam exists and is exactly where you say: LiveConsoleReadout.activeIsWhoop and .batteryPercent at LiveState.swift:1116 and :1126.
    • All five Today readers still take the raw field gated on connected alone. LiquidTodayView.swift:2658-2662 and :2874, TodayView.swift:5790, TodayScreen.kt:1394 and :1904.
    • The mechanism is the important part and it holds. Nothing clears LiveState.batteryPct. Every batteryPct = nil in the tree belongs to a source's OWN field (FTMSSource, HuamiHRSource, StandardHRSource, OuraLiveSource), not to the shared LiveState. And clearBiometrics nils ouraBatteryPct with a comment naming [iOS][Oura] Live Console shows WHOOP pairing state and WHOOP battery while Oura Ring 5 is active #2075, right beside the strap's batteryPct which it leaves. So the strap's last charge satisfies both halves of the gate the moment a ring sets connected.

    Being explicit that this was read from main rather than reproduced is the right call, and it does not weaken the report. The five call sites are unambiguous.

    The two you flagged and left out are worse than the ones you filed

    You described the macOS sidebar and the menu-bar stat as "arguably the same bug one screen over". Having looked, they are a strictly worse variant, and I would fold them in rather than leave them.

    // RootView.swift:548
    Text(live.batteryPct.map { String(localized: "Battery \(Int($0))%") } ?? String(localized: "Strap not connected"))
    
    // MenuBarContent.swift:215
    live.batteryPct.map { "\(Int($0.rounded()))%" } ?? ""

    Neither gates on connected at all. So they do not merely show the strap's charge under a ring; they show it when nothing is connected to anything, which needs no Oura ring to reproduce.

    The sidebar is the sharper case. Its only honest branch is the nil one, and since batteryPct is never cleared, that branch stops being reachable the first time a WHOOP ever connects. "Strap not connected" is effectively dead text on any install that has paired a strap once, and the line above it will keep asserting a percentage indefinitely.

    Scope

    I would do all seven surfaces in one change rather than the five.

    Three surfaces were dealt with in #2076. This report finds five more. If the sidebar and the menu bar are left, they become a third pass over the same class, and the reason the first two passes each missed something is that the class is defined by a shared mutable field rather than by anything a reader can see locally. A sweep of live.batteryPct readers is the only thing that ends it.

    Your suggested fix is the right one and I would not change its shape: resolve activeIsWhoop once per view and route the percentage through LiveConsoleReadout.batteryPercent, with charging and the #713 runtime estimate on the same flag since both are strap-only facts.

    Agreed out of scope

    The Settings strap card and the onboarding line, for the reason you give: they sit under explicitly WHOOP-labelled headings, which is the same call #2076 made for the Data Sources pill. A number under a heading that names the strap is not claiming to be the active device's.

  2. pipiche38 commented on Sep 14, 2026

    @pipiche38
    Author

    Agreed on all of it, and thanks for checking the lines. The sidebar's dead "Strap not connected" branch is a sharper description than mine — it needs no ring at all.

    All seven are in #2214. The Today five go through LiveConsoleReadout.batteryPercent with charging and the #713 estimate on the same flag; the sidebar and the menu bar gain the connected gate they never had and read the same seam, with the sidebar getting the console's middle state (a link with no reading yet draws Battery —) so "not connected" is only ever said when it's true.

    One shape note: the Today leaves observe only LiveState on purpose (AppModel republishes bpm at HR rate), so rather than each leaf reading model.deviceRegistry the way the console does, LiveState now carries activeIsWhoop, set by SourceCoordinator on every active-device change from the same rule. It's the twin of Android's AppViewModel.activeIsWhoop flow, which exists for the same reason.

    Read from the code and built against it, not yet reproduced on hardware — I'll confirm on a ring + WHOOP 5 pair on the next flash.

  3. ryanbr commented on Sep 14, 2026

    @ryanbr
    Owner

    @pipiche38 the sweep is in as a8e88d7, and there is a testing build carrying it: testing-latest, staging 507 / 388 on base 11.7.0, commit 67149e4. All four artifacts published, all four legs green.

    Since you offered to run the ring-plus-WHOOP pair, the cases worth watching, in the order they are likely to be wrong:

    The Today header under a streaming ring. It should draw NOTHING now. Not a percentage, and not the crossed-out bolt either. VoiceOver should skip the control rather than read "Strap battery, strap not connected". That is the delta you caught, and it is the one I would check first because it is the only change here that was made on an argument rather than on a reading of the code.

    The macOS sidebar and the menu-bar stat. These are the two you flagged and left out. They had no connected gate at all, so on any machine that has ever paired a strap they showed a percentage permanently, ring or no ring. They should now say "Strap not connected" and "--" respectively when the strap is not the active device, and the sidebar's nil branch should be reachable again for the first time since whenever that install first connected a WHOOP.

    The Android WHOOP source row. It should show no battery and report the WHOOP as absent while a ring is active. This is the one I got wrong on the first pass by routing it through the seam, so it is showing the ring's charge under a WHOOP label if I have got it wrong a second time.

    The strap as active device, unchanged. Every reading should be exactly as before: percentage, charging bolt, the runtime estimate. If anything blanked that should not have, that is this change overreaching rather than the ring case failing.

    Test Centre is deliberately untouched: its "Current charge" reads the raw strap SoC because inspecting the strap's own state is what that screen is for.

    Leaving this issue open until you have run it. Nothing here is hardware-checked on my side either, and the pair is the case neither of us could reason our way to.

    One thing that came out of the second pass and is worth more than the call-site fixes: LiveState.batteryPct now carries documentation. It had none at all, which is how a field that is strap-only, never cleared, and not qualified by the connected flag beside it got read raw by seven surfaces across two separate reports. It now states all three, and says which of the two correct readings a surface needs depending on what its label claims. That is the part most likely to stop a third report.

  4. pipiche38 commented on Sep 18, 2026

    @pipiche38
    Author

    Hardware run, case 1 — Gen 3 ring + WHOOP 5/MG on the same iPhone, iOS 27.0, build integration/oura-full@b348eafd = upstream/main@64b5c2da5 (11.8.0, so a8e88d71 is on it) plus the Oura integration branch; report.txt line 4 confirms the build.

    Today under a streaming ring — PASS. Ring active (the OURA badge on the score card), live HR flowing (the HEART RATE card says "Live · beat by beat", 80 bpm), the WHOOP 5/MG paired to the same phone (it attaches at every launch — Found existing WHOOP 5.0 / MG connection — attaching in the log — then stands down under #1881 as the non-active device; whether batteryPct still held a value from an earlier link is not something I can read off this capture, so this is the header-hidden check, not a proof that a stale value was present to leak). The top of Today draws no battery control at all: no percentage, no crossed-out bolt, no "~X days left". Data Sources shows "Synced from / View sources" and "Strap history / Synced 1 min ago" and nothing else — no "Strap battery" row. That is the delta this issue was about, and it holds on hardware.

    Not run yet: the macOS sidebar / menu bar (the WHOOP is the phone's, the Mac bench has no strap to disconnect), Android (no device here), and the WHOOP-flipped-back-active regression case. I can do the last one on the phone if you want it before closing; the first two I cannot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions