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