~/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="your@appleid.com"
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_pflegendas 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.
/healthzzeigt, 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
taktSekunden, sondern bis zum nächsten Vielfachen vontakt(_bis_zur_naechsten_grenzeinapp/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.
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.
Als Umgebungsvariablen setzen (nicht in die YAML — die liegt im Klartext neben dem Code):
export ICLOUD_USER="your@appleid.com" export ICLOUD_APP_PASSWORD="abcd-efgh-ijkl-mnop"Namen der Kalender herausfinden:
python -m app.checkUnter "Kalender (CalDAV)" stehen sie so, wie Apple sie führt — das ist nicht immer der Name, den die Kalender-App anzeigt.
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.
Das Ressort steht im Link, nicht im Feed
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
- 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:
nachrichtbraucht 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.nachrichtenrechnet 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
nachrichtender Anreißer der Aufmachermeldung mitten im Satz abriss. Zu üppig gerechnet bleibt ein Streifen Weiß stehen, in den noch eine Meldung gepasst hätte.spruchbraucht 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()inmain.pynicht kennt, merkt der Knoten die Änderung nicht und malt erst nachrefresh_secondsneu. - 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.measureund ein Eintrag inplatz.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_contentund 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-radiusmit 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./previewundtools/vorschau.pyzeigen 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:
mindestpausehochsetzen (ineink-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}, ineink-kueche.yamlauskommentiert 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“):
- Aktion „Inhalt von URL abrufen“:
- URL:
http://<pi>:8080/signal - Methode:
POST - Anfragetext:
JSON, Feldname(Text) mitgute_nacht
- URL:
- „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:
- In der Home-Assistant-Begleit-App auf dem iPhone unter den App-Einstellungen den Sensor „Fokus“ aktivieren.
- Die entstehende Entity (etwa
sensor.iphone_von_tom_fokus) inconfig.yamlunterhomeassistant.entitiesmit dem Labelfocuseintragen. - 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:
- eine Warnung, die noch nie zu sehen war
- eine Einblendung
- eine Warnung, deren Haltedauer noch läuft
- 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“):
- URL:
http://<server>:8080/zeigen - Methode:
POST, Anfragetext:JSON - Ein Feld
ansicht(Text) mitabfahrten - Bei gesetztem Token: Kopfzeile
AuthorizationmitBearer <token> - 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:
- Aktion „Aktuellen Titel abrufen".
- Aktion „Inhalt von URL abrufen":
- URL:
http://<pi>:8080/now-playing - Methode:
POST - Anfragetext:
JSON, Feldertitle,artistunddurationaus dem abgerufenen Titel
- URL:
- „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:
- Neu und stark genug (
nina.alert_severity, bei Eilmeldungen immer, bei HA-Regelntakeover: true) → das Gerät bekommt die Ansichtalert, ganzflächig. - Ausgeliefert, aber die Frist läuft → sobald
/renderdas Bild wirklich abgegeben hat, startetnina.haltedauer(Standard drei Minuten). Bis die um ist, bleibt die Ansichtalert. Zwischendurch fällige Neuzeichnungen liefern dasselbe Vollbild; die Frist schieben sie nicht. - Frist abgelaufen → die Meldung wandert in die Zeile am oberen Rand des normalen Dashboards und bleibt dort.
- 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_secondsneu 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.alerthat 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
/plankam 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:/htmlund/renderzeigen 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
- App-Passwort setzen,
python -m app.checklaufen lassen, Kalendernamen inconfig.yamleintragen. - Koordinaten fürs Wetter und — falls gewünscht — den Regionalschlüssel für NINA eintragen.
/htmlim Browser öffnen und das Layout bauen;/html?device=kueche&view=alertfür die Vollbildmeldung.- Wenn die Hardware da ist:
esphome/secrets.yamlausfüllen und flashen (siehe oben). Danach den Spannungsteiler gegen ein Multimeter kalibrieren, denmultiply-Faktor nachziehen undusb_thresholdvom Einrichtungswert-1auf den gemessenen setzen. - Optional, nur für Müll und Abfahrten:
waste_collection_scheduleunddb_infoscreenin 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.
/renderrendert 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 (
clkGPIO7,mosiGPIO9,csGPIO44,dcGPIO10,busyGPIO4,resetGPIO38) undmodel: 7.50inv2. Das Dashboard steht auf dem Panel. - Der BUSY-Pin ohne
inverted. Am Panel gemessen: BUSY liegt LOW, solange gearbeitet wird.epaper7in5wartet 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 undusb_threshold. Kalibrieren gegen eine volle Zelle (≈ 4,15 V, USB abgezogen), Rechenweg siehe "Akkustand auf dem Panel". usb_thresholdhä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_yinrenderer.UHRFELDnachgezogen — 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_schlafenbeim 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