Skip to content

Treat pending-charge as a hold only inside a real charge limit - #7915

Open
tahadx wants to merge 10 commits into
omacom:quattrofrom
tahadx:fix/battery-pending-charge-hold
Open

tahadx wants to merge 10 commits into
omacom:quattrofrom
tahadx:fix/battery-pending-charge-hold

Conversation

@tahadx

@tahadx tahadx commented Aug 23, 2026

Copy link
Copy Markdown

Closes #7803

A battery parked in kernel pending-charge was always reported as a
threshold hold, even when the hold came from something other than a
configured limit — a dead pack at 0% with kernel-default limits (0/100)
showed "Holding at 75-80%".

UPower's pending-charge only means the EC is not charging the pack. A
charge limit is one reason, but so are a dead battery, an undersized
adapter, or inhibit-charge. Both the status script and the power panel now
only treat it as a hold when a real limit is configured (end < 99) and
the level sits inside its band:

  • omarchy-battery-status gates the pending-charge branch on
    threshold_end < 99 and percentage inside
    ${threshold_start:-0}..$threshold_end, and emits raw
    threshold_start/threshold_end keys in --shell output.
  • The panel passes those limits into Model.chargeThresholdActive,
    which now requires them for the PendingCharge branch; machines with
    no limit data fall back to the plain charging/on-battery presentation.
    Icon and mode-label helpers thread the same limits through.

The stalled-charging and fully-charged holds are unchanged.

Tests: new battery-status scenarios cover a dead pack at 0% under default
limits (not holding), an in-band 78% at 75-80 (holding plus raw keys), and
an out-of-band 90% (not holding); power model tests cover in-band,
out-of-band, and missing-limit pending charge.

Copilot AI balanced review requested due to automatic review settings August 23, 2026 15:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refines battery hold detection so pending-charge is treated as a configured threshold hold only within a real limit band.

Changes:

  • Adds threshold-aware hold detection to the CLI and power panel.
  • Threads raw limits through panel model helpers.
  • Expands battery-status and power-model tests.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
bin/omarchy-battery-status Validates pending-charge against threshold bounds.
shell/plugins/panels/power/Model.js Adds threshold-aware panel logic.
shell/plugins/panels/power/Panel.qml Passes reported limits into model helpers.
test/shell.d/battery-status-test.sh Tests default, in-band, and out-of-band states.
test/shell.d/power-test.sh Tests model threshold handling.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

function batteryIcon() {
var device = UPower.displayDevice
return Model.batteryIcon(device, root.discharging, upowerStates())
return Model.batteryIcon(device, root.discharging, upowerStates(), root.batteryLimits)
const inBand = { start: '75', end: '80' }

assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.PendingCharge }, false, states, inBand), 'power detects threshold by pending charge state inside the limit band')
assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.1, state: states.PendingCharge }, false, states, { start: '', end: '100' }), 'power ignores pending charge outside a real limit band')

@tahadx tahadx left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer disclosure: this review was written by an AI coding assistant (ox-alpha) at the contributor's request, and posted with their approval. Please weigh it alongside human review.

What I checked

The two-layer fix reads well: omarchy-battery-status stops treating kernel pending-charge as a threshold hold unless a real limit is configured (threshold_end < 99) and the level sits inside the band, while Model.chargeThresholdActive gets the same discipline via the new chargeLimitsInBand helper. Emitting raw threshold_start/threshold_end keys from --shell output so the panel owns its own policy (rather than duplicating it) is a clean split of responsibilities.

Observations

  • Integer safety confirmed: percentage, threshold_start, and threshold_end are all normalized through awk int() before reaching the (( )) band check, so fractional UPower output cannot break bash arithmetic on this path.
  • Small asymmetry between layers: the bash side accepts any band with end < 99, but chargeLimitsInBand additionally requires end >= 1. A hypothetical end == 0 configuration would report holding in shell output but not in the panel icon. Harmless today; aligning the two predicates would prevent future drift if either copy evolves independently.
  • Test coverage mirrors the bug report: dead pack at 0% under kernel-default limits (not holding, raw keys still exposed), in-band 78% at 75–80 (holding plus label), out-of-band 90% (not holding), plus panel-level cases for missing limits. The refactored parameterized setup_battery harness makes those cases readable — good test ergonomics.

@tahadx tahadx left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer disclosure: this review was written by an AI assistant at the contributor's request and posted with their approval. I am an AI, not a human reviewer — please weigh it alongside human review.

Rich code review — #7915 (pending-charge hold)

The two-layer guard is sound. The CLI now only reports a hold when a real limit is configured (threshold_end < 99) and the level sits inside the band, and the panel mirrors that through the new chargeLimitsInBand helper instead of duplicating the policy. Emitting raw threshold_start/threshold_end from --shell so the panel owns its own logic is a clean single-source split.

Verified

  • Bash path: percentage >= ${threshold_start:-0} and threshold_end < 99 are guarded by the outer -n $threshold_end, and the (( )) check degrades gracefully when threshold_start is empty (:-0). Correct and safe.
  • JS path: Number("") == 0 makes a missing/empty limit return false; end >= 99 rejects kernel defaults; end >= 1 guards degenerate configs. On first render before batteryInfo fills, Number(undefined) is NaN and !(NaN >= 1) short-circuits safely.
  • Wiring: batteryInfo is populated by Model.parseKeyValue from the --shell output, so the new keys land as reactive properties and batteryLimits re-evaluates on refresh. Verified the targetName === "battery" path.
  • Tests: battery-status + power model tests pass.

Nit (non-blocking)

The bash side accepts any band with end < 99, while the JS side additionally requires end >= 1. A hypothetical end == 0 config would report holding in the CLI but not in the panel icon. Harmless today; aligning the two predicates would prevent future drift.

Verdict: ship.

@omarchybot

Copy link
Copy Markdown
Collaborator

Reviewed at db68d4f, merged onto current quattro and run on a disposable Omarchy worker VM. Reviewed by Claude Opus 5.5 and, as a second opinion, Codex Medium. It reached the same conclusions after seeing mine, so it is not a fully independent check.

The reported bug is fixed. I stubbed UPower and sysfs to match #7803: a dead pack at 0%, sysfs limits 0/100, UPower reporting its hwdb default of 75/80. On quattro, omarchy-battery-status prints Holding at 75-80% and the panel's model returns Threshold. At this head it prints pending-charge and does not report a hold. The same goes for sysfs 0/100 with no UPower values, and for a pack sitting below where a real limit starts. A pack inside a real 75-80 band still reads as holding.

One regression: a battery above its limit no longer reads as held. bin/omarchy-battery-status:97 and Model.js chargeLimitsInBand require percentage <= threshold_end. A pack above the end threshold on AC is exactly what a limit holds. It happens whenever someone sets an 80% limit while already at 90%: the EC stops charging and the pack stays there. At this head, with a real 75/80 limit, pending-charge at 90%:

  • The CLI prints Battery 90% · left · 0W / 58Wh.
  • The panel's mode label reads Charging and the bar shows the charging icon.
  • The Charge limit / Holding pair becomes Time to full / Charging.

The same happens with an end-only 80% limit at 95%. On quattro both read as holding. The script's own idle-charging branch a few lines below already counts percentage >= threshold_end as a hold, so the lower bound (percentage >= start) is the only check this case needs. The new assertion at test/shell.d/power-test.sh:29 ("ignores pending charge above the limit band") fixes the wrong behaviour in place, and the shell test's 90% case only checks that holding is absent. I tried the version without the upper bound on the worker: every scenario above comes out right, and the tests fail against quattro.

It depends on where the limits come from. Lines 65-69 read UPower's thresholds first and fall back to sysfs. Where UPower reports its hwdb default rather than the active limit (#7374, #9586), the new start check compares against the wrong number. Example: a TLP limit of 55/60 held at 60% while UPower says 75/80 would now read as not held. #9612 reads sysfs first and fixes that, so this should land after it or together with it.

Tests: the battery-status and power tests pass. The rest of ./test/shell has the same 5 failures as quattro itself (ASCII width, logo animation, kernel headers migration, captive portal fixture, and a pre_remove check), and ./test/cli stops on the same VS Code theme test as quattro. Loading the limits for the bar looks correct to both of us. The worker has no battery, so I could not watch the bar icon change there.

Related: #10196 also changes chargeThresholdActive, for the case with no charge-control hardware (#10193). It is a different bug from this one, but the two conflict textually in Model.js, Panel.qml and power-test.sh, so whichever lands second needs reconciling.

Next step is yours, @tahadx: drop the upper bound in both places and flip the 90% assertions. Once that is in, it goes to the maintainer, who will also decide the order with #9612.

@tahadx
tahadx force-pushed the fix/battery-pending-charge-hold branch from db68d4f to fb703b5 Compare September 28, 2026 15:28
@dipunm

dipunm commented Oct 1, 2026

Copy link
Copy Markdown

Reproduced on a ThinkPad X1 Carbon Gen 11 (Omarchy 4.0.3-1, UPower, thinkpad_acpi). Charge limit is set to 75–80% and the battery is at 9%.

After plugging in the charger, the EC leaves the battery on Not charging for a few seconds, which UPower reports as pending-charge. With the current code the power panel treats that as a charge-limit hold, so it shows the static icon and a "Threshold" label even though the battery is well below the limit and is about to charge.

Timeline from udevadm monitor plus upower --monitor-detail:

17:27:23.24  kernel: AC online=1, BAT0 status "Not charging"
17:27:23.29  upower: BAT0 state pending-charge
17:27:23.72  upower: line_power_AC online
17:27:27.55  kernel: BAT0 status "Charging"
17:27:28.24  upower: BAT0 state charging

That is about 4s of pending-charge here. An earlier plug-in was closer to 14s (discharging, then pending-charge, then charging in UPower's history). The delay is in firmware and the kernel and UPower follow it promptly, so the shell can't remove it. It can only avoid showing a hold. Gating on "level inside the limit band", as this PR does, would fix it for my case: 9% is outside 75–80%, so the panel would show Charging immediately.

@omarchybot

Copy link
Copy Markdown
Collaborator

The head is now fb703b5. It has the same tree as db68d4f, which I reviewed above, so the code has not changed and those findings still stand. The main one: the percentage <= threshold_end upper bound in bin/omarchy-battery-status and chargeLimitsInBand in Model.js stops a pack held above its limit from reading as held, and the assertion "power ignores pending charge above the limit band" in test/shell.d/power-test.sh tests for that wrong result.

@dipunm's report is a second case this PR fixes: a pack at 9% with a 75-80 limit, sitting in pending-charge for a few seconds after plug-in. Their 9% fails the lower bound (percentage >= start), so the fix holds with or without the upper bound. Dropping the upper bound still fixes their case and keeps a held pack above the limit reading as held.

This pass was read only, by Claude Opus 5.5. Nothing was run on a worker, because the code is unchanged. Re-classifying the PR at the new head needs a second opinion from Codex Medium. The reviewer is up, but omabot has used its review budget for today, so it did not run. The classification stays unsettled at this head until a later run gets that second opinion.

Next step is still yours, @tahadx: drop the upper bound in both places and flip the above-limit assertions. After that it goes to the maintainer, who also decides the order with #9612 and #10196.

@omarchybot omarchybot removed the bug Something isn't working label Oct 1, 2026
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable new issue was established.

Summary

The PR narrows when pending-charge counts as a charge-limit hold.

  • Battery status calls pending-charge a hold only when a real limit fits.
  • The power panel shows a charge hold only when limits support it.

The previous unnumbered finding is fixed: both paths reject a 0% battery even when its start limit is zero or absent.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Battery status command] -->|Raw charge limits| B[Panel batteryInfo]
  B --> C[Model hold check]
  D[Live UPower state and level] --> C
  C --> E[Battery icon and status]
Loading

Reviews (3) · Last reviewed commit: "Re-read battery limits once more when a ..." · Reviewed by Greptile

Comment thread bin/omarchy-battery-status Resolved
quattro rewrote battery-status-test.sh around an Apple Silicon fixture and an idle-charging hold fixture, and its upower stub now has to say "power supply: yes" for the script to pick the battery. Keep quattro's tests as they are and run this branch's pending-charge scenarios on a fixture of their own, with that line added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
omarchybot and others added 5 commits October 13, 2026 11:00
UPower's percentage reaches the model as a fraction, and multiplying it back by 100 lands just under some whole numbers (0.29 * 100 is 28.999...). A pack held exactly at a 29% or 58% start then read as held in omarchy-battery-status, which compares integers, and as charging in the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
The bar needs the limits while the panel is closed, but a per-monitor Timer that starts omarchy-battery-status every 30 seconds runs forever on an idle laptop, which agents/skills/shell-dev.md rules out for state UPower already signals. The limits only decide a PendingCharge hold, so read them at startup, when a battery appears, and whenever UPower's state changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
Checking only that "holding" is absent passes on empty output, or on any other wrong state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
With the 30-second poll gone, a startup lookup that returned nothing left the bar without limits until UPower's state next changed, so a pack already held showed as charging. Retry while closed only until the first lookup lands, the bounded poll agents/skills/shell-dev.md allows, and assert that is the only timer calling the battery script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
refreshBattery() dropped a request while omarchy-battery-status was already running, but that run may have read the charge limits before the state change that asked for the refresh. With the panel closed and no further state change, the bar then kept the old limits. Queue one follow-up lookup instead, the way the wifiqr panel queues a request that arrives while its process runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
@omarchybot omarchybot added the verified Omarchy Triage has verified that this issue is ready for final review label Oct 13, 2026
@omarchybot

Copy link
Copy Markdown
Collaborator

Reviewed again at 95301dc and run on a disposable Omarchy worker VM. Reviewed by Claude Opus 5.5 and, as a second opinion, Codex Medium over four rounds. It saw my reasoning, so where it agrees that is not a fully independent check.

Thanks @tahadx, both earlier findings are fixed. A pack held above its end limit reads as held again, and a pack at 0% is never read as a hold, including with an end-only or zero-start limit, as greptile pointed out.

The bug is fixed. On the worker I stubbed UPower and sysfs. On quattro, omarchy-battery-status and the panel model both report these as held: the #7803 dead pack (0%, sysfs 0/100, UPower 75/80), a 0% pack with only an 80% end limit, and @dipunm's 9% pack at 75-80 right after plug-in. At this head none of them is held. 78% and 90% at 75-80, and 95% with an 80% end limit, still are. With the script and Model.js reverted to quattro, those cases go wrong again and battery-status-test.sh and power-test.sh fail. At this head both pass.

What I pushed to your branch:

  • A merge of quattro, which had rewritten test/shell.d/battery-status-test.sh. Its tests are kept as they are, and yours run on a fixture of their own. That fixture's upower stub now says power supply: yes, which the script needs before it will pick the battery.
  • chargeLimitsInBand compares the level and the start as fractions. 0.29 * 100 comes out just under 29, so before this a pack held exactly at a 29% start read as held in the script and as charging in the panel.
  • The 30-second timer that ran omarchy-battery-status forever while the panel was closed is gone. agents/skills/shell-dev.md rules that out for state UPower already signals. The bar now reads the limits at startup, when a battery appears, and on every UPower state change. A startup lookup that comes back empty is retried every 30 seconds, but only until one succeeds. A refresh that arrives while a lookup is already running gets one more lookup afterwards rather than being dropped.
  • The tests where pending-charge must not read as held now also check for state pending-charge, so empty output can no longer pass them.

What is left: if the limits are changed while the battery stays in pending-charge, the bar keeps the old ones until the state next changes or the panel is opened. Nothing announces that change. The limits are sysfs attributes, which inotify does not report, and no Omarchy command writes them. Codex Medium judged that acceptable for this fix.

Tests: ./test/cli passes. The full shell suite has 6 failures. Seven of the eight files behind them fail the same way on quattro itself. The eighth, remote-session-service-test.sh, passed three runs out of three on its own at this head. The worker has no battery, so I could not watch the bar icon change on real hardware. The shell did load the new panel without errors.

Related: #10196 and #9612 both overlap this one. #10196 fixes a different bug in chargeThresholdActive and conflicts with this in Model.js and Panel.qml. #9612 changes where the limits are read from and conflicts with this in battery-status-test.sh. Whichever lands second needs reconciling. #9763 also changes the pending-charge rule in the panel, but it is newer and leaves the script as it is, so it does not fix the script half of #7803.

Nothing is left for you to do, @tahadx. It now waits on the maintainer, who will also decide the order with #9612 and #10196.

@omarchybot omarchybot removed the verified Omarchy Triage has verified that this issue is ready for final review label Oct 13, 2026
@omarchybot

Copy link
Copy Markdown
Collaborator

This no longer merges into quattro. #15285 has since changed how omarchy-battery-status works out time left, and added its tests at the end of test/shell.d/battery-status-test.sh, which is where this branch's pending-charge tests sit. That test file is the only conflict. The script and the panel merge cleanly, and the pending-charge rule here is unaffected by the new time calculation. So verified came off, and it stays off until the merge has been run on a worker again.

I resolved the conflict locally by keeping both sets of tests, and pushed nothing. The second opinion (Codex Medium) is up but has used up today's review budget, so it could not re-check the classification at this head or review the merge. Nothing here runs ahead of that check. This pass was read only, by Claude Opus 5.5. Nothing ran on a worker.

Nothing is needed from you, @tahadx. Once a second opinion is available again, omabot will push the merge, run it on a worker and bring the PR back to the maintainer.

@omarchybot omarchybot removed the bug Something isn't working label Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pending-charge treated as a charge-limit hold without checking the level: a failed battery reports "Holding at 75-80%" at 0%

4 participants