Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
14 changes: 13 additions & 1 deletion bin/omarchy-battery-status
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,15 @@ fi

charge_holding=false
if [[ $ac_online == "true" && -n $threshold_end ]]; then
if [[ $state == "pending-charge" ]]; then
# UPower's pending-charge only means the EC is not charging the pack — a
# configured limit is one reason, but so are a dead battery, an undersized
# adapter, or inhibit-charge. Only call it a hold when a real limit is
# configured and the level sits at or above its start — a pack held above
# its end threshold (e.g. 90% with an 80% limit) is still held.
if [[ $state == "pending-charge" ]] &&
(( threshold_end >= 1 )) &&
(( threshold_end < 99 )) &&
(( percentage >= ${threshold_start:-0} )); then
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
charge_holding=true
elif [[ $state == "fully-charged" ]] && (( percentage < 99 )); then
charge_holding=true
Expand Down Expand Up @@ -119,6 +127,10 @@ if [[ $shell_output == "true" ]]; then
fi
fi

# Raw limits for the panel's hold logic; empty when the kernel exposes none.
[[ -n $threshold_start ]] && printf 'threshold_start\t%s\n' "$threshold_start"
[[ -n $threshold_end ]] && printf 'threshold_end\t%s\n' "$threshold_end"

exit 0
fi

Expand Down
25 changes: 19 additions & 6 deletions shell/plugins/panels/power/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,41 +49,53 @@ function batteryFraction(device) {
return device && device.isPresent ? Math.max(0, Math.min(1, device.percentage)) : 0
}

function chargeThresholdActive(device, onBattery, states) {
function chargeLimitsInBand(limits, fraction) {
var end = Number(limits && limits.end)
if (!(end >= 1) || end >= 99) return false
var start = Number(limits && limits.start) || 0
return fraction * 100 >= start
}

function chargeThresholdActive(device, onBattery, states, limits) {
var d = device || {}
var s = states || {}
if (!(d && d.isPresent && !onBattery)) return false

var fraction = batteryFraction(d)
if (d.state === s.Discharging) return false
if (d.state === s.PendingCharge) return true
if (d.state === s.PendingCharge) {
// pending-charge covers any reason the EC declines to charge — including a
// dead battery or an undersized adapter — so only treat it as a hold when
// the script reports a real limit and the level sits at or above its start.
return chargeLimitsInBand(limits, fraction)
}
if (d.state === s.FullyCharged && fraction < 0.99) return true
if (d.state !== s.Charging || fraction >= 0.99) return false

return Number(d.changeRate || 0) <= 0.2 || Number(d.timeToFull || 0) >= 8 * 60 * 60
}

function batteryIcon(device, onBattery, states) {
function batteryIcon(device, onBattery, states, limits) {
var d = device || {}
if (!d.isPresent) return ""

var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
var index = Math.max(0, Math.min(9, Math.floor(d.percentage * 10)))
var threshold = chargeThresholdActive(d, onBattery, states)
var threshold = chargeThresholdActive(d, onBattery, states, limits)

if (threshold) return defaultIcons[index]
if (d.state === states.FullyCharged) return "󰂅"
if (!onBattery) return chargingIcons[index]
return defaultIcons[index]
}

function modeLabel(device, onBattery, states) {
function modeLabel(device, onBattery, states, limits) {
var d = device || {}
if (!d.isPresent) return ""

var percentage = d.isPresent ? d.percentage : 0
if (chargeThresholdActive(d, onBattery, states)) return "Threshold"
if (chargeThresholdActive(d, onBattery, states, limits)) return "Threshold"
if (onBattery) return "On battery"
if (!onBattery && percentage >= 1) return "Fully charged"
return "Charging"
Expand All @@ -97,6 +109,7 @@ if (typeof module !== "undefined") {
parseProfiles: parseProfiles,
profileIcon: profileIcon,
batteryFraction: batteryFraction,
chargeLimitsInBand: chargeLimitsInBand,
chargeThresholdActive: chargeThresholdActive,
batteryIcon: batteryIcon,
modeLabel: modeLabel
Expand Down
32 changes: 27 additions & 5 deletions shell/plugins/panels/power/Panel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,21 @@ Panel {
setProfile(profiles[profileIndex])
}

// Raw charge limits reported by omarchy-battery-status; empty until the
// first refresh lands and absent entirely on machines without a limit.
readonly property var batteryLimits: ({
start: batteryInfo.threshold_start,
end: batteryInfo.threshold_end
})

function batteryIcon() {
var device = UPower.displayDevice
return Model.batteryIcon(device, root.discharging, upowerStates())
return Model.batteryIcon(device, root.discharging, upowerStates(), root.batteryLimits)
}

function modeLabel() {
var device = UPower.displayDevice
return Model.modeLabel(device, root.discharging, upowerStates())
return Model.modeLabel(device, root.discharging, upowerStates(), root.batteryLimits)
}

function profileIcon(name) {
Expand All @@ -71,7 +78,7 @@ Panel {
}
readonly property bool chargeThresholdActive: {
var device = UPower.displayDevice
return Model.chargeThresholdActive(device, root.discharging, upowerStates())
return Model.chargeThresholdActive(device, root.discharging, upowerStates(), root.batteryLimits)
}
readonly property bool batteryFull: fullyCharged || (!root.discharging && batteryFraction >= 1)
readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive
Expand Down Expand Up @@ -135,11 +142,17 @@ Panel {
function refresh() {
if (!batteryPresent) return

if (!batteryProc.running) batteryProc.running = true
refreshBattery()
if (!profilesProc.running) profilesProc.running = true
if (!systemProc.running) systemProc.running = true
}

function refreshBattery() {
if (!batteryPresent) return

if (!batteryProc.running) batteryProc.running = true
}

function updateKeyValue(raw, targetName) {
var next = Model.parseKeyValue(raw)
// Keep last known good data if a refresh briefly returns nothing — happens
Expand Down Expand Up @@ -199,7 +212,12 @@ Panel {
}
}

onBatteryPresentChanged: if (!batteryPresent) close()
onBatteryPresentChanged: {
if (!batteryPresent) close()
else refreshBattery()
}

Component.onCompleted: refreshBattery()

visible: batteryPresent
implicitWidth: batteryPresent ? button.implicitWidth : 0
Expand Down Expand Up @@ -230,6 +248,10 @@ Panel {

Timer { interval: 5000; running: root.opened; repeat: true; onTriggered: root.refresh() }

// The bar icon needs limits even while the panel is closed; poll the
// battery script only so a real PendingCharge hold shows correctly.
Timer { interval: 30000; running: root.batteryPresent && !root.opened; repeat: true; onTriggered: root.refreshBattery() }

// Rotate the status phrase while the panel is open and we're in a
// rotating state (charging or on battery). The text swap is wrapped in a
// fade so the changeover reads as one organism rather than a hard cut.
Expand Down
80 changes: 68 additions & 12 deletions test/shell.d/battery-status-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT

mkdir -p "$tmp_dir/bin"
mkdir -p "$tmp_dir/power/BAT0"
printf '900000\n' >"$tmp_dir/power/BAT0/current_now"
printf '12000000\n' >"$tmp_dir/power/BAT0/voltage_now"
cat >"$tmp_dir/bin/upower" <<'STUB'
#!/bin/bash

Expand All @@ -20,32 +17,91 @@ if [[ $1 == "-e" ]]; then
fi

if [[ $1 == "-i" ]]; then
cat <<'INFO'
cat "${UPOWER_INFO:?}"
exit 0
fi

exit 1
STUB
chmod +x "$tmp_dir/bin/upower"

setup_battery() {
local state=$1 percentage=$2 start=$3 end=$4
rm -rf "$tmp_dir/power"
mkdir -p "$tmp_dir/power/BAT0" "$tmp_dir/power/AC"
printf '900000\n' >"$tmp_dir/power/BAT0/current_now"
printf '12000000\n' >"$tmp_dir/power/BAT0/voltage_now"
printf 'Mains\n' >"$tmp_dir/power/AC/type"
printf '1\n' >"$tmp_dir/power/AC/online"
[[ -n $start ]] && printf '%s\n' "$start" >"$tmp_dir/power/BAT0/charge_control_start_threshold"
[[ -n $end ]] && printf '%s\n' "$end" >"$tmp_dir/power/BAT0/charge_control_end_threshold"
UPOWER_INFO="$tmp_dir/info"
cat >"$UPOWER_INFO" <<INFO
native-path: BAT0
state: discharging
state: $state
energy: 28.3 Wh
energy-full: 56.7 Wh
energy-rate: 7.3 W
time to empty: 2.5 hours
percentage: 51%
percentage: $percentage%
INFO
exit 0
fi
export UPOWER_INFO
}

exit 1
STUB
chmod +x "$tmp_dir/bin/upower"
run_shell() {
OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" \
"$ROOT/bin/omarchy-battery-status" --shell
}

shell_output=$(OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell)
setup_battery discharging 51 "" ""
shell_output=$(run_shell)

grep -Fx $'percentage\t51%' <<<"$shell_output" >/dev/null || fail "battery status reports percentage"
grep -Fx $'state\tdischarging' <<<"$shell_output" >/dev/null || fail "battery status reports state"
grep -Fx $'rate\t10.8W' <<<"$shell_output" >/dev/null || fail "battery status reports live sysfs power rate"
grep -Fx $'size\t56Wh' <<<"$shell_output" >/dev/null || fail "battery status reports full capacity"
grep -Fx $'time\t2h 30m' <<<"$shell_output" >/dev/null || fail "battery status reports remaining time"

# A dead pack parked in pending-charge with kernel-default limits (0/100) must
# not read as a threshold hold.
setup_battery pending-charge 0 "0" "100"
shell_output=$(run_shell)
grep -Fx $'state\tpending-charge' <<<"$shell_output" >/dev/null || fail "default-limit pending charge keeps its raw state"

grep -Fx $'threshold_start\t0' <<<"$shell_output" >/dev/null || fail "default limits are still reported"
grep -Fx $'threshold_end\t100' <<<"$shell_output" >/dev/null || fail "default limits are still reported"

if matches=$(rg -n '^state\tholding$' <<<"$shell_output"); then
fail "kernel-default limits do not turn pending charge into a hold" "$matches"
fi

# A configured band reports as holding while inside it, and exposes the raw
# limits for the panel's own hold logic.
setup_battery pending-charge 78 "75" "80"
shell_output=$(run_shell)
grep -Fx $'state\tholding' <<<"$shell_output" >/dev/null || fail "in-band pending charge reports as holding"
grep -Fx $'threshold_start\t75' <<<"$shell_output" >/dev/null || fail "shell output exposes the limit start"
grep -Fx $'threshold_end\t80' <<<"$shell_output" >/dev/null || fail "shell output exposes the limit end"
grep -Fx $'threshold\t75-80%' <<<"$shell_output" >/dev/null || fail "threshold label spans the band"

# Below the band the hold does not apply (e.g. 9% with a 75-80 limit right
# after plug-in, while the EC briefly reports pending-charge).
setup_battery pending-charge 9 "75" "80"
shell_output=$(run_shell)
if matches=$(rg -n '^state\tholding$' <<<"$shell_output"); then
fail "below-limit pending charge does not report as holding" "$matches"
fi

# Above the band the pack is still held by the limit (e.g. 90% with an 80%
# limit set while already charged past it).
setup_battery pending-charge 90 "75" "80"
shell_output=$(run_shell)
grep -Fx $'state\tholding' <<<"$shell_output" >/dev/null || fail "above-limit pending charge reports as holding"
grep -Fx $'threshold\t75-80%' <<<"$shell_output" >/dev/null || fail "above-limit hold keeps the threshold label"

if matches=$(rg -n 'omarchy-battery-(capacity|remaining|remaining-time)' "$ROOT/bin" "$ROOT/test" "$ROOT/shell" "$ROOT/docs"); then
fail "battery status owns capacity and remaining calculations" "$matches"
fi

pass "battery status owns capacity and remaining calculations"
pass "battery status distinguishes real holds from generic pending charge"
8 changes: 7 additions & 1 deletion test/shell.d/power-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ assertDeepEqual(
assert(power.profileIcon('performance').length > 0, 'power maps profile icons')
assertEqual(power.batteryFraction({ isPresent: true, percentage: 1.5 }), 1, 'power clamps battery fraction')

assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.PendingCharge }, false, states), 'power detects threshold by pending charge state')
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.9, state: states.PendingCharge }, false, states, inBand), 'power treats pending charge above the limit band as a hold')
assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.5, state: states.PendingCharge }, false, states, inBand), 'power ignores pending charge below 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')
assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.PendingCharge }, false, states), 'power requires reported limits before treating pending charge as a hold')
assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 0.1, timeToFull: 120 }, false, states), 'power detects threshold by stalled charging')
assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 1.0, timeToFull: 120 }, false, states), 'power does not flag active charging as threshold')
assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.5, state: states.Discharging }, false, states), 'power does not flag discharging as threshold')
Expand Down