Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 11 additions & 1 deletion bin/omarchy-battery-status
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,13 @@ 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 inside its band.
if [[ $state == "pending-charge" ]] &&
(( threshold_end < 99 )) &&
(( percentage >= ${threshold_start:-0} && percentage <= threshold_end )); then
charge_holding=true
elif [[ $state == "fully-charged" ]] && (( percentage < 99 )); then
charge_holding=true
Expand Down Expand Up @@ -119,6 +125,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
24 changes: 18 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 && fraction * 100 <= end
}

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 this level could plausibly be held.
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 Down
13 changes: 10 additions & 3 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
72 changes: 60 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,83 @@ 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"

# Above the band the hold no longer applies even though the state persists.
setup_battery pending-charge 90 "75" "80"
shell_output=$(run_shell)
if matches=$(rg -n '^state\tholding$' <<<"$shell_output"); then
fail "out-of-band pending charge does not report as holding" "$matches"
fi

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"
6 changes: 5 additions & 1 deletion test/shell.d/power-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ 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.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