|
@@ -0,0 +1,293 @@
|
|
|
|
|
+#!/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
|