battery-saver.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. #!/bin/bash
  2. #
  3. # Apple Battery Saver
  4. #
  5. # Schaltet den Mac kontrolliert ab, bevor der Akku in die Tiefentladung
  6. # gerät. Löst auf Ladestand ODER minimaler Zellspannung aus - je nachdem,
  7. # was zuerst eintritt.
  8. #
  9. # Die Zellspannung ist der eigentliche Schutz: Der Ladestand in Prozent ist
  10. # nur eine Schätzung aus dem Kapazitätsmodell des Akkus. Driftet dessen
  11. # Kalibrierung, wird die Prozentanzeige unbrauchbar - genau das zerstört
  12. # Akkus. Die Zellspannung ist dagegen ein direkter Messwert.
  13. #
  14. # Verwendung:
  15. # battery-saver.sh Normaler Lauf (durch launchd)
  16. # battery-saver.sh --status Aktuelle Messwerte + Bewertung anzeigen
  17. # battery-saver.sh --test Volle Logik simulieren, ohne abzuschalten
  18. #
  19. # https://git.smallmountains.de/Marius/Apple-Battery-Saver
  20. set -uo pipefail
  21. VERSION="1.0.0"
  22. # ---------------------------------------------------------------- Defaults
  23. # Werden von der Konfigurationsdatei überschrieben, falls vorhanden.
  24. WARN_PCT=20 # Ladestand in %, ab dem gewarnt wird
  25. HALT_PCT=10 # Ladestand in %, ab dem abgeschaltet wird
  26. WARN_MV=3550 # Minimale Zellspannung in mV, ab der gewarnt wird
  27. HALT_MV=3400 # Minimale Zellspannung in mV, ab der abgeschaltet wird
  28. CONSECUTIVE=3 # So viele Messungen in Folge müssen auslösen
  29. WARN_COOLDOWN=600 # Mindestabstand zwischen zwei Warnungen (Sekunden)
  30. DRY_RUN=0 # 1 = niemals wirklich abschalten
  31. LOG=/var/log/battery-saver.log
  32. STATE=/var/run/battery-saver.state
  33. CONFIG="${BATTERY_SAVER_CONFIG:-/usr/local/etc/battery-saver.conf}"
  34. [ -r "$CONFIG" ] && . "$CONFIG"
  35. # --------------------------------------------------- Konfiguration prüfen
  36. # Ein Dienst, der als root den Rechner abschalten darf, muss sich gegen
  37. # Tippfehler in der Konfigurationsdatei wehren. Ein versehentliches
  38. # HALT_MV=44000 würde sonst im Akkubetrieb sofort auslösen.
  39. config_error=""
  40. check_range() {
  41. local name="$1" val="$2" min="$3" max="$4"
  42. if ! [[ "$val" =~ ^[0-9]+$ ]] || [ "$val" -lt "$min" ] || [ "$val" -gt "$max" ]; then
  43. config_error="${config_error} ${name}='${val}' ist unzulässig (erlaubt: ${min}-${max})\n"
  44. fi
  45. }
  46. check_range WARN_PCT "$WARN_PCT" 0 100
  47. check_range HALT_PCT "$HALT_PCT" 0 100
  48. check_range WARN_MV "$WARN_MV" 0 4400
  49. check_range HALT_MV "$HALT_MV" 0 4400
  50. check_range CONSECUTIVE "$CONSECUTIVE" 1 100
  51. check_range WARN_COOLDOWN "$WARN_COOLDOWN" 0 86400
  52. if [ "$HALT_PCT" -gt "$WARN_PCT" ] 2>/dev/null; then
  53. config_error="${config_error} HALT_PCT (${HALT_PCT}) darf nicht über WARN_PCT (${WARN_PCT}) liegen\n"
  54. fi
  55. if [ "$HALT_MV" -gt "$WARN_MV" ] 2>/dev/null; then
  56. config_error="${config_error} HALT_MV (${HALT_MV}) darf nicht über WARN_MV (${WARN_MV}) liegen\n"
  57. fi
  58. MODE="daemon"
  59. case "${1:-}" in
  60. --status) MODE="status" ;;
  61. --test) MODE="test"; DRY_RUN=1 ;;
  62. --version) echo "Apple Battery Saver $VERSION"; exit 0 ;;
  63. "") ;;
  64. *) echo "Unbekannte Option: $1" >&2; exit 2 ;;
  65. esac
  66. # ---------------------------------------------------------------- Helpers
  67. log() {
  68. local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
  69. if [ "$MODE" = "daemon" ]; then
  70. echo "$msg" >> "$LOG" 2>/dev/null
  71. else
  72. echo "$msg"
  73. fi
  74. }
  75. # Ermittelt den aktuell angemeldeten Benutzer, um aus dem root-Kontext
  76. # des LaunchDaemons heraus in dessen GUI-Session hineinzureichen.
  77. console_user() { stat -f "%Su" /dev/console 2>/dev/null; }
  78. notify() {
  79. local title="$1" message="$2"
  80. local user uid
  81. user="$(console_user)"
  82. if [ -z "$user" ] || [ "$user" = "root" ]; then
  83. return 0
  84. fi
  85. uid="$(id -u "$user" 2>/dev/null)" || return 0
  86. launchctl asuser "$uid" sudo -u "$user" \
  87. osascript -e "display notification \"${message}\" with title \"${title}\" sound name \"Submarine\"" \
  88. >/dev/null 2>&1
  89. }
  90. # Sauberes Herunterfahren: erst über System Events, damit Programme ihre
  91. # Dokumente sichern können; nur falls keine GUI-Session existiert, hart.
  92. do_shutdown() {
  93. local user uid
  94. user="$(console_user)"
  95. if [ -n "$user" ] && [ "$user" != "root" ]; then
  96. uid="$(id -u "$user" 2>/dev/null)"
  97. if [ -n "$uid" ]; then
  98. log "Fahre herunter (grafisch, über System Events)"
  99. launchctl asuser "$uid" sudo -u "$user" \
  100. osascript -e 'tell application "System Events" to shut down' \
  101. >/dev/null 2>&1 && return 0
  102. fi
  103. fi
  104. log "Fahre herunter (shutdown -h now)"
  105. /sbin/shutdown -h now >/dev/null 2>&1
  106. }
  107. # State-Datei: key=value, überlebt zwischen den launchd-Läufen
  108. state_get() {
  109. local key="$1"
  110. [ -r "$STATE" ] || { echo 0; return; }
  111. local v
  112. v="$(grep -m1 "^${key}=" "$STATE" 2>/dev/null | cut -d= -f2)"
  113. [[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0
  114. }
  115. state_set() {
  116. local key="$1" val="$2" tmp
  117. tmp="$(mktemp "${STATE}.XXXXXX" 2>/dev/null)" || return 0
  118. grep -v "^${key}=" "$STATE" 2>/dev/null > "$tmp"
  119. echo "${key}=${val}" >> "$tmp"
  120. mv -f "$tmp" "$STATE" 2>/dev/null
  121. }
  122. # Fehlerhafte Konfiguration: warnen und nichts tun. Niemals abschalten.
  123. if [ -n "$config_error" ]; then
  124. log "FEHLER: Konfiguration ungültig (${CONFIG}) - Dienst greift nicht ein"
  125. printf "%b" "$config_error" | while IFS= read -r l; do [ -n "$l" ] && log "$l"; done
  126. if [ "$MODE" != "daemon" ]; then
  127. echo "FEHLER: Konfiguration ungültig (${CONFIG}):" >&2
  128. printf "%b" "$config_error" >&2
  129. echo "Es wird nicht abgeschaltet, solange dies nicht behoben ist." >&2
  130. fi
  131. exit 0
  132. fi
  133. # ---------------------------------------------------------------- Messung
  134. IOREG="$(ioreg -rn AppleSmartBattery 2>/dev/null | sed 's/^ *//')"
  135. if [ -z "$IOREG" ]; then
  136. log "FEHLER: Akku nicht auslesbar (ioreg leer) - keine Aktion"
  137. exit 0
  138. fi
  139. INSTALLED="$(echo "$IOREG" | grep -m1 '"BatteryInstalled"' | awk -F'= ' '{print $2}')"
  140. EXTERNAL="$(echo "$IOREG" | grep -m1 '"ExternalConnected"' | awk -F'= ' '{print $2}')"
  141. PCT="$(echo "$IOREG" | grep -m1 '"CurrentCapacity"' | awk -F'= ' '{print $2}')"
  142. PACK_MV="$(echo "$IOREG" | grep -m1 '^"Voltage"' | awk -F'= ' '{print $2}')"
  143. CELLS="$(echo "$IOREG" | grep -o '"CellVoltage"=([^)]*)' | head -1 | sed 's/.*=(//;s/)//')"
  144. MIN_MV=""
  145. if [ -n "$CELLS" ]; then
  146. MIN_MV="$(echo "$CELLS" | tr ',' '\n' | grep -E '^[0-9]+$' | sort -n | head -1)"
  147. fi
  148. # Jede Unsicherheit führt zu "nichts tun". Ein Programm, das den Rechner
  149. # abschalten darf, muss bei unklarer Datenlage stillhalten.
  150. if [ "$INSTALLED" != "Yes" ]; then
  151. log "Kein Akku installiert - keine Aktion"
  152. exit 0
  153. fi
  154. if ! [[ "$PCT" =~ ^[0-9]+$ ]] || [ "$PCT" -gt 100 ]; then
  155. log "FEHLER: Ladestand unplausibel ('${PCT}') - keine Aktion"
  156. exit 0
  157. fi
  158. if [ -n "$MIN_MV" ] && { ! [[ "$MIN_MV" =~ ^[0-9]+$ ]] || [ "$MIN_MV" -lt 1000 ] || [ "$MIN_MV" -gt 5000 ]; }; then
  159. log "WARNUNG: Zellspannung unplausibel ('${MIN_MV}') - nur Prozentgrenze aktiv"
  160. MIN_MV=""
  161. fi
  162. # ---------------------------------------------------------------- Bewertung
  163. HALT_REASON=""
  164. WARN_REASON=""
  165. if [ "$PCT" -le "$HALT_PCT" ]; then
  166. HALT_REASON="Ladestand ${PCT} % <= ${HALT_PCT} %"
  167. elif [ -n "$MIN_MV" ] && [ "$MIN_MV" -le "$HALT_MV" ]; then
  168. HALT_REASON="Zellspannung ${MIN_MV} mV <= ${HALT_MV} mV"
  169. elif [ "$PCT" -le "$WARN_PCT" ]; then
  170. WARN_REASON="Ladestand ${PCT} % <= ${WARN_PCT} %"
  171. elif [ -n "$MIN_MV" ] && [ "$MIN_MV" -le "$WARN_MV" ]; then
  172. WARN_REASON="Zellspannung ${MIN_MV} mV <= ${WARN_MV} mV"
  173. fi
  174. # ---------------------------------------------------------------- Status
  175. if [ "$MODE" = "status" ]; then
  176. echo "Apple Battery Saver $VERSION - Status"
  177. echo "----------------------------------------"
  178. echo "Netzteil angeschlossen : ${EXTERNAL}"
  179. echo "Ladestand : ${PCT} %"
  180. echo "Packspannung : ${PACK_MV} mV"
  181. echo "Zellspannungen : ${CELLS:-nicht verfügbar}"
  182. echo "Niedrigste Zelle : ${MIN_MV:-nicht verfügbar} mV"
  183. echo
  184. echo "Schwellwerte"
  185. echo " Warnung : <= ${WARN_PCT} % oder <= ${WARN_MV} mV"
  186. echo " Abschaltung: <= ${HALT_PCT} % oder <= ${HALT_MV} mV"
  187. echo " Auslösung nach ${CONSECUTIVE} Messungen in Folge"
  188. echo
  189. if [ -n "$HALT_REASON" ]; then
  190. echo "Bewertung: ABSCHALTGRENZE erreicht (${HALT_REASON})"
  191. elif [ -n "$WARN_REASON" ]; then
  192. echo "Bewertung: Warngrenze erreicht (${WARN_REASON})"
  193. else
  194. echo "Bewertung: unkritisch"
  195. fi
  196. if [ "$EXTERNAL" = "Yes" ]; then
  197. echo "Zustand : am Netz - Überwachung pausiert, es wird nicht abgeschaltet"
  198. else
  199. echo "Zustand : Akkubetrieb - Überwachung aktiv"
  200. fi
  201. echo
  202. echo "Zähler (Messungen in Folge): $(state_get halt_count)"
  203. echo "Logdatei: ${LOG}"
  204. exit 0
  205. fi
  206. # ---------------------------------------------------------------- Logik
  207. # Am Netz: nichts tun, Zähler zurücksetzen.
  208. if [ "$EXTERNAL" = "Yes" ]; then
  209. if [ "$(state_get halt_count)" -ne 0 ]; then
  210. log "Netzteil angeschlossen - Zähler zurückgesetzt"
  211. state_set halt_count 0
  212. fi
  213. [ "$MODE" = "test" ] && echo "Am Netz - keine Aktion."
  214. exit 0
  215. fi
  216. if [ -n "$HALT_REASON" ]; then
  217. COUNT="$(state_get halt_count)"
  218. COUNT=$((COUNT + 1))
  219. state_set halt_count "$COUNT"
  220. log "Abschaltgrenze erreicht (${HALT_REASON}) - Messung ${COUNT}/${CONSECUTIVE}"
  221. if [ "$COUNT" -ge "$CONSECUTIVE" ]; then
  222. if [ "$DRY_RUN" = "1" ]; then
  223. log "TESTMODUS: würde jetzt herunterfahren (${HALT_REASON})"
  224. echo "TESTMODUS: würde jetzt herunterfahren - ${HALT_REASON}"
  225. else
  226. log "Schalte ab: ${HALT_REASON}"
  227. notify "Akku kritisch" "Der Mac wird jetzt heruntergefahren (${HALT_REASON})."
  228. sleep 5
  229. state_set halt_count 0
  230. do_shutdown
  231. fi
  232. else
  233. notify "Akku kritisch" "Abschaltung in Kürze - bitte Arbeit sichern (${PCT} %)."
  234. fi
  235. exit 0
  236. fi
  237. # Unter Warngrenze, aber über Abschaltgrenze
  238. if [ -n "$WARN_REASON" ]; then
  239. state_set halt_count 0
  240. NOW="$(date +%s)"
  241. LAST="$(state_get last_warn)"
  242. if [ $((NOW - LAST)) -ge "$WARN_COOLDOWN" ]; then
  243. state_set last_warn "$NOW"
  244. log "Warnung: ${WARN_REASON}"
  245. notify "Akku wird knapp" "Noch ${PCT} % - bitte anschließen. Abschaltung bei ${HALT_PCT} %."
  246. fi
  247. [ "$MODE" = "test" ] && echo "Warngrenze erreicht - ${WARN_REASON}"
  248. exit 0
  249. fi
  250. # Alles unkritisch
  251. if [ "$(state_get halt_count)" -ne 0 ]; then
  252. state_set halt_count 0
  253. fi
  254. [ "$MODE" = "test" ] && echo "Unkritisch: ${PCT} %, niedrigste Zelle ${MIN_MV:-n/a} mV"
  255. exit 0