Repository navigation
Description
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
activeIsWhooponce per Today view frommodel.deviceRegistryexactly asStrandiOS/Widgets/WidgetPublish.swift:16-23does (LiveConsoleReadout.activeIsWhoop(devices:activeId:)), pass it intoStrapBatteryDisplay.resolve,LiquidStrapBatteryRowandStrapBatteryRow, and take the % fromLiveConsoleReadout.batteryPercent. Gatechargingand the runtime estimate on the same flag.LiquidBatteryDisplayTestsalready pinsStrapBatteryDisplay.resolve's truth table, so anactiveIsWhoop: falserow there is the regression test. - Android:
AppViewModel.activeIsWhoop(AppViewModel.kt:197) already exists for Compose; collect it inTodayScreenand route the twoliveSnapsites throughLiveConsoleReadout.batteryPercent(activeIsWhoop, whoopPct, ringPct)withviewModel.ouraBatteryPct, the wayDevicesScreen.kt:263does. Mirror the test vector inLiveConsoleReadoutTest.
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
- Register a WHOOP 5.0 and an Oura ring; let the WHOOP connect once so
batteryPctis populated. - Make the ring the active device and let it stream live HR (log:
Oura: receiving live data - first HR …). - 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
- 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
ryanbr commentedon Sep 14, 2026
Thanks @pipiche38. Checked every claim against the code rather than reading it, and all of them hold, at the line numbers you gave.
Confirmed
LiveConsoleReadout.activeIsWhoopand.batteryPercentatLiveState.swift:1116and:1126.connectedalone.LiquidTodayView.swift:2658-2662and:2874,TodayView.swift:5790,TodayScreen.kt:1394and:1904.LiveState.batteryPct. EverybatteryPct = nilin the tree belongs to a source's OWN field (FTMSSource,HuamiHRSource,StandardHRSource,OuraLiveSource), not to the sharedLiveState. AndclearBiometricsnilsouraBatteryPctwith 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'sbatteryPctwhich it leaves. So the strap's last charge satisfies both halves of the gate the moment a ring setsconnected.Being explicit that this was read from
mainrather 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.
Neither gates on
connectedat 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
nilone, and sincebatteryPctis 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.batteryPctreaders is the only thing that ends it.Your suggested fix is the right one and I would not change its shape: resolve
activeIsWhooponce per view and route the percentage throughLiveConsoleReadout.batteryPercent, withchargingand 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.
fix(macos): gate the sidebar and menu-bar battery on the link, and re…
pipiche38 commentedon Sep 14, 2026
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.batteryPercentwithchargingand the #713 estimate on the same flag; the sidebar and the menu bar gain theconnectedgate they never had and read the same seam, with the sidebar getting the console's middle state (a link with no reading yet drawsBattery —) so "not connected" is only ever said when it's true.One shape note: the Today leaves observe only
LiveStateon purpose (AppModelrepublishesbpmat HR rate), so rather than each leaf readingmodel.deviceRegistrythe way the console does,LiveStatenow carriesactiveIsWhoop, set bySourceCoordinatoron every active-device change from the same rule. It's the twin of Android'sAppViewModel.activeIsWhoopflow, 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.
battery: draw nothing under a ring, rather than claiming the strap is…
battery: say whose charge it is on all seven surfaces, not three (#2216)
ryanbr commentedon Sep 14, 2026
@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
connectedgate 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.batteryPctnow carries documentation. It had none at all, which is how a field that is strap-only, never cleared, and not qualified by theconnectedflag 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.pipiche38 commentedon Sep 18, 2026
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, soa8e88d71is on it) plus the Oura integration branch;report.txtline 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 — attachingin the log — then stands down under #1881 as the non-active device; whetherbatteryPctstill 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.
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(today): draw the active ring's own battery in the Liquid header i…
fix(today): draw the active ring's own battery in the Liquid header i…
today: draw the active ring's own battery in the Liquid header (#2374)
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
fix(oura): publish the ring's history drain to LiveState.backfilling …
liquid: one-line Today header with a sync line, battery in the quick …
Keep the Today device circle on screen for every active device