| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- #!/bin/bash
- #
- # Apple Battery Saver
- #
- # Schaltet den Mac kontrolliert ab, bevor der Akku in die Tiefentladung
- # gerät. Löst auf Ladestand ODER minimaler Zellspannung aus - je nachdem,
- # was zuerst eintritt.
- #
- # Die Zellspannung ist der eigentliche Schutz: Der Ladestand in Prozent ist
- # nur eine Schätzung aus dem Kapazitätsmodell des Akkus. Driftet dessen
- # Kalibrierung, wird die Prozentanzeige unbrauchbar - genau das zerstört
- # Akkus. Die Zellspannung ist dagegen ein direkter Messwert.
- #
- # Verwendung:
- # battery-saver.sh Normaler Lauf (durch launchd)
- # battery-saver.sh --status Aktuelle Messwerte + Bewertung anzeigen
- # battery-saver.sh --test Volle Logik simulieren, ohne abzuschalten
- #
- # https://git.smallmountains.de/Marius/Apple-Battery-Saver
- set -uo pipefail
- VERSION="1.0.0"
- # ---------------------------------------------------------------- Defaults
- # Werden von der Konfigurationsdatei überschrieben, falls vorhanden.
- WARN_PCT=20 # Ladestand in %, ab dem gewarnt wird
- HALT_PCT=10 # Ladestand in %, ab dem abgeschaltet wird
- WARN_MV=3550 # Minimale Zellspannung in mV, ab der gewarnt wird
- HALT_MV=3400 # Minimale Zellspannung in mV, ab der abgeschaltet wird
- CONSECUTIVE=3 # So viele Messungen in Folge müssen auslösen
- WARN_COOLDOWN=600 # Mindestabstand zwischen zwei Warnungen (Sekunden)
- DRY_RUN=0 # 1 = niemals wirklich abschalten
- LOG=/var/log/battery-saver.log
- STATE=/var/run/battery-saver.state
- CONFIG="${BATTERY_SAVER_CONFIG:-/usr/local/etc/battery-saver.conf}"
- [ -r "$CONFIG" ] && . "$CONFIG"
- # --------------------------------------------------- Konfiguration prüfen
- # Ein Dienst, der als root den Rechner abschalten darf, muss sich gegen
- # Tippfehler in der Konfigurationsdatei wehren. Ein versehentliches
- # HALT_MV=44000 würde sonst im Akkubetrieb sofort auslösen.
- config_error=""
- check_range() {
- local name="$1" val="$2" min="$3" max="$4"
- if ! [[ "$val" =~ ^[0-9]+$ ]] || [ "$val" -lt "$min" ] || [ "$val" -gt "$max" ]; then
- config_error="${config_error} ${name}='${val}' ist unzulässig (erlaubt: ${min}-${max})\n"
- fi
- }
- check_range WARN_PCT "$WARN_PCT" 0 100
- check_range HALT_PCT "$HALT_PCT" 0 100
- check_range WARN_MV "$WARN_MV" 0 4400
- check_range HALT_MV "$HALT_MV" 0 4400
- check_range CONSECUTIVE "$CONSECUTIVE" 1 100
- check_range WARN_COOLDOWN "$WARN_COOLDOWN" 0 86400
- if [ "$HALT_PCT" -gt "$WARN_PCT" ] 2>/dev/null; then
- config_error="${config_error} HALT_PCT (${HALT_PCT}) darf nicht über WARN_PCT (${WARN_PCT}) liegen\n"
- fi
- if [ "$HALT_MV" -gt "$WARN_MV" ] 2>/dev/null; then
- config_error="${config_error} HALT_MV (${HALT_MV}) darf nicht über WARN_MV (${WARN_MV}) liegen\n"
- fi
- MODE="daemon"
- case "${1:-}" in
- --status) MODE="status" ;;
- --test) MODE="test"; DRY_RUN=1 ;;
- --version) echo "Apple Battery Saver $VERSION"; exit 0 ;;
- "") ;;
- *) echo "Unbekannte Option: $1" >&2; exit 2 ;;
- esac
- # ---------------------------------------------------------------- Helpers
- log() {
- local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
- if [ "$MODE" = "daemon" ]; then
- echo "$msg" >> "$LOG" 2>/dev/null
- else
- echo "$msg"
- fi
- }
- # Ermittelt den aktuell angemeldeten Benutzer, um aus dem root-Kontext
- # des LaunchDaemons heraus in dessen GUI-Session hineinzureichen.
- console_user() { stat -f "%Su" /dev/console 2>/dev/null; }
- notify() {
- local title="$1" message="$2"
- local user uid
- user="$(console_user)"
- if [ -z "$user" ] || [ "$user" = "root" ]; then
- return 0
- fi
- uid="$(id -u "$user" 2>/dev/null)" || return 0
- launchctl asuser "$uid" sudo -u "$user" \
- osascript -e "display notification \"${message}\" with title \"${title}\" sound name \"Submarine\"" \
- >/dev/null 2>&1
- }
- # Sauberes Herunterfahren: erst über System Events, damit Programme ihre
- # Dokumente sichern können; nur falls keine GUI-Session existiert, hart.
- do_shutdown() {
- local user uid
- user="$(console_user)"
- if [ -n "$user" ] && [ "$user" != "root" ]; then
- uid="$(id -u "$user" 2>/dev/null)"
- if [ -n "$uid" ]; then
- log "Fahre herunter (grafisch, über System Events)"
- launchctl asuser "$uid" sudo -u "$user" \
- osascript -e 'tell application "System Events" to shut down' \
- >/dev/null 2>&1 && return 0
- fi
- fi
- log "Fahre herunter (shutdown -h now)"
- /sbin/shutdown -h now >/dev/null 2>&1
- }
- # State-Datei: key=value, überlebt zwischen den launchd-Läufen
- state_get() {
- local key="$1"
- [ -r "$STATE" ] || { echo 0; return; }
- local v
- v="$(grep -m1 "^${key}=" "$STATE" 2>/dev/null | cut -d= -f2)"
- [[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0
- }
- state_set() {
- local key="$1" val="$2" tmp
- tmp="$(mktemp "${STATE}.XXXXXX" 2>/dev/null)" || return 0
- grep -v "^${key}=" "$STATE" 2>/dev/null > "$tmp"
- echo "${key}=${val}" >> "$tmp"
- mv -f "$tmp" "$STATE" 2>/dev/null
- }
- # Fehlerhafte Konfiguration: warnen und nichts tun. Niemals abschalten.
- if [ -n "$config_error" ]; then
- log "FEHLER: Konfiguration ungültig (${CONFIG}) - Dienst greift nicht ein"
- printf "%b" "$config_error" | while IFS= read -r l; do [ -n "$l" ] && log "$l"; done
- if [ "$MODE" != "daemon" ]; then
- echo "FEHLER: Konfiguration ungültig (${CONFIG}):" >&2
- printf "%b" "$config_error" >&2
- echo "Es wird nicht abgeschaltet, solange dies nicht behoben ist." >&2
- fi
- exit 0
- fi
- # ---------------------------------------------------------------- Messung
- IOREG="$(ioreg -rn AppleSmartBattery 2>/dev/null | sed 's/^ *//')"
- if [ -z "$IOREG" ]; then
- log "FEHLER: Akku nicht auslesbar (ioreg leer) - keine Aktion"
- exit 0
- fi
- INSTALLED="$(echo "$IOREG" | grep -m1 '"BatteryInstalled"' | awk -F'= ' '{print $2}')"
- EXTERNAL="$(echo "$IOREG" | grep -m1 '"ExternalConnected"' | awk -F'= ' '{print $2}')"
- PCT="$(echo "$IOREG" | grep -m1 '"CurrentCapacity"' | awk -F'= ' '{print $2}')"
- PACK_MV="$(echo "$IOREG" | grep -m1 '^"Voltage"' | awk -F'= ' '{print $2}')"
- CELLS="$(echo "$IOREG" | grep -o '"CellVoltage"=([^)]*)' | head -1 | sed 's/.*=(//;s/)//')"
- MIN_MV=""
- if [ -n "$CELLS" ]; then
- MIN_MV="$(echo "$CELLS" | tr ',' '\n' | grep -E '^[0-9]+$' | sort -n | head -1)"
- fi
- # Jede Unsicherheit führt zu "nichts tun". Ein Programm, das den Rechner
- # abschalten darf, muss bei unklarer Datenlage stillhalten.
- if [ "$INSTALLED" != "Yes" ]; then
- log "Kein Akku installiert - keine Aktion"
- exit 0
- fi
- if ! [[ "$PCT" =~ ^[0-9]+$ ]] || [ "$PCT" -gt 100 ]; then
- log "FEHLER: Ladestand unplausibel ('${PCT}') - keine Aktion"
- exit 0
- fi
- if [ -n "$MIN_MV" ] && { ! [[ "$MIN_MV" =~ ^[0-9]+$ ]] || [ "$MIN_MV" -lt 1000 ] || [ "$MIN_MV" -gt 5000 ]; }; then
- log "WARNUNG: Zellspannung unplausibel ('${MIN_MV}') - nur Prozentgrenze aktiv"
- MIN_MV=""
- fi
- # ---------------------------------------------------------------- Bewertung
- HALT_REASON=""
- WARN_REASON=""
- if [ "$PCT" -le "$HALT_PCT" ]; then
- HALT_REASON="Ladestand ${PCT} % <= ${HALT_PCT} %"
- elif [ -n "$MIN_MV" ] && [ "$MIN_MV" -le "$HALT_MV" ]; then
- HALT_REASON="Zellspannung ${MIN_MV} mV <= ${HALT_MV} mV"
- elif [ "$PCT" -le "$WARN_PCT" ]; then
- WARN_REASON="Ladestand ${PCT} % <= ${WARN_PCT} %"
- elif [ -n "$MIN_MV" ] && [ "$MIN_MV" -le "$WARN_MV" ]; then
- WARN_REASON="Zellspannung ${MIN_MV} mV <= ${WARN_MV} mV"
- fi
- # ---------------------------------------------------------------- Status
- if [ "$MODE" = "status" ]; then
- echo "Apple Battery Saver $VERSION - Status"
- echo "----------------------------------------"
- echo "Netzteil angeschlossen : ${EXTERNAL}"
- echo "Ladestand : ${PCT} %"
- echo "Packspannung : ${PACK_MV} mV"
- echo "Zellspannungen : ${CELLS:-nicht verfügbar}"
- echo "Niedrigste Zelle : ${MIN_MV:-nicht verfügbar} mV"
- echo
- echo "Schwellwerte"
- echo " Warnung : <= ${WARN_PCT} % oder <= ${WARN_MV} mV"
- echo " Abschaltung: <= ${HALT_PCT} % oder <= ${HALT_MV} mV"
- echo " Auslösung nach ${CONSECUTIVE} Messungen in Folge"
- echo
- if [ -n "$HALT_REASON" ]; then
- echo "Bewertung: ABSCHALTGRENZE erreicht (${HALT_REASON})"
- elif [ -n "$WARN_REASON" ]; then
- echo "Bewertung: Warngrenze erreicht (${WARN_REASON})"
- else
- echo "Bewertung: unkritisch"
- fi
- if [ "$EXTERNAL" = "Yes" ]; then
- echo "Zustand : am Netz - Überwachung pausiert, es wird nicht abgeschaltet"
- else
- echo "Zustand : Akkubetrieb - Überwachung aktiv"
- fi
- echo
- echo "Zähler (Messungen in Folge): $(state_get halt_count)"
- echo "Logdatei: ${LOG}"
- exit 0
- fi
- # ---------------------------------------------------------------- Logik
- # Am Netz: nichts tun, Zähler zurücksetzen.
- if [ "$EXTERNAL" = "Yes" ]; then
- if [ "$(state_get halt_count)" -ne 0 ]; then
- log "Netzteil angeschlossen - Zähler zurückgesetzt"
- state_set halt_count 0
- fi
- [ "$MODE" = "test" ] && echo "Am Netz - keine Aktion."
- exit 0
- fi
- if [ -n "$HALT_REASON" ]; then
- COUNT="$(state_get halt_count)"
- COUNT=$((COUNT + 1))
- state_set halt_count "$COUNT"
- log "Abschaltgrenze erreicht (${HALT_REASON}) - Messung ${COUNT}/${CONSECUTIVE}"
- if [ "$COUNT" -ge "$CONSECUTIVE" ]; then
- if [ "$DRY_RUN" = "1" ]; then
- log "TESTMODUS: würde jetzt herunterfahren (${HALT_REASON})"
- echo "TESTMODUS: würde jetzt herunterfahren - ${HALT_REASON}"
- else
- log "Schalte ab: ${HALT_REASON}"
- notify "Akku kritisch" "Der Mac wird jetzt heruntergefahren (${HALT_REASON})."
- sleep 5
- state_set halt_count 0
- do_shutdown
- fi
- else
- notify "Akku kritisch" "Abschaltung in Kürze - bitte Arbeit sichern (${PCT} %)."
- fi
- exit 0
- fi
- # Unter Warngrenze, aber über Abschaltgrenze
- if [ -n "$WARN_REASON" ]; then
- state_set halt_count 0
- NOW="$(date +%s)"
- LAST="$(state_get last_warn)"
- if [ $((NOW - LAST)) -ge "$WARN_COOLDOWN" ]; then
- state_set last_warn "$NOW"
- log "Warnung: ${WARN_REASON}"
- notify "Akku wird knapp" "Noch ${PCT} % - bitte anschließen. Abschaltung bei ${HALT_PCT} %."
- fi
- [ "$MODE" = "test" ] && echo "Warngrenze erreicht - ${WARN_REASON}"
- exit 0
- fi
- # Alles unkritisch
- if [ "$(state_get halt_count)" -ne 0 ]; then
- state_set halt_count 0
- fi
- [ "$MODE" = "test" ] && echo "Unkritisch: ${PCT} %, niedrigste Zelle ${MIN_MV:-n/a} mV"
- exit 0
|