259 lines
12 KiB
Bash
Executable File
259 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# The battery, and the machines that do not have one.
|
|
#
|
|
# This is the first thing Panama has shipped that only exists on some hardware,
|
|
# and the failure that matters is not a wrong percentage -- it is a desktop
|
|
# growing battery chrome, or a laptop showing a confident 0% because a file
|
|
# could not be read. So the properties pinned here are mostly about absence:
|
|
#
|
|
# 1. No battery means `available` is false and every surface hides. Not 0%,
|
|
# not "Unknown" in the bar, not an empty card on the Power page.
|
|
# 2. A machine with no mains supply at all is on wall power. A desktop must
|
|
# never be treated as running on battery, or every battery-specific idle
|
|
# timing would apply to it.
|
|
# 3. The charge-limit control appears only where the firmware has one.
|
|
# 4. The threshold write goes through panama-sudo with a reason, never bare
|
|
# sudo, and is read back rather than assumed.
|
|
# 5. Health -- design-capacity percentage and charge cycles -- is reported
|
|
# where sysfs reports it and is SILENT where it does not. Most of these
|
|
# files are optional and plenty of firmware omits them, so the tempting
|
|
# failure is a tile reading "100% of design capacity, 0 cycles" on a
|
|
# three-year-old battery that simply never said. That is worse than no
|
|
# tile: it is a confident wrong answer about whether hardware is dying.
|
|
#
|
|
# The helper is driven against fixture sysfs trees; the QML side is pinned
|
|
# statically, since a battery cannot be simulated into the running shell.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-battery"
|
|
service="$repo_dir/config/dot/quickshell/services/Battery.qml"
|
|
cluster="$repo_dir/config/dot/quickshell/modules/bar/StatusCluster.qml"
|
|
page="$repo_dir/config/dot/quickshell/modules/settings/PowerPage.qml"
|
|
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
|
aliases="$repo_dir/config/dot/quickshell/config/Settings.qml"
|
|
|
|
findings=()
|
|
note() { findings+=("$1"); }
|
|
|
|
[[ -x "$helper" ]] || { printf 'battery contract: %s is not executable\n' "$helper" >&2; exit 1; }
|
|
|
|
work="$(mktemp -d)"
|
|
trap 'rm -rf "$work"' EXIT
|
|
|
|
# A fake machine. `battery <pct>` and `mains <0|1>` are both optional, and
|
|
# leaving one out means the machine genuinely does not have it.
|
|
fixture() {
|
|
local name="$1" battery="${2:-}" mains="${3:-}" threshold="${4:-}"
|
|
local root="$work/$name"
|
|
mkdir -p "$root/sys/class/power_supply"
|
|
if [[ -n "$battery" ]]; then
|
|
mkdir -p "$root/sys/class/power_supply/BAT0"
|
|
printf 'Battery\n' >"$root/sys/class/power_supply/BAT0/type"
|
|
printf '%s\n' "$battery" >"$root/sys/class/power_supply/BAT0/capacity"
|
|
printf 'Discharging\n' >"$root/sys/class/power_supply/BAT0/status"
|
|
[[ -n "$threshold" ]] && printf '%s\n' "$threshold" \
|
|
>"$root/sys/class/power_supply/BAT0/charge_control_end_threshold"
|
|
fi
|
|
if [[ -n "$mains" ]]; then
|
|
mkdir -p "$root/sys/class/power_supply/AC0"
|
|
printf 'Mains\n' >"$root/sys/class/power_supply/AC0/type"
|
|
printf '%s\n' "$mains" >"$root/sys/class/power_supply/AC0/online"
|
|
fi
|
|
printf '%s\n' "$root"
|
|
}
|
|
|
|
ask() {
|
|
local root="$1"; shift
|
|
PANAMA_HW_SYS="$root/sys" PANAMA_PATH="$repo_dir" "$helper" "$@" 2>/dev/null
|
|
}
|
|
|
|
field() { jq -r "$2" <<<"$1" 2>/dev/null; }
|
|
|
|
# ── 1. A desktop ─────────────────────────────────────────────────────────────
|
|
|
|
desktop="$(fixture desktop)"
|
|
status="$(ask "$desktop" status)"
|
|
[[ "$(field "$status" .available)" == "false" ]] \
|
|
|| note 'a machine with no battery reports one as available'
|
|
[[ "$(field "$status" .acOnline)" == "true" ]] \
|
|
|| note 'a machine with no mains supply is reported as running on battery'
|
|
|
|
paths="$(ask "$desktop" paths)"
|
|
[[ "$(field "$paths" .battery)" == "" ]] \
|
|
|| note 'a machine with no battery resolves a battery path anyway'
|
|
|
|
# ── 2. A laptop ──────────────────────────────────────────────────────────────
|
|
|
|
laptop="$(fixture laptop 64 1)"
|
|
status="$(ask "$laptop" status)"
|
|
[[ "$(field "$status" .available)" == "true" ]] || note 'a battery was not detected'
|
|
[[ "$(field "$status" .percent)" == "64" ]] \
|
|
|| note "the charge level is wrong (got $(field "$status" .percent))"
|
|
[[ "$(field "$status" .acOnline)" == "true" ]] || note 'a plugged-in laptop reads as unplugged'
|
|
|
|
unplugged="$(fixture unplugged 41 0)"
|
|
status="$(ask "$unplugged" status)"
|
|
[[ "$(field "$status" .acOnline)" == "false" ]] \
|
|
|| note 'a laptop with mains offline still reads as on wall power'
|
|
|
|
# ── 3. The charge limit appears only where it exists ─────────────────────────
|
|
|
|
paths="$(ask "$laptop" paths)"
|
|
[[ "$(field "$paths" .threshold)" == "" ]] \
|
|
|| note 'a machine without a charge threshold resolves one anyway, so the control would appear and do nothing'
|
|
|
|
limited="$(fixture limited 80 1 80)"
|
|
paths="$(ask "$limited" paths)"
|
|
[[ "$(field "$paths" .threshold)" != "" ]] \
|
|
|| note 'a machine with a charge threshold does not expose it'
|
|
[[ "$(field "$(ask "$limited" status)" .chargeLimit)" == "80" ]] \
|
|
|| note 'the charge limit is not reported'
|
|
|
|
# ── 4. The write is privileged, named, and verified ──────────────────────────
|
|
|
|
grep -q 'panama-sudo' "$helper" \
|
|
|| note 'the threshold write does not go through panama-sudo'
|
|
if grep -nE '(^|[^-[:alnum:]])sudo ' "$helper" | grep -q -v 'panama-sudo'; then
|
|
note 'the helper calls bare sudo somewhere, so the prompt would not name the change'
|
|
fi
|
|
grep -q -- '--reason' "$helper" \
|
|
|| note 'the privileged write does not state a reason, so the prompt would not say what it changes'
|
|
|
|
# Out-of-range values are refused before any password is asked for.
|
|
PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold 10 >/dev/null 2>&1 \
|
|
&& note 'a threshold below the supported range was accepted'
|
|
PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold abc >/dev/null 2>&1 \
|
|
&& note 'a non-numeric threshold was accepted'
|
|
|
|
# ── 5. Health, where the firmware reports it ─────────────────────────────────
|
|
#
|
|
# Two sysfs spellings for the same fact, depending on whether the driver
|
|
# reports energy or charge. Both have to work, or half the laptops in the world
|
|
# get a blank tile.
|
|
|
|
with_health() {
|
|
local name="$1" prefix="$2" full="$3" design="$4" cycles="${5:-}"
|
|
local root
|
|
root="$(fixture "$name" 72 1)"
|
|
printf '%s\n' "$full" >"$root/sys/class/power_supply/BAT0/${prefix}_full"
|
|
printf '%s\n' "$design" >"$root/sys/class/power_supply/BAT0/${prefix}_full_design"
|
|
[[ -n "$cycles" ]] && printf '%s\n' "$cycles" >"$root/sys/class/power_supply/BAT0/cycle_count"
|
|
printf '%s\n' "$root"
|
|
}
|
|
|
|
# A pack that has lost a tenth of its design capacity, in energy units.
|
|
energy="$(with_health energy energy 45000000 50000000 312)"
|
|
status="$(ask "$energy" status)"
|
|
[[ "$(field "$status" .healthPercent)" == "90" ]] \
|
|
|| note "health is not computed from energy_full against energy_full_design (got $(field "$status" .healthPercent))"
|
|
[[ "$(field "$status" .cycleCount)" == "312" ]] \
|
|
|| note "the charge cycle count is not reported (got $(field "$status" .cycleCount))"
|
|
|
|
# The same pack, on a driver that reports charge rather than energy.
|
|
charge="$(with_health charge charge 45000000 50000000 312)"
|
|
status="$(ask "$charge" status)"
|
|
[[ "$(field "$status" .healthPercent)" == "90" ]] \
|
|
|| note 'health is not read from the charge_full spelling, so a driver that reports charge shows no health at all'
|
|
|
|
# A pack that reports neither. Absent, null, or zero -- anything but a number
|
|
# that looks like an answer.
|
|
plain="$(ask "$laptop" status)"
|
|
for key in healthPercent cycleCount; do
|
|
value="$(field "$plain" ".$key")"
|
|
case "$value" in
|
|
""|null|0) ;;
|
|
*) note "a battery whose firmware reports no $key was given one anyway ($value), which is a confident wrong answer about whether the hardware is dying" ;;
|
|
esac
|
|
done
|
|
|
|
# And a machine with no battery at all invents nothing.
|
|
desktop_status="$(ask "$desktop" status)"
|
|
for key in healthPercent cycleCount; do
|
|
value="$(field "$desktop_status" ".$key")"
|
|
case "$value" in
|
|
""|null|0) ;;
|
|
*) note "a machine with no battery reported a $key of $value" ;;
|
|
esac
|
|
done
|
|
|
|
# A design capacity of zero would divide by it. Firmware does report this.
|
|
zeroed="$(with_health zeroed energy 45000000 0)"
|
|
status="$(ask "$zeroed" status)"
|
|
value="$(field "$status" .healthPercent)"
|
|
case "$value" in
|
|
""|null|0) ;;
|
|
*) note "a zero design capacity produced a health percentage of $value" ;;
|
|
esac
|
|
|
|
# The status JSON stays parseable in every one of those cases -- an empty
|
|
# substitution would have made it valid-looking but wrong above, and invalid
|
|
# here.
|
|
for candidate in "$energy" "$charge" "$laptop" "$desktop" "$zeroed"; do
|
|
jq -e . >/dev/null 2>&1 <<<"$(ask "$candidate" status)" \
|
|
|| note 'status stopped emitting valid JSON once the health fields were added'
|
|
done
|
|
|
|
# The service carries them through, and null-safely: `null > 0` is false in
|
|
# QML, which is what makes an absent reading hide rather than render as an
|
|
# empty tile.
|
|
for property in healthPercent cycleCount; do
|
|
grep -q "property .*$property" "$service" \
|
|
|| note "the battery service does not expose $property, so the tile would read undefined"
|
|
done
|
|
|
|
# ── 6. The QML side hides itself ─────────────────────────────────────────────
|
|
|
|
grep -q 'property bool available' "$service" \
|
|
|| note 'the battery service has no availability flag'
|
|
grep -q 'Settings.showBattery && Battery.available' "$cluster" \
|
|
|| note 'the bar indicator does not gate on both the preference and the hardware'
|
|
# The Power page and the components it is built from. A card lifted into a
|
|
# component of its own is a normal thing to do, and every assertion below would
|
|
# quietly stop meaning anything if it only ever read PowerPage.qml.
|
|
power_surface=("$page")
|
|
for candidate in "$(dirname "$page")"/{Power,Battery,Idle}*.qml; do
|
|
[[ -r "$candidate" && "$candidate" != "$page" ]] && power_surface+=("$candidate")
|
|
done
|
|
|
|
grep -q 'visible: Battery.available' "${power_surface[@]}" \
|
|
|| note 'the Power page battery card does not hide on a machine without one'
|
|
grep -q 'visible: Battery.chargeLimitSupported' "${power_surface[@]}" \
|
|
|| note 'the charge limit control does not hide where the firmware has none'
|
|
|
|
# The health tiles follow the same rule as everything else here: a reading the
|
|
# firmware did not give is a tile that is not drawn.
|
|
for property in healthPercent cycleCount; do
|
|
grep -q "Battery.$property" "${power_surface[@]}" \
|
|
|| note "the Power page never shows $property, so the battery health the helper reads goes nowhere"
|
|
grep -qE "visible: .*Battery\.$property" "${power_surface[@]}" \
|
|
|| note "the $property tile is drawn unconditionally, so a battery that reports none shows an empty one"
|
|
done
|
|
|
|
# The alias layer has to carry the key, or the binding silently reads undefined
|
|
# and the indicator never appears. This exact mistake was made writing it.
|
|
for key in showBattery batteryLowPercent batteryCriticalPercent; do
|
|
grep -q "property .*$key" "$aliases" \
|
|
|| note "Settings.qml does not alias $key, so the binding reads undefined"
|
|
done
|
|
|
|
for key in showBattery batteryLowPercent batteryCriticalPercent batteryChargeLimit; do
|
|
grep -q "key: \"$key\"" "$schema" || note "the schema has no $key entry"
|
|
done
|
|
|
|
# No subprocess on the polling path: the whole point of resolving paths once.
|
|
if grep -A4 'Timer {' "$service" | grep -q 'running: true' && grep -q 'Process' "$service"; then
|
|
grep -q 'onTriggered: root.refresh()' "$service" \
|
|
|| note 'the poll timer does something other than re-read files'
|
|
fi
|
|
|
|
if (( ${#findings[@]} > 0 )); then
|
|
printf 'battery contract: %d finding(s)\n' "${#findings[@]}" >&2
|
|
printf ' - %s\n' "${findings[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'battery contract: PASS\n'
|