add charging watchdog
This commit is contained in:
@@ -364,24 +364,24 @@
|
||||
"acLockTimeout": 1800,
|
||||
"acSuspendTimeout": 0,
|
||||
"acSuspendBehavior": 0,
|
||||
"acProfileName": "",
|
||||
"acProfileName": "2",
|
||||
"acPostLockMonitorTimeout": 3600,
|
||||
"batteryMonitorTimeout": 600,
|
||||
"batteryLockTimeout": 300,
|
||||
"batterySuspendTimeout": 3600,
|
||||
"batterySuspendBehavior": 2,
|
||||
"batteryProfileName": "",
|
||||
"batteryProfileName": "0",
|
||||
"batteryPostLockMonitorTimeout": 300,
|
||||
"batteryChargeLimit": 100,
|
||||
"batteryNotifyChargeLimit": false,
|
||||
"batteryCriticalThreshold": 5,
|
||||
"batteryNotifyChargeLimit": true,
|
||||
"batteryCriticalThreshold": 10,
|
||||
"batteryNotifyCritical": true,
|
||||
"batteryLowThreshold": 15,
|
||||
"batteryNotifyLow": true,
|
||||
"batteryChargeLimitNotificationType": 0,
|
||||
"batteryChargeLimitNotificationType": 1,
|
||||
"batteryLowNotificationType": 1,
|
||||
"batteryCriticalNotificationType": 1,
|
||||
"batteryAutoPowerSaver": false,
|
||||
"batteryAutoPowerSaver": true,
|
||||
"lockBeforeSuspend": true,
|
||||
"loginctlLockIntegration": true,
|
||||
"fadeToLockEnabled": true,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# battery-charge-watchdog
|
||||
#
|
||||
# Detects the Dell/UCSI fault where the battery reports a non-discharging
|
||||
# status (Charging / Full / Not charging) while charge_now is actually
|
||||
# falling. In that state UPower never fires CriticalPowerAction, so the
|
||||
# machine runs to 0% and dies with no warning and no clean shutdown.
|
||||
#
|
||||
# The only trustworthy signal is the charge_now trend: current_now is
|
||||
# reported unsigned on this hardware, so its sign says nothing about
|
||||
# direction, and `status` is precisely what lies during the fault.
|
||||
#
|
||||
# Raises a critical desktop notification and keeps re-raising it until
|
||||
# charging genuinely resumes or the status turns honest (Discharging,
|
||||
# where UPower's own safety net takes over again).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BAT=${BAT:-/sys/class/power_supply/BAT0}
|
||||
AC=${AC:-/sys/class/power_supply/AC}
|
||||
|
||||
# How often to sample.
|
||||
POLL_INTERVAL=${POLL_INTERVAL:-20}
|
||||
# How often to re-raise the alert while the fault persists.
|
||||
REPEAT_INTERVAL=${REPEAT_INTERVAL:-60}
|
||||
# Drop below the running peak, in 1/1000ths of full charge, before we
|
||||
# call it a fault. 15 = 1.5% of full charge, comfortably above gauge jitter.
|
||||
DROP_PERMILLE=${DROP_PERMILLE:-15}
|
||||
# Emergency hibernate floor, in percent. 0 disables it (default: notify only).
|
||||
HIBERNATE_AT=${HIBERNATE_AT:-0}
|
||||
|
||||
# Statuses that mean "UPower will not treat this as discharging", and so
|
||||
# will not act on PercentageAction no matter how low the battery gets.
|
||||
is_lying_status() {
|
||||
case "$1" in
|
||||
Charging | Full | "Not charging" | Unknown) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# sysfs reads can block for minutes when the UCSI/PD controller wedges,
|
||||
# which is the very condition we are watching for. Never read unguarded.
|
||||
read_attr() {
|
||||
local v
|
||||
v=$(timeout 5 cat "$1" 2>/dev/null) || return 1
|
||||
[[ -n $v ]] || return 1
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
read_int() {
|
||||
local v
|
||||
v=$(read_attr "$1") || return 1
|
||||
[[ $v =~ ^-?[0-9]+$ ]] || return 1
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
log() { printf '%s %s\n' "$(date '+%F %T')" "$*"; }
|
||||
|
||||
notify_id=0
|
||||
notify() {
|
||||
local urgency=$1 timeout_ms=$2 title=$3 body=$4 fresh=${5:-0}
|
||||
local args=(-a "Battery Watchdog" -u "$urgency" -t "$timeout_ms"
|
||||
-h string:category:device -i battery-caution)
|
||||
# Reuse one notification so the alert updates in place instead of
|
||||
# stacking, except when we deliberately want a new one to pop.
|
||||
if [[ $fresh -eq 0 && $notify_id -gt 0 ]]; then
|
||||
notify-send "${args[@]}" -r "$notify_id" "$title" "$body" >/dev/null 2>&1
|
||||
else
|
||||
local id
|
||||
id=$(notify-send "${args[@]}" -p "$title" "$body" 2>/dev/null) && notify_id=$id
|
||||
fi
|
||||
}
|
||||
|
||||
fmt_duration() {
|
||||
local s=$1
|
||||
((s < 0)) && s=0
|
||||
if ((s < 3600)); then
|
||||
printf '%dm' $(((s + 30) / 60))
|
||||
else
|
||||
printf '%dh%02dm' $((s / 3600)) $(((s % 3600) / 60))
|
||||
fi
|
||||
}
|
||||
|
||||
charge_full=$(read_int "$BAT/charge_full") || {
|
||||
log "FATAL: cannot read $BAT/charge_full"
|
||||
exit 1
|
||||
}
|
||||
((charge_full > 0)) || {
|
||||
log "FATAL: charge_full is $charge_full"
|
||||
exit 1
|
||||
}
|
||||
drop_threshold=$((charge_full * DROP_PERMILLE / 1000))
|
||||
# Clearing the alert needs less movement than raising it: real charging
|
||||
# adds roughly 1%/min here, so this confirms recovery within a minute.
|
||||
recover_threshold=$((drop_threshold / 3))
|
||||
((recover_threshold > 0)) || recover_threshold=1
|
||||
|
||||
log "started: charge_full=${charge_full}uAh threshold=${drop_threshold}uAh" \
|
||||
"poll=${POLL_INTERVAL}s repeat=${REPEAT_INTERVAL}s hibernate_at=${HIBERNATE_AT}%"
|
||||
|
||||
peak=0 # highest charge_now seen in the current non-discharging run
|
||||
trough=0 # low-water mark within the current fault, for recovery
|
||||
alerting=0 # are we currently in the fault state
|
||||
alert_start_t=0 # when the fault was first detected
|
||||
alert_start_c=0 # charge_now at that moment
|
||||
last_notify=0
|
||||
last_escalation=100
|
||||
|
||||
while :; do
|
||||
now=$(date +%s)
|
||||
status=$(read_attr "$BAT/status") || status="Unreadable"
|
||||
charge=$(read_int "$BAT/charge_now") || charge=""
|
||||
capacity=$(read_int "$BAT/capacity") || capacity=""
|
||||
ac=$(read_int "$AC/online") || ac="?"
|
||||
|
||||
if [[ -z $charge ]]; then
|
||||
# A hung or unreadable gauge is itself a symptom worth surfacing.
|
||||
log "WARN: charge_now unreadable (status=$status)"
|
||||
sleep "$POLL_INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
if is_lying_status "$status"; then
|
||||
if ((alerting)); then
|
||||
# Recovery is measured against the low-water mark of this fault,
|
||||
# not the pre-fault peak: after draining 90%->50%, real charging
|
||||
# must clear the alert promptly, not on the way back up to 90%.
|
||||
((charge < trough)) && trough=$charge
|
||||
if ((charge - trough >= recover_threshold)); then
|
||||
log "RESOLVED: charging resumed (status=$status capacity=${capacity}%)"
|
||||
notify normal 10000 "Battery charging restored" \
|
||||
"Charge is rising again at ${capacity}%. Status: ${status}." 1
|
||||
alerting=0
|
||||
peak=$charge
|
||||
last_escalation=100
|
||||
fi
|
||||
else
|
||||
((charge > peak)) && peak=$charge
|
||||
deficit=$((peak - charge))
|
||||
if ((deficit >= drop_threshold)); then
|
||||
alerting=1
|
||||
trough=$charge
|
||||
alert_start_t=$now
|
||||
alert_start_c=$charge
|
||||
last_notify=0
|
||||
log "FAULT: status=$status but charge fell ${deficit}uAh from peak" \
|
||||
"(capacity=${capacity}% ac_online=${ac})"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((alerting)); then
|
||||
deficit=$((peak - charge))
|
||||
if ((now - last_notify >= REPEAT_INTERVAL)); then
|
||||
elapsed=$((now - alert_start_t))
|
||||
drained=$((alert_start_c - charge))
|
||||
eta=""
|
||||
# Need a wide enough window for the rate to mean anything;
|
||||
# over a few seconds the gauge quantisation dominates.
|
||||
if ((elapsed >= 60 && drained > 0)); then
|
||||
# seconds until charge_now hits zero at the observed rate
|
||||
eta=$(fmt_duration $((charge * elapsed / drained)))
|
||||
eta=" Est. ${eta} to empty."
|
||||
fi
|
||||
|
||||
# Pop a brand-new notification when crossing a danger step,
|
||||
# so it is not silently folded into the existing one.
|
||||
fresh=0
|
||||
for step in 20 10 5; do
|
||||
if [[ -n $capacity ]] && ((capacity <= step && last_escalation > step)); then
|
||||
last_escalation=$step
|
||||
fresh=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
notify critical 0 "⚠ Battery draining while reporting \"${status}\"" \
|
||||
"Charge controller fault: ${capacity}% and falling, but status reads \"${status}\" and AC online=${ac}.${eta}
|
||||
UPower will NOT hibernate in this state. Reseat the USB-C/dock cable or save your work now." \
|
||||
"$fresh"
|
||||
|
||||
log "ALERT: capacity=${capacity}% status=$status deficit=${deficit}uAh${eta}"
|
||||
last_notify=$now
|
||||
fi
|
||||
|
||||
if ((HIBERNATE_AT > 0)) && [[ -n $capacity ]] && ((capacity <= HIBERNATE_AT)); then
|
||||
log "EMERGENCY: capacity=${capacity}% <= ${HIBERNATE_AT}%, hibernating"
|
||||
notify critical 0 "Hibernating now" \
|
||||
"Battery at ${capacity}% with a charge-controller fault." 1
|
||||
systemctl hibernate
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Discharging or similar: honest reporting, UPower handles it.
|
||||
if ((alerting)); then
|
||||
log "RESOLVED: status turned honest ($status) - UPower safety net active again"
|
||||
notify normal 10000 "Battery status corrected" \
|
||||
"Now reporting \"${status}\" at ${capacity}%. Normal low-battery handling applies." 1
|
||||
alerting=0
|
||||
last_escalation=100
|
||||
fi
|
||||
peak=0
|
||||
fi
|
||||
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=Battery charge-controller fault watchdog
|
||||
Documentation=man:notify-send(1)
|
||||
# Needs the session bus for notifications.
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
# Makes the unit inert on machines without a battery, so it can be
|
||||
# enabled unconditionally on the desktop as well as the laptop.
|
||||
ConditionPathExists=/sys/class/power_supply/BAT0/charge_now
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.config/scripts/battery-charge-watchdog.sh
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Set HIBERNATE_AT=5 to add an emergency hibernate floor (0 = notify only).
|
||||
Environment=POLL_INTERVAL=20
|
||||
Environment=REPEAT_INTERVAL=60
|
||||
Environment=DROP_PERMILLE=15
|
||||
Environment=HIBERNATE_AT=0
|
||||
|
||||
# Read-only monitoring; no need for any write access to the system.
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=yes
|
||||
NoNewPrivileges=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryMax=64M
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
Reference in New Issue
Block a user