222 lines
9.2 KiB
Bash
Executable File
222 lines
9.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# The battery, for the shell and the Power page.
|
|
#
|
|
# panama-battery paths resolve which sysfs files to watch
|
|
# panama-battery status one JSON reading, for scripts and contracts
|
|
# panama-battery set-threshold N cap charging at N% (needs root)
|
|
#
|
|
# `paths` exists so the shell does not have to poll a subprocess. Globbing is
|
|
# the one thing QML cannot do -- a battery is BAT0 on most machines, BAT1 on
|
|
# some, CMB0 on a few, and the mains supply is AC, AC0, ADP1 or ACAD depending
|
|
# on the firmware -- so this resolves the names once and the shell reads the
|
|
# files directly from then on, the way Vitals.qml reads procfs.
|
|
#
|
|
# Which machine has what is panama-hw's question, so the search lives there and
|
|
# this asks it rather than keeping a second copy of the answer.
|
|
#
|
|
# Charge thresholds are a root write to a sysfs attribute, and the only part of
|
|
# this that needs privilege. It goes through panama-sudo so the prompt names
|
|
# what is being changed, rather than asking for a password with polkit's
|
|
# generic "run a program as another user".
|
|
|
|
set -uo pipefail
|
|
|
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
|
HW="$PANAMA_PATH/bin/panama-hw"
|
|
SYS="${PANAMA_HW_SYS:-/sys}"
|
|
|
|
battery_dir() {
|
|
[[ -x "$HW" ]] || return 1
|
|
"$HW" battery-path 2>/dev/null
|
|
}
|
|
|
|
# Every system battery, for the machines that have two. The primary pack
|
|
# answers `paths` and drives the watch files; these answer the questions
|
|
# where ignoring the second pack gives a wrong answer -- the total charge,
|
|
# and which packs a threshold write must reach.
|
|
all_battery_dirs() {
|
|
local supply type scope
|
|
for supply in "$SYS"/class/power_supply/*; do
|
|
[[ -r "$supply/type" ]] || continue
|
|
type="$(cat "$supply/type" 2>/dev/null)"
|
|
[[ "$type" == "Battery" ]] || continue
|
|
scope="$(cat "$supply/scope" 2>/dev/null || echo System)"
|
|
[[ "$scope" == "Device" ]] && continue
|
|
printf '%s\n' "$supply"
|
|
done
|
|
}
|
|
|
|
# The mains supply, if the machine has one. A desktop has none, and that is
|
|
# not an error: panama-hw's `ac` predicate treats "no mains at all" as being on
|
|
# wall power, and the shell falls back to the same assumption.
|
|
mains_dir() {
|
|
local supply
|
|
for supply in "$SYS"/class/power_supply/*; do
|
|
[[ -r "$supply/type" ]] || continue
|
|
[[ "$(cat "$supply/type" 2>/dev/null)" == "Mains" ]] || continue
|
|
printf '%s\n' "$supply"
|
|
return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
read_int() {
|
|
local file="$1" value
|
|
[[ -r "$file" ]] || return 1
|
|
value="$(cat "$file" 2>/dev/null)" || return 1
|
|
[[ "$value" =~ ^[0-9]+$ ]] || return 1
|
|
printf '%s\n' "$value"
|
|
}
|
|
|
|
# How much of the pack's design capacity is left, as a whole percent.
|
|
#
|
|
# Energy (µWh) and charge (µAh) are the two spellings of the same pair and a
|
|
# pack has one or the other, never both worth trusting, so each is tried in the
|
|
# order the kernel prefers. Summed across every system pack for the same reason
|
|
# the percentage is: on a two-battery machine, one pack's wear is not the
|
|
# machine's.
|
|
#
|
|
# Fails rather than guessing when the firmware exposes no design capacity,
|
|
# which plenty does not. A health figure computed from a design capacity that
|
|
# was itself invented is a confident number that is wrong, and this page has
|
|
# already decided once (no time-to-empty) that it would rather say nothing.
|
|
battery_health() {
|
|
local dir full design total_full=0 total_design=0
|
|
while IFS= read -r dir; do
|
|
full="$(read_int "$dir/energy_full" || read_int "$dir/charge_full")" || continue
|
|
design="$(read_int "$dir/energy_full_design" || read_int "$dir/charge_full_design")" || continue
|
|
(( design > 0 )) || continue
|
|
total_full=$(( total_full + full ))
|
|
total_design=$(( total_design + design ))
|
|
done < <(all_battery_dirs)
|
|
(( total_design > 0 )) || return 1
|
|
printf '%s\n' "$(( (total_full * 100 + total_design / 2) / total_design ))"
|
|
}
|
|
|
|
# Full charge cycles, from the primary pack and then from whichever pack
|
|
# answers.
|
|
#
|
|
# A reported zero is treated as "the firmware does not count", not as a battery
|
|
# that has never been charged: a great many laptops export cycle_count as a
|
|
# permanent 0, and "0 cycles" beside a four-year-old pack reads as a fact
|
|
# rather than the absence of one.
|
|
battery_cycles() {
|
|
local primary="$1" dir value
|
|
if [[ -n "$primary" ]] && value="$(read_int "$primary/cycle_count")" && (( value > 0 )); then
|
|
printf '%s\n' "$value"
|
|
return 0
|
|
fi
|
|
while IFS= read -r dir; do
|
|
if value="$(read_int "$dir/cycle_count")" && (( value > 0 )); then
|
|
printf '%s\n' "$value"
|
|
return 0
|
|
fi
|
|
done < <(all_battery_dirs)
|
|
return 1
|
|
}
|
|
|
|
cmd_paths() {
|
|
local battery mains threshold=""
|
|
battery="$(battery_dir)" || battery=""
|
|
mains="$(mains_dir)" || mains=""
|
|
|
|
# Only report the threshold file when it exists AND is writable through
|
|
# root -- a machine whose kernel exposes a read-only stub would otherwise
|
|
# get a control that silently does nothing.
|
|
if [[ -n "$battery" && -r "$battery/charge_control_end_threshold" ]]; then
|
|
threshold="$battery/charge_control_end_threshold"
|
|
fi
|
|
|
|
printf '{"battery":"%s","mains":"%s","threshold":"%s"}\n' \
|
|
"$battery" "$mains" "$threshold"
|
|
}
|
|
|
|
cmd_status() {
|
|
local battery mains capacity="" state="Unknown" online=1 threshold=0
|
|
local health="" cycles=""
|
|
battery="$(battery_dir)" || battery=""
|
|
mains="$(mains_dir)" || mains=""
|
|
|
|
if [[ -n "$battery" ]]; then
|
|
capacity="$(read_int "$battery/capacity")" || capacity=""
|
|
[[ -r "$battery/status" ]] && state="$(cat "$battery/status" 2>/dev/null)"
|
|
threshold="$(read_int "$battery/charge_control_end_threshold")" || threshold=0
|
|
|
|
# With two packs, one pack's percentage is not the machine's: sum the
|
|
# stored and full energy across every system battery and answer with
|
|
# the real total. Single-battery machines never reach this.
|
|
local dirs=() dir now full total_now=0 total_full=0
|
|
mapfile -t dirs < <(all_battery_dirs)
|
|
if (( ${#dirs[@]} > 1 )); then
|
|
for dir in "${dirs[@]}"; do
|
|
now="$(read_int "$dir/energy_now" || read_int "$dir/charge_now")" || continue
|
|
full="$(read_int "$dir/energy_full" || read_int "$dir/charge_full")" || continue
|
|
total_now=$(( total_now + now ))
|
|
total_full=$(( total_full + full ))
|
|
done
|
|
(( total_full > 0 )) && capacity=$(( (total_now * 100 + total_full / 2) / total_full ))
|
|
fi
|
|
|
|
health="$(battery_health)" || health=""
|
|
cycles="$(battery_cycles "$battery")" || cycles=""
|
|
fi
|
|
if [[ -n "$mains" ]]; then
|
|
online="$(read_int "$mains/online")" || online=0
|
|
fi
|
|
|
|
# health and cycles are JSON null when sysfs does not report them, which is
|
|
# most desktops and a fair number of laptops. Null rather than 0: a zero
|
|
# would render as a dead battery that has never been charged.
|
|
printf '{"available":%s,"percent":%s,"status":"%s","acOnline":%s,"chargeLimit":%s,"healthPercent":%s,"cycleCount":%s}\n' \
|
|
"$([[ -n "$capacity" ]] && echo true || echo false)" \
|
|
"${capacity:-0}" "$state" \
|
|
"$([[ "$online" == "1" ]] && echo true || echo false)" \
|
|
"$threshold" \
|
|
"${health:-null}" "${cycles:-null}"
|
|
}
|
|
|
|
cmd_set_threshold() {
|
|
local value="${1:-}" battery file
|
|
[[ "$value" =~ ^[0-9]+$ ]] || { echo 'set-threshold needs a percentage' >&2; return 2; }
|
|
(( value >= 50 && value <= 100 )) || { echo 'threshold must be between 50 and 100' >&2; return 2; }
|
|
|
|
# Every pack that has the attribute, not just the first: capping one
|
|
# battery of a two-battery machine leaves the other charging to full,
|
|
# which is the opposite of what the person asked for.
|
|
local files=() dir
|
|
while IFS= read -r dir; do
|
|
[[ -e "$dir/charge_control_end_threshold" ]] && files+=("$dir/charge_control_end_threshold")
|
|
done < <(all_battery_dirs)
|
|
(( ${#files[@]} > 0 )) || { echo 'this machine cannot set a charge threshold' >&2; return 1; }
|
|
|
|
# tee rather than a redirect: the redirect is performed by the calling
|
|
# shell, which is not the one holding root. One authorization writes every
|
|
# pack.
|
|
"$PANAMA_PATH/bin/panama-sudo" \
|
|
--reason "Capping battery charging at ${value}% to reduce wear" \
|
|
-- sh -c "printf '%s\n' '$value' | tee ${files[*]} >/dev/null" || return 1
|
|
|
|
# Read it back rather than reporting success from the write's exit code:
|
|
# some firmware silently clamps or ignores the value.
|
|
read_int "${files[0]}"
|
|
}
|
|
|
|
case "${1:-status}" in
|
|
paths) cmd_paths ;;
|
|
status) cmd_status ;;
|
|
set-threshold) shift; cmd_set_threshold "$@" ;;
|
|
-h|--help)
|
|
cat <<'USAGE'
|
|
usage: panama-battery [paths|status|set-threshold <50-100>]
|
|
|
|
paths JSON: which sysfs files hold the battery, mains and threshold
|
|
status JSON: one reading of charge, state, power source, limit,
|
|
health against design capacity and charge cycles (the last
|
|
two null where the firmware does not report them)
|
|
set-threshold cap charging at N percent (asks for a password)
|
|
USAGE
|
|
;;
|
|
*) echo "panama-battery: unknown command: $1" >&2; exit 2 ;;
|
|
esac
|