Featured image of post power-state-target: Wie ich den Energiezustand meines Laptops in systemd modelliert habe

power-state-target: Wie ich den Energiezustand meines Laptops in systemd modelliert habe

Ein kleines Debian-Paket, das den aktuellen Energiezustand (Akku/Netz) als systemd-Targets modelliert und damit Dienste und Timer sauber vom Power-State abhängig macht.

Es gibt diese kleinen Probleme, die einen jahrelang unterschwellig nerven, bis man sie endlich einmal sauber löst. Genau in diese Kategorie fällt bei mir das Thema „Was macht das System, wenn ich auf Akku bin – und was darf es nur am Netz?“

Klar, es gibt Tools wie TLP, Powertop & Co., und viele Desktop-Umgebungen haben ihre eigenen Energiespar-Profile. Aber ich wollte etwas anderes:

Ich wollte den aktuellen Power-Zustand (Akku vs. Netz) als systemd-Target haben – sauber integriert, automatisierbar, paketiert und über meine eigene Debian-Repository-Infrastruktur ausrollbar.

Das Ergebnis ist dieses kleine, aber sehr praktische Projekt: power-state-target.


Problem: Der Energiezustand als „unsichtbare“ Information

UPower weiß, ob die Maschine auf Akku läuft. Der Desktop weiß es. Der Nutzer sieht irgendwo ein Batteriesymbol. Aber für viele meiner eigenen Dienste, Timer und Skripte ist diese Info nicht direkt greifbar.

Typische Fragen:

  • „Starte diesen Dienst nur, wenn das Gerät am Netz hängt.“
  • „Fahre diese IO-intensiven Jobs bitte nicht auf Akku.“
  • „Auf Akku darf etwas weiterlaufen, aber nur in einem Light-Mode.“

Man kann natürlich überall lokale Checks einbauen – upower, busctl, eigene Daemons, irgendwelche kleinen Wrapper. Aber das ist alles verteilt, unübersichtlich und schlecht testbar.

Systemd bietet für solche Zustände eigentlich eine perfekte Abstraktion: Targets.

Also war der Plan:

  • Ich möchte ein power-ac.target und ein power-battery.target haben.
  • Genau einer dieser Targets soll immer „aktiv“ sein – je nachdem, ob das System auf Akku oder am Netz hängt.
  • Dienste können sich dann einfach daran „dranhängen“, statt selbst Power-Logik zu implementieren.

Ziel: systemd als Single Source of Truth für den Power-State

Die Idee hinter power-state-target ist bewusst minimalistisch:

  • Keine eigene fette Daemon-Logik.
  • Nutzung von vorhandener Infrastruktur: UPower und systemd.
  • Ein kleiner Listener, der den Zustand aus UPower liest und passende systemd-Targets setzt.
  • Das Ganze als Debian-Paket, das sich in meine bestehende Build- und Release-Infrastruktur einfügt.

Im Kern besteht das Projekt aus:

  • zwei systemd-Targets: power-ac.target und power-battery.target
  • einem systemd-Service: power-state-listener.service
  • einem kleinen Shell-Skript: /usr/libexec/update-power-state
  • Debian-Packaging + automatisierter Build- und Release-Pipeline über Gitea

Die Targets: power-ac und power-battery

Die beiden Targets sind bewusst extrem simpel gehalten. Sie dienen als Zustandsmarkierungen, nicht als eigene Logik-Einheiten:

1
2
3
4
# /usr/lib/systemd/system/power-ac.target
[Unit]
Description=AC Power Mode
AllowIsolate=yes
1
2
3
4
# /usr/lib/systemd/system/power-battery.target
[Unit]
Description=Battery Power Mode
AllowIsolate=yes

Ein paar Gedanken dazu:

  • Sie haben keine eigenen [Install]-Sektionen – sie werden nicht direkt aktiviert, sondern vom Listener-Service gesetzt.
  • Über AllowIsolate=yes kann man sie theoretisch auch isolieren, falls man das einmal für Experimente nutzen möchte.
  • Für andere Units sind sie vor allem als Anker gedacht: Wants=power-ac.target, BindsTo=power-battery.target, PartOf= usw.

Die eigentliche Intelligenz steckt im Listener.


Der Listener: power-state-listener.service

Der Listener ist ein kleiner systemd-Service, der genau eine Aufgabe hat:

UPower beobachten und bei Änderungen der Energiequelle das passende Target aktivieren.

Die Unit dazu sieht so aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# /usr/lib/systemd/system/power-state-listener.service
[Unit]
Description=Wait for UPower power-state change and update targets
Wants=upower.service
After=upower.service

[Service]
Type=simple
ExecStartPre=/usr/libexec/update-power-state
ExecStart=/bin/sh -c \
  'upower --monitor-detail | head -n 1 > /dev/null; \
   /usr/libexec/update-power-state'
Restart=on-success
RestartSec=0

[Install]
WantedBy=multi-user.target

Die wichtigen Punkte:

  • ExecStartPre=/usr/libexec/update-power-state sorgt dafür, dass der aktuelle Zustand sofort beim Start des Dienstes gesetzt wird (z.B. nach einem Boot).
  • ExecStart=... upower --monitor-detail hängt sich an die UPower-Monitor-API und wartet auf ein Ereignis (zum Beispiel: Stromkabel gezogen oder eingesteckt).
  • Sobald ein Ereignis eintritt, läuft danach erneut update-power-state, dann beendet sich der Dienst erfolgreich.
  • Durch Restart=on-success wird der Service automatisch neu gestartet. Ergebnis: eine kleine Ereignis-Schleife, die immer reagiert, wenn sich der Power-State ändert, ohne selbst busy zu sein.

Das ist im Grunde ein reaktives Mini-Framework: systemd + UPower erledigen 95 % der Arbeit, der Rest ist eine dünne Klebeschicht.


Die eigentliche Logik: /usr/libexec/update-power-state

Das Herzstück ist ein Shell-Skript, das genau zwei Dinge tut:

  1. Den aktuellen UPower-Status („bin ich auf Akku?“) abfragen.
  2. Die passenden systemd-Targets setzen.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/sh

get_state() {
    busctl get-property org.freedesktop.UPower \
        /org/freedesktop/UPower \
        org.freedesktop.UPower \
        OnBattery \
    | awk '{print $2}'
}

apply_state() {
    state="$1"
    if [ "$state" = "true" ]; then
        systemctl start power-battery.target
        systemctl stop  power-ac.target
    else
        systemctl start power-ac.target
        systemctl stop  power-battery.target
    fi
}

apply_state "$(get_state)"

Ein paar Details dazu:

  • busctl get-property ... OnBattery holt sich direkt den OnBattery-Property von UPower über D-Bus.
  • UPower liefert hier ein b true oder b false – über awk '{print $2}' ziehe ich mir einfach den Wahrheitswert heraus.
  • Wenn true, wird power-battery.target gestartet und power-ac.target gestoppt.
  • Wenn false, genau umgekehrt.

Damit ist sichergestellt, dass nie beide Targets gleichzeitig aktiv sind, sondern immer genau einer der beiden Zustände gilt.


Integration in Debian: Paket power-state-target

Weil ich praktisch alles, was ich produktiv nutzen will, in .deb-Pakete verpacke, war von Anfang an klar: Das hier wird ein normales Debian-Paket mit sauberem debian/-Verzeichnis.

Die debian/control ist bewusst minimal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Source: power-state-target
Section: utils
Priority: optional
Maintainer: 0xMax42 <debian@0xMax42.io>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.7.0
Rules-Requires-Root: no

Package: power-state-target
Architecture: all
Depends: ${misc:Depends}, upower
Description: Power state target management
 Introduces a systemd target that reflects the current power state
 of the system (on battery, on AC power) and updates it
 dynamically as the power state changes.
  • Architektur ist all, weil es sich nur um Skripte und Unit-Files handelt.
  • Die einzige echte Laufzeit-Abhängigkeit ist upower.
  • Rules-Requires-Root: no – gebaut werden kann also auch ohne Root, sofern die Build-Dependencies vorhanden sind.

Die Installation der Dateien übernimmt debian/install:

1
2
3
4
usr/libexec/update-power-state usr/libexec/
usr/lib/systemd/system/power-state-listener.service usr/lib/systemd/system/
usr/lib/systemd/system/power-ac.target usr/lib/systemd/system/
usr/lib/systemd/system/power-battery.target usr/lib/systemd/system/

debian/rules ist dementsprechend trivial:

1
2
3
4
#!/usr/bin/make -f

%:
	dh $@

Das Ganze ist als native Paketquelle (debian/source/format = 3.0 (native)) angelegt, da Projekt und Paket faktisch identisch sind.


Automatisierter Debian-Build mit build.sh

Um das Paket nicht nur lokal, sondern auch automatisiert im CI bauen zu können, gibt es ein eigenes Build-Skript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#!/bin/bash
set -euo pipefail

REQUIRED_PKGS=(build-essential devscripts dkms debhelper dh-dkms)

missing=()
for pkg in "${REQUIRED_PKGS[@]}"; do
  dpkg -s "$pkg" &>/dev/null || missing+=("$pkg")
done

if (( ${#missing[@]} )); then
  echo "🔧 Installing missing build packages: ${missing[*]}"
  sudo apt-get update -qq
  sudo apt-get install -y --no-install-recommends "${missing[@]}"
fi

DIST_DIR="dist"

TAG="${TAG_NAME:-$(exit 1)}"
echo "🔖 Using tag: $TAG"

curl -s https://git.0xmax42.io/actions/deb-changelog-action/raw/branch/main/bootstrap.sh | bash -s -- \
  --version "v0" \
  --tag "$TAG" \
  --package_name "power-state-target" \
  --author_name "0xMax42" \
  --author_email "Mail@0xMax42.io"

PKG_NAME=$(dpkg-parsechangelog --show-field Source)
PKG_VERSION=$(dpkg-parsechangelog --show-field Version)

tar --exclude=debian -czf ../power-state-target_${PKG_VERSION}.orig.tar.gz .

echo "Building Debian package..."
dpkg-buildpackage -us -uc

mkdir -p "$DIST_DIR"
rm -rf "$DIST_DIR"/*

for file in ../${PKG_NAME}*_${PKG_VERSION}_*.deb \
            ../${PKG_NAME}*_${PKG_VERSION}_*.ddeb \
            ../${PKG_NAME}*_${PKG_VERSION}_*.buildinfo \
            ../${PKG_NAME}*_${PKG_VERSION}_*.changes \
            ../${PKG_NAME}*_${PKG_VERSION}.dsc \
            ../${PKG_NAME}*_${PKG_VERSION}.*.tar.* \
            ../${PKG_NAME}*_${PKG_VERSION}.tar.*; do
  [[ -f "$file" ]] && { mv "$file" "$DIST_DIR/"; echo "📦 Moved $(basename "$file")$DIST_DIR/"; }
done

"dpkg-buildpackage" -T clean

echo "Build complete. Output in $DIST_DIR/"

Wichtige Punkte:

  • Das Script kümmert sich darum, dass alle benötigten Build-Pakete installiert sind (typische build-essential/devscripts/debhelper-Kette).
  • Die Versionierung und der Changelog werden über ein externes Script aus meiner deb-changelog-action automatisiert.
  • Alle entstehenden Artefakte werden in ein zentrales dist/-Verzeichnis verschoben, sodass der CI-Job sie leicht weiterverarbeiten kann.

TAG_NAME kommt dabei aus dem CI-Kontext – das ist die Git-Tag-Version, die zum Release gehört.


CI/CD: Vom Commit zum Debian-Paket in der eigenen Repo

Da das Projekt auf meiner privaten Gitea-Instanz liegt, laufen die CI-Pipelines ebenfalls dort. Es gibt zwei wesentliche Workflows:

  1. release.yml – erstellt automatisch Releases und Changelogs.
  2. build.yml – baut aus einem Release ein Debian-Paket und schiebt es in meine debrepo.

Auto-Changelog & Release

Der Release-Workflow triggert bei Pushes auf main:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
on:
  push:
    branches:
      - main

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Release
        uses: https://git.0xmax42.io/actions/auto-changelog-release-action@v1
        with:
          token: ${{ secrets.RELEASE_PUBLISH_TOKEN }}

Die auto-changelog-release-action kümmert sich darum, anhand der Commits (per git-cliff-Konfiguration) einen Changelog zu erzeugen, eine neue Version zu bestimmen, den Tag zu setzen und das Release im Gitea-Repository zu veröffentlichen.

Debian-Build und Push in debrepo

Sobald ein Release veröffentlicht ist, greift der zweite Workflow:

1
2
3
on:
  release:
    types: [published]

Der Job:

  • checkt den Quellcode aus,
  • ruft build.sh mit dem Tag aus dem Release auf,
  • macht ein Sparse Checkout meiner debrepo (nur power-state-target/),
  • kopiert die .deb, .dsc und Tarballs in dieses Verzeichnis,
  • commitet neue Artefakte auf den packages-Branch.

Damit landet jede neue Version von power-state-target automatisch in meiner eigenen APT-Repository-Struktur – und kann dann wie gewohnt über apt installiert werden.


git-cliff: Konsistente Changelogs mit Emojis

Da ich inzwischen sehr viel Wert auf saubere, konsistente Changelogs lege, kommt auch hier wieder git-cliff zum Einsatz. Die cliff.toml ist an meinen üblichen Stil angepasst – inklusive Emoji-Gruppierung:

  • 🚀 Features
  • 🐛 Bug Fixes
  • ⚙️ Miscellaneous Tasks
  • usw.

Für power-state-target sieht der erste Release-Eintrag aktuell so aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
## [0.1.0] - 2025-12-02

### 🚀 Features

- Add systemd targets and service for dynamic power state switching - (...)

### 🐛 Bug Fixes

- *(config)* Correct systemd installation paths in debian install - (...)

### ⚙️ Miscellaneous Tasks

- *(ci)* Add build and release workflows for package automation - (...)
- *(build)* Add script for automated Debian package build - (...)
- Add git-cliff changelog configuration file - (...)
- *(debian)* Add packaging files for power-state-target - (...)

Nichts weltbewegendes, aber genau das ist der Punkt: saubere kleine Bausteine, die später zusammenspielen können.


Wie man power-state-target praktisch nutzt

Der eigentliche Mehrwert entsteht dadurch, dass andere Units auf die Targets reagieren können.

Ein paar typische Szenarien:

1. Dienste nur am Netz erlauben

Angenommen, du hast einen Dienst, der nur laufen soll, wenn das System am Netz hängt, z.B. ein Backup- oder Dedupe-Job.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[Unit]
Description=Heavy IO Job (AC only)
Wants=power-ac.target
After=power-ac.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/heavy-job.sh

[Install]
WantedBy=multi-user.target
WantedBy=power-ac.target

Hier kann man zusätzlich mit ConditionPathExists, Timer-Units, StartLimit* usw. arbeiten – aber der Kern ist: „nur sinnvoll, wenn AC da ist“.

2. Reduzierter Modus auf Akku

Statt Dienste komplett abzuschalten, kann man auch alternative Konfigurationen fahren, z.B. einen „Light-Modus“ auf Akku und „Full-Modus“ am Netz. Man modelliert dann zwei Units, die jeweils an ein anderes Target gekoppelt sind.

3. Timer, die auf den Power-State hören

Timer-Units können ebenfalls indirekt über die Targets gesteuert werden, etwa indem nur der eigentliche Service an das passende Target gebunden wird.

Das Schöne: Alle Entscheidungen laufen über systemd, nicht über wilde Shell-Ifs überall im System.


Ausblick: Was man darauf noch aufbauen kann

power-state-target ist bewusst klein gehalten. Aber genau dadurch lässt es sich gut als Baustein für komplexere Setups verwenden:

  • Dynamisches Umschalten des CPU-Governors über separate Dienste.
  • Anpassung von Backup-Strategien (z.B. nur Metadaten auf Akku, Voll-Backups am Netz).
  • Steuerung von Container- oder VM-Last abhängig vom Power-State.
  • Integration in bestehende Tuning-Tools, indem diese die Targets nur noch als Signalquelle verwenden.

Ich mag solche Projekte, weil sie zwei Welten verbinden:

  • Auf der einen Seite: klassische Desktop-/Laptop-Welt mit Akku, UPower, grafischen Energiemanagern.
  • Auf der anderen: systemd- und serverartige Steuerung, bei der Zustände explizit modelliert sind.

power-state-target macht den Energiezustand zu einem ersten Bürger in systemd – und damit für alles Weitere komponierbar.


Fazit

Für sich genommen ist power-state-target ein kleines Projekt:

  • ein Shell-Skript,
  • zwei Targets,
  • ein Service,
  • etwas Debian-Packaging und CI.

Aber für mich ist es genau die Art von Infrastruktur, die ich mag:

  • klar umrissen,
  • gut integrierbar,
  • automatisiert gebaut und ausgeliefert,
  • und mit einem einfachen mentalen Modell: „Bin ich auf Akku oder am Netz?“ wird zu „Welches systemd-Target ist aktiv?“

Und ab da kann der Rest des Systems sich darauf verlassen.

Erstellt mit Hugo
Theme Stack gestaltet von Jimmy