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