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_pin ist 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:

BereichHome-Assistant-EntitätAktualisierung
Fenster offensensor.offene_fenstersofort bei Änderung
Wetterweather.forecast_home_2bei Wetterwechsel
Auto-Akkusensor.skoda_enyaq_batteriestandin 10-%-Schritten
Müllabfuhrsensor.abfallsammlungtäglich
Termine heutesensor.termine_heutebei Kalenderänderung
Außentemperatursensor.wolf_..._aussentemperaturin 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.