Auf der #rp26 in Berlin, in der Maker Area, stand am Seeed-Stand ein Display, das mich nicht mehr losgelassen hat. Kein grelles Tablet, kein leuchtender Bildschirm — ein ruhiges, mattes ePaper-Panel, das einfach etwas anzeigte und dabei aussah, als würde es das seit Tagen tun, ohne Strom zu ziehen. Ich stand davor und dachte: Das will ich an meiner Wand haben. Direkt neben dem Ausgang.
Denn genau da entscheidet sich morgens Wichtiges. Ist noch ein Fenster auf? Brauche ich eine Jacke? Muss die Tonne raus, und was steht heute eigentlich an? Drei Blicke aufs Handy — oder ein Blick an die Wand.
Was da genau hängt
Das Gerät heißt Seeed Studio XIAO 7,5″ ePaper Panel (C3-Type). Die Eckdaten:
- 800 × 480 Pixel, monochrom, 7,5 Zoll
- ESP32-C3 (als XIAO-Modul) sitzt direkt auf der Rückseite
- läuft mit ESPHome, in meinem Fall Version 2026.6.5
- knapp 70 Euro im Seeed-Shop
Knapp 70 Euro — und ab da hängt an der Wand ein Display, das nur dann Strom zieht, wenn sich wirklich etwas ändert.
ePaper ist für so ein „Rausgeh-Board" ideal. Es leuchtet nicht, es flimmert nicht, und es braucht nur beim Neuzeichnen Energie. Ein Bildschirm, der 23 Stunden am Tag dasselbe zeigt, muss nicht 23 Stunden lang leuchten. Genau das war der Reiz.
Der schnelle Start mit dem ESPHome Device Builder
Weil ich Home Assistant ohnehin auf einem Intel NUC laufen habe, war der Einstieg kurz. Der ESPHome Device Builder (bei mir 1.1.0) kennt das Panel schon: Gerät auswählen, anlegen, fertig. Der erste Flash war ein simples „Hello World", das ich per USB-Kabel aufgespielt habe — danach geht alles drahtlos über OTA.
Zwei Dinge muss man wissen, sonst bleibt das Display schwarz:
- Das richtige Modell heißt
7.50inv2. Nimmst du ein falsches, bleibt der Schirm leer oder invertiert. - Der
busy_pinist invertiert (inverted: true). Steht das nicht drin, hängt der Refresh.
Und der Klassiker beim ersten OTA-Versuch: No route to host. Kein Code-Problem — das Gerät war schlicht noch nicht als Integration in Home Assistant adoptiert. Erst wenn HA das Panel unter Einstellungen → Geräte & Dienste kennt, fließen die Sensorwerte. Vorher zeigt das Display brav das Layout, aber überall nur „–".
Werte aus Home Assistant holen
Das eigentliche Prinzip ist simpel: Das Panel rechnet nichts selbst. Es abonniert über die ESPHome-API die Zustände aus Home Assistant. Jeder Wert bekommt einen homeassistant-Sensor:
| Bereich | Home-Assistant-Entität | Aktualisierung |
|---|---|---|
| Fenster offen | sensor.offene_fenster | sofort bei Änderung |
| Wetter | weather.forecast_home_2 | bei Wetterwechsel |
| Auto-Akku | sensor.skoda_enyaq_batteriestand | in 10-%-Schritten |
| Müllabfuhr | sensor.abfallsammlung | täglich |
| Termine heute | sensor.termine_heute | bei Kalenderänderung |
| Außentemperatur | sensor.wolf_..._aussentemperatur | in ganzen Grad |
Ein Detail, das mich überrascht hat: Ob eine Entität für den Sprachassistenten „exposed" ist oder nicht, ist ESPHome egal. Das Panel liest sie trotzdem über die API. Für den Assistenten sind meine Energie- und Wärmepumpen-Sensoren nicht freigegeben — aufs Display kommen sie problemlos.
Gebaut habe ich das Ganze übrigens im Dialog mit einem KI-Assistenten (Claude), der über den Home-Assistant-MCP direkt in meine Instanz schauen konnte. Er hat die Entitäten also nicht geraten, sondern ausgelesen und verstanden — welche Sensoren es gibt, welche Einheit sie haben, welcher Wert wohin gehört. Das hat das Mapping von „ich will Fenster, Wetter, Müll" auf konkrete Entity-IDs enorm abgekürzt.
Die Termine waren der kniffligste Teil
Ich wollte oben auf dem Panel die heutigen Termine sehen — Geburtstage und Family. Beide Kalender hatte ich vorher per WebDAV in Home Assistant eingebunden, das war Voraussetzung. Nur: Ein HA-Kalender-Entity liefert über seine Attribute immer nur das eine nächste Event. Egal wie weit weg. So stand bei mir tagelang ein Geburtstag „in zwei Wochen" auf dem Display.
Die saubere Lösung liegt in Home Assistant, nicht im Panel: ein Template-Sensor, der per calendar.get_events die Ereignisse von heute abholt und als Liste bereitstellt. Das Panel liest dann nur noch diese Liste. Kleiner Umweg, großer Unterschied — plötzlich stehen da genau die Termine, die heute zählen, und mehrere gleichzeitig.
Refresh mit Verstand
Mein erster Entwurf hat das Display alle 60 Sekunden neu gezeichnet. Schlechte Idee. Bei Live-Werten wie PV-Erzeugung oder Wärmepumpen-Leistung, die ständig zappeln, wird aus dem ruhigen ePaper ein nervöses Blinklicht — jeder Vollrefresh flackert einmal kurz schwarz.
Also andersherum gedacht: Das Panel zeichnet nur neu, wenn sich wirklich etwas ändert. Fenster auf oder zu? Sofort. Sonst mit Augenmaß:
- Auto-Akku nur in 10-%-Schritten
- Außentemperatur nur bei ganzen Grad
- Müll, Wetter und Termine ändern sich ohnehin nur täglich
Ein kleines Script sammelt gleichzeitige Änderungen fünf Sekunden lang ein und zeichnet dann einmal sauber — sonst überschlagen sich beim Booten elf Sensoren gegenseitig. Dazu oben rechts Datum und Uhrzeit des letzten Refresh, damit ich sehe, wie aktuell das Bild ist.
Die finale Code-Basis
Das ist die komplette ESPHome-Konfiguration, wie sie bei mir läuft. Die Entity-IDs sind meine — Wolf-Wärmepumpe, Senec-Speicher, Škoda Enyaq, meine Müll- und Kalender-Sensoren. Die musst du auf deine anpassen. Und: API-Key und Fallback-Passwort habe ich hier durch Platzhalter ersetzt, deine eigenen stehen im Device Builder.
# Board: Seeed Studio XIAO ESP32C3
esphome:
name: seeed
friendly_name: SEEED
esp32:
variant: esp32c3
logger:
api:
encryption:
key: "DEIN_API_KEY" # kommt aus dem Device Builder
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: SEEED Fallback Hotspot
password: "DEIN_FALLBACK_PASSWORT"
captive_portal:
time:
- platform: homeassistant
id: ha_time
script:
- id: refresh_display
mode: restart
then:
- delay: 5s
- component.update: main_display
text_sensor:
- platform: homeassistant
id: muell_typ
entity_id: sensor.abfallsammlung
on_value: { script.execute: refresh_display }
- platform: homeassistant
id: wetter_state
entity_id: weather.forecast_home_2
on_value: { script.execute: refresh_display }
- platform: homeassistant
id: termine_geb
entity_id: sensor.termine_heute
attribute: geburtstage
on_value: { script.execute: refresh_display }
- platform: homeassistant
id: termine_fam
entity_id: sensor.termine_heute
attribute: family
on_value: { script.execute: refresh_display }
sensor:
- platform: homeassistant
id: fenster
entity_id: sensor.offene_fenster
on_value: { script.execute: refresh_display }
- platform: homeassistant
id: auto_akku
entity_id: sensor.skoda_enyaq_batteriestand
filters: [ { delta: 10 } ] # nur in 10-%-Schritten
on_value: { script.execute: refresh_display }
- platform: homeassistant
id: aussen
entity_id: sensor.wolf_bm2_0x30_220103_aussentemperatur
filters: [ { delta: 1.0 } ] # nur bei ganzen Grad
on_value: { script.execute: refresh_display }
- platform: homeassistant
id: wetter_temp
entity_id: weather.forecast_home_2
attribute: temperature
filters: [ { delta: 1.0 } ]
on_value: { script.execute: refresh_display }
- platform: homeassistant
id: muell_tage
entity_id: sensor.abfallsammlung
attribute: daysTo
on_value: { script.execute: refresh_display }
font:
- file: "gfonts://Inter@700"
id: font_title
size: 30
glyphs: ' &()/:.,-%°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzäöüÄÖÜß'
- file: "gfonts://Inter@700"
id: font_section
size: 22
glyphs: ' &()/:.,-%°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzäöüÄÖÜß'
- file: "gfonts://Inter@500"
id: font_label
size: 18
glyphs: ' &()/:.,-%°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzäöüÄÖÜß'
- file: "gfonts://Inter@700"
id: font_value
size: 46
glyphs: ' &()/:.,-%°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzäöüÄÖÜß'
- file: "gfonts://Inter@400"
id: font_small
size: 16
glyphs: ' &()/:.,-%°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzäöüÄÖÜß'
- file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf"
id: font_icon_big
size: 54
glyphs: &mdi_glyphs
- "\U000F0004" # account
- "\U000F0026" # alert
- "\U000F0079" # battery
- "\U000F00E9" # cake
- "\U000F01B4" # delete
- "\U000F02DC" # home
- "\U000F0590" # weather-cloudy
- "\U000F0591" # weather-fog
- "\U000F0593" # weather-lightning
- "\U000F0594" # weather-night
- "\U000F0595" # weather-partly-cloudy
- "\U000F0596" # weather-pouring
- "\U000F0597" # weather-rainy
- "\U000F0598" # weather-snowy
- "\U000F0599" # weather-sunny
- "\U000F059D" # weather-windy
- file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf"
id: font_icon_small
size: 22
glyphs: *mdi_glyphs
spi:
clk_pin: GPIO8
mosi_pin: GPIO10
display:
- platform: waveshare_epaper
id: main_display
cs_pin: GPIO3
dc_pin: GPIO5
busy_pin:
number: GPIO4
inverted: true
reset_pin: GPIO2
model: 7.50inv2
update_interval: 3600s # Fallback stuendlich (Uhrzeit / Tageswechsel)
lambda: |-
auto num = [&](int x, int y, esphome::font::Font *f,
esphome::sensor::Sensor *s, const char *fmt) {
if (s->has_state()) it.printf(x, y, f, fmt, s->state);
else it.print(x, y, f, "--");
};
// Zweispaltige Terminliste
int e_top = 330, e_lh = 29, e_max = 5;
auto draw_line = [&](int idx, const char* icon, const std::string &txt) {
int col = idx / e_max, row = idx % e_max;
if (col > 1) return;
int x = 24 + col * 390;
int y = e_top + row * e_lh;
it.print(x, y, id(font_icon_small), icon);
it.print(x + 30, y, id(font_section), txt.c_str());
};
auto add_events = [&](const char* icon, esphome::text_sensor::TextSensor *ts, int &idx) {
if (!ts->has_state()) return;
std::string s = ts->state; size_t pos = 0;
while (pos <= s.size()) {
size_t nl = s.find('\n', pos);
std::string line = (nl == std::string::npos) ? s.substr(pos) : s.substr(pos, nl - pos);
if (!line.empty()) draw_line(idx++, icon, line);
if (nl == std::string::npos) break;
pos = nl + 1;
}
};
// Kopfzeile mit Datum + Uhrzeit
it.print(20, 12, id(font_title), "ÜBERSICHT");
std::string stand = "--";
auto now = id(ha_time).now();
if (now.is_valid()) stand = now.strftime("%d.%m.%Y %H:%M");
if (id(aussen).has_state())
it.printf(788, 16, id(font_small), TextAlign::TOP_RIGHT,
"Außen %.0f°C Stand %s", id(aussen).state, stand.c_str());
else
it.printf(788, 16, id(font_small), TextAlign::TOP_RIGHT, "Stand %s", stand.c_str());
it.filled_rectangle(0, 52, 800, 3);
// Reihe A: 3 Kacheln
it.rectangle(16, 66, 248, 134);
it.rectangle(276, 66, 248, 134);
it.rectangle(536, 66, 248, 134);
// Kachel 1: Fenster
{
int x = 16;
it.print(x+16, 76, id(font_label), "Fenster");
int cnt = id(fenster).has_state() ? (int)id(fenster).state : -1;
const char* ic = "\U000F02DC";
const char* sub = "alle zu";
if (cnt > 0) { ic = "\U000F0026"; sub = "offen"; }
it.print(x+16, 104, id(font_icon_big), ic);
if (cnt < 0) it.print(x+96, 112, id(font_value), "--");
else it.printf(x+96, 112, id(font_value), "%d", cnt);
it.print(x+96, 164, id(font_label), sub);
}
// Kachel 2: Wetter
{
int x = 276;
it.print(x+16, 76, id(font_label), "Wetter");
std::string wc = id(wetter_state).has_state() ? id(wetter_state).state : "";
const char* wi = "\U000F0026"; std::string wl = "--";
if (wc=="sunny") { wi="\U000F0599"; wl="Sonnig"; }
else if (wc=="clear-night") { wi="\U000F0594"; wl="Klar"; }
else if (wc=="partlycloudy") { wi="\U000F0595"; wl="Wolkig"; }
else if (wc=="cloudy") { wi="\U000F0590"; wl="Bewölkt"; }
else if (wc=="fog") { wi="\U000F0591"; wl="Nebel"; }
else if (wc=="rainy") { wi="\U000F0597"; wl="Regen"; }
else if (wc=="pouring") { wi="\U000F0596"; wl="Starkregen"; }
else if (wc=="snowy"||wc=="snowy-rainy") { wi="\U000F0598"; wl="Schnee"; }
else if (wc=="lightning"||wc=="lightning-rainy") { wi="\U000F0593"; wl="Gewitter"; }
else if (wc=="windy"||wc=="windy-variant") { wi="\U000F059D"; wl="Windig"; }
else if (!wc.empty()) { wi="\U000F0026"; wl=wc; }
it.print(x+16, 104, id(font_icon_big), wi);
if (id(wetter_temp).has_state())
it.printf(x+96, 112, id(font_value), "%.0f°C", id(wetter_temp).state);
else if (id(aussen).has_state())
it.printf(x+96, 112, id(font_value), "%.0f°C", id(aussen).state);
else
it.print(x+96, 112, id(font_value), "--");
it.print(x+96, 164, id(font_label), wl.c_str());
}
// Kachel 3: Auto
{
int x = 536;
it.print(x+16, 76, id(font_label), "Auto");
it.print(x+16, 104, id(font_icon_big), "\U000F0079");
num(x+96, 112, id(font_value), id(auto_akku), "%.0f %%");
it.print(x+96, 164, id(font_label), "Akku");
}
// Reihe B: Muell
it.rectangle(16, 214, 768, 66);
it.print(30, 226, id(font_icon_big), "\U000F01B4");
it.print(104, 222, id(font_label), "Nächste Abholung");
{
std::string typ = "--";
if (id(muell_typ).has_state()) {
typ = id(muell_typ).state;
size_t p = typ.find(" in ");
if (p != std::string::npos) typ = typ.substr(0, p);
}
if (id(muell_tage).has_state()) {
float d = id(muell_tage).state;
if (d < 0.5f) it.printf(104, 246, id(font_section), "%s heute", typ.c_str());
else if (d < 1.5f) it.printf(104, 246, id(font_section), "%s morgen", typ.c_str());
else it.printf(104, 246, id(font_section), "%s in %.0f Tagen", typ.c_str(), d);
} else {
it.print(104, 246, id(font_section), typ.c_str());
}
}
// Reihe C: Termine heute (volle Breite, zweispaltig)
it.print(20, 296, id(font_section), "TERMINE HEUTE");
it.line(16, 320, 784, 320);
{
int idx = 0;
add_events("\U000F00E9", id(termine_geb), idx); // cake = Geburtstage
add_events("\U000F0004", id(termine_fam), idx); // account = Family
if (idx == 0)
it.print(24, 330, id(font_section), "keine Termine heute");
}
Fazit
Das Panel hängt jetzt seit ein paar Wochen im Flur, und es hat genau die Rolle übernommen, für die es gedacht war: die stille Instanz, die morgens meine drei Fragen beantwortet, bevor ich die Tür aufmache. Kein Wischen, kein Entsperren, kein Leuchten in der Nacht. Es steht einfach da und stimmt.
Der ehrlichste Teil kam beim Refresh. Ein Display, das sich zurückhält und nur bei echten Änderungen aufwacht, fühlt sich viel wertiger an als eines, das im Sekundentakt zappelt. ePaper zwingt dich zu dieser Disziplin — und das ist gut so. Wenn du Home Assistant schon laufen hast, ist das hier ein Wochenendprojekt für knapp 70 Euro, das dir jeden Morgen ein bisschen Handy-Griff spart.
