From aed8cf9dd3e741f0bd39e95011e471b2e473e5f0 Mon Sep 17 00:00:00 2001 From: Frank Zechert Date: Tue, 18 Aug 2026 12:25:15 +0200 Subject: [PATCH] add charging watchdog --- user/.chezmoiignore | 4 +- ...ange_after_battery-charge-watchdog.sh.tmpl | 33 +++ .../DankMaterialShell/settings.json | 12 +- .../executable_battery-charge-watchdog.sh | 206 ++++++++++++++++++ .../user/battery-charge-watchdog.service | 32 +++ 5 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 user/.chezmoiscripts/run_onchange_after_battery-charge-watchdog.sh.tmpl create mode 100644 user/dot_config/scripts/executable_battery-charge-watchdog.sh create mode 100644 user/dot_config/systemd/user/battery-charge-watchdog.service diff --git a/user/.chezmoiignore b/user/.chezmoiignore index 7c6621d..a8ed0e0 100644 --- a/user/.chezmoiignore +++ b/user/.chezmoiignore @@ -76,7 +76,9 @@ .config/spicetify .config/spotify .config/spotify-launcher.conf -.config/systemd +# Ignore only the symlink farms generated by `systemctl --user enable`, +# so that real unit files can still be managed. +.config/systemd/user/*.wants .config/teams-for-linux .config/yay diff --git a/user/.chezmoiscripts/run_onchange_after_battery-charge-watchdog.sh.tmpl b/user/.chezmoiscripts/run_onchange_after_battery-charge-watchdog.sh.tmpl new file mode 100644 index 0000000..5da44f0 --- /dev/null +++ b/user/.chezmoiscripts/run_onchange_after_battery-charge-watchdog.sh.tmpl @@ -0,0 +1,33 @@ +{{- if eq .chezmoi.os "linux" -}} +#!/usr/bin/env bash +# +# Enable the battery charge-controller watchdog. +# +# Re-runs whenever the unit or the watchdog script changes, so that an +# already-running instance picks up the new version. + +set -e +[ "${CHEZMOI}" -eq 1 ] || exit 1 + +# unit hash: {{ include (joinPath .chezmoi.sourceDir "dot_config/systemd/user/battery-charge-watchdog.service") | sha256sum }} +# script hash: {{ include (joinPath .chezmoi.sourceDir "dot_config/scripts/executable_battery-charge-watchdog.sh") | sha256sum }} + +# Applying from a TTY or a container has no user bus; skip rather than +# aborting the whole apply. +if ! systemctl --user show-environment > /dev/null 2>&1; then + echo "No systemd user session, skipping battery-charge-watchdog activation" + exit 0 +fi + +systemctl --user daemon-reload + +# The unit carries ConditionPathExists on the battery, so enabling it on a +# machine without one is harmless: it simply never starts. +systemctl --user enable --now battery-charge-watchdog.service + +# enable --now will not restart an instance that is already running, so an +# updated script or unit would otherwise not take effect until next login. +systemctl --user try-restart battery-charge-watchdog.service + +# vim: syntax=bash ft=template +{{- end -}} diff --git a/user/dot_config/DankMaterialShell/settings.json b/user/dot_config/DankMaterialShell/settings.json index bb7f2f6..07f2be5 100644 --- a/user/dot_config/DankMaterialShell/settings.json +++ b/user/dot_config/DankMaterialShell/settings.json @@ -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, diff --git a/user/dot_config/scripts/executable_battery-charge-watchdog.sh b/user/dot_config/scripts/executable_battery-charge-watchdog.sh new file mode 100644 index 0000000..87514e9 --- /dev/null +++ b/user/dot_config/scripts/executable_battery-charge-watchdog.sh @@ -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 diff --git a/user/dot_config/systemd/user/battery-charge-watchdog.service b/user/dot_config/systemd/user/battery-charge-watchdog.service new file mode 100644 index 0000000..845f02a --- /dev/null +++ b/user/dot_config/systemd/user/battery-charge-watchdog.service @@ -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