~/eink $ grep "^## " README.md

~/eink $ cat README.md

E-Ink Dashboard

Renderserver für E-Ink-Knoten. Der Server holt Termine, Müllplan und Sensorwerte, rendert daraus ein HTML-Layout, fotografiert es und liefert ein 1-Bit-PNG aus. Die Knoten selbst rendern nichts — sie fragen alle paar Sekunden nach, was sie zeigen sollen, und malen nur dann neu, wenn sich etwas geändert hat.

Dadurch änderst du Layouts, ohne je wieder zu flashen.

Aufbau

app/
  main.py        HTTP-Endpunkte
  config.py      YAML-Config einlesen
  schedule.py    welche Ansicht wann
  alerts.py      was das Dashboard unterbrechen darf
  layouts.py     welches Widget auf welchem Platz steht
  platz.py       wie viel Höhe jedes Widget bekommt (Mindestmaß, Priorität)
  battery.py     Zellspannung -> Prozent
  stromquelle.py Kabel oder Notstrom, aus dem Verlauf der Spannung
  push.py        den Stand aufs MQTT geben, sobald er sich ändert
  spruch.py      der Spruch des Tages (eine Funktion des Datums)
  check.py       Quellen einmal durchprobieren (python -m app.check)
  measure.py     Bausteinhöhen ausmessen (python -m app.measure)
  renderer.py    Templates, dann PNG -> 1 Bit fürs Panel
  chromium.py    HTML -> PNG, über Playwright
  zeichen.py     Wetter- und Warnsymbole als Inline-SVG
  sources/       Kalender, Wetter, Nachrichten, Warnungen, Home Assistant
templates/
  base.html      Rahmen, zieht Schriften, style.css und die Panelmaße rein
  fonts.css      IBM Plex als base64 (erzeugt, nicht von Hand ändern)
  style.css      die Formensprache: Modul, Marke, invers
  layout.html    setzt Layouts aus Plätzen und Widgets zusammen
  widgets/       ein Widget je Datei
  alert.html     Vollbildmeldung (eigenes Template)
  now_playing.html  läuft gerade (eigenes Template)
tools/
  vorschau.py    alle Ansichten mit Beispieldaten rendern
  fetch_fonts.py Schriften holen und einbetten
  uhrfeld.py     ausmessen, wo die Uhr auf dem Panel steht (-> renderer.UHRFELD)
  mqtt_zertifikate.sh  eigene CA und Serverzertifikat für den Broker
design/          der Entwurf, aus dem das Layout stammt
mosquitto/       Konfiguration des Brokers zwischen Server und Knoten
esphome/         Firmware-Konfiguration je Knoten
  secrets.yaml   WLAN und Schlüssel, nicht im Repo
tests/           ein Thema je Datei (siehe Tests)

Einrichten

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp config.example.yaml config.yaml

Gerendert wird mit Chromium über Playwright. Das Paket kommt mit requirements.txt, der Browser dazu nicht:

playwright install chromium
playwright install-deps chromium      # auf dem Pi nötig

Schriften musst du nicht installieren: IBM Plex steckt als base64 in templates/fonts.css und reist im Stylesheet mit.

Zugangsdaten kommen aus Umgebungsvariablen, nicht aus der YAML. Die Vorlage dafür liegt daneben:

cp .env.example .env      # ausfüllen; .env steht in .gitignore
set -a; source .env; set +a

Wer lieber ohne Datei arbeitet, exportiert sie von Hand:

export ICLOUD_USER="[email protected]"
export ICLOUD_APP_PASSWORD="XXXX-XXXX-XXXX-XXXX"
export HA_TOKEN="ey..."
export EINK_TOKEN="$(openssl rand -hex 32)"   # optional, siehe Zugriffsschutz

Fehlt eine Variable, bekommt die Config einen leeren String — der Server startet trotzdem und die betroffene Quelle fällt aus. python -m app.check sagt, welche:

Kalender (CalDAV)
  fehlt          ICLOUD_USER / ICLOUD_APP_PASSWORD sind nicht gesetzt

Das iCloud-Passwort ist ein anwendungsspezifisches Passwort (appleid.apple.com → Anmeldung und Sicherheit). Das normale Passwort funktioniert wegen der Zwei-Faktor-Authentifizierung nicht.

Starten:

uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload

Im Container

Der Weg oben ist der zum Entwickeln: venv, uvicorn --reload, ein Browser daneben. Für alles andere gibt es zwei Abbilder — eines für den Broker, eines für den Server — und ein Skript, das beide startet und die ganze Strecke prüft.

cp .env.example .env          # ausfüllen, mindestens MQTT_PASSWORT
cp config.example.yaml config.yaml
docker compose up -d          # oder: podman compose up -d

Danach steht der Server auf :8080 und der Broker auf :8883. Beide Ports gehen nach außen, weil der Knoten aus dem WLAN kommt und nicht aus dem Container-Netz.

Der Broker richtet sich selbst ein. Beim ersten Start legt er seine eigene CA an, stellt sich ein Serverzertifikat aus und schreibt die Passwortdatei aus MQTT_USER und MQTT_PASSWORT — dasselbe, was tools/mqtt_zertifikate.sh und mosquitto_passwd von Hand tun. Nichts davon liegt im Abbild: Ein privater Schlüssel dort wäre in jeder Registry, in die es jemand einmal schöbe. Er entsteht im Volume zertifikate und überlebt damit jeden Neustart, aber kein docker volume rm.

Die Adressen im Zertifikat sind der Punkt, an dem es sonst still schiefgeht. Innerhalb des Container-Netzes heißt der Broker broker, für den Knoten heißt er 192.168.178.137 — und wer nicht im Zertifikat steht, scheitert am Namen, obwohl die CA stimmt. Deshalb steht die Adresse aus dem Heimnetz in MQTT_ADRESSEN; die internen Namen sind immer dabei.

Der Knoten braucht die CA als Textblock in seiner YAML:

docker compose cp broker:/mosquitto/certs/ca.crt ./ca.crt

Das ist nicht dieselbe CA wie die in certs/. Wer vom Broker aus brew auf den im Container wechselt, tauscht den Block certificate_authority: in esphome/eink-kueche.yaml aus und flasht einmal. Andersherum genauso. Ein MQTT_ZERT_NEU=1 stellt neu aus und kostet denselben Gang zum Panel — deshalb passiert es nur auf Zuruf.

Drei Angaben stimmen im Container nie mit denen auf dem Mac überein: der Name des Brokers, der Pfad zur CA und push.enabled. Sie stehen deshalb nicht in der config.yaml, sondern in compose.yaml als Umgebung — EINK_PUSH_BROKER, EINK_PUSH_CA, EINK_PUSH. Vor dem Start legt container/server/konfig.py sie über die eingehängte Konfiguration und schreibt das Ergebnis nach /tmp; die eigene Datei bleibt unangetastet und darf schreibgeschützt sein. Eine zweite config.yaml nur für den Container wäre die Alternative gewesen — und liefe der ersten binnen eines Monats hinterher.

Das Passwort geht diesen Weg nicht mit: In der Kopie steht weiter der Platzhalter ${MQTT_PASSWORT}, den app/config.py beim Laden auflöst.

Prüfen, ob die Quellen im Container ankommen — derselbe Aufruf wie draußen, nur eben drinnen:

docker compose exec server python -m app.check

Beides zusammen durchtesten

container/durchtesten.sh              # baut, startet, prüft, räumt auf
container/durchtesten.sh --pytest     # dazu die Testsuite im Abbild
container/durchtesten.sh --behalten   # Container stehen lassen zum Nachsehen

Das Skript findet docker oder podman selbst und hängt an nichts, was schon läuft: eigenes Netz, eigene Container, eigenes Volume, ein gewürfeltes Passwort und ein gewürfeltes Token. Es öffnet keine Ports auf dem Rechner — jede Prüfung läuft im Container, damit ein laufender Broker auf 8883 oder ein belegtes 8080 den Lauf nicht stört. Am Ende ist alles wieder weg.

Geprüft wird nicht, ob Prozesse laufen. Jede Störung, die dieses Projekt bisher gekostet hat, war eine, bei der etwas lief und trotzdem nichts ankam: ein Broker mit gebundenem Port und totem TLS-Handshake, ein Zertifikat ohne die richtige Adresse, eine Klingel, die im Poll unterging. Also geht das Skript jedes Mal die ganze Strecke:

Broker Anmeldung mit CA, Benutzer und Passwort kommt durch
anonym, falsches Passwort und Klartext auf 8883 werden abgewiesen
auf 1883 horcht nichts
das Zertifikat gilt für die Adresse, unter der gleich verbunden wird, und läuft nicht im nächsten Jahr ab
der private Schlüssel ist nur für mosquitto lesbar
Server /healthz meldet ok und kennt seine Ansichten
/plan ohne Token gibt 401, mit Header und mit ?token= gibt es Ansicht und Revision
/render liefert ein PNG in genau der angeforderten Panelgröße, quer wie hochkant
/preview und /html kommen durch
dieselbe Revision zweimal — eine wackelnde ließe den Knoten dauernd zeichnen
beides /healthz meldet push.verbunden, der Server ist also wirklich angemeldet
die aufbewahrte Serveradresse liegt beim Broker — über TLS dorthin gekommen
python -m app.check läuft im Abbild durch

Mit --pytest kommt die Suite aus tests/ dazu, ausgeführt in genau dem Abbild, das auch der Dienst benutzt. Ihren Broker-Teil überspringt test_push.py dort: Im Serverabbild liegt kein mosquitto, und das soll auch so bleiben — die echte Verbindung prüft das Skript eine Zeile weiter oben ohnehin gegen den richtigen Broker.

Wo Abbilder es schwerer haben als der Mac

Das Serverabbild wiegt rund ein Gigabyte, und daran ist Chromium schuld. Er ist nicht wegzurechnen: Das Layout benutzt Grid und Flexbox so, wie es nur eine echte Engine umsetzt (siehe Rendern). Der erste Bau dauert deshalb ein paar Minuten; danach liegt die Schicht im Cache, solange sich requirements.txt nicht ändert.

Zwei Kleinigkeiten, die man einmal sucht und nie wieder: python:*-slim bringt keine Zeitzonendatenbank mit — ohne tzdata scheitert app/main.py in seiner ersten Zeile an ZoneInfo("Europe/Berlin"), und der Container startet gar nicht erst. Und Chromium legt seine Puffer in /dev/shm, wo 64 MB vorgegeben sind; deshalb steht in compose.yaml ein shm_size und in app/chromium.py --disable-dev-shm-usage (eine HA-App kann shm_size nicht setzen). Ohne das stirbt er gelegentlich mitten im Screenshot, und im Log steht nichts als ein abgebrochener Kanal.

Als Home-Assistant-Add-on

Seit 09/2026 läuft der Server nicht mehr auf einem eigenen Pi, sondern als lokale App auf dem Home-Assistant-Pi (192.168.178.36). Broker ist die Mosquitto-App daneben. Der Grund ist schlicht einer weniger: ein Rechner, ein Update-Weg, ein Backup — und die HA-Quelle braucht kein Langzeit-Token mehr, weil der Supervisor jeder App ihr eigenes gibt.

Das Abbild ist dasselbe wie oben. homeassistant-addon/ enthält nur, was eine App zusätzlich braucht: das Manifest, einen Vorspann vor einrichten.sh und ein Stück Dockerfile, das hinten angehängt wird.

./tools/addon_bauen.sh                 # Bauort zusammenstellen, nach ha:/local_apps/eink-server
ssh ha 'ha apps reload && ha apps install local_eink_server'   # später: rebuild

Der Supervisor baut auf dem Pi selbst; der erste Bau dauert wegen Chromium eine Weile. Außen steht der Server auf :8082 und nicht auf :8081 wie vorher: Die OpenThread-Border-Router-App läuft im Host-Netz und belegt 8080 und 8081 selbst.

Was wohin gehört:

Wo Warum dort
config.yaml des Servers /app_configs/local_eink_server/config.yaml (in der App: /config) änderbar ohne Neubau; homeassistant.enabled: true, base_url: http://supervisor/core
Passwörter, Token Optionen der App (UI) liegen in /data/options.json, nur für root lesbar; start.sh reicht sie weiter und gibt dann die Rechte an eink ab
CA des Brokers /ssl/eink/ca.crt dieselbe CA wie bisher — der Block certificate_authority: im Knoten bleibt gleich
Serverzertifikat + Schlüssel /ssl/eink/server.crt, server.key aus tools/mqtt_zertifikate.sh 192.168.178.36 core-mosquitto; core-mosquitto ist der Name, unter dem die App den Broker erreicht

In der Mosquitto-App: certfile: eink/server.crt, keyfile: eink/server.key, cafile: eink/ca.crt, require_certificate: false (die Knoten weisen sich mit Passwort aus, nicht mit Zertifikat) und ein Login eink.

Prüfen:

curl http://192.168.178.36:8082/healthz
ssh ha 'ha apps logs local_eink_server'

python -m app.check läuft im Container der App (docker exec über das Terminal-Add-on mit Schutzmodus aus, oder über das Portainer-Add-on).

Die Dateien unter pi/ stammen aus der Zeit mit eigenem Pi und systemd. Sie sind als überholt markiert und bleiben nur als Rückweg liegen.

Datenquellen

Alles außer Home Assistant läuft ohne zusätzlichen Dienst im Haus. Ob die Zugangsdaten stimmen und was tatsächlich ankommt, beantwortet ein Aufruf:

python -m app.check

Der geht jede Quelle einzeln durch, zeigt die gefundenen Kalender, das aktuelle Wetter, die Schlagzeilen und die aktiven Warnungen — und braucht dafür weder Panel noch laufenden Server.

Eine tote Quelle bricht den Durchlauf nicht ab, sondern bekommt ihre eigene Zeile:

Wetter (Open-Meteo)
  Fehler         Open-Meteo

Abfahrten (EFA)
  Haltestelle    de:08212:1011

Das ist der Punkt des Werkzeugs: Man ruft es auf, weil etwas nicht geht, und will dann sehen, was sonst noch nicht geht — und was doch.

Eine fehlende Quelle kostet den Knoten seinen Durchlauf

Am 2026-09-04 am Panel gemessen und deshalb hier notiert, weil es von außen wie ein Netzwerkfehler aussieht und keiner ist:

[E][http_request.idf]: HTTP Request failed; URL: .../plan?...; Code: -1

Code: -1 heißt nicht "nicht erreichbar". Die Verbindung stand — am Server war sie 18 s lang offen —, nur kam keine Antwort. Der Knoten gibt nach 15 s auf (http_request: timeout: 15s).

Der Grund lag drei Schichten tiefer: Home Assistant war noch nicht eingerichtet, homeassistant.local löste ins Leere auf, und fetch_states lief für jede Entity einzeln in sein Zeitlimit. Drei Entities, gut 15 Sekunden. Bei einer Cache-Frist von 30 s für diese Quelle traf das jeden zweiten bis dritten Abruf — und /plan sammelt alle Quellen mit asyncio.gather ein, wartet also auf die langsamste.

An der Wurzel behoben: Der Server holt die Quellen selbst, im Hintergrund, und eine Anfrage wartet auf keine — siehe "Der Takt der Quellen" weiter unten. Zwei Regeln betreffen den Hintergrunddienst genauso, festgehalten in tests/test_quellen_ausfall.py:

  • Nicht eingerichtet heißt: gar nicht erst versuchen. Ohne Token kann die REST-API von Home Assistant nur 401 antworten, ohne Zugangsdaten kann iCloud nur 401 antworten. Beides wird vorher abgefangen, mit einer Meldung im Log — und zwar einmal, nicht bei jedem Abruf.
  • Ein toter Host wird einmal bezahlt, nicht einmal je Entity. Scheitert die Verbindung (nicht die einzelne Entity), bricht die Schleife ab. Aus 15 s werden 5, und das Zeitlimit der Quelle liegt bewusst so weit unter den 15 s des Knotens, dass alle Quellen zusammen darunter bleiben.

Was ein Widget angeht, ändert das nichts: Eine Zeile ohne Wert fällt einzeln weg, das Layout wächst zu. Nur schnell muss es gehen.

Der Takt der Quellen

Jede Quelle wird in ihrem eigenen Takt geholt, und der steht in der config.yaml:

quellen:
  nina: 1min       # Warnungen sollen schnell durchkommen
  ha: 30s          # lokal und billig - daran hängt die Müllabfuhr
  abfahrten: 1min  # so oft ändert die Fahrplanauskunft ihre Prognose
  feed: 10min      # der Tagesschau-Feed gibt selbst ttl 90 an
  calendar: 5min   # CalDAV ist langsam, Termine ändern sich selten
  weather: 30min   # Open-Meteo rechnet ohnehin nur viertelstündlich neu

Sekunden nackt oder mit Einheit — 600 und 10min sind dasselbe. Eine weggelassene Quelle behält ihre Voreinstellung; ein Tippfehler im Namen ist ein Fehler beim Start und kein stilles Ignorieren.

Der wichtige Teil steht aber nicht in den Zahlen, sondern im Wort "wird geholt". Der Server fragt von sich aus nach, in einer eigenen Schleife je Quelle. Wenn der Knoten kommt, bekommt er, was schon dasteht — er wartet nie auf eine Verbindung nach draußen.

Vorher war es andersherum: Ein Wert galt eine Frist lang, und wessen Anfrage zufällig nach deren Ablauf kam, der bezahlte den Abruf. Genau daran hing der Code: -1 weiter oben. Der Unterschied ist messbar — derselbe Test, einmal gegen jede Fassung, mit einer Quelle, die 30 s hängt:

alte Fassung (Frist):        /plan nach 30,5 s
neue Fassung (Hintergrund):  /plan nach  0,26 s

Das ist kein kürzeres Zeitlimit, sondern ein anderer Aufbau: Eine hängende Quelle kostet ihre eigene Schleife Zeit, nicht den Funk des Knotens. Festgehalten in tests/test_quellen_takt.py.

Drei Dinge, die dazugehören:

  • Ein Fehlschlag behält den letzten guten Wert. Der Unterschied zwischen "der Kalender ist gerade nicht erreichbar" und "es gibt heute keine Termine" ist auf dem Panel nicht zu sehen — im Code schon. Dafür wirft jede Quelle NichtErreichbar, statt still eine leere Liste zurückzugeben; erst das macht die beiden Fälle unterscheidbar. Vorher versprach _quelle_pflegen das zwar, aber der Fehler kam dort nie an — jede Quelle hatte ihn vorher selbst abgefangen. Nicht dafür gedacht ist "gar nicht eingerichtet": Das ist kein Ausfall, sondern eine Entscheidung, und die Antwort darauf bleibt die leere Liste.

  • Der Kaltstart holt einmal synchron. Solange noch gar nichts vorliegt, muss jemand anfangen. Danach nie wieder.

  • /healthz zeigt, wie alt jede Quelle ist. Auf E-Ink sieht ein alter Wert aus wie ein neuer; ohne diese Anzeige fiele es nirgends auf, wenn das Wetter seit Stunden dasselbe ist, weil jeder Abruf scheitert:

    "quellen": {
      "weather": {"takt": 1800, "eingerichtet": true, "alter": 412.3},
      "nina":    {"takt": 60,   "eingerichtet": true, "alter": 12.1}
    }

Was das nicht kann: Zeitfenster. "Abfahrten nur 6–9 und 16–19 Uhr" wäre ein echtes Cron und ist bewusst nicht gebaut — der Server hängt am Strom, ein Abruf mehr kostet dort nichts. Nachrüsten ließe es sich an einer Stelle (_quelle_pflegen).

Alles aus derselben Minute

Drei Dinge laufen hier nebeneinander: Der Knoten zeichnet seine Uhrzeit selbst, jede Quellenschleife im Server holt in ihrem Takt nach, und das Bild entsteht in dem Moment, in dem der Knoten es abruft. Begänne jede dieser Schleifen dann, wenn sie eben fertig war, liefe sie nach dem Start mal bei Sekunde 30 los, mal bei 47, und mit jedem Durchlauf ein Stück weiter, weil die Arbeit selbst Zeit kostet. Auf dem Panel stünde dann eine 12:41-Uhr neben einem Countdown aus 12:40.

Alles liegt deshalb auf der Wanduhr:

  • Der Server wartet nicht takt Sekunden, sondern bis zum nächsten Vielfachen von takt (_bis_zur_naechsten_grenze in app/main.py). Eine Minutenquelle holt zur vollen Minute, eine Zehnminutenquelle um :00, :10, :20 — unabhängig davon, wann der Prozess gestartet wurde.
  • Die Taktschleife für MQTT liegt auf demselben Raster (alle 30 s zum Vielfachen), damit ein Knoten nicht zwischen zwei Ständen landet.
  • Der Knoten zeichnet seine eigene Uhr um :00, ohne Versatz gegenüber dem Server. Er fragt nicht im Takt nach, kann also auch nicht in dessen Nachladen laufen: Der Stand kommt, wenn der Server ihn hat.

Das kostet einmalig etwas Wartezeit nach dem Start und bringt zwei Dinge: Was auf dem Panel steht, stammt aus derselben Minute, und ein Minutencountdown überspringt keine Zahl. Eine Schleife, die 60 s nach ihrer Arbeit schläft, läuft mit mehr als 60 s Periode und sieht irgendwann zwei Minuten in einem Durchlauf.

Festgehalten in tests/test_quellen_takt.py.

Kalender vom iPhone (iCloud über CalDAV)

Der Server liest den iCloud-Kalender direkt, es muss nichts auf dem iPhone installiert oder freigegeben werden. Was er sieht, sind alle Kalender des Apple-Kontos — auch geteilte.

  1. Anwendungsspezifisches Passwort erzeugen: appleid.apple.com → Anmeldung und Sicherheit → App-spezifische Passwörter. Das normale Apple-ID-Passwort funktioniert wegen der Zwei-Faktor-Authentifizierung nicht.

  2. Als Umgebungsvariablen setzen (nicht in die YAML — die liegt im Klartext neben dem Code):

    export ICLOUD_USER="[email protected]"
    export ICLOUD_APP_PASSWORD="abcd-efgh-ijkl-mnop"
  3. Namen der Kalender herausfinden:

    python -m app.check

    Unter "Kalender (CalDAV)" stehen sie so, wie Apple sie führt — das ist nicht immer der Name, den die Kalender-App anzeigt.

  4. Auswählen, welche aufs Panel sollen. Leere Liste heißt: alle.

    calendar:
      enabled: true
      calendars: ["Privat", "Familie"]
      days_ahead: 7

Serientermine werden aufgelöst (recurring-ical-events), ganztägige Termine erscheinen als "ganztägig" — deren Startzeit ist Mitternacht und wäre als Uhrzeit irreführend.

Wenn nichts ankommt: Fast immer ist es das Passwort. iCloud antwortet auf ein falsches App-Passwort mit einem gewöhnlichen 401, das im Log als "CalDAV-Verbindung fehlgeschlagen" landet.

Wetter

Open-Meteo, kein Schlüssel, kein Konto, keine Registrierung. Nur die Koordinaten deines Orts:

weather:
  enabled: true
  latitude: 49.0094
  longitude: 8.4044
  forecast_days: 3

Zwei Nachkommastellen reichen — das sind rund 500 m. Auf dem Panel stehen danach die aktuelle Temperatur, der Zustand als Wort ("Bedeckt", "Leichter Regen"), der Wind und ein Streifen mit den nächsten Stunden, in dem die laufende invertiert markiert ist. Die Wortliste steht in app/sources/weather.py.

Die Symbole sind Inline-SVG aus app/zeichen.py — bewusst grob gehalten (sieben Zeichen für alle WMO-Codes), weil auf einem Panel, das man aus zwei Metern liest, der Unterschied zwischen "Nieselregen" und "leichtem Regen" kein Bild wert ist. Aus geraden Strichen statt Graustufen gebaut, damit sie die Wandlung nach 1 Bit unverändert überstehen.

Abfahrten

Direkt aus der Fahrplanauskunft des Verkehrsverbunds, ohne Schlüssel und ohne Konto. Die meisten deutschen Verbünde betreiben dieselbe Software — EFA von Mentz —, und deren Abfahrtsmonitor beantwortet Anfragen frei; voreingestellt ist der Server des KVV.

abfahrten:
  enabled: true
  haltestelle: de:08212:1011
  richtung: [Karlsruhe, Wörth, Pforzheim]

Die ID der eigenen Haltestelle findet ein kleines Werkzeug — nach Namen oder, zuverlässiger, nach den Koordinaten aus der Karten-App:

python tools/haltestelle.py 49.00873 8.40374
  de:08212:1011      KA Marktplatz (Pyramide U)  (0 m entfernt)
  de:08212:1003      KA Marktplatz (Kaiserstraße U)  (218 m entfernt)

Eine Angabe entscheidet darüber, ob die Tafel brauchbar ist:

richtung: sagt, wohin man fährt. Ohne sie steht die halbe Tafel voll mit Bahnen, in die man nie einsteigt — an der Beispielstraße endete am 2026-09-08 jede zweite Fahrt in Musterdorf selbst. Verglichen wird gegen das Ziel und nicht gegen das Gleis: EFA meldete dort für beide Richtungen "Gleis 3". Der Zusatz in Klammern zählt dabei nicht mit, sonst enthielte "Musterdorf (b. Karlsruhe)" ausgerechnet das Wort, nach dem man sucht.

Mehrere Richtungen dürfen nebeneinander stehen; die Tafel mischt sie dann nach Zeit, und das Ziel in jeder Zeile sagt, welche es ist. Nur die Endstationen aufzuschreiben reicht nicht immer: Fährt auf einem Abschnitt gerade ein Ersatzverkehr, endet er unterwegs und heißt dann auch so. An der Beispielstraße ist das im Moment Mittelstadt — ohne diese Zeile fehlt die ganze Richtung Pforzheim.

Bewusst ohne fussweg: — also ohne einen Filter, der wegnimmt, was näher ist als der Weg zur Haltestelle. So einer flackert am Rand: Die Prognose eines Ersatzverkehrs wanderte am 2026-09-08 innerhalb einer Minute von +5 auf +7, und die Zeile verschwand und kam zurück:

16:31:40  SEV, echt 16:37  ->  auf der Tafel
16:32:00  SEV, echt 16:37  ->  weg (noch 5:00 minus ein Wimpernschlag)
16:32:20  SEV, echt 16:39  ->  wieder da

Dazu kommt, dass das Bild eine Minute an der Wand hängt: Gefiltert wird beim Abruf, heruntergezählt beim Zeichnen. Die Zeile stand also noch mit "in 5 min" da, als es real vier waren, und war beim nächsten Durchlauf fort — von außen sieht das aus wie ein Fehler. Wer eine Bahn noch bekommt, weiß der Mensch davor ohnehin besser als der Server; steht fussweg: noch in einer Config, sagt das Log beim Start Bescheid.

Ob die Richtung passt, beantwortet python -m app.check: Der Aufruf zeigt die nächsten Abfahrten und daneben die Ziele, die der Filter gerade wegnimmt.

Abfahrten (EFA)
  Haltestelle    de:08212:1011
  Richtung       Karlsruhe, Wörth, Germersheim, Knielingen, Pforzheim, Mittelstadt
  weggefiltert   Musterdorf
                 12:30 in 10 min S5         Wörth Badepark (pünktlich)
                 12:32 in 12 min SEV        Mittelstadt (pünktlich)
                 12:49 in 29 min S5         Wörth Badepark (pünktlich)

Auf dem Panel steht die echte Abfahrtszeit, die Verspätung als "+6" daneben und ein Ausfall statt des Ziels.

Zwei Zeitangaben, nicht eine: klein die Uhrzeit, groß der Countdown daneben. Beide werden gebraucht und keine ersetzt die andere. Der Countdown ist die Zahl, nach der man aufsteht; die Uhrzeit ist die, mit der man plant — und die einzige, die nicht altert. Das Bild wird kurz nach der vollen Minute geholt und passt damit zur Uhr daneben (siehe Alles aus derselben Minute); bleibt eine Runde aus, weil das WLAN klemmt, zählt der Countdown still falsch weiter. Die Uhrzeit daneben stimmt auch dann noch.

Der Countdown kostet allerdings etwas, und das ist der Grund für seine Grenzen: Solange er läuft, holt der Knoten jede Minute ein ganzes neues Bild. Der Minutentick der Uhr läuft ohnehin — es kommt also nicht der Takt dazu, sondern das Bild darin. Der blinkende Volldurchlauf rückt davon nicht näher zusammen: voll_nach misst die Zeit seit dem letzten und nicht die Durchläufe dazwischen (siehe Der eigene Treiber).

Deshalb zählt die Tafel nur herunter, was innerhalb der nächsten Stunde fährt, und nur, solange überhaupt etwas ansteht. Nachts steht dort keine Zeile, der Zähler steht still, und das Panel steht mit ihm.

Geklingelt wird dafür nie. Ein Push hängt an der Inhaltsrevision, und der Countdown steht bewusst nicht darin — sonst sähe jede Quellenschleife im Minutentakt eine Änderung, und weil der Server jede davon meldet, ginge minütlich eine Nachricht raus.

Wer die Abfahrten lieber aus einer Home-Assistant-Entity nimmt, kann das weiterhin: Steht abfahrten.enabled auf false und im Layout eine entity:, liest das Widget deren Attribute (siehe Home Assistant). Sind beide da, gilt die eigene Quelle.

Nachrichten

Voreingestellt ist der Tagesschau-Feed. Der mischt Artikel mit Videobeiträgen und einem Dauereintrag für den Livestream — beides wird herausgefiltert, sonst verdrängt es auf zwei Zeilen Platz die echten Meldungen.

feed:
  enabled: true
  urls:
    - https://www.tagesschau.de/infoservices/alle-meldungen-100~rss2.xml
    - url: https://www.tagesschau.de/inland/regional/badenwuerttemberg/index~rss2.xml
      name: "Tagesschau BW"
  max_items: 5

Mehrere Feeds werden zu einer Liste verschmolzen, entdoppelt und nach Zeit sortiert. Entdoppelt, weil die Ressortfeeds sich mit "alle Meldungen" überschneiden: Dieselbe Wirtschaftsmeldung kommt aus beiden, erkannt wird sie am Link ohne Query. Auf dem Panel steht keine Zeitungsauswahl, sondern eine Nachrichtenlage: Welche Redaktion schneller war, entscheidet die Reihenfolge, nicht die Stelle in der Config. Ein Feed, der nicht antwortet, nimmt die anderen nicht mit, und max_items gilt je Feed — sonst verdrängte ein geschwätziger die übrigen ganz. Die alte Schreibweise url: mit einer einzelnen Adresse gilt weiter.

name: überschreibt die Marke. Nötig ist das nur bei Feeds, die sich selbst umständlich nennen: "Regionalnachrichten aus Baden-Württemberg" passt in keine einzeilige Marke, und ohne Angabe steht dort dann die Domain.

Welches Ressort eine Meldung hat, liefert RSS nicht mit. Es steht aber im Pfad des Artikellinks, und app/sources/rss.py liest es dort heraus:

/inland/innenpolitik/wahl-100.html          Inland · Innenpolitik
/kultur/muppet-show-100.html                Kultur
/inland/regional/badenwuerttemberg/x-1.html Baden-Württemberg

Zwei Ebenen, mehr nicht — sonst wird die Marke zur Zeile. Bei regional ist das Bundesland die Auskunft; dass es eine Regionalmeldung ist, sagt der Ortsname schon selbst. Andere Häuser bauen ihre Adressen genauso, deshalb steht im Code nichts Tagesschau-Eigenes; Feeds mit flachen Links (Deutschlandfunk) liefern eben nichts, und dann bleibt die Quelle allein.

"Alle Meldungen" heißt nicht alle

Ein Feed mit Ressorterkennung aus dem Link ersetzt ein Dutzend Ressort-Feeds — vorausgesetzt, er enthält sie wirklich. Nachgemessen am 05.09.2026 über ein Fenster von 32 Stunden, jeweils "wie viele Meldungen dieses Ressorts fehlen in alle-meldungen":

inland/regional     66 von 67      praktisch vollständig draußen
wirtschaft           4 von  8      /arbeitsmarkt/ und /unternehmen/
inland               1 von 16
ausland              0 von 12      vollständig enthalten
wissen               0 von  1      vollständig enthalten

Wer Regionales oder Wirtschaft will, hängt den passenden Feed dazu. Die vollständige Liste steht bei der Tagesschau.

Zwei Module für dieselben Daten

nachrichten zeigt mehrere Schlagzeilen untereinander, mit Quelle und Uhrzeit je Zeile. Wie viele, entscheidet das Modul nach dem freien Platz.

nachricht (Einzahl) zeigt genau eine — die neueste, dafür mit Quelle, Ressort, Uhrzeit und dem Anreißer aus dem Feed. Fünf Zeilen, die man im Vorbeigehen nicht liest, sind weniger wert als eine, die man versteht. Wie viel Anreißer, sagt wieder der Platz: Die Mindesthöhe ist Marke und ganze Schlagzeile, alles darüber wird Anreißer (siehe Platz einteilen).

"Steht, bis eine neuere kommt" braucht dafür kein Gedächtnis im Server: Die Liste ist nach Zeit sortiert, die neueste steht vorn und bleibt dort, bis eine Redaktion etwas Neueres schickt.

Was max_items darüber hinaus holt, ist Vorrat für die Eilmeldungserkennung (siehe unten).

Ein Liveticker kommt von der Seite, nicht aus dem Feed

Ein Liveblog steht als ein Eintrag im RSS-Feed, und dieser Eintrag ändert sich nicht mehr: Titel und Anreißer stammen aus dem Moment, in dem die Redaktion den Ticker aufgemacht hat. Auf dem Panel stand dann abends noch "Die Wahllokale haben geöffnet", während auf der Seite längst ausgezählt wurde. Für ein Gerät, dessen ganzer Zweck der aktuelle Stand ist, ist das der schlechteste denkbare Fehler — und einer, den man ihm nicht ansieht.

Deshalb holt der Server für Liveblogs die Seite selbst und nimmt daraus, was der Feed nicht mitliefert (app/sources/liveblog.py):

Titel      die aktuelle Überschrift der Seite
Anreißer   der neueste Tickereintrag, mit seiner Zwischenüberschrift
Stand      dateModified aus den strukturierten Daten der Seite

Der neue Stand sortiert die Meldung auch neu: Ein Ticker, der morgens aufgemacht wurde und abends noch läuft, ist die aktuellste Meldung des Tages und steht dann auch dort. Und weil das Thema groß genug für einen Ticker ist, hebt der Server die Priorität der Meldung an — der Anreißer bekommt mehr Zeilen, ohne dass in der Config etwas steht.

Erkannt wird ein Liveblog am Pfad /newsticker/ im Artikellink; das ist die Auskunft der Redaktion, "Liveblog" im Titel nur, wie wir sie hinschreiben.

Das kostet höchstens zwei zusätzliche Abrufe je Aktualisierung, und nur unter den Meldungen, die überhaupt erscheinen könnten: Eine Liveblogseite ist über ein halbes Megabyte, und tiefer als auf Platz zwei kommt ein Ticker auf dem Panel selten. Antwortet die Seite nicht oder ändert die Redaktion ihre Klassennamen, bleibt es beim Feed — der Ticker steht dann veraltet da wie vorher, statt gar nicht.

feed:
  liveblog: false     # Voreinstellung: true

Warnungen (NINA)

Dieselbe offene Schnittstelle, die auch die NINA-App abfragt — Warnungen von MoWaS, DWD und Katwarn, ohne Schlüssel und ohne Konto. Nötig ist der Amtliche Regionalschlüssel, zwölfstellig mit Nullen aufgefüllt:

nina:
  enabled: true
  ars: "082120000000"      # Karlsruhe Stadt: 08212 + Nullen
  alert_severity: Severe   # Minor | Moderate | Severe | Extreme
  haltedauer: 3min         # so lange bleibt das Vollbild stehen

alert_severity entscheidet, ab wann eine Warnung das ganze Panel übernimmt. Bei "Minor" stünde auch jede Hitzewarnung im Vollbild, und das Dashboard wäre im Sommer wochenlang keines mehr.

haltedauer entscheidet, wie lange sie dort bleibt, bevor sie zur Zeile wird — Begründung unter Erst ganzflächig, dann als Zeile. 0 heißt: einmal zeigen, dann sofort Zeile, also das Verhalten von vor dem MQTT-Umbau.

Home Assistant (optional)

Ohne HA fehlen das Zuhause-Modul, die Müllabfuhr und der Fokus-Abgleich für die Nachtfenster. Kalender, Wetter, Abfahrten, Nachrichten und Warnungen laufen unabhängig davon — in config.example.yaml steht HA deshalb auf false.

Das Zuhause-Modul kennt keine einzige Entity fest; was es zeigt, steht im Layout:

- name: zuhause
  zeilen:
    - { entity: temperatur,  name: "Wohnzimmer", einheit: " °C" }
    - { entity: fenster_bad, name: "Fenster Bad", invers: ["offen", "on"] }

invers listet die Zustände, bei denen der Wert schwarz hinterlegt steht — der eine Ausreißer, auf den man schauen soll. Auf 1 Bit ist das der einzige Kontrast, der ohne Dithering auskommt, und deshalb sparsam einzusetzen.

Die Abfahrten holt der Server selbst (siehe Abfahrten), nicht von hier. Der Weg über eine Entity bleibt aber offen: Sie liest eine Liste aus einem Attribut, und weil die Felder von Integration zu Integration anders heißen, steht ihre Übersetzung im Layout (liste: und felder:, siehe templates/widgets/abfahrten.html).

Wer es nutzt, kann zusätzlich Zustände als meldenswert markieren:

homeassistant:
  entities:
    waschmaschine: binary_sensor.waschmaschine_fertig
  alerts:
    - entity: waschmaschine     # Label von oben, nicht die Entity-ID
      label: "Zuhause"
      title: "Waschmaschine ist fertig"
      when: "on"

Spruch des Tages

Ein Zitat, ein Aphorismus, eine Filmzeile — der einzige Baustein, den man bewusst ansieht, statt ihn im Vorbeigehen zu erfassen.

spruch:
  enabled: true

Mehr ist nicht nötig: Die Sprüche kommen aus data/sprueche.txt, gut hundert Zeilen, die zum Projekt gehören. Eine Liste zu pflegen ist genau das, was man nach zwei Wochen nicht mehr tut.

Das Format ist eine Zeile je Spruch, Text | Urheber, Leerzeilen und # werden übersprungen. Eigene kommen dazu, sie ersetzen die mitgelieferten nicht:

spruch:
  enabled: true
  sprueche:
    - text: "Nicht vergessen: Müll rausbringen."
      von: "Die Vernunft"
    # Kurzform, wenn die Herkunft egal ist:
    - "Der Weg ist das Ziel."

Nur die eigenen und keine mitgelieferten: datei: "". Eine andere Sammlung: datei: /pfad/zu/eigene-sprueche.txt. Fehlt die Datei, steht eine Warnung im Log und der Server läuft weiter — ein Zitat ist kein Grund, ein Panel schwarz zu lassen.

Danach spruch in ein Layout aufnehmen, sonst steht es nirgends:

rechts:
  - name: abfahrten
    prio: 3
  - name: termine
    prio: 2
  - name: nachricht
    prio: 3
  - musik
  - name: spruch

Ganz unten und ohne prio:, also mit der kleinsten: Wird die Spalte zu voll, ist der Spruch das erste, was geht — neben Abfahrten, Terminen und einer Meldung ist ein Aphorismus das Entbehrlichste. Wie viel Platz er bekommt, solange er dasteht, rechnet der Server aus dem Spruch selbst: Ein kurzer steht in einer Zeile vollständig da, ein langer braucht zwei, und unter zweien wäre er ein Streifen mit dem Namen des Urhebers und ohne Zitat — schlimmer als gar nichts. Eine Zahl in der Config braucht das nicht (siehe Platz einteilen).

Keine Quelle. Der Spruch steht in einer Datei, nicht im Netz: Hier wird nichts abgerufen, nichts kann scheitern, nichts hat einen Takt. Das Panel zeigt seinen Spruch auch dann, wenn das WLAN weg ist.

Warum nicht gewürfelt

Weil der Knoten sonst alle paar Sekunden das Panel neu zeichnete.

Ein zufällig gewählter Spruch wechselte bei jeder Anfrage. Damit änderte sich die Revision, /plan meldete "neues Bild", der Knoten holte es — und beim nächsten Poll dasselbe von vorn. Jeder Durchlauf blitzt sichtbar und blockiert die Schleife ein paar Sekunden.

Deshalb ist die Wahl eine Funktion des Datums: sprueche[tag.toordinal() % len(sprueche)]. Für denselben Tag kommt immer derselbe heraus, egal wer wann fragt und wie oft der Server neu startet. Der Reihe nach statt gewürfelt, und über den Jahreswechsel hinweg: Bei vierzehn Sprüchen sieht man erst alle, bevor sich einer wiederholt.

Und warum er keine Quellenschleife aufweckt

Der Spruch steht in der Revision, aber im Zeit-Teil davon — zusammen mit dem Zähler, der die Uhr in der Fußzeile weiterstellt (siehe Push). Der Unterschied entscheidet, was nachts um zwölf passiert:

steht im Inhalt steht in der Zeit
Der Knoten holt das neue Bild ja ja
Löst einen Quellenabruf aus ja nein
Geht auf …/revision (alte Firmware) ja nein
Geht auf …/stand sofort mit der 30-s-Schleife

Um Mitternacht wechselt das Datum und damit der Spruch, ohne dass irgendwo etwas passiert wäre. Stünde er im Inhalt, sähe die erste Quellenschleife danach eine Änderung und behandelte ein Zitat wie eine Unwetterwarnung. So nimmt ihn die Taktschleife mit — spätestens dreißig Sekunden nach Mitternacht, und das genügt vollkommen.

Wie lang er sein darf

Rund 120 Zeichen. Der Spruch steht in der rechten Spalte, die 538 px breit ist und nach Innenabstand 506 px Text trägt; bei 17 px sind das etwa 60 Zeichen je Zeile, also zwei Zeilen. Wird es mehr, warnt der Server beim Laden der Config, und das Widget kürzt mit „…“:

spruch.sprueche[1] ist 148 Zeichen lang (mehr als 120) und wird auf dem
Panel gekürzt: Ich weiß nicht, ob es besser wird, wenn …

Zwei Zeilen und nicht drei: Die dritte kostet den Nachbarn eine ganze Zeile und dem Spruch nichts als Länge.

Kürzer ist ohnehin besser. Ein Spruch, den man aus zwei Metern in einem Blick liest, wirkt; einer über vier Zeilen wird zum Absatz, und Absätze liest an einer Küchenwand niemand.

Die Herkunft steht als Marke über dem Spruch, nicht als Zeile darunter. Die Marke kostet das Modul ohnehin ihre Höhe; eine eigene Zeile darunter käme obendrauf — und für "Goethe" eine Zeile Spruch herzugeben wäre der falsche Tausch.

Steht eine Meldung im Bild, fällt der Spruch ganz weg. Siehe Platz einteilen.

Tests

pip install -r requirements-dev.txt
pytest

Dasselbe im Abbild, zusammen mit einem echten Broker und der ganzen Strecke dazwischen, macht container/durchtesten.sh (siehe Im Container).

Ein Thema je Datei, benannt nach dem, was sie prüft — englisch, wo das Modul oder die Route englisch heißt (schedule.py, /plan), deutsch, wo es deutsch heißt (platz.py, stromquelle.py, die Quellen).

Datei worum es geht
test_schedule.py welche Ansicht gerade gilt — Reihenfolge der Regeln, Zeit, Wochentag, Fokus, Signale, Home Assistant
test_signal.py POST /signal und die Regeln, die daran hängen
test_zeigen.py POST /zeigen — eine Ansicht auf Zuruf, und wer wen sticht
test_zeitangaben.py Zeitangaben aus iPhone-Kurzbefehlen lesen
test_now_playing.py die gemeldete Wiedergabe: merken, verfallen, anzeigen
test_plan.py /plan und die Inhaltsrevision
test_render_etag.py die Marke an /render — was schon auf dem Panel steht, kommt nicht zweimal
test_chromium.py dass das PNG genau die Panelgröße hat
test_push.py MQTT: die Klingel, die Serveradresse, gegen einen echten Broker
test_auth.py die Token-Prüfung
test_sources.py die Datenquellen gegen aufgezeichnete Antworten
test_quellen_takt.py der Server holt die Quellen selbst, Anfragen warten nie
test_quellen_ausfall.py was passiert, wenn eine Quelle nicht da ist
test_liveblog.py der Liveticker, geholt von der Seite statt aus dem Feed
test_abfahrten.py die Abfahrtstafel: EFA-Antworten, Richtungsfilter, Countdown
test_check.py python -m app.check übersteht die Ausfälle, die es zeigen soll
test_alerts.py was das Dashboard überhaupt unterbrechen darf
test_alert_lifecycle.py derselbe Weg einmal ganz: Vollbild, Haltedauer, Zeile, Erlöschen
test_views.py Ansichten, Formatvarianten, Bausteine
test_platz.py die Platzverteilung — Mindesthöhe zuerst, der Rest nach Priorität, nie Leerraum
test_design.py was mit dem Entwurf dazugekommen ist — Warnstufen, Wetterstunden, und dass die Zeichen als SVG kommen statt als Schriftzeichen
test_stufen.py vier Graustufen statt zwei
test_invert.py Schwarz und Weiß tauschen
test_uhr.py die Uhrzeit, die der Knoten selbst malt
test_battery.py aus Volt Prozent machen: Kurve, Plausibilität, Revision
test_stromquelle.py Kabel oder Akku — was erst der Verlauf verrät
test_spruch.py der Spruch des Tages
test_alte_konfigschluessel.py die Warnung vor Schlüsseln, die weggefallen sind

Keiner geht ins Netz

tests/conftest.py sperrt jede Quelle, die ein Test nicht selbst ersetzt hat: Sie meldet sich als nicht erreichbar, und der Server nimmt dafür den Leerwert — genau der Kaltstartfall, den er ohnehin kennt. Erkannt wird das am Funktionsobjekt in der Registratur; steht dort noch die Funktion aus dem Quellmodul, war kein Test dran.

Der Anlass war ein Fehlschlag, der wochenlang keiner war. test_render_etag.py legte fünf Quellen still — die fünf, die es beim Schreiben gab — und abfahrten nicht. Weil sein Fixture den Cache zwischen zwei Aufrufen leert, fragte jeder Aufruf wirklich bei der EFA nach; rutschte dazwischen eine Bahn eine Minute weiter, fiel ausgerechnet der Test, der behauptet, dass sich die Marke nicht ändert. Eine Liste von Stubs ist immer die Liste vom Tag ihrer Entstehung. Die Umkehrung altert nicht.

Nebenbei ist der Lauf um ein Drittel kürzer geworden — das war die Wartezeit auf echte Server.

Vier Dateien brauchen mehr als Python. test_chromium.py, test_alert_lifecycle.py und ein Test in test_uhr.py starten wirklich einen Browser — ohne playwright install chromium laufen sie nicht. test_push.py startet für einen Teil seiner Tests einen eigenen Mosquitto auf einem freien Port und überspringt diesen Teil, wenn keiner installiert ist.

Entwickeln ohne Hardware

Das ist der Punkt, an dem du jetzt anfangen kannst:

URL wofür
/html?device=kueche rohes HTML, im Browser, mit Live-Reload
/preview?device=kueche so sieht es nach Dithering aus
/render?device=kueche genau das PNG, das der Knoten bekommt
/plan?device=kueche Ansicht, Takt und Revision als JSON
/html?device=kueche&batt=3.85 mit Akkustand, ohne dass ein Knoten einen melden muss
/html?device=kueche&view=alert die Vollbildmeldung layouten, ohne auf eine echte zu warten
/preview?device=kueche&w=480&h=800 dieselbe Ansicht in einem anderen Panelformat
POST /signal ein Stichwort vom iPhone-Kurzbefehl melden (siehe Situationsabhängige Ansichten)
POST /zeigen eine Ansicht auf Zuruf einblenden (siehe Situationsabhängige Ansichten)
POST /now-playing laufenden Titel melden (siehe Situationsabhängige Ansichten)

Arbeite am Layout über /html. Wenn es dir gefällt, schau dir /preview an — dort siehst du, was das Dithering aus feinen Linien und Grautönen macht. Der Unterschied ist regelmäßig ernüchternd.

Eigene Ansichten bauen

Eine Ansicht entsteht aus Plätzen und Widgets, und beides steht in der Config. Es gibt kein JavaScript und keine iframes: Was auf dem Panel landet, soll aus dem Kontext hervorgehen, den der Server zusammenstellt — und nicht aus etwas, das erst beim Rendern nachgeladen wird und dann fehlen kann.

Sieben Plätze, für jedes Gerät dieselben

Quer:

┌───────────────────────────────────────┐
│ warnung        (nur bei Meldung)      │
├───────────────────────────────────────┤
│ ueberall       (volle Breite)         │
├─────────────┬─────────────────┬───────┤
│ oben        │ rechts          │ extra │
│ (Uhr,       │                 │       │
│  Wetter)    │                 │       │
├─────────────┤                 │       │
│ links       │                 │       │
├─────────────┴─────────────────┴───────┤
│ unten          (Fußzeile)             │
└───────────────────────────────────────┘

Hochkant gibt es keine zweite Spalte — auf 480 px Breite bliebe für sie zu wenig. oben wird dort ein eigener Kopfblock, in dem Uhr und Wetter nebeneinander statt untereinander stehen, und extra ein Band über den Modulen:

┌────────────────────────┐
│ warnung                │
├────────────────────────┤
│ oben   (Uhr | Wetter)  │
├────────────────────────┤
│ ueberall               │
├────────────────────────┤
│ extra                  │
├────────────────────────┤
│ links                  │
│ dann rechts            │
├────────────────────────┤
│ unten                  │
└────────────────────────┘

Derselbe Inhalt, andere Form. Zwei Plätze wechseln dabei die Gestalt, und das entscheidet layout.html, nicht das Stylesheet: oben löst hochkant seinen Modulrahmen auf und wird zur Kopfzeile, extra wandert aus der Spaltenreihe nach oben.

Bleibt rechts leer, nimmt links die ganze Breite. Bleibt extra leer, bleibt es beim vierbereichigen Layout. Alles das passiert von selbst.

Die Warnung steht über allem — auch über der Uhr. Das ist der Punkt: Wer ins Zimmer kommt, soll sie sehen, bevor er irgendetwas anderes liest.

Layouts

layouts:
  standard:                          # Grundlage für alle
    oben:    [uhr, wetter]
    warnung: [meldung]
    unten:   [fuss]

  dashboard:
    links:  [stunden, zuhause]
    rechts: [abfahrten, termine, nachricht, musik]

  morgens:
    links:  [stunden, zuhause]
    rechts: [abfahrten, termine, nachricht]

Jedes Layout erbt die Plätze von standard und überschreibt nur, was es selbst nennt; unten: [] räumt einen Platz ausdrücklich frei. Ein Widget hinzufügen heißt: Namen in eine Liste schreiben. Entfernen: Namen löschen. Kein Template anfassen, kein Python.

Wer etwas einstellen will, schreibt statt des Namens eine Zuordnung:

    links:
      - stunden
      - name: zuhause
        prio: 2
        zeilen:
          - { entity: temperatur,  name: "Wohnzimmer", einheit: " °C" }
          - { entity: fenster_bad, name: "Fenster Bad", invers: ["offen"] }

Hochkant fällt die zweite Spalte weg — alle Module stehen dann untereinander, erst links, dann rechts. Wo diese Reihenfolge nicht passt, sortiert ein hochkant:-Block sie um:

  dashboard:
    links:  [stunden, zuhause]
    rechts: [abfahrten, termine, nachricht, musik]
    hochkant:
      links:  [stunden]
      rechts: [abfahrten, termine, zuhause, nachricht, musik]

Er erbt die Plätze des eigenen Layouts und überschreibt nur die genannten. Das ist bewusst kein zweites Layout: Es sind dieselben Widgets mit denselben Einstellungen, nur anders sortiert — ein zweites Layout müsste bei jeder Änderung mitgepflegt werden.

Die Regeln unter windows: nennen dann ein Layout beim Namen — das ist die Trennung: Die Regeln wählen die Ansicht, das Layout beschreibt sie.

Widgets

Jedes Widget ist eine eigene HTML-Datei in templates/widgets/. Der Dateiname ohne .html ist der Name, unter dem es im Layout steht.

Die dritte Spalte sagt, was eine Einheit ist — die Größe, in der das Modul wächst, wenn Platz da ist, und die prio:, mindestens: und die Obergrenzen zählen (siehe Platz einteilen). Module mit "—" haben eine feste Höhe.

Widget zeigt eine Einheit
uhr Uhrzeit groß, mit Datum darüber —
wetter Zeichen, Temperatur, Zustand und Wind —
stunden die nächsten Stunden als Streifen, die laufende invertiert —
zuhause frei zusammengestellte Zustände aus Home Assistant eine Zeile
abfahrten nächste Abfahrten mit Linie, Ziel, Countdown, Verspätung, Ausfall eine Abfahrt
termine Termine des Tages ein Termin
nachrichten mehrere Schlagzeilen, mit Quelle und Uhrzeit je Zeile eine Schlagzeile
nachricht nur die neueste, dafür mit Ressort und Anreißer eine Textzeile
musik laufender Titel mit Fortschrittsbalken —
meldung Warnzeile, Warnband oder Eilmeldungskopf — je nach Stufe —
fuss Zeitstempel und Akkustand für die Fußzeile —
woche Wochenübersicht ein Tag
muell nächste Leerung (Home Assistant) —
akku Ladestand des Knotens, geschätzt aus der Zellspannung —
naechster_termin Einzeiler mit dem nächsten Termin —
spruch Spruch des Tages aus data/sprueche.txt, Herkunft als Marke eine Textzeile

Ein eigenes Widget ist eine Datei, sonst nichts:

{# templates/widgets/luftfeuchte.html #}
{% if ha.luftfeuchte %}
<div class="modul">
    <div class="marke">Luftfeuchte</div>
    <div class="hz-zeile">
        <span class="hz-name">Bad</span>
        <span class="hz-wert">{{ ha.luftfeuchte.state }} %</span>
    </div>
</div>
{% endif %}

Die Bausteine der Formensprache stehen in templates/style.css: modul ist der Kasten mit Trennlinie darunter, marke die kleine versale Überschrift, invers der schwarze Hintergrund für den einen Wert, auf den man schauen soll. Mehr braucht es für die meisten Widgets nicht.

Danach luftfeuchte in einen Platz schreiben — fertig. Jedes Widget sieht den kompletten Kontext (now, days, weather, headlines, ha, banners, …), dazu seine eigenen Einstellungen als opt, die Namen seiner Nachbarn auf demselben Platz als nachbarn und drei Angaben zum Platz:

budget wie viele Einheiten es zeigen darf; 0 heißt "gar nicht"
waechst ob es den Rest der Spalte auffängt (genau eins je Spalte)
slot in welcher Spalte es steht — daran hängt die Zeilenbreite

Jedes Widget prüft selbst, ob es etwas zu zeigen hat. Ohne Wetter, ohne Home Assistant, ohne Termine bleibt die Stelle leer, statt dass die Ansicht kaputtgeht. Deshalb kann meldung bedenkenlos in standard stehen: An 99 % der Tage rendert es nichts.

Wenn Plätze und Widgets nicht reichen

Manche Ansichten ergeben sich nicht aus Spalten — alert.html füllt die ganze Fläche mit einer Meldung, now_playing.html zentriert einen Titel. Dafür gibt es weiterhin eigene Templates: Findet der Server einen Layoutnamen nicht, sucht er ein Template gleichen Namens (<ansicht>.<format>.html, dann <ansicht>.html). Die Widgets lassen sich darin genauso einsetzen:

{% with opt = {} %}{% include "widgets/uhr.html" %}{% endwith %}

Platz einteilen

Auf einem Panel gibt es keinen Scrollbalken: Was zu viel ist, wird abgeschnitten. Die Regel des Entwurfs dagegen lautet nie Leerraum, nie Platzhalter — fällt ein Widget aus, wächst das benachbarte Modul in den Platz. Beides zusammen heißt: Jedes Modul muss wissen, wie viel Höhe ihm gerade zusteht.

Der Vorgänger war eine Tabelle namens GRENZEN: "Termine 2 Zeilen, Zuhause 4, Meldung 220 Zeichen", je einmal für "mit Warnband" und einmal ohne. Sie funktionierte, hatte aber zwei Fehler, und beide waren derselbe: Sie kannte nur zwei Zustände. Eine einzeilige Warnung passte in keinen davon und musste im Template mit Sonderfällen nachgebaut werden; und fielen zwei Nachbarn aus statt einem, stand der frei gewordene Platz in keiner Zeile der Tabelle.

Heute steht in app/platz.py stattdessen, was jedes Widget an Höhe braucht und was ihm eine weitere Einheit kostet. Der Rest ist Rechnen, in zwei Schritten:

1. Jedes Modul bekommt seine Mindesthöhe. Das ist die geringste Menge Information, bei der es sich noch lohnt, das Modul überhaupt zu zeigen: eine Abfahrt, ein Termin, bei einer Meldung Marke und ganze Schlagzeile. Reicht die Spalte dafür nicht, fällt das unwichtigste Modul ganz weg — halb dastehen darf keins. Ein Modul, das nur zur Hälfte da ist, sagt weniger aus als eines, das fehlt, und es sieht nach Fehler aus statt nach Entscheidung.

2. Was übrig bleibt, geht nach Priorität. Und zwar reihum: Wer im Verhältnis zu seiner Priorität am wenigsten von den freien Pixeln hat, ist als nächster dran. Nicht "der Wichtigste nimmt alles" — bei doppelter Priorität bekommt ein Modul auf Dauer die doppelte Fläche, aber der zweite kommt auch dran.

Im Layout sieht das so aus:

rechts:
  - name: abfahrten
    anzahl: 3
    prio: 3
  - name: termine
    prio: 2
  - name: nachricht
    prio: 3
  - musik
  - name: spruch          # ohne prio: also 1 — geht als erster

prio: ist die einzige Schraube, die man dafür braucht; ohne Angabe gilt

  1. Sie sagt zweierlei zugleich, und das ist Absicht: wer bei Platzmangel weichen muss, und wer bei freiem Platz zuerst wächst. Beides ist dieselbe Frage — was ist hier wichtiger.

mindestens: verschiebt die Untergrenze. mindestens: 3 bei den Abfahrten heißt: Die dritte gehört zur Mindesthöhe, und wenn sie nicht hineinpasst, fällt das Modul weg, statt auf zwei zu schrumpfen. Für wen zwei Abfahrten keine Auskunft sind, ist das die richtige Ansage.

Obergrenzen heißen weiter wie bisher: anzahl (abfahrten, nachrichten), limit (termine, zuhause), tage (woche), dazu hoechstens: bei jedem Modul. Sie sind Grenzen, keine Vorgaben: anzahl: 3 heißt "höchstens drei", und wer weniger Platz hat, sieht weniger. Umgekehrt heben sie die gemessene Obergrenze nicht auf — sonst schöbe anzahl: 20 den Text unter die Fußzeile.

ab: überspringt die vordersten Einträge. nachrichten und woche kennen es, und beide aus demselben Grund: Steht darüber schon ein Modul mit demselben Anfang — nachricht mit der Aufmachermeldung, termine mit heute —, stünde er sonst zweimal da. Bei woche ist ab: 1 deshalb schon die Voreinstellung; ab: 0 nimmt den heutigen Tag mit.

Mehr Schrauben gibt es nicht, und es braucht sie nicht: Der Rest der Spalte geht von selbst an das wichtigste sichtbare Modul, und wer nicht mehr hineinpasst, verschwindet von selbst.

Die Mindesthöhe kann vom Inhalt abhängen

Für eine Liste ist sie eine feste Zahl — eine Abfahrt ist eine Abfahrt. Für Fließtext ist sie es nicht, und eine Konstante wäre dort entweder zu knapp oder zu großzügig:

  • nachricht braucht als Minimum die ganze Schlagzeile, nicht eine Zeile davon. Eine kurze belegt eine Zeile, eine lange drei. Eine angeschnittene Schlagzeile mit vollständigem Anreißer darunter liest sich wie ein Fehler; über drei Zeilen hinaus wird gekürzt, denn eine vierte liest ohnehin niemand im Vorbeigehen.

  • nachrichten rechnet dasselbe für jede Schlagzeile einzeln, und daraus wird eine Preisliste statt eines Preises: Die erste Meldung kostet, was ihr Titel an Zeilen braucht, die zweite, was ihrer braucht, und so weiter. Gemessen ist das Modul mit kurzen Titeln — eine Zeile je Meldung —, in der Wirklichkeit bricht etwa die Hälfte um.

    Ein Mittelwert wäre für jede einzelne falsch, und zwar in beide Richtungen. Zu knapp gerechnet geht der Fehlbetrag nicht zu Lasten dieses Moduls, sondern des wachsenden daneben: Es ist das einzige, das unter seine Inhaltshöhe gedrückt werden kann, und verliert dann seine letzte Zeile — so fiel es auf, als in der Ansicht nachrichten der Anreißer der Aufmachermeldung mitten im Satz abriss. Zu üppig gerechnet bleibt ein Streifen Weiß stehen, in den noch eine Meldung gepasst hätte.

  • spruch braucht so viele Zeilen, wie das Zitat hat, höchstens zwei. Ein kurzer steht in einer Zeile vollständig da; ihm zwei zu geben hieße, eine leere Zeile zu reservieren, die die Nachbarn gebrauchen könnten.

Wenn die Einheiten nicht gleich viel kosten

Der Normalfall ist eine Zahl: Eine Abfahrt kostet 42 Pixel, die nächste auch. Wo das nicht stimmt, tritt an die Stelle des einen Preises eine Liste (stufen in app/platz.py) — in der Reihenfolge, in der die Einheiten anfallen, denn ein Modul zeigt seine Meldungen von oben nach unten. Die Verteilung rechnet ohnehin Einheit für Einheit; sie fragt nur, was die nächste kostet, und bekommt entweder immer dieselbe Antwort oder eben nicht.

Genau ein Widget braucht das bisher: nachrichten. Der Rest kommt mit einer Zahl aus, und das soll auch so bleiben — eine Preisliste ist teurer zu lesen als eine Zahl.

Die Priorität darf sich zur Laufzeit ändern

Es gibt genau eine solche Regel: Läuft gerade ein Liveblog, zählt die Meldung mehr. Macht eine Redaktion einen Ticker auf, ist das Thema groß genug, dass die Nachbarn Platz hergeben — der Anreißer wächst, ohne dass irgendwo eine Zahl umgeschrieben wird.

Mehr solche Regeln gibt es absichtlich nicht. Jede weitere macht schwerer vorhersagbar, was auf dem Panel steht, und das ist der Preis, den dynamische Prioritäten kosten.

Was das CSS dazu beiträgt

Genau eine Zeile: Das Modul, das den Rest der Spalte auffängt, bekommt flex: 1 1 auto (.modul.waechst), alle anderen behalten ihre Höhe (.spalte > * { flex: 0 0 auto }). Ohne diese zweite Regel staucht Flexbox jedes Modul ein Stück, und weil keins seinen Inhalt mitstaucht, wird überall eine Zeile mittendurch abgeschnitten: die Uhr sechs Pixel kürzer, "Licht 3 an" auf halber Höhe gekappt.

Welches Modul das ist, sagt nicht die Config, sondern die Verteilung: das wichtigste, das nach dem ersten Schritt noch sichtbar ist. Stünde es in der Config, wüchse womöglich ausgerechnet das Modul, das gleich darauf ganz verschwindet — und in einer vollen Spalte gäbe dann niemand nach.

Als letzte Reißleine schneidet die Spalte selbst ab (overflow: hidden). Damit malt in keinem Fall etwas über die Fußzeile hinaus.

Die Zahlen sind gemessen, nicht geschätzt

python -m app.measure
### landscape 800x480

  FLAECHE   451
  BREITE    {'links': 227, 'rechts': 506, 'extra': 230}
  ZEICHEN   {'titel': 50, 'anreisser': 59, 'spruch': 56}
  Warnzeile 41   Band 1 Zeile 79   2 Zeilen 104

  MASSE:
     uhr                Mass(113, 0)
     abfahrten          Mass(79, 42)
     termine            Mass(70, 33)
     nachricht          Mass(70, 23, aufschlag=9)
     spruch             Mass(94, 24)
     …

  Weicht ab:
    nichts — die eingetragenen Werte stimmen.

Die erste Zahl ist die Höhe des Moduls mit seiner Mindesthöhe — inklusive Marke, Innenabstand und Trennlinie, denn genau so viel nimmt es der Spalte weg. Die zweite ist, was jede weitere Einheit kostet. aufschlag ist der Sonderfall, den es zweimal gibt: nachrichten setzt ab der zweiten Schlagzeile unter jede eine eigene Quellenzeile, der Sprung von eins auf zwei kostet deshalb einmalig mehr als jeder Schritt danach.

Das Werkzeug rendert jedes Widget durch dieselbe Engine wie /render und liest die Höhen aus dem Browser. Blockhöhen zu addieren ginge schief — zwischen zwei Modulen fallen Ränder zusammen, und mehrere Module reagieren auf ihre Nachbarn. Es vergleicht mit den Werten in app/platz.py und meldet jede Abweichung; nach jeder Änderung an style.css neu laufen lassen, mit python -m app.measure portrait auch hochkant.

Der unangenehme Fall, an dem der Vorgänger scheiterte, ist damit kein Sonderfall mehr: Eine einzeilige Warnung nimmt der Fläche 41 px, ein Band 79 oder 104 — die Verteilung rechnet mit der Zahl, die gerade gilt, statt zwischen "mit Band" und "ohne" zu wählen. Gerechnet wird dabei über den Titel der Warnung, nicht über ihren ausführlichen Text: Im Band steht die Überschrift, der lange Text erst auf der Vollbildseite.

Nichts zu zeigen heißt: nicht da

Ein Modul ohne Inhalt verschwindet ganz, statt eine Leerzeile oder ein "Keine Meldungen." zu hinterlassen — und was es nicht belegt, gehört den anderen. Die Bedingung steht dafür an einer Stelle (platz.zeigt_sich), nicht in jedem Template noch einmal: Liefen die beiden auseinander, rechnete der Server mit einem Modul, das gar nicht da ist, und unten bliebe ein Loch.

Im Template ist davon nur {% if budget %} übrig — eine Null heißt "zeig nichts", ob nun die Daten fehlten oder die Spalte die Mindesthöhe nicht trug.

Wer die Aussage "heute nichts" trotzdem will, schreibt sie ausdrücklich hin:

- name: termine
  leer: "Heute nichts vor"

Für eigene Widgets gilt das Ausmessen nicht automatisch: Ein Widget ohne Eintrag in platz.MASSE bekommt eine Einheit und seine Höhe, mehr nicht. Wer es staffeln will, trägt es dort ein und lässt python -m app.measure die Zahlen dazu ausrechnen.

Alle Ansichten auf einmal ansehen

python tools/vorschau.py

Rendert jede Ansicht in beiden Formaten mit erfundenen, aber gemeinen Daten — ein ausgefallener Zug, ein offenes Fenster, ein zu langer Warntext, ein Tag mit mehr Terminen als Zeilen. Ohne Server, ohne Kalender, ohne Home Assistant. Jedes Bild kommt zweimal: einmal als Graustufe zum Beurteilen des Layouts und einmal als .panel.png nach derselben Wandlung, die /render macht. Nur das zweite zeigt, was wirklich auf dem Panel landet.

Drei Fallstricke

  • Neue Daten gehören in die Revision. Zeigt dein Widget etwas, das _revision() in main.py nicht kennt, merkt der Knoten die Änderung nicht und malt erst nach refresh_seconds neu.
  • Was nicht in die Panelhöhe passt, wird nicht kleiner, sondern abgeschnitten. Der Inhaltsbereich schneidet unten ab, damit nichts über die Fußzeile läuft — sichtbar wird ein zu volles Layout also als fehlende letzte Zeile, nicht als Fehler. Dagegen hilft python -m app.measure und ein Eintrag in platz.MASSE.
  • Die Panelhöhe ist eine harte Grenze, kein Vorschlag. Im Körper steht deshalb ein Grid mit minmax(0, 1fr) und darin ein eigener .content-Kasten: Ohne das dehnt eine zu lange Spalte das Raster, statt ihren Inhalt zu stauchen.

Tippfehler fallen beim Start auf: Der Server prüft jede in einer Regel genannte Ansicht und jedes in einem Layout genannte Widget gegen das, was es wirklich gibt, und schreibt sonst eine Fehlerzeile ins Log. Ein fehlendes Widget lässt nur seine Stelle leer, ein fehlendes Layout beantwortet den Abruf mit 404 und der Liste der vorhandenen — der Dienst läuft weiter.

Rendern

Vom HTML zum PNG geht es über Chromium: Playwright startet den Browser beim Serverstart, jeder Abruf bekommt eine eigene Seite, und der Screenshot kommt in exakt der Panelauflösung heraus (app/chromium.py).

Es gibt keine zweite Engine zur Auswahl. WeasyPrint wäre die leichte Alternative — reines Python über Pango/Cairo, ein Fünftel der Zeit, ein Drittel des Speichers und damit die einzige Aussicht auf einen Pi Zero 2 W. Es scheitert an diesem Layout: Das Raster benutzt Grid und Flexbox so, wie WeasyPrint sie nicht umsetzt — verschachtelte Spalten, display: contents im Kopfblock, flex: 1 für das Modul, das den Rest der Spalte auffängt. grid-column: 1 / -1 kommt gar nicht an. Was dabei herauskommt, ist kein schlichteres Bild, sondern ein kaputtes.

Steht render.backend noch in deiner config.yaml, sagt der Server es beim Start ins Log und liest den Schlüssel nicht. Wer den Pi Zero doch will, baut das Stylesheet zurück und holt sich WeasyPrint aus der Versionsgeschichte.

Installiert wird Chromium mit:

playwright install chromium
playwright install-deps chromium      # auf dem Pi nötig

Playwright liefert seit 1.37 offizielle Chromium-Builds für Debian 12 arm64 — also für Raspberry Pi OS Bookworm.

Schriften reisen mit

IBM Plex (Sans, Sans Condensed, Mono) steckt als base64 in templates/fonts.css, erzeugt von tools/fetch_fonts.py. Zwei Gründe:

  • Chromium bekommt das HTML per set_content und löst relative Pfade nicht auf — eine Schriftdatei neben dem Stylesheet wäre unerreichbar.
  • Eine auf dem Renderhost fehlende Schrift fällt still auf eine andere zurück, mit anderen Metriken und damit anderem Umbruch. Genau der Unterschied zwischen "sieht auf dem Mac gut aus" und "steht auf dem Pi anders auf dem Panel".

Kostet 134 KB im Stylesheet, die nie das Netz sehen: Zum Knoten geht nur das fertige PNG.

Aus demselben Grund sind die Wetter- und Warnzeichen Inline-SVG (app/zeichen.py) und keine Textzeichen: ☀ ☁ ☂ ⚠ liegen außerhalb des eingebetteten Latin-Subsets und kämen vom Host — auf einem schlanken Pi OS also womöglich als leeres Rechteck. Als SVG sind sie Teil des Dokuments, in jeder Größe scharf und aus geraden Strichen statt Graustufen gebaut, was die Wandlung nach 1 Bit unverändert übersteht.

Auf welcher Hardware das läuft

Ein Pi 4 oder 5 ist die geradlinige Wahl: RAM im Überfluss, Playwright mit offiziellen arm64-Builds, Renderzeit im Sekundenbereich.

Ein Pi Zero 2 W (512 MB) reicht für Chromium nicht — ~280 MB neben dem OS gehen dort nicht mehr sinnvoll auf. Für diese Hardware müsste erst das Stylesheet zurückgebaut und eine leichte Engine wieder eingebaut werden (siehe oben). Wer es versucht, nimmt in jedem Fall ein 64-Bit-Image (arm64): Für aarch64 gibt es fertige Wheels für Pillow und cffi; auf dem 32-Bit-Image fehlen sie, dann kompiliert der Zero 2 W sie selbst.

Die Renderzeit ist Wartezeit, in der der Knoten dasteht und nichts tut — auch seine Uhr nicht. Ein vorgerendertes und zwischengespeichertes PNG würde das auflösen (siehe "Nächste Schritte").

Layoutregeln für E-Ink

  • Nur Schwarz und Weiß. Jedes Grau wird gedithert und grieselt.
  • Hierarchie über Schriftgröße, -gewicht und Laufweite, nie über Farbe.
  • Keine Schatten, keine Verläufe, kein border-radius mit Antialiasing.
  • Mindestens 13px, und die nur für versale Marken. Du liest das aus zwei Metern.
  • Weißraum trennt besser als Linien. Zwei Linienstärken reichen: 3 px zwischen Spalten und Ansichtsteilen, 2 px zwischen Modulen.
  • Invertiert (schwarze Fläche, weiße Schrift) ist der einzige Kontrast, der ohne Dithering auskommt — deshalb sparsam und immer für dieselbe Aussage: "das hier ist der Ausreißer".

Das Grau aus dem Entwurf

Der Entwurf setzt Modulmarken und Nebenwerte in #5a5a5a. Auf dem Panel geht das nicht auf: Nach der Gammakorrektur landet der Ton bei rund 56 % Helligkeit, und Floyd-Steinberg macht daraus ein Schachbrett. Bei 13 px mit 0,14em Laufweite frisst das die Buchstabenformen — genau dort, wo der Entwurf Ruhe wollte, entstünde Unruhe.

Deshalb steht der Wert an einer Stelle und nicht über vierzig Regeln verteilt:

render:
  gedaempft: "#000"    # "#5a5a5a" = der Ton aus dem Entwurf

Zum Vergleichen am echten Panel umstellen und /preview aufrufen; das Stylesheet muss dafür nicht angefasst werden.

Und das Panel kann mehr. Die 7,5"-Zelle beherrscht vier Graustufen. ESPHomes waveshare_epaper spricht sie nicht an — Graustufenpfade gibt es dort nur fürs 2.9"-GDEW029T5 und das 1.54"-GDEW0154M09, für kein 7.5"-Modell, und color_palette: 4-gray nimmt die Plattform nicht an (dazu läuft esphome/feature-requests#3035). Genau dafür steht jetzt der eigene Treiber unter esphome/components/epaper7in5 bereit; wie man ihn einschaltet, steht unter "Der eigene Treiber".

Zwei Zeilen sind es dann, nicht eine — und sie gehören zusammen:

# config.yaml
render:
  stufen: 4
  gedaempft: "#5a5a5a"    # der Ton aus dem Entwurf
# esphome/eink-kueche.yaml
image:
  - platform: online_image
    type: GRAYSCALE         # statt BINARY

display:
  - platform: epaper7in5
    modus: grau

Drei Stellen, weil drei Schichten beteiligt sind: was gerendert wird, was über die Leitung geht und was gezeichnet wird. Zwei davon zu ändern und die dritte zu vergessen gibt kein Fehlerbild, nur ein falsches.

Was das ausmacht, sieht man nur in der Vergrößerung: Bei zwei Tönen zerfällt "#5a5a5a" bei 13 px in ein Schachbrett, das die Buchstabenformen frisst; bei vieren ist es einfach ein Grau. Zum Selbstanschauen stufen umstellen und tools/vorschau.py laufen lassen — die .panel.png daneben zeigt den Unterschied.

Der Preis steht bei "Der eigene Treiber": Der Graudurchlauf ist immer ganzflächig und dauert rund doppelt so lange wie ein schneller — und er schließt den Teil-Refresh aus, mit dem der Knoten die Uhr malt.

Die Gammakorrektur in config.yaml ist die wichtigste Stellschraube. Am Monitor sieht alles gut aus; am Panel saufen die Mitteltöne ab. Rechne damit, den Wert nach dem ersten echten Test zwischen 1.6 und 2.2 nachzuziehen.

Wenn das Panel negativ steht

devices:
  kueche:
    invert: true

Steht auf dem Panel alles seitenverkehrt hell und dunkel, während /preview richtig aussieht, gehört hier true hin.

Mit dem eigenen Treiber braucht es das nicht, der Wert steht auf false. Und es dreht sich auch nicht irgendwo ein Bit; der Grund ist bekannt:

waveshare_epaper schiebt den Bildpuffer komplementiert heraus (this->data(~(this->buffer_[i]))), lässt aber Register 0x50 auf 0x10 stehen — und mit gelöschtem Bit 5 liest der Controller ein gesetztes Bit als Schwarz. Beides zusammen ergibt ein negatives Panel. Nachzulesen an Waveshares eigenem Treiber: EPD_7IN5_V2_Clear() löscht nach Weiß, indem es 0xFF an 0x10 und 0x00 an 0x13 schickt — bei ihrem 0x50 = 0x10. Ihr Teil-Refresh setzt 0xA9 und schreibt danach unkomplementiert. Bit 5 ist der Unterschied, und epaper7in5 setzt es.

Der Schalter bleibt trotzdem, für den Fall, dass ein anderes Panel ihn braucht.

Der Ausgleich sitzt deshalb dort, wo er ohne Flashen erreichbar ist: beim Gerät, neben rotate. Beides beschreibt, wie dieses eine Panel angeschlossen ist — nicht, wie das Bild aussehen soll. Zwei Regeln hängen daran:

  • Ganz am Ende der Kette. Erst gamma, drehen und rastern auf dem Bild, das jemand sehen soll, dann der Tausch. Andersherum rasterte Floyd-Steinberg ein anderes Bild, und das Ergebnis wäre nicht mehr das Negativ, sondern nur ähnlich.
  • Nur /render. /preview und tools/vorschau.py zeigen weiter, was am Panel stehen soll. Wer in den Browser schaut, will das Panel sehen und nicht die Leitung.

Der eigene Treiber

Unter esphome/components/epaper7in5 liegt ein eigener Displaytreiber, den eink-kueche.yaml über external_components einbindet. Er ersetzt waveshare_epaper für dieses Panel. Der Grund steht ausführlich unter Warum waveshare_epaper nicht reicht; kurz: Der eingebaute Treiber kann für das 7,5"-Panel nur ganzflächig blinken, sein Teil-Refresh-Zweig setzt das Fenster fest auf das ganze Panel, und er wartet auf BUSY mit vertauschter Polarität.

Drei Betriebsarten, modus entscheidet:

modus Wellenform Dauer Blinkt Ergebnis
voll aus dem OTP ~3 s ja sauber, keine Reste
schnell aus Registern ~1,5 s nein lässt mit der Zeit Reste stehen
grau aus Registern ~3 s nein vier Töne, immer ganzflächig

schnell ist die Voreinstellung und in einer Küche der eigentliche Gewinn: Bei minütlicher Uhr blitzt das Panel sonst jede Minute schwarz-weiß durch. Weil jeder schnelle Durchlauf einen Rest stehen lässt, kommt spätestens voll_nach nach dem letzten vollen wieder einer mit der vollen Wellenform — voreingestellt sind zehn Minuten.

Gemessen und nicht gezählt: Eine Zahl von Durchläufen ("nach zwölf schnellen") ginge nur auf, solange allein die Uhr tickt. Mit der Abfahrtstafel hängt die Zahl der Durchläufe am Fahrplan — abends zweimal die Stunde, im Berufsverkehr jede Minute —, und ein Zähler machte daraus das Verkehrte: Der Abstand zwischen zwei vollen Durchläufen wäre genau dann am größten, wenn ohnehin niemand hinsieht. Was jemand vor dem Panel sieht, ist die Zeit, also steht sie auch in der Config.

Fällig heißt dabei nicht sofort, siehe unten: Ein voller Durchlauf wartet aufs nächste ganze Bild.

modus und render.stufen gehören zusammen. Zwei Töne an den Graumodus oder vier an einen Monomodus gibt kein Fehlerbild und keine Meldung, nur ein falsches Bild:

render.stufen: 2   <->   modus: voll | schnell
render.stufen: 4   <->   modus: grau

Das Panel schlafen legen — oder nicht

Vorweg, weil der Name in die Irre führt: Das sichtbare Bild verschwindet nie. E-Paper hält es ohne Strom. Schlafen gelegt wird der Controller-Chip.

panel_schlafen: true schickt beim Herunterfahren Kommando 0x07. Der Controller schläft und verliert dabei seinen Bildspeicher — und ohne den weiß danach niemand mehr, was auf dem Panel steht; der erste Durchlauf muss deshalb ein voller sein, also einmal blinken. false schaltet nur die Hochspannung ab (0x02), der Speicher bleibt gültig, und der erste Durchlauf nach einem Neustart darf ein Teil-Refresh sein.

In esphome/eink-kueche.yaml steht false. Für true spräche der Ruhestrom im Deep Sleep, und den gibt es hier nicht: "Herunterfahren" heißt Neustart oder OTA, und dort ist ein erzwungenes Vollbild kein Gewinn.

Woran der Treiber merkt, ob der Bildspeicher noch gültig ist: Drei Werte liegen im RTC-Speicher des ESP32 (.rtc.data, 12 Byte) und überleben einen Neustart. RTC-Speicher und Panel hängen am selben 3,3-V-Zweig — steht die Kennung noch drin, hatte auch das Panel durchgehend Strom. Fällt der Strom aus, wird beides gleichzeitig ungültig, und der nächste Durchlauf ist von selbst ein voller.

Der BUSY-Pin meldet sich selbst

Ein Durchlauf dauert Sekunden. Meldet BUSY nach Millisekunden "fertig", ist etwas faul — falsche Polarität, lose Leitung, gar nicht angeschlossen. Der Treiber schreibt dann eine Warnung ins Log und wartet ersatzweise blind eine plausible Zeit ab, statt in ein noch laufendes Bild zu schreiben:

[W][epaper7in5]: BUSY meldete nach 3 ms fertig — Polarität prüfen, warte 800 ms

Damit sind beide Fehler im Log unterscheidbar — nie warten und ewig warten (Timeout while displaying image!) —, statt dass einer von beiden stumm bleibt.

"took a long time for an operation"

Diese Zeilen im Log stimmen, und sie sind nicht zu beheben — nur zu verschieben:

[W][component]: interval took a long time for an operation (4709 ms), max is 2190 ms
[W][component]: a scheduled task took a long time for an operation (768 ms), max is 50 ms
[W][component]: a scheduled task took a long time for an operation (114 ms), max is 50 ms

Ein Durchlauf des Panels hält die ESPHome-Schleife an: ganzflächig rund vier Sekunden, für ein Fenster knapp eine. Der Treiber wartet dabei auf BUSY, und solange läuft weder WLAN noch API. Das liegt am Panel, nicht an der Konfiguration. Die 114 ms sind übrigens etwas anderes — das ist der /plan-Aufruf selbst, ESPHomes http_request blockiert ebenfalls. Diese Zeilen sind mit dem Umbau auf MQTT fast verschwunden: /plan wird nur noch gerufen, wenn die Uhr steht.

ESPHome hebt seine Warnschwelle nach jeder Meldung selbst an, kommt aber nicht über 2550 ms hinaus (WARN_IF_BLOCKING_OVER_CS, ein uint8_t in Zentisekunden). Ein voller Durchlauf liegt darüber und warnt deshalb für immer; Aufrufe aus Skripten heraus warnen ohnehin ab 50 ms, ganz ohne Anhebung.

Was sich dagegen tun ließ, steht im Treiber: Ein fälliger voller Durchlauf wird aufgeschoben, wenn gerade nur ein Fenster gezeichnet wird. Sonst träfe voll_nach irgendwann den Minutentick der Uhr — vier Sekunden Stillstand für den kleinsten Anlass des Tages. Jetzt wartet der Putz, bis ohnehin ein ganzes Bild gezeichnet wird; dort steht der Knoten sowieso, weil er gerade 48 KB geladen hat. Spätestens nach usb.refresh_seconds ist das der Fall.

Zwei Stellschrauben bleiben dir:

  • mindestpause hochsetzen (in eink-kueche.yaml). Das ändert nichts an den Meldungen selbst, sondern daran, wie oft ein Durchlauf überhaupt stattfinden darf — vier Sekunden Stillstand seltener. Der Preis ist Verspätung, nie eine verlorene Meldung: Was während der Pause ankommt, wird danach gezeichnet.
  • Die Meldungen abschalten. logger: logs: {component: ERROR}, in eink-kueche.yaml auskommentiert vorbereitet. Danach meldet sich allerdings auch eine Komponente nicht mehr, die wirklich klemmt.

Wirklich weg wäre das Blockieren nur mit einem Treiber, der das Warten auf BUSY über mehrere Schleifendurchläufe verteilt statt in einem delay() zu sitzen. Das ist ein Umbau, kein Handgriff — und lohnt erst, wenn das Blockieren stört und nicht bloß die Zeile im Log.

Auf E-Ink ist COLOR_ON weiß

Die eine Stelle, an der dieser Treiber jemanden hereinlegt — und zwar lautlos. Wer in der Lambda des Displays etwas mit dem naheliegenden COLOR_ON malt, malt weiß auf weiß: kein Fehler, keine Meldung, nur ein leeres Feld. Die Kette dahinter:

helles Pixel  ->  online_image setzt das Bit (is_color_on prüft Helligkeit)
              ->  it.image zeichnet gesetzte Bits mit COLOR_ON
              ->  draw_absolute_pixel_internal löscht dafür das Pufferbit
              ->  auf dem Panel weiß

Genau deshalb steht das Bild ohne invert: true richtig herum — die Konvention ist in sich stimmig, sie heißt nur verkehrt. Für Zeichnen von Hand gibt es deshalb Namen, die sagen, was sie tun:

it.print(x, y, id(font), epaper7in5::TINTE, TextAlign::BASELINE_LEFT, text);

TINTE ist schwarz, PAPIER weiß. Beide stehen in components/epaper7in5/epaper7in5.h.

Was der Ausschnitt sonst noch könnte

Die Uhr benutzt ihn bereits (siehe unten). Was fehlt, ist der allgemeine Fall: Niemand sagt dem Knoten, welcher Ausschnitt sich sonst geändert hat. Dafür müsste /plan das nächste Bild schon kennen — also das PNG bei Revisionswechsel vorrendern (steht ohnehin auf der Liste), gegen das zuletzt ausgelieferte diffen und das Rechteck mitschicken. Der Gewinn wäre doppelt: ein Durchlauf von Bruchteilen einer Sekunde, und statt 48 KB nur noch der Ausschnitt über die Leitung.

Stand

Läuft am Panel. Zurück geht es jederzeit — platform: waveshare_epaper mit model: 7.50inv2, und solange usb_threshold: -1 gilt, per OTA. Dann aber uhr.vom_knoten in der config.yaml auf false stellen: Der eingebaute Treiber kann kein einzelnes Rechteck auffrischen, das Uhrfeld bliebe leer.

Die Wellenformtabellen stammen aus GxEPD2 und GxEPD2_4G von Jean-Marc Zingg, die sie ihrerseits aus dem Demo-Code von Good Display fürs GDEW075T7 haben — dasselbe Panel, derselbe Controller. Wer das Projekt weiterveröffentlicht, sieht dort nach der Lizenz.

Situationsabhängige Ansichten

Welche Ansicht ein Gerät bekommt, entscheidet schedule.py anhand dessen, was der Server gerade weiß. Mehr macht es nicht.

Insbesondere steht dort nicht, wie lange der Knoten danach schlafen darf. Daran hinge sonst der größere Teil dieses Kapitels: Schlafdauern, die am Fensterende abgeschnitten werden, Kalendertermine, die das Aufwachen vorziehen, eine gemeldete Weckzeit als dritte Quelle. Der Knoten hängt am Kabel und bleibt wach, die Zelle ist nur Notstrom (siehe Ein Takt für alles) — ein Panel, das nie schläft, ist immer schon wach, wenn jemand hinsieht.

Bleibt die Frage, die tatsächlich Gestaltung ist: Was soll um diese Uhrzeit, an diesem Wochentag, in dieser Lage auf dem Panel stehen.

view: nennt ein Layout aus layouts: (siehe Eigene Ansichten bauen) oder ein eigenes Template. Die Regeln stehen unter windows: beim Gerät und werden von oben nach unten geprüft — die erste, die zutrifft, gewinnt. Spezielles gehört deshalb nach oben.

windows:
  # Musik läuft -> die Wiedergabe.
  musik:
    view: now_playing
    playing: true

  # Auf Zuruf vom Kurzbefehl, unabhängig von der Uhr.
  gute_nacht:
    view: gute_nacht
    signal: gute_nacht

  # Werktags morgens: Termine groß, Nachrichten lesbar.
  morgen:
    view: morgens
    start: "06:00"
    end: "09:00"
    weekdays: [0, 1, 2, 3, 4]

  # Nachts.
  nacht:
    view: dashboard
    start: "22:00"
    end: "06:00"

Jede Bedingung ist optional, und alle angegebenen müssen zutreffen:

Bedingung Bedeutung
start / end Uhrzeitfenster, darf über Mitternacht gehen
weekdays [0, 1, 2, 3, 4] = Mo–Fr
focus Name eines Apple-Fokus, schlägt die Uhrzeit (siehe unten)
signal Stichwort aus POST /signal, gilt bis es verfällt
ha Label aus entities -> erlaubte Zustände
playing true = nur bei gemeldeter Wiedergabe, false = nur ohne

Eine Regel ganz ohne Bedingungen trifft immer zu und taugt damit als Auffangregel am Ende.

Fehlende Zustände lassen eine Regel nicht zutreffen. Ist Home Assistant aus, der Sensor weg oder das Label nicht in entities eingetragen, greift die Musikregel nicht und es geht mit der nächsten weiter. Das ist Absicht: Lieber das gewohnte Dashboard als eine Ansicht, die auf einem Zustand von vorgestern beruht.

Die Stromquelle ist keine Bedingung mehr. Sie war es, solange am Kabel ein anderer Betrieb galt als am Akku; seit es nur noch einen gibt, entscheidet sie nichts. Der Knoten meldet sie weiter (auf eink/<gerät>/akku), aber nur noch für den Hinweis neben dem Akkustand — siehe Akkustand auf dem Panel.

Der Wochenplan steht in der Config

Nachts ein anderes Bild als morgens, und am Wochenende ein anderes als werktags — das kennst du ja, du hast es selbst so eingestellt. Deshalb steht es einfach direkt in config.yaml, als zwei Nachtfenster:

nacht_werktag:
  view: dashboard
  start: "22:00"
  end: "06:00"
  weekdays: [0, 1, 2, 3, 4]

nacht_wochenende:
  view: dashboard
  start: "22:00"
  end: "08:00"
  weekdays: [5, 6]

Dabei zählt bei einem Fenster über Mitternacht der Wochentag des Morgens, in den hinein es reicht — Freitag 23 Uhr wird also schon als „Samstag“ behandelt und läuft bis 08:00 statt bis 06:00. Das übernimmt Window._weekday_matches() in config.py.

Das löst den Regelfall vollständig und ganz ohne Apple-API, Shortcuts oder Home Assistant.

Auf Zuruf: ein Signal vom Kurzbefehl

Manches folgt keiner Uhrzeit. Du gehst heute früher ins Bett, oder du willst morgens beim Aufstehen ein anderes Panel sehen als um dieselbe Zeit an einem freien Tag. Dafür gibt es ein Stichwort, das von außen gemeldet wird und eine Weile gilt:

POST /signal
{"name": "gute_nacht"}

In config.yaml hängt ein Fenster daran:

gute_nacht:
  view: gute_nacht
  signal: gute_nacht

Der Name ist frei wählbar — Groß-/Kleinschreibung und Leerzeichen außen räumt der Server weg. Er muss nur in beiden Dateien derselbe sein. Die Ansicht dahinter musst du bauen (siehe Eigene Ansichten bauen); ein view:, das es nicht gibt, meldet der Server beim Start.

Bewusst als JSON-Body und nicht als URL-Parameter: Zeiten mit + in der Zeitzone kämen dort als Leerzeichen an und würden still verfälscht.

Wie lange es gilt. Ohne Angabe zwei Stunden. Mit minutes eine eigene Dauer, mit until ein Zeitpunkt:

{"name": "gute_nacht", "minutes": 480}
{"name": "guten_morgen", "until": "07:30"}

Bei until reicht die blanke Uhrzeit — welches Datum dazugehört, rechnet der Server aus, er weiß ja, wie spät es ist. Abends um 22 Uhr wird aus "06:00" der nächste Morgen, nachts um 00:30 noch derselbe. Die lange Form mit vollem Datum geht auch, ISO 8601 und RFC 2822; dann aber ohne Zeitzone schicken, damit der Server sie aus config.yaml ergänzt — ein fest eingebautes +02:00 wäre den ganzen Winter eine Stunde daneben.

minutes: 0 löscht ein Signal von Hand. Alles über 24 Stunden lehnt der Server ab: Was länger gilt, ist kein Signal mehr, sondern eine Einstellung — und die gehört in die config.yaml, wo sie einen Neustart überlebt.

Das Panel schaltet sofort um. Der Server sieht nach dem Melden von sich aus nach, ob ein Gerät jetzt anders aussähe, und schickt den neuen Stand direkt aufs MQTT (_sofort_melden in app/main.py). Ohne das hinge es an den Schleifen: Der Beobachter läuft nach einem Quellenabruf, die Taktschleife alle 30 Sekunden — und ein Signal kommt weder aus einer Quelle noch zu einer runden Sekunde. Wer "Hey Siri, gute Nacht" sagt und danach eine halbe Minute auf das alte Bild sieht, hält den Kurzbefehl für kaputt.

Zweimal dasselbe Signal kostet trotzdem nur einen Durchlauf: Verglichen wird gegen den zuletzt verschickten Stand, und ist der derselbe, geht nichts raus. Dasselbe gilt fürs Löschen — das Zurückschalten ist genauso eine Änderung und geht genauso sofort.

Ein Gerät nimmt /signal bewusst nicht entgegen. Ein Signal ist eine Aussage über den Moment, nicht über einen Bildschirm; welches Panel daraus etwas macht, steht in dessen windows:. Zwei Panels in einer Wohnung, in der jemand ins Bett geht, sind beide gemeint.

Signale liegen im Arbeitsspeicher und gehen beim Serverneustart verloren. Danach greift wieder der Plan aus der Config; das ist der gewollte Rückfall, kein Verlust.

Kurzbefehl einrichten (Automation → „Wenn Fokus ‚Schlafen‘ eingeschaltet wird“):

  1. Aktion „Inhalt von URL abrufen“:
    • URL: http://<pi>:8080/signal
    • Methode: POST
    • Anfragetext: JSON, Feld name (Text) mit gute_nacht
  2. „Vor Ausführung fragen“ deaktivieren — sonst kommt nachts nur eine Rückfrage statt eines HTTP-Requests.

Für den Morgen dasselbe mit „Fokus ausgeschaltet“ und einem zweiten Namen. Weil der Knoten wach ist und am Broker hängt, steht das neue Bild binnen Sekunden da.

Zum Testen von Hand:

curl -X POST http://localhost:8080/signal \
  -H 'Content-Type: application/json' \
  -d '{"name": "gute_nacht", "minutes": 30}'

GET /plan gibt die gerade gültigen Signale unter signals zurück — damit siehst du beim Einrichten sofort, ob eines angekommen ist.

Fenster an einen Apple-Fokus koppeln

Statt eines Kurzbefehls geht auch der Fokus selbst, wenn Home Assistant im Haus ist. Ein Fenster bindet sich dann daran:

nacht_werktag:
  ...
  focus: "Schlafen"   # exakter Name hängt von der Spracheinstellung ab

Einrichtung:

  1. In der Home-Assistant-Begleit-App auf dem iPhone unter den App-Einstellungen den Sensor „Fokus“ aktivieren.
  2. Die entstehende Entity (etwa sensor.iphone_von_tom_fokus) in config.yaml unter homeassistant.entities mit dem Label focus eintragen.
  3. In Home Assistant unter Entwicklerwerkzeuge → Zustände den exakten State-Text ablesen, während der Fokus aktiv ist (deutsch meist „Schlafen“, englisch „Sleep“), und als focus:-Wert im Fenster eintragen.

Ist der Sensor gesetzt und liefert einen Wert, entscheidet er — unabhängig von der Uhrzeit im Fenster. Liefert er keinen verlässlichen Wert (Sensor fehlt, HA nicht erreichbar, unavailable/unknown), fällt die Logik auf die konfigurierte Uhrzeit zurück; ein App- oder Netzwerkhänger legt damit nicht die ganze Nachtsteuerung lahm.

Einschränkung: Der Begleit-App-Sensor aktualisiert sich nur opportunistisch (Hintergrundaktualisierung, Standortwechsel) und kann ein paar Minuten hinter dem tatsächlichen Fokuswechsel liegen. Wer es auf die Sekunde will, nimmt POST /signal oben — das ist derselbe Weg, nur ohne Umweg über HA.

Auf Zuruf: eine Ansicht einblenden

Ein Signal sagt, wie die Lage ist, und die windows: entscheiden, was daraus folgt. Manchmal will man aber gar keine Lage beschreiben, sondern etwas sehen: Man steht in der Küche, die Bahn geht gleich, und auf dem Panel steht das Dashboard.

POST /zeigen
{"ansicht": "abfahrten"}

Das Panel schaltet um und nach einer Weile von selbst zurück. Was ein Name bedeutet, steht in der config.yaml:

zeigen:
  dauer: 90s                                    # gilt, wo unten keine steht
  ansichten:
    warnungen:   { view: alert, dauer: 3min }
    abfahrten:   { dauer: 60s }
    kalender:    { dauer: 2min }
    nachrichten: { dauer: 2min }

Ohne view: ist die Ansicht der Name selbst, ohne dauer: gilt die des Blocks. Ein Name, der dort gar nicht steht, geht trotzdem — er meint die gleichnamige Ansicht. Das ist der Punkt an der Sache: Ein neues Layout in layouts: ist damit ein neuer Sprachbefehl, ohne dass eine Zeile Code oder Config dazukommt. Der Block ist nur dafür da, einem Namen eine andere Ansicht oder eine andere Dauer zu geben — warnungen heißt eben nicht alert, weil man das nicht sagt.

Die Dauern sind am Lesen gemessen und nicht an der Technik. Eine Abfahrtstafel ist nach einem Blick erledigt, eine Warnung mit Handlungsempfehlung ist ein Absatz — dieselbe Überlegung wie bei nina.haltedauer, und deshalb dieselbe Zahl.

Welches Panel. Ohne device sind alle gemeint: Wer im Flur "zeige Abfahrten" sagt, meint das, was er von dort aus sieht, und will kein Panel benennen müssen. Mit device genau eines. Dass dieser Endpunkt ein Gerät entgegennimmt und /signal nicht, ist derselbe Unterschied noch einmal: Eine Einblendung ist ein Wunsch an einen Bildschirm.

{"ansicht": "warnungen", "device": "kueche"}   nur dieses Panel
{"ansicht": "kalender", "sekunden": 300}       fünf Minuten statt der Vorgabe
{"ansicht": "abfahrten", "sekunden": 0}        sofort zurück zum Üblichen

Alles über einer Stunde lehnt der Server ab. Was so lange gelten soll, ist kein Zuruf mehr, sondern ein Zustand — und der gehört in die windows: des Geräts, wo er einen Neustart überlebt. Dieselbe Grenze zieht /signal bei 24 Stunden, aus demselben Grund und eine Größenordnung höher: Ein Signal beschreibt einen Abend, eine Einblendung einen Moment.

Was eine Einblendung nicht darf: eine Warnung verdecken, die noch niemand gesehen hat. Die Rangfolge steht an einer Stelle (_ansicht_waehlen in app/main.py) und lautet von oben nach unten:

  1. eine Warnung, die noch nie zu sehen war
  2. eine Einblendung
  3. eine Warnung, deren Haltedauer noch läuft
  4. was die windows: des Geräts ergeben

Der Zuruf steht zwischen den beiden Warnungszeilen, und das ist die einzige Entscheidung, die hier wirklich getroffen wird. Eine ungesehene Warnung darf ein Kurzbefehl nicht wegdrücken — sonst wäre er ein Weg, sich Warnungen abzugewöhnen. Umgekehrt gilt: Wer die Warnung gesehen hat und danach "zeig mir die Abfahrten" sagt, hat sich entschieden; sie bleibt dann als Zeile oben stehen, statt das Panel für den Rest ihrer Haltedauer zu belegen.

Verdrängt wird dabei nichts. Eine ungesehene Warnung, die während einer Einblendung hereinkommt, gewinnt sofort; eine mit Haltedauer kommt zurück, sobald die Einblendung verfällt.

Hin und zurück gehen beide sofort. Wie beim Signal sieht der Server nach dem Aufruf von sich aus nach und schickt den neuen Stand direkt aufs MQTT. Für den Rückweg gibt es dafür einen Wecker: Er hängt an nichts als der Uhr, und ohne ihn fiele er der Taktschleife zu, die alle 30 Sekunden nachsieht — eine Tafel, die eine Minute stehen soll, stünde dann schon mal anderthalb.

Einblendungen liegen im Arbeitsspeicher und sind nach einem Serverneustart weg. Das ist der gewollte Rückfall: Danach gilt wieder der Plan aus der Config.

Drei Ansichten liegen dafür schon bei — abfahrten, kalender und nachrichten. Es sind gewöhnliche Layouts, jedes mit einem Thema in der breiten Spalte und Uhr und Wetter daneben, wie überall:

  abfahrten:
    rechts:
      - name: abfahrten
        titel: "Abfahrten"
        entity: abfahrt
        anzahl: 8

warnungen braucht keines, es zeigt auf das vorhandene alert.html.

Kurzbefehl einrichten (Kurzbefehle → + → Aktion „Inhalt von URL abrufen“):

  1. URL: http://<server>:8080/zeigen
  2. Methode: POST, Anfragetext: JSON
  3. Ein Feld ansicht (Text) mit abfahrten
  4. Bei gesetztem Token: Kopfzeile Authorization mit Bearer <token>
  5. Den Kurzbefehl „Abfahrten zeigen“ nennen — der Name ist das, was du sagst: „Hey Siri, Abfahrten zeigen“.

Für jeden weiteren Befehl dasselbe mit einem anderen ansicht-Wert. „Vor Ausführung fragen“ gehört ausgeschaltet, sonst kommt statt des Requests eine Rückfrage.

Zum Ausprobieren von Hand:

curl -X POST http://localhost:8080/zeigen \
  -H 'Content-Type: application/json' \
  -d '{"ansicht": "abfahrten", "sekunden": 30}'

GET /healthz zeigt unter eingeblendet, was gerade auf welchem Panel steht und wie viele Sekunden es noch bleibt — die Antwort auf die Frage, die man beim Einrichten wirklich hat: „warum steht da die Abfahrtstafel?“

Was gerade läuft (Apple Music vom iPhone)

Apple bietet keine serverseitige Schnittstelle dafür: Was auf dem iPhone läuft, weiß nur das iPhone. Deshalb dieselbe Richtung wie beim Signal — das Telefon meldet, der Server merkt es sich.

POST /now-playing
{"title": "Ghosts", "artist": "Japan", "duration": 275}

artist, album und duration sind freiwillig, ein leerer Titel löscht den Eintrag. duration ist die Laufzeit in Sekunden; damit verfällt der Eintrag ungefähr dann, wenn der Titel zu Ende ist, statt nach der pauschalen Frist von NOW_PLAYING_TTL (12 min). Verfallen muss er, weil uns niemand das Ende meldet — sonst stünde derselbe Song bis zum Serverneustart auf dem Panel und die musik-Regel bliebe mit ihm hängen.

Kurzbefehl einrichten:

  1. Aktion „Aktuellen Titel abrufen".
  2. Aktion „Inhalt von URL abrufen":
    • URL: http://<pi>:8080/now-playing
    • Methode: POST
    • Anfragetext: JSON, Felder title, artist und duration aus dem abgerufenen Titel
  3. „Vor Ausführung fragen" deaktivieren.

Zum Testen von Hand:

curl -X POST http://localhost:8080/now-playing \
  -H 'Content-Type: application/json' \
  -d '{"title": "Ghosts", "artist": "Japan", "duration": 275}'

GET /plan?device=kueche zeigt danach now_playing als Ansicht — damit siehst du beim Einrichten sofort, ob die Meldung angekommen ist.

Der wunde Punkt: iOS hat keinen Auslöser für Titelwechsel. Apples Automationsliste kennt Zeit, Ort, Nachrichten, Fokus, Bluetooth, NFC, „App geöffnet" und einiges mehr — nichts davon betrifft die Wiedergabe. Brauchbar sind deshalb nur:

  • „Wenn App ‚Musik' geöffnet wird" — meldet beim Aufmachen, nicht beim Weiterskippen. Als Gegenstück dieselbe Automation für „geschlossen" mit leerem Titel, damit das Panel danach zurückfällt.
  • Bluetooth- oder CarPlay-Verbindung — meldet beim Aufsetzen der Kopfhörer.
  • Aktionstaste oder Kurzbefehl im Kontrollzentrum — von Hand, dafür verlässlich.

Wer den Titel wirklich bei jedem Wechsel automatisch will, kommt an iOS vorbei nicht weiter. Der übliche Ausweg ist Scrobbling: ein Player, der die Apple-Music-Bibliothek abspielt und an Last.fm meldet, plus ein Quellmodul hier, das user.getRecentTracks abfragt — der laufende Titel ist dort als nowplaying markiert. Das wäre ein weiteres Modul in app/sources/ neben Wetter und Nachrichten, kein Umbau.

Läuft die Musik dagegen über HomePod, Apple TV oder Music Assistant, brauchst du das alles nicht: Dann steht der Zustand ohnehin als media_player in Home Assistant, und die Regel hängt an ha: statt an playing:. Das Widget kann beide Quellen und nimmt die gemeldete Wiedergabe zuerst.

Meldungen im Vollbild

Manches ist wichtiger als der Terminplan. Drei Quellen dürfen das Dashboard deshalb komplett ersetzen (templates/alert.html):

Quelle löst aus bei Rang
NINA Warnung ab nina.min_severity höchster
Feed Titel enthält ein feed.alert_keywords-Wort und ist frisch mittlerer
Home Assistant ein Zustand aus homeassistant.alerts trifft zu niedrigster

Erst ganzflächig, dann als Zeile

Eine Meldung durchläuft vier Zustände, und der Server merkt sich pro Gerät, wo sie steht:

  1. Neu und stark genug (nina.alert_severity, bei Eilmeldungen immer, bei HA-Regeln takeover: true) → das Gerät bekommt die Ansicht alert, ganzflächig.
  2. Ausgeliefert, aber die Frist läuft → sobald /render das Bild wirklich abgegeben hat, startet nina.haltedauer (Standard drei Minuten). Bis die um ist, bleibt die Ansicht alert. Zwischendurch fällige Neuzeichnungen liefern dasselbe Vollbild; die Frist schieben sie nicht.
  3. Frist abgelaufen → die Meldung wandert in die Zeile am oberen Rand des normalen Dashboards und bleibt dort.
  4. Erloschen → Warnung aus dem NINA-Dashboard verschwunden, Eilmeldung zu alt, HA-Zustand zurückgesprungen: Die Zeile verschwindet von selbst. Das sticht die Haltedauer — was es nicht mehr gibt, wird nicht festgehalten.

Schwächere Warnungen (ab min_severity, aber unter alert_severity) überspringen Schritt 1 bis 3 und stehen von Anfang an nur als Zeile da.

Ein Blick über /preview zählt nicht als Auslieferung — sonst würde ein Kontrollblick im Browser dem Panel die Warnung wegnehmen. Der Zustand liegt nur im Speicher: Nach einem Serverneustart übernimmt eine noch laufende Warnung einmal erneut das Panel. Das ist die harmlosere Richtung.

Warum es die Haltedauer braucht

Weil der Knoten auf jede Änderung sofort reagiert (siehe Der Knoten hört zu) und nichts ein Vollbild sonst festhält: Eine Eilmeldung, die zwei Sekunden nach der Warnung eintrudelt, bekäme das Panel, und die Warnung wäre nie gelesen worden. Der Warntag hat gezeigt, was auf so einem Bild steht — Überschrift, Beschreibung, Handlungsanweisung. Das ist ein Absatz, kein Satz.

Gemessen wird ab der ersten Auslieferung, nicht ab der letzten. Andernfalls schöbe jedes Neuzeichnen die Frist vor sich her, und bei einer Unwetterwarnung, die stundenlang gilt, käme das Dashboard nie zurück.

Schritt 3 braucht nichts weiter: Der Wechsel ändert die Revision, und die Taktschleife des Servers (alle 30 s) schickt den neuen Stand von selbst aufs MQTT — auch dann, wenn in derselben Zeit keine Quelle etwas Neues gebracht hat. Ein Sonderweg, der den Knoten für diesen einen Wechsel eigens anstößt, erübrigt sich damit.

Wie lange eine Meldung überhaupt gilt, wird nirgends gespeichert — es ergibt sich aus der Quelle selbst: Die Eilmeldung verfällt über ihr Alter (alert_max_age_minutes, Standard 90), die NINA-Warnung verschwindet aus dem Dashboard-Endpunkt, der HA-Zustand wechselt zurück. Damit übersteht die Logik einen Serverneustart, ohne dass eine alte Warnung kleben bleibt, und es braucht keinen Mechanismus zum Wegklicken.

Warum nicht zwei Bilder auf einmal

Naheliegend wäre, dem Knoten beide Bilder mitzugeben — erst das Vollbild, dann das mit der Zeile — und ihn selbst umschalten zu lassen. Das spart einen Abruf und ist trotzdem die teurere Rechnung: Der Knoten ist wach, wenn die Zeile fällig wird, und ein zweites 48-KB-Bild im PSRAM aufzuheben kostet mehr Sorgfalt als ein Abruf, der ohnehin stattfindet.

Die Erkennung von Eilmeldungen hängt am Titel: Der Tagesschau-Feed hat keine eigene Kategorie dafür und auch keinen separaten Eilmeldungs-Feed — geprüft, /eilmeldung/index~rss2.xml gibt es nicht. Übrig bleibt das Schlagwort im Titel, und das steht deshalb in der Config statt im Code:

feed:
  alert_keywords: ["Eilmeldung"]
  alert_max_age_minutes: 90

Steht das Wort am Anfang des Titels, wird es dort entfernt — über der Meldung steht es ohnehin schon in 30 Pixeln.

Zum Layouten musst du auf keine echte Eilmeldung warten: /html?device=kueche&view=alert zeigt die Ansicht direkt, notfalls mit Platzhaltertext.

Ein Takt für alles

Der Knoten schläft nicht. Er hängt am Kabel, bleibt wach und hört am Broker zu; gefragt wird nur noch im Notfall (siehe Push: der Server klingelt). Was er dann bekommt:

GET /plan?device=kueche
{
  "view": "dashboard",
  "poll_seconds": 60,        // Erbstück, siehe unten
  "revision": "7abfec5f3102",
  "alert": false,
  "signals": [],
  ...
}

Ein Betriebszustand, nicht zwei. Das Panel hängt zu 95 % am Kabel, und die Zelle überbrückt nur die Minuten, in denen jemand es abnimmt und woanders hinhängt — Notstrom. Gäbe es daneben einen Schlafbetrieb am Akku, wäre der halbe Server damit beschäftigt, vorauszurechnen, wann der Knoten das nächste Mal gebraucht wird. Deshalb kennt die Config kein sleep_seconds, kein default_sleep_seconds, kein alert_followup_seconds, keine Bedingung power: und keine Weckzeit. Der Akkustand wird gemessen und angezeigt (siehe Akkustand auf dem Panel), er entscheidet nur nichts.

Die Revision ist ein kurzer Hash über alles, was gerade sichtbar wäre: Termine, Schlagzeilen, Wetter, Meldungen, Akkustand — plus einen Zeitzähler, der alle takt.refresh_seconds weiterspringt. Der Knoten vergleicht sie mit der, die er zuletzt gemalt hat, und lädt nur bei Unterschied ein Bild. Daraus folgt beides auf einmal:

  • Eine Eilmeldung steht binnen Sekunden auf dem Panel. Sie ändert die Revision sofort, unabhängig vom Refresh-Takt.
  • Sonst wird im Takt von refresh_seconds neu gezeichnet.
devices:
  kueche:
    takt:
      poll_seconds: 10       # nur noch für ungeflashte Knoten
      refresh_seconds: 900

poll_seconds steht noch in der Antwort, aber kein aktueller Knoten liest es mehr: Er wartet auf MQTT und fragt nur, wenn die Uhr steht. Der Schlüssel bleibt aus demselben Grund wie das Thema revision — ein Knoten mit alter Firmware richtet sich danach. Mit dem letzten Flash fällt beides.

Was an refresh_seconds hängt, entscheidet der Abschnitt darunter. Solange der Server die Uhrzeit selbst ins Bild malt, hängt sie daran: 60 s gibt eine minutengenaue Uhr, und der Preis ist Verschleiß, denn jeder Durchlauf geht über das ganze Panel. Malt der Knoten sie selbst, hängt an refresh_seconds nur noch der Zeitstempel unten links — dann darf der Wert weit nach oben. Die mitgelieferte config.example.yaml bleibt bei den vorsichtigen 300 s, weil sie von keinem eigenen Treiber ausgeht.

Zieht jemand das Kabel, ändert sich am Takt nichts. Sichtbar wird es nur neben dem Akkustand, wo dann „Notstrom" steht — und weil das die Revision ändert, steht es binnen Sekunden auch wirklich da.

Die Uhrzeit malt der Knoten

Die Uhr war der einzige Grund, warum sich minütlich etwas änderte. Alles andere auf dem Panel — Termine, Schlagzeilen, Wetter, Abfahrten — ändert sich selten, und wenn, dann sagt es die Revision von selbst. Für eine Ziffer gingen also jeden Tag rund 1400 PNGs à 48 KB über die Leitung, wurden 1400 Bilder gerendert und 1400 Durchläufe über 800×480 Pixel geschrieben.

Mit uhr.vom_knoten dreht sich das um:

devices:
  kueche:
    uhr:
      vom_knoten: true
      format: "%H:%M"

Der Server lässt das Uhrfeld leer und legt seine Maße in die Antwort von /plan:

"uhr": {"x": 16, "y": 32, "breite": 227, "hoehe": 69,
        "grund_x": 19, "grund_y": 97, "format": "%H:%M"}

Der Knoten kennt die Uhrzeit selbst (time: platform: sntp), schreibt sie zur vollen Minute in dieses Rechteck und frischt nur dieses Rechteck auf — set_fenster aus dem eigenen Treiber. Kein Bild über die Leitung, kein Durchlauf über das ganze Panel. Der Server wird für die Uhr nicht gefragt; fällt er aus, läuft sie weiter, und nur der Rest friert ein.

Woher der Knoten weiß, wie spät es ist

SNTP ist der Normalfall — und zugleich die einzige Zutat, die von außerhalb des Hauses kommen muss. Ein Router, der ausgehendes NTP sperrt, und das Uhrfeld bliebe für immer leer: kein Fehler im Log, nur ein weißes Rechteck.

Deshalb gibt es einen zweiten Weg. /plan schickt seine eigene Zeit mit:

"server_time": "2026-09-05T00:14:03+02:00",
"server_epoch": 1788560043

Fehlt SNTP, rechnet der Knoten aus dieser Zahl und millis() weiter. Die Ungenauigkeit ist der Netzweg einer Anfrage im eigenen WLAN — Millisekunden, für eine Anzeige in Minuten also reichlich. Welcher Weg gerade gilt, steht bei jedem Wechsel im Log:

[I][uhr]: Zeitquelle: SNTP
[I][uhr]: Zeitquelle: Server (SNTP steht nicht)

Ausgelöst wird der Wechsel der Ziffern von zwei Seiten: on_time: seconds: 0 trifft die Minute genau, feuert aber nur mit gültiger Uhr — und ohne die bräuchte man es ja gerade. Darunter liegt deshalb ein interval: 10s. Beide rufen dasselbe Skript, und das rechnet erst nach und vergleicht:

uhr_text    was dastehen soll
uhr_gemalt  was wirklich auf dem Panel steht

Sind sie gleich, passiert nichts. Deshalb darf oft nachgerechnet werden, und deshalb kostet das zweite Netz nichts. Es ist zugleich die einzige Stelle im Knoten, die weiß, wie spät es ist — das Display malt nur noch ab. Stünde die Rechnerei zweimal da, einmal fürs Ticken und einmal fürs frisch geladene Bild, liefe sie irgendwann auseinander.

Damit stehen auf dem Panel zwei Uhrzeiten, und sie bedeuten Verschiedenes:

oben im Kopf unten links
sagt wie spät es ist wann die Daten entstanden
malt der Knoten der Server, ins Bild
ändert sich jede Minute erst beim nächsten Rendern

Der Zeitstempel unten soll gerade nicht mitlaufen. Auf E-Ink sieht ein eingefrorenes Bild aus wie ein aktuelles — er ist die einzige Stelle, an der man einen stehengebliebenen Knoten erkennt. Liefe er minütlich mit, wäre er wertlos. Umgekehrt taugt jetzt die Uhr oben als Lebenszeichen: Steht sie, lebt der Knoten nicht mehr.

Wann der Knoten aussetzt

"uhr": null heißt: diesmal nicht du. Das passiert in drei Fällen, und der erste ist der häufige:

  • Eine Meldung steht über der Uhr. Das Warnband schiebt den Kopf nach unten, und wie weit, hängt an der Länge des Warntexts. Dann zeichnet der Server die Ziffern wieder selbst — und das Panel wird ohnehin gerade neu geschrieben, die Meldung hat die Revision ja verändert.
  • Eine andere Ansicht als dashboard. alert hat ihre eigene Uhr an anderer Stelle.
  • vom_knoten: false, oder ein Knoten ohne eigenen Treiber. Dann bekommt er nie ein Loch zu sehen, das er nicht füllen kann.

Das Feld gehört zum Bild, nicht zum Moment

Die Nachricht mit dem neuen Uhrfeld kommt sofort, das Bild dazu über zehn Sekunden später — der Durchlauf des Panels plus die Mindestpause. Dazwischen liegen zwei verschiedene Ansichten auseinander: die, für die das Feld gilt, und die, die wirklich dasteht.

Übernimmt der Knoten das Feld beim Eintreffen der Nachricht, malt er in dieser Lücke die Uhr der nächsten Ansicht in die aktuelle. Am deutlichsten am Ende einer Einblendung: Die Abfahrtstafel steht noch auf dem Panel, mit den Ziffern, die der Server hineingemalt hat, und der Knoten stempelt seine eigene an die Stelle, wo das Dashboard seine Uhr hat. Auf dem Panel sind das zwei leicht versetzte Uhrzeiten nebeneinander, für ein paar Sekunden:

17:33:00  [bild]  f0432d63df55   die Abfahrtstafel wird gemalt
17:33:08  [stand] 6cd76e869849   Einblendung vorbei, das Uhrfeld ist zurück
17:33:16  [uhr]   (leer) -> 17:33   <- der Knoten stempelt, ins falsche Bild
17:33:29  [bild]  6cd76e869849   erst jetzt kommt das Dashboard

Deshalb führt der Knoten das Feld doppelt: uhr_neu_* ist, was der Server als Nächstes will, uhr_* gilt für das, was auf dem Panel steht. Übernommen wird in on_download_finished — im selben Moment wie das Bild, zu dem es gehört, und an keiner anderen Stelle.

Das ist auch der Grund, warum die Übernahme nicht in der Display-Lambda steht, wo sie näher läge: Die ruft auch der Minutentick auf, wenn er nur sein Rechteck auffrischt. Dort übernommen, zöge ein Uhrentick das Feld mit, ohne dass sich am Bild etwas geändert hätte — derselbe Fehler, nur seltener.

Der Nebeneffekt ist der richtige: Kommt das Bild gar nicht an, weil der Server weg ist, bleibt das alte Feld gültig — und die Uhr läuft weiter, an der Stelle, an der auch das Loch ist.

Wenn du am Layout schraubst

Das Rechteck ist ausgemessen, nicht ausgerechnet — wo die Tinte innerhalb ihres Feldes sitzt, ergibt sich aus der CSS-Zeilenhöhe und den Metriken der Schrift, und FreeType am Knoten misst ohnehin anders als Chromium. Nach jeder Änderung an style.css oder am Kopf des Layouts:

python tools/uhrfeld.py

Die Ausgabe ist fertig zum Einsetzen in renderer.UHRFELD. Vergisst du es, schlägt tests/test_uhr.py an: Der Test rendert beide Fassungen — mit Ziffern und mit Loch —, sucht den Unterschied und prüft, ob er im gemeldeten Rechteck liegt. Zwei Pixel Versatz reichen ihm.

Ein Detail aus derselben Ecke: Das leere Feld hält seinen Platz über einen unsichtbaren Platzhalter aus echten Ziffern (.uhr-zeit:empty::before), nicht über eingetragene Maße. Sonst fiele die Uhr quer auf null Höhe zusammen und gäbe hochkant ihre Spalte ans Wetter ab — das Layout sähe verschieden aus, je nachdem wer die Uhr zeichnet. Mit tabular-nums ist 00:00 auf den Pixel so breit wie jede andere Uhrzeit. Nur ein format: mit Sekunden bräuchte dort einen längeren Platzhalter und im ESPHome-Block ein paar Glyphen mehr.

Dieselbe Revision noch einmal als ETag

/render schickt die Revision zusätzlich als Marke mit und nimmt sie zurück:

GET /render?device=kueche
200  ETag: W/"7abfec5f3102"   Cache-Control: no-cache

GET /render?device=kueche   If-None-Match: W/"7abfec5f3102"
304

Der Knoten fragt normalerweise erst /plan und lädt nur bei neuer Revision — die Marke ist das zweite Netz darunter, für die Fälle, in denen er die Revision gar nicht kennt: online_image fragt von sich aus nach, jemand ruft die URL von Hand auf, der Server ist zwischendurch neu gestartet. Ohne die Marke stünden in jedem dieser Fälle ein voller Rendervorgang und 48 KB über WLAN an, während der Knoten mit aktivem Funk wartet.

Die Marke steht fest, bevor gerendert wird — sie ist ja derselbe Hash, den /plan ankündigt. Genau darin liegt der Gewinn: Bei einer 304 wird kein Browser angeworfen.

Sie ist absichtlich schwach (W/). Die Revision fasst den Akkustand in Fünferschritten zusammen, zwei Bilder mit gleicher Marke können sich also um ein paar Prozentpunkte unterscheiden. "Gleichwertig" stimmt, "byteweise gleich" wäre gelogen.

Auf der Knotenseite hängt daran eine Bedingung: on_download_finished bekommt ein cached, das bei einer 304 wahr ist. Nur wenn es falsch ist, wird das Panel neu gezeichnet — sonst blinkte es ohne Anlass.

Ein Last-Modified schickt der Server bewusst nicht, obwohl online_image auch danach fragt und sein Fehlen als No header with name last-modified found protokolliert. Der Zeitpunkt, an dem sich der Bildinhalt zuletzt geändert hat, wird nirgends geführt — ihn zu erfinden wäre eine Aussage über etwas, das der Server nicht weiß, und die Marke leistet dasselbe genauer. Die Logzeile bleibt also stehen; sie ist eine Notiz, kein Fehler.

Warum der Poll die Grundlage bleibt

Nicht weil Push fehlt — es gibt ihn, siehe Push: der Server klingelt. Aber er ist die Abkürzung, nicht der Weg. Eine Abfrage ist ein JSON-Objekt von rund 200 Byte an einem Gerät am Netzteil; das kostet nichts, und dafür bekommt man einen Mechanismus, der keine Zustellung braucht. Geht eine Push-Nachricht verloren, sagt das niemand — der Knoten fragt trotzdem weiter und holt sich das Bild verspätet statt gar nicht.

Der direkte Weg wäre gewesen, den Knoten über seinen web_server-Endpunkt anzustoßen. Dann müsste der Server die Adresse jedes Knotens kennen und aktuell halten; über den Broker kennt keiner den anderen.

Strom: Kabel, Zelle, Prozent

An der Frage „Kabel oder Akku?" hängt kein Verhalten mehr — der Knoten bleibt in jedem Fall wach. Geblieben ist, was auf dem Panel steht: der Ladestand in Prozent und daneben das Wort „Notstrom", sobald das Kabel draußen ist. Dieses Kapitel ist der Weg dorthin — was der Knoten an einer einzelnen Messung erkennen kann, was erst der Server im Verlauf sieht, und wie aus Volt Prozent werden.

Wie der Knoten USB erkennt

Nicht damit er sich anders verhält — das tut er nicht —, sondern damit neben dem Prozentwert das richtige Wort steht: „Notstrom" oder gar nichts. Erkannt wird es über die Akkuspannung, nicht über einen eigenen Pin, und das reicht nur zur Hälfte. Gemessen an diesem Aufbau (kalibrierte Volt, 16 Messungen je Ablesung):

Zustand Spannung
am Lader, lädt noch 4,22 V
am Kabel, Lader fertig 4,159 V … 4,169 V
Akku, voll, 20 Minuten geruht 4,149 V

Der erste Fall ist eindeutig. Die beiden anderen sind es nicht: Zwischen ihnen liegen zwanzig Millivolt, und die Ablesung kennt nur Zehnerschritte. Ein Schwellwert dazwischen kippelt.

Deshalb steht usb_threshold in esphome/eink-kueche.yaml unter dem Band, bei 4,14: Am Kabel sagt der Knoten damit verlässlich "USB", und eine abgezogene volle Zelle rutscht binnen Minuten darunter — wach mit WLAN zieht sie rund 80 mA.

Warum überhaupt so genau, wenn kein Verhalten daran hängt? Weil der Server nach einem Neustart kein Gedächtnis hat: app/stromquelle.py (gleich darunter) fängt bei "Akku" an und braucht einen steigenden Verlauf, um auf "Kabel" umzuspringen. Träfe dann ein "Akku" vom Kabel ein, stünde tagelang „Notstrom" auf einem Panel, das am Stecker hängt — sichtbar falsch, und nichts korrigiert es, solange der Lader die Spannung oben festnagelt. Andersherum korrigiert sich der Irrtum von selbst, sobald die Spannung fällt.

Liefert der ADC keinen Wert, gilt Akku — dieselbe Annahme, die der Server selbst trifft, solange er den Knoten noch nie hat steigen sehen.

Und was der Schwellwert nicht sehen kann

Zwei Fälle bleiben übrig, und beide entscheidet app/stromquelle.py im Server aus dem Verlauf der gemeldeten Spannung. Er kann das, weil ihm der Knoten bei jedem /plan seinen Messwert mitschickt und weil er sich mehrere davon merkt — der Knoten kennt immer nur den einen von gerade eben.

Halbleere Zelle am Ladegerät. Die misst 3,9 V und damit weniger als eine volle ohne Kabel; die Bereiche überlappen sich vollständig. Was sie trennt, ist die Richtung: Eine Zelle, an der niemand lädt, steigt nicht. Ein Anstieg von 40 mV gegenüber dem niedrigsten der letzten sechs Werte heißt deshalb "da lädt jemand", und der Notstrom-Hinweis fällt weg.

Volle Zelle am Kabel, Lader fertig. Hier steigt nichts mehr und der Knoten kippelt um seinen Schwellwert. Diesen Fall löst kein Messwert, sondern eine Rastung: Wer einmal als "am Kabel" gilt, bleibt es, bis die Spannung 100 mV unter ihren Höchststand gefallen ist. Am Kabel passiert das nie — der Lader schiebt nach. Am Akku passiert es immer, denn dort fällt sie und hört nicht wieder auf.

Die 100 mV sind der gemessene Abstand: Vom Ladeschluss (4,22 V) auf die Standspannung am Kabel (4,169 V) sind es rund fünfzig, und dabei bleibt es. Der Rest ist Abstand zum Rauschen.

Ein Irrtum ist hier billig geworden: An diesem Urteil hängt kein Verhalten mehr, nur ein Wort auf dem Panel und die Frage, ob bei niedrigem Stand gewarnt wird. Und das Gedächtnis liegt bewusst nur im Arbeitsspeicher: Nach einem Serverneustart gilt wieder, was der Knoten sagt.

Akkustand auf dem Panel

Dieselbe Messung, zweiter Zweck: Das Widget akku zeigt den Ladestand in Prozent. Der Weg dahin geht über den Server, nicht über die Firmware.

eink/kueche/akku   {"volt": 3.874, "quelle": "battery"}

Der Knoten schickt nur Volt. Prozent daraus macht app/battery.py anhand einer Entladekurve — und die lässt sich damit verbessern, ohne je wieder zu flashen. Dieselbe Überlegung wie bei den Layouts.

Der Rückweg geht über MQTT, alle fünf Minuten und einmal bei jedem Verbinden. Damit gibt es keinen Grund mehr, /plan regelmäßig aufzurufen. Als Parameter (?device=kueche&power=battery&batt=3.874) nimmt der Server den Wert weiterhin entgegen — ein Knoten mit alter Firmware schickt ihn so.

/render bekommt den Wert nicht mit, und das ist kein Versehen. Umschreiben ließe sich die Bild-URL durchaus — online_image.set_url gibt es, und für die Serveradresse nutzt der Knoten es auch (siehe Wo der Server steht, sagt der Server). Nur löscht jedes set_url den gemerkten ETag, und mit ihm die Antwort „304, nichts Neues", an der der ganze Takt hängt. Eine Spannung, die sich bei jeder Ablesung um ein paar Millivolt bewegt, würde so bei jedem Durchlauf ein volles Bild erzwingen. Der Server merkt sich deshalb die zuletzt gemeldete Spannung je Gerät und benutzt sie beim Rendern. Zwischen Meldung und Abruf liegen Sekunden.

Unplausible Werte fliegen raus (MIN_VOLTS/MAX_VOLTS), und zwar ohne den letzten guten zu überschreiben: Eine 0 V aus einem ADC, der noch nicht eingeschwungen ist, soll nicht dazu führen, dass auf dem Panel plötzlich "0 %" steht. Hat noch nie ein Knoten etwas gemeldet, bleibt der Platz leer — beim Layouten im Browser ist das der Normalfall. Zum Ausprobieren hängst du &batt=3.85 an /html oder /preview.

Der Prozentwert steht immer da, der Hinweis nur wenn er etwas sagt. Am Kabel ist "am Kabel" keine Nachricht, sondern der Normalfall — auf E-Ink kostet jedes Wort Platz und jede Änderung einen Refresh. Fällt das Kabel heraus, ist das eine: Dann steht „Notstrom" neben dem Wert. Fällt der Stand zusätzlich unter LOW_PERCENT (15 %), setzt das Widget ihn invers, damit man es über den Raum sieht.

Am Kabel misst der ADC die Ladespannung, nicht die Ruhespannung der Zelle — der Prozentwert liegt dort also eher zu hoch. Er steht trotzdem da: Weil die Zelle Notstrom ist, will man vorher wissen, wie viel Reserve sie hat, und ein etwas zu optimistischer Wert ist dafür besser als keiner.

Der Wert ist eine Schätzung, keine Tankanzeige. Zwischen 4,0 V und 3,7 V liegen zwei Drittel der Kapazität, und in diesem Bereich bewegt sich die Spannung kaum — dieselben 3,85 V können 55 % oder 65 % heißen. Unter Last sackt sie zusätzlich ab, deshalb misst der Knoten am Anfang seines Durchlaufs und nicht während des Refreshs. Für "noch gut" oder "bald laden" reicht das; auf die einzelne Prozentzahl sollte man nichts geben.

In die Revision geht der Stand nur in Fünferschritten ein. Ohne das würde am Kabel jede Zuckung der ADC-Messung alle zehn Sekunden einen vollen E-Paper-Refresh auslösen — und den will man zählen können, nicht dauernd haben.

Push: der Server klingelt

Der Server meldet jede Änderung über MQTT, der Knoten hängt dauerhaft am Broker und zeichnet, sobald er darf. Der Poll ist kein Takt, sondern ein Notnagel: einmal alle 15 Minuten, und nur, wenn der schnelle Weg nachweislich nicht geht.

Andersherum — der Poll trägt, Push kürzt ab — ginge es nicht: Ein Ersatzweg, der nur im Notfall läuft, funktioniert im Notfall nicht.

Damit das keine zweite, halb funktionierende Zustellkette wird, hängt alles an einer Entscheidung:

Die Nachricht trägt die Revision, nicht die Daten.

Also genau den Wert, den /plan in derselben Sekunde geliefert hätte. Der Knoten hält ihn gegen seine zuletzt gezeichnete Revision und entscheidet allein. Daraus folgt der Rest von selbst:

  • Ein Codepfad am Knoten. Er kann gar nicht unterscheiden, ob der Wert aus /plan kam oder aus der Klingel.
  • Eine doppelte Nachricht ist folgenlos — die Revision stimmt dann schon.
  • Eine verlorene kostet Verspätung, nie Richtigkeit. Die aufbewahrte Nachricht auf dem Thema steht weiter da, und wer neu verbindet, bekommt sie ausgeliefert. Deshalb steht nirgends etwas über Quittungen, Reihenfolge oder Wiederholungen: Es braucht sie nicht.

Wer wen kennt

Knoten  ──wählt sich ein──►  Broker  ◄──wählt sich ein──  Server

Beide Seiten sind Clients. Der Server kennt die Adresse keines Knotens, der Knoten öffnet keinen Port, und niemand muss erreichbar sein außer dem Broker. Die Kopplung läuft über einen Thema-Namen: eink/<gerät>/revision.

Das ist der Grund für MQTT und gegen den naheliegenden Weg (ein web_server: am Knoten und ein POST vom Server): Der hätte die Abhängigkeitsrichtung umgedreht, die dieses System robust macht, und am Knoten eine offene Steuerfläche hinterlassen.

Wo der Server steht, sagt der Server

Eine Sache stand dieser Anordnung lange im Weg: Der Knoten kannte den Server doch — als feste Adresse in seiner Firmware. Zieht der Server um, weil der Router eine andere IP vergeben hat oder das Ganze auf einen neuen Rechner gewandert ist, hilft dem Knoten kein Broker: Er fragt weiter ins Leere und das Panel friert ein. Jedes Panel abschrauben, aufmachen und flashen.

Also sagt der Server es selbst. Über denselben Broker, unter demselben Gerätenamen, aufbewahrt:

eink/<gerät>/stand       {"rev":…,"lage":…,"uhr":…}   — der ganze Stand
eink/<gerät>/server      "hier bin ich"               — die Adresse
eink/<gerät>/akku        {"volt":…,"quelle":…}        — der Rückweg
eink/server/leben        "da" | "weg"                 — lebt der Server noch
eink/<gerät>/revision    "schau nach"                 — die alte Klingel

Vier davon gehen vom Server aus, akku ist der einzige Weg zurück (siehe Der Rückweg). revision trägt weiter den nackten Hash und liegt nur noch da, damit ein Knoten mit alter Firmware nicht verstummt — mit dem letzten Flash fällt es weg.

In der config.yaml steht die Adresse einmal:

push:
  server_url: http://192.168.178.137:8080

Der Knoten legt die einkompilierte Adresse beim Start in eine Laufzeitvariable und lässt sie sich davon überschreiben. Zwei Wege gehen über die Adresse — /plan baut sie in jedem Durchlauf neu zusammen, die Bild-URL steht fest im online_image —, deshalb zieht die zweite über online_image.set_url mit. Ohne das fragte der Knoten den neuen Server, welches Bild er beim alten holen soll.

Die Adresse aus der Firmware bleibt trotzdem stehen, und das ist kein Rest, sondern der Boden: Sie trägt den Knoten bis zur ersten Nachricht — beim Start ist der Bildschirm schon einmal da, bevor MQTT verbunden ist — und den ganzen Weg, wenn gar kein Broker läuft. Wer server_url leer lässt, bekommt genau den Zustand von vorher: Jeder Knoten bleibt bei dem, was in seiner YAML steht.

Veröffentlicht wird bei jedem Verbinden, nicht nur beim ersten. Der Broker hält aufbewahrte Nachrichten zwar von selbst, aber er kann sie über einen eigenen Neustart verloren haben — und dann hinge jeder Knoten an einer Adresse, die vielleicht der Grund für den Neustart war.

Am Knoten steht dann im Log:

[I][server]: Adresse vom Broker: http://192.168.178.137:8080 (bisher http://192.168.178.99:8080)

Und wenn nichts mehr geht, steht dort die Adresse, an der es scheitert:

[W][plan]: http://192.168.178.99:8080 nicht erreichbar, nutze Standardwert

Der Auslöser ist die Inhaltsrevision

Der Server holt seine Quellen ohnehin im Hintergrund (siehe "Der Takt der Quellen"). Nach jedem geglückten Abruf rechnet er je Gerät nach, ob das Panel jetzt anders aussähe — und nur dann klingelt es.

Dafür zerfällt die Revision in zwei Teile, und das ist die eigentliche Feinheit:

Teil Inhalt wozu
Inhalt Termine, Schlagzeilen, Wetter, Meldungen, Akku der Auslöser
Zeit Zähler, springt alle takt.refresh_seconds nur für den Knoten

Die Zeitmarke springt von selbst weiter, ohne dass sich etwas geändert hätte. Als Anlass zu klingeln wäre sie wertlos — sie würde alle zehn Minuten grundlos das Panel wecken. Verschickt wird trotzdem die volle Revision: Sonst verglich der Knoten gegen einen Wert, den er nie zu sehen bekommt, und zeichnete bei jedem Klingeln neu.

Beides steht in tests/test_push.py als Vertrag, und beide Tests fallen, wenn man die Trennung aufhebt.

Wie weit die Revision in die Listen greift, rechnet der Server aus den Layouts (_revisionstiefe in app/main.py). Von Schlagzeilen und Abfahrten holt er mehr, als je eines zeigt — Vorrat für den Richtungsfilter und für ausgefallene Nachbarn —, und in die Revision darf nur, was auch dastehen kann. Beide Richtungen sind Fehler: Zu wenig, und die siebte Abfahrt ändert sich, ohne dass jemand neu zeichnet; zu viel, und das Panel schreibt sich für eine Schlagzeile neu, die niemand zu sehen bekommt. Feste Zahlen taugen dafür nicht — die erste neue Ansicht macht sie still falsch.

Auf stand geht deshalb noch eine zweite Schleife: alle 30 Sekunden, und sie schickt nur, wenn sich der Stand wirklich unterscheidet. Ohne sie erführe der Knoten von allem, was nur die Uhr weiß, erst beim nächsten Inhaltswechsel — vom abgelaufenen Countdown einer Abfahrt, vom Spruch um Mitternacht, und von der abgelaufenen Haltedauer einer Warnung. Auf revision geht sie ausdrücklich nicht: Ein Knoten mit alter Firmware zeichnet dort bei jeder Nachricht, und ein Countdown im Minutentakt wäre für ihn ein Refresh je Minute.

Warum der Server nicht filtert

Naheliegend wären zwei Schwellen an dieser Stelle: eine Liste von Quellen, die überhaupt klingeln dürfen, und eine Sperrfrist je Gerät gegen einen Sturm. Der Gedanke stimmt — jeder Durchlauf blitzt sichtbar und hält den Knoten Sekunden an —, die Stelle nicht.

Der bundesweite Warntag am 2026-09-10 hat vorgeführt, warum. Die Warnung ging um 10:59:59 raus, auf dem Panel stand sie um 11:03. Die NINA-API war unter Last, der erste Abruf lief in sein Zeitlimit, der zweite brachte den Kopf der Meldung, und die Beschreibung kam eine Minute später nach. Eine Sperrfrist hätte sie nicht nur verzögert, sondern verschluckt: Der Server merkt sich die neue Revision, bevor er eine Frist prüfen könnte, also gäbe es beim nächsten Durchlauf nichts mehr zu melden. Bis zum Poll.

Beide Schwellen wären Meinungen des Servers darüber, was den Knoten interessiert — getroffen von einer Stelle, die nicht weiß, was gerade auf dem Panel steht. Deshalb meldet der Server jede Änderung, und ob und wann gezeichnet wird, entscheidet der Knoten. Der weiß als Einziger, wann er zuletzt gemalt hat und ob gerade ein Durchlauf läuft.

Wer dringend: oder sperrfrist: in seiner config.yaml stehen hat, bekommt beim Start eine Zeile im Log und sonst nichts — sie werden gelesen und verworfen.

Der Broker

Wer ihn nicht selbst einrichten will, nimmt das Abbild aus container/ — es tut alles, was hier von Hand steht, beim ersten Start selbst (siehe Im Container). Der Rest dieses Abschnitts beschreibt den Weg auf dem Mac, und er bleibt der Bezugspunkt: Was das Abbild anders macht, steht dort.

Auf dem Mac:

brew install mosquitto && mosquitto_passwd -c /opt/homebrew/etc/mosquitto/passwd eink

Dann die Zertifikate:

./tools/mqtt_zertifikate.sh 192.168.178.137

Das Argument ist die Adresse, unter der Server und Knoten den Broker ansprechen — sie kommt als SAN ins Zertifikat, und stimmt sie nicht, scheitert die Verbindung am Namen statt an der CA. Deshalb gehört dem Broker eine feste Adresse im Router: Ein neuer DHCP-Zug macht das Zertifikat falsch.

Eigene CA statt Let's Encrypt, weil der Broker im Heimnetz keinen Namen hat, den eine öffentliche CA beglaubigen könnte — und keinen braucht. Es gibt genau zwei Gegenstellen, beide bekommen dieselbe CA fest eingebaut. Das ist strenger als der öffentliche Weg: Dort könnte jede der hundert mitgelieferten CAs ein gültiges Zertifikat für denselben Namen ausstellen, hier kommt genau eines durch. Laufzeit zehn Jahre, mit Absicht — ein jährlich ablaufendes Zertifikat nimmt eines Tages die Klingel mit, und zwar still, weil der Poll weiterträgt.

Die Konfiguration liegt als Vorlage unter mosquitto/mosquitto.conf und gehört auf dem Mac nach /opt/homebrew/etc/mosquitto/mosquitto.conf, auf einem Pi nach /etc/mosquitto/conf.d/eink.conf (dort andere Pfade für password_file und persistence_location). Vier Zeilen darin sind die wichtigen:

allow_anonymous false     # wer hier senden darf, kann das Panel verschleißen
persistence true          # aufbewahrte Nachrichten überleben einen Neustart
listener 8883 0.0.0.0     # der Knoten kommt aus dem WLAN, nicht von localhost
certfile  …/certs/server.crt   # samt cafile und keyfile darunter

Die Reihenfolge ist nicht beliebig: Was vor dem ersten listener steht, gilt für den ganzen Broker, was danach kommt, nur für den Listener davor. Die Zugangsregeln stehen deshalb oben.

Die Zertifikate liegen neben der Broker-Konfiguration, nicht im Projekt. Das Skript legt die Kopie selbst dorthin, und der Grund ist eine Fehlersuche, die man kein zweites Mal führen möchte: ~/Documents ist unter macOS TCC-geschützt. Ein Launch-Agent hat dort keinen Zugriff, und den Zustimmungsdialog bekommt ein Hintergrunddienst nie zu sehen — mosquitto bleibt im fopen() auf ca.crt stehen. Der Port ist dann gebunden (das passiert davor), aber die Ereignisschleife läuft nie an: TCP nimmt Verbindungen an, der TLS-Handshake läuft in den Timeout, und im Log steht kein Wort, nicht einmal starting. Im Vordergrund gestartet läuft derselbe Broker mit derselben Konfiguration einwandfrei, weil das Terminal die Berechtigung hat.

Solange der Knoten noch die alte Firmware trägt, steht in der Vorlage ein zweiter listener 1883 — er spricht Klartext, bis er das nächste Mal geflasht wird. Dass er das bis dahin darf, macht nichts schlechter: Er tut es seit dem ersten Tag, und das Passwort steht ohnehin in seiner Firmware. Neu wäre nur, ihm die Klingel wochenlang wegzunehmen. Diese Zeile fällt mit dem Flash — wer sie danach stehen lässt, hält eine Tür offen, durch die niemand mehr geht.

Starten und prüfen:

brew services start mosquitto && mosquitto_sub -h 192.168.178.137 -p 8883 --cafile certs/ca.crt -u eink -P "$MQTT_PASSWORT" -t 'eink/#' -v

Das Passwort steht an drei Stellen: im Broker (mosquitto_passwd), in der Umgebung des Servers (MQTT_PASSWORT, die config.yaml liest es über ${...}) und in esphome/secrets.yaml als mqtt_password. Genau dieses Passwort ist der Grund für TLS: Der Inhalt der Nachrichten ist harmlos — ein Revisions-Hash, der "schau nach" bedeutet —, aber es ginge bei jedem Verbindungsaufbau im Klartext durchs WLAN. Wer es mitliest, kann das Panel beliebig oft neu zeichnen lassen: ein Blitzen im Sekundentakt und ein Akku, der in Stunden statt Wochen leer ist.

Einschalten

config.yaml:

push:
  enabled: true
  broker: 192.168.178.137
  port: 8883
  tls: true
  ca_cert: certs/ca.crt
  username: eink
  password: "${MQTT_PASSWORT}"
  server_url: http://192.168.178.137:8080

ca_cert leer zu lassen hieße Systemspeicher — richtig nur für einen Broker mit öffentlichem Namen und öffentlichem Zertifikat.

server_url ist die Adresse, unter der die Knoten diesen Server erreichen — nicht die, unter der er horcht. Raten kann er das nicht: Er sieht seine eigene nur so, wie das Netz sie ihm zeigt (0.0.0.0 im Container), und welcher Name im WLAN der richtige ist, weiß nur, wer das Netz gebaut hat. Leer lassen heißt: nichts veröffentlichen.

Am Knoten stehen Adresse, Präfix und Benutzer oben in den substitutions:, die CA als Textblock im mqtt:-Abschnitt (certificate_authority:). Ein Zertifikat ist nichts Geheimes und steht deshalb in der YAML statt in secrets.yaml — wer das Skript neu laufen lässt, muss den Block aber mit dem neuen certs/ca.crt ersetzen, sonst kommt der Knoten nicht mehr herein.

Der Knoten prüft auch den Namen. Weil mbedTLS bei vorhandener SAN-Erweiterung nur die dNSName-Einträge ansieht, legt das Skript eine IP-Adresse doppelt ins Zertifikat — als IP: und als DNS:. Ohne die zweite Form scheitert der Knoten am Namen, obwohl die CA stimmt.

Ob es funktioniert, sagt /healthz:

"push": {
  "an": true, "verbunden": true, "server_url": "http://192.168.178.137:8080",
  "zuletzt": {"kueche": 8.7}
}

"verbunden": false heißt: Broker nicht erreichbar, falsches Passwort oder falsche Adresse. Ohne diese Zeile bliebe ein Tippfehler still — es klingelte nie, und von außen sähe das genauso aus wie "es hat sich nichts geändert". zuletzt sagt je Gerät, vor wie vielen Sekunden es zuletzt geklingelt hat; ein Gerät ohne Eintrag hat seit dem Serverstart keine Nachricht bekommen.

Am Knoten steht im Log:

[I][klingel]: a3f9c1d20b44 statt 7abfec5f3102 — hole nach

Und wenn dort steht schon steht, ist auch alles richtig: Das ist die aufbewahrte Nachricht nach einem Neustart, die genau das beschreibt, was schon auf dem Panel steht.

Der Knoten hört zu

Am Knoten steht der Umbau in esphome/eink-kueche.yaml, und er besteht eigentlich aus einem einzigen Satz:

Der Rückruf entscheidet nichts. Er legt einen Auftrag hin.

Kommt etwas auf stand an, schreibt on_message die Werte in Globals — revision, panel_lage, die sechs Felder der Uhr — und ist fertig. Gemalt wird woanders:

- interval: 1s
  then:
    - if:
        condition:
          lambda: |-
            if (id(bild_laeuft)) return false;
            if (id(revision).empty()) return false;
            if (id(revision) == id(drawn_revision)) return false;
            if (id(letzter_refresh_ms) == 0) return true;
            return (millis() - id(letzter_refresh_ms)) >=
                   ${mindestpause} * 1000UL;
        then:
          - script.execute: zeichnen

Eine Sekunde klingt nach Polling, ist aber keines: Hier geht nichts ins Netz. Der Knoten vergleicht zwei Zeichenketten in seinem eigenen Speicher und rechnet eine Subtraktion.

Der Grund für den Umweg über die Schleife ist derselbe wie überall in diesem Kapitel. Zeichnete der Rückruf direkt, müsste er zwei Fälle abweisen — "es wird gerade gemalt" und "die Mindestpause läuft noch" —, und ein abgewiesener Auftrag ist ein verlorener. Genau daran ging am Warntag die Beschreibung der Warnung verloren, einmal im Server und einmal, spiegelbildlich, im Knoten. So wartet sie stattdessen: Der Auftrag steht in revision, bis er erledigt ist.

mindestpause: 5 ist die ganze Drosselung, und sie zählt ab dem Ende des letzten Durchlaufs. Das Ende ist wörtlich gemeint: component.update: eink_display kehrt erst zurück, wenn warte_bereit_() die BUSY-Leitung des Panels wieder auf fertig gesehen hat. Der Durchlauf kostet damit von sich aus ein paar Sekunden; die fünf sind, was darüber hinaus gewartet wird. Ein zusätzliches delay im Skript zeichnen wäre dasselbe ein zweites Mal gezählt.

0 in letzter_refresh_ms heißt "noch nie gemalt" und überspringt die Pause — beim Start soll niemand darauf warten, dass ein leeres Panel sich füllt. Der Fingerdruck auf Taste 1 setzt beides zurück und geht damit ebenfalls vor.

Wenn niemand mehr redet

Ein Knoten, der auf Nachrichten wartet, hat ein Problem, das ein pollender nicht hat: Stille sieht aus wie Ruhe. Zwei Fälle sehen auf dem Panel genau wie ein ereignisloser Tag aus — der Broker ist weg, oder der Server ist es.

Gegen den zweiten hinterlegt der Server beim Verbinden einen Letzten Willen: eink/server/leben steht auf "da", solange er lebt, und der Broker setzt es auf "weg", wenn die Verbindung abreißt. Bei einem geordneten Ende schreibt der Server es selbst, bevor er geht — der Letzte Wille greift dann gar nicht, und das ist der Sinn: Er ist für den Fall da, in dem niemand mehr etwas schreiben kann.

Den ersten Fall sieht der Knoten direkt an seiner eigenen Verbindung. Beide zusammen ergeben den Notfalltakt:

- interval: ${notfall_takt}      # 15min
    condition:
      or:
        - not: {mqtt.connected: }
        - lambda: 'return !id(server_lebt);'

Dann erfindet er einen Stand, den es nicht gibt (notfall-<sekunden>), der Wächter oben sieht einen Unterschied und holt das Bild. Was wirklich darauf steht, sagt danach der Server — sobald er wieder redet. Das ist der ganze Rest des alten Polls: einmal alle 15 Minuten statt einmal die Minute, und nur, wenn der schnelle Weg nachweislich nicht geht.

Der Rückweg: die Zellspannung

eink/<gerät>/akku ist das einzige Thema, auf dem der Knoten sendet:

{"volt": 3.85, "quelle": "usb"}

Alle fünf Minuten und einmal bei jedem Verbinden, nicht aufbewahrt — eine Spannung von gestern ist keine Auskunft. Der Server nimmt sie entgegen, bewertet sie (siehe Wie der Knoten USB erkennt) und zeigt den Prozentwert an.

Damit gibt es keinen Grund mehr, regelmäßig zu fragen. /plan bleibt für genau eine Sache: die Uhrzeit, wenn SNTP nicht durchkommt. Alle fünf Minuten wird nachgesehen, ob die Uhr steht; im Normalfall geht dort nie eine Anfrage raus.

Zugriffsschutz

Ohne EINK_TOKEN sind alle Endpunkte offen — im eigenen Netz in Ordnung, der Server schreibt beim Start eine Warnung ins Log. Mit gesetztem Token verlangen /plan, /render, /preview, /html und /signal eine Authentifizierung; /healthz bleibt offen, damit Monitoring weiter funktioniert.

export EINK_TOKEN="$(openssl rand -hex 32)"

Zwei Wege, das Token mitzuschicken:

# bevorzugt: Header (taucht nicht in Logs auf)
curl -H "Authorization: Bearer $EINK_TOKEN" 'http://pi:8080/plan?device=kueche'

# Notnagel: Query-Parameter — so holt der Knoten sein Bild
curl 'http://pi:8080/render?device=kueche&token=...'

Im ESPHome-YAML gehört derselbe Wert in die Substitution token.

/plan schickt den Token als Header; nur das Bild holt der Knoten mit dem Query-Parameter. Wer ihn auch dort aus den Zugriffslogs heraushaben will, stellt ${render_pfad} am Knoten auf einen Pfad ohne &token= um und hängt dem online_image denselben request_headers:-Block an wie dem http_request — ESPHome kann das seit 2025.x.

Von außerhalb des Heimnetzes

Nicht einfach den Port freigeben. Ein Token allein reicht dafür nicht:

  • Über einfaches HTTP wandert das Token im Klartext durch fremde WLANs und ist mitlesbar. Ohne HTTPS ist es praktisch wirkungslos.
  • Die Lese-Endpunkte sind das eigentliche Risiko, nicht /signal: /html und /render zeigen deinen kompletten Kalender, deine Sensorwerte und den Müllplan. Wer da reinkommt, sieht deine Termine.

Der empfohlene Weg ist deshalb, den Dienst gar nicht öffentlich zu machen, sondern iPhone und Pi in ein Mesh-VPN zu hängen (Tailscale, WireGuard). Der Kurzbefehl ruft dann die private Adresse auf, funktioniert aus dem Hotel-WLAN genauso wie zu Hause, und von außen ist nichts erreichbar. Das Token bleibt trotzdem sinnvoll als zweite Schicht.

Falls es doch öffentlich sein muss: ausschließlich über HTTPS (Reverse Proxy mit Zertifikat oder Cloudflare Tunnel), und dann nur /signal und /now-playing durchreichen statt des ganzen Dienstes.

Firmware flashen

Vom MacBook aus, ohne Zusatztreiber: Der ESP32-S3 spricht selbst USB und meldet sich als /dev/cu.usbmodem…. Die CP210x-Treibergeschichte betrifft nur ältere ESP32-Boards.

Einmalig:

uv tool install esphome --python 3.12
cp esphome/secrets.example.yaml esphome/secrets.yaml

Dann esphome/secrets.yaml ausfüllen: WLAN, api_key, ota_password. Der api_key ist 32 Byte base64 (openssl rand -base64 32) und wird beim Einbinden in Home Assistant abgefragt. Dieselbe Aufteilung wie bei der Config: Die Vorlage liegt im Repo, die ausgefüllte Datei steht in .gitignore.

ESPHome läuft nicht auf Python 3.14 — daher das --python 3.12. uv legt sich die Version bei Bedarf selbst hin.

Flashen:

esphome run esphome/eink-kueche.yaml

Der erste Durchlauf zieht die ESP-IDF-Toolchain nach ~/.platformio — mehrere hundert MB, rechne mit 10 bis 20 Minuten. Danach fragt ESPHome nach dem Port; /dev/cu.usbmodem… nehmen. Meldet esptool "no serial data received", das Board in den Bootloader zwingen: B gedrückt halten, R kurz tippen, B loslassen, dann erneut.

Ab dem zweiten Mal bietet derselbe Befehl OTA over the air an — dann ohne Kabel. Das geht jederzeit, weil der Knoten durchgehend wach ist; zu Zeiten des Deep Sleep war er dafür nur die paar Sekunden erreichbar, in denen er sein Bild holte, und das war die eigentliche Falle beim Einbau in den Rahmen.

Praktische Folge für den Rahmen bleibt sie trotzdem: erst flashen und ein Bild sehen, dann zuschrauben, und die USB-C-Buchse samt der Tasten B und R erreichbar lassen. Beim ersten Aufbau braucht man beides mehrfach.

Nächste Schritte

  1. App-Passwort setzen, python -m app.check laufen lassen, Kalendernamen in config.yaml eintragen.
  2. Koordinaten fürs Wetter und — falls gewünscht — den Regionalschlüssel für NINA eintragen.
  3. /html im Browser öffnen und das Layout bauen; /html?device=kueche&view=alert für die Vollbildmeldung.
  4. Wenn die Hardware da ist: esphome/secrets.yaml ausfüllen und flashen (siehe oben). Danach den Spannungsteiler gegen ein Multimeter kalibrieren, den multiply-Faktor nachziehen und usb_threshold vom Einrichtungswert -1 auf den gemessenen setzen.
  5. Optional, nur für Müll und Abfahrten: waste_collection_schedule und db_infoscreen in Home Assistant einrichten (beides HACS, letzteres deckt den KVV ab). Danach die Feldnamen der Abfahrtsliste im Layout eintragen.

Was danach lohnend wäre:

  • Das PNG vorrendern statt im Request. /render rendert heute synchron, während der Knoten wartet — bei jedem Revisionswechsel steht er also erst einmal. Die Datenquellen stehen bereits bereit (siehe "Der Takt der Quellen"), das Bild nicht. Ein bei Revisionswechsel vorgerendertes PNG würde die Wartezeit auf den reinen Transfer von ~48 KB reduzieren — und die Wahl der Renderengine zur reinen Speicherfrage machen.
  • Eilmeldungen im Feed erkennen. Eine echte Eilmeldung aus dem RSS-Feed hätte eine eigene Behandlung verdient — größer gesetzt, länger stehend. Dafür muss die Erkennung aber erst schärfer werden als "das Wort Eilmeldung kommt vor", sonst gilt jede Programmänderung als Eilmeldung.

Stand der Hardware

Am 2026-09-04 auf dem EE04 mit dem 7,5"-Panel bestätigt:

  • Alle Displaypins (clk GPIO7, mosi GPIO9, cs GPIO44, dc GPIO10, busy GPIO4, reset GPIO38) und model: 7.50inv2. Das Dashboard steht auf dem Panel.
  • Der BUSY-Pin ohne inverted. Am Panel gemessen: BUSY liegt LOW, solange gearbeitet wird. epaper7in5 wartet entsprechend, das Flag bleibt weg.
  • Die Akkumessung — GPIO1 als ADC hinter dem über GPIO6 geschalteten Teiler. Der Knoten meldet Volt über MQTT, der Server macht Prozent daraus.
  • Die Kette insgesamt: WLAN, /plan, /render, Bild auf dem Panel.

Warum waveshare_epaper nicht reicht

Der eingebaute Treiber hat für dieses Panel drei Eigenheiten, und alle drei sind der Anlass für esphome/components/epaper7in5.

Die Wartebedingung ist verkehrt herum. BUSY ist LOW, solange das Panel arbeitet, und geht HIGH, wenn es fertig ist — so steht es in Waveshares eigenem Treiber (while(!DEV_Digital_Read(EPD_BUSY_PIN))) und so hält es GxEPD2 (_busy_level = LOW). WaveshareEPaper7P5InV2::wait_until_idle_() wartet dagegen while (this->busy_pin_->digital_read()), also solange das Panel frei ist. Jeder Aufruf läuft damit in den 10-Sekunden-Timeout, dreimal je Bild — das ist das Timeout while displaying image! im Log. Dass das Bild trotzdem ankommt, liegt an den festen delay()-Zeiten daneben; deshalb der Befund in esphome/issues#4910: "The display refresh works". inverted: true am busy_pin wäre der Weg, das gegen ESPHome zu drehen — epaper7in5 wartet von sich aus richtig herum, deshalb gehört das Flag dort nicht hin.

Vier Graustufen beherrscht das Panel, ESPHome spricht sie für kein 7.5"-Modell an (siehe "Das Grau aus dem Entwurf").

Regionsbegrenzter Refresh: Der UC8179 nimmt über Kommando 0x90 Fensterkoordinaten entgegen, ESPHome verdrahtet sie im 7.50inv2p-Zweig aber fest auf das ganze Panel und schiebt anschließend den kompletten Framebuffer. "Partiell" heißt dort also: ganze Fläche, andere Waveform, kein Blinken — nicht "nur die Uhr neu". Dazu ein Detail, das leicht kostet: Der Zähler at_update_ ist ein gewöhnliches Member und liegt nicht im RTC-RAM, jeder Neustart setzt ihn auf 0 zurück. Der eigene Treiber legt seine Zähler deshalb ins RTC-RAM.

Was noch offen ist

  • Der multiply-Faktor der Akkumessung, und damit beides, was daran hängt: der Prozentwert auf dem Panel und usb_threshold. Kalibrieren gegen eine volle Zelle (≈ 4,15 V, USB abgezogen), Rechenweg siehe "Akkustand auf dem Panel".
  • usb_threshold hängt an derselben Kalibrierung. Steht er falsch, steht neben dem Prozentwert das falsche Wort — mehr passiert nicht. Der aktuelle Wert (4.14) ist am 2026-09-06 gemessen, siehe "Wie der Knoten USB erkennt".
  • Ob FreeType am Knoten dieselben Ziffern setzt wie Chromium im Renderer. Sitzen sie schief, gehören grund_x/grund_y in renderer.UHRFELD nachgezogen — Serverseite, kein zweites Flashen. Siehe "Die Uhrzeit malt der Knoten".
  • Ob das Panel nach dem Refresh sauber stromlos gelegt wird. Bleibt es dauerhaft unter voller Spannung, nimmt es über die Zeit Schaden. Der eigene Treiber macht daraus eine Entscheidung statt eines Zufalls — strom_aus_() nach jedem Durchlauf, panel_schlafen beim Herunterfahren, siehe "Der eigene Treiber".

~/eink $ git log -1 --format="%h · %cs · %s"

827c499c · 2026-11-11 · Merge branch 'fix/ci-pakete' into 'main'

~/eink $ glab ci status

pipeline #6: bestanden ✓

~/eink $ git remote get-url origin

https://gitlab.com/Tom4331/eink-dashboard

~/eink $ cd ~ # zurück zu wolske.dev