#!/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