LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Build an E-Paper Status Dashboard

hardwaree-paperesp32raspberry-piesphomehomelabdiy

An e-paper status dashboard is the project that turns an ordinary homelab metric into a piece of low-key furniture: a small white panel on the wall that shows the things you actually want to know — your network bandwidth, your solar output, the weather, the laundry status, when the next train leaves — without lighting up your room or asking for attention. It is also the project that teaches you how aggressively the display technology shapes the software. E-paper is bistable, slow, and stubborn about ghosting; the limitations are not bugs to engineer around but design constraints that force a sane refresh cadence, a sparser layout, and a different programming model than a screen-on dashboard. Build one and you stop thinking about pixels-per-second and start thinking about updates-per-hour and milliwatts-per-day. This post walks the hardware decision (a Raspberry Pi versus an ESP32 with ESPHome), the refresh physics that decide what you can change and how often, the rendering pipeline from data source to panel, and the enclosure and mounting choices that decide whether your dashboard lives on the desk for a month or on the wall for years.


The Display Technology Is the Constraint

Before any line of code, the most useful thing you can do is understand what an e-paper panel actually does, because almost every interesting design decision in the project falls out of its physics. The exhaustive physics of how e-ink and the broader e-reader world work is its own post, but the dashboard-relevant summary is short.

An e-paper panel is a grid of tiny capsules containing charged black and white pigment particles suspended in a clear fluid. Applying an electric field across a capsule pulls one color to the front; cut the field and the particles stay where they are — the display is bistable and holds the image with zero power. Refreshing means waveform-driven re-electrification of every pixel, which physically takes hundreds of milliseconds to a couple of seconds, depending on the panel and the refresh mode. That single fact decides three things:

  • Update cadence: a panel that takes 2 seconds to refresh is not animated. You design for a screen that changes infrequently — every minute, every fifteen minutes, every hour — not for continuous motion.
  • Ghosting: pixel transitions are imperfect. After many partial updates the panel retains faint shadows of previous frames, and you must periodically do a full refresh (a controlled black-white-black flash across the whole panel) to clear them.
  • Power profile: between refreshes, the panel consumes essentially zero power. Your software’s job is to wake briefly, render, push pixels, and go back to sleep. This is the inverse of an LCD dashboard.

Three panel families matter for a homelab build. Two-color (black-and-white) panels are the fastest, support partial refresh, and are the right default — Waveshare’s 2.13", 2.9", 3.52", 4.2", and 7.5" black-and-white modules are the workhorses. Three-color (black-white-red or black-white-yellow) panels add an accent color but cannot do partial refresh and have much longer full-refresh times (5–30 seconds), so they suit signage-style designs that change rarely. Seven-color Spectra panels approach a color photo aesthetic but refresh in tens of seconds and need careful color management. For a metrics dashboard that updates every few minutes, black-and-white panels in the 4.2" to 7.5" range are the sweet spot — readable, fast enough, cheap.

The size you pick is the canvas. A 4.2" panel at 400×300 pixels fits four to six “tiles” of information at comfortable reading size. A 7.5" panel at 800×480 fits a dozen tiles, a calendar, and a weather strip. Going larger gets impressive but expensive and noticeably slower to refresh.


Hardware: Raspberry Pi or ESP32?

The first real design decision is the controller. There are two clean answers and one ugly compromise.

Raspberry Pi (Zero 2 W or 3A+) gives you a full Linux box at the panel. You SSH in, you run Python, you have unlimited RAM relative to the problem, you can pull from any data source over the network with one requests.get() call, and you can rsync new code over with no firmware-flashing ceremony. The cost is power — a Pi Zero 2 W draws around 0.5 W idling, 1.5+ W under load — which makes battery operation impractical, and Linux boot time is too slow to deep-sleep between refreshes. Pi-based dashboards almost always plug into wall power.

ESP32 is the right answer if you want a battery-powered dashboard. With deep sleep, the ESP32 sips ~10 microamps between refreshes, and a 2000 mAh lithium cell can keep a panel running for three to six months on a charge when refreshing every 15–30 minutes. The ESP32 has Wi-Fi, more than enough flash and RAM to render a useful layout, and a thriving ecosystem (Arduino, ESPHome, MicroPython). The cost is the embedded programming model: you cannot just pip install whatever library you want; you have a few hundred KB of working RAM, no filesystem in the Linux sense, and a slower CPU. Dedicated ESP32 e-paper driver boards from Waveshare and Seeed (the XIAO ePaper series, LILYGO T5-4.7, Inkplate) integrate the panel connector, an 18650 holder, and a battery management IC into one PCB — these are the right starting point.

The ugly compromise is a Pi running on battery with cleverness — wake-on-LAN, scheduled boots, etc. It mostly does not work; Pis were not designed for that duty cycle. Pick the right side of the split and live with it.

Controller Power profile Best fit Software Effort
Pi Zero 2 W / 3A+ Wall power only (~1 W idle) Always-on wall dashboard Python, anything Low to medium
ESP32 + deep sleep Battery (~10 µA sleep) Battery panel anywhere ESPHome / Arduino Medium
Dedicated EPD board (Inkplate, XIAO ePaper, LILYGO T5) Battery, integrated Quick-start battery panel ESPHome / Arduino Low
Pi on battery Bad Avoid Mismatch High frustration

For a first build, my honest default is a 7.5" Waveshare panel on a dedicated ESP32 driver board (or an Inkplate 6Color/Inkplate 10) running ESPHome, on a small lithium battery — you get the long-runtime, mount-anywhere experience that makes the project feel finished.


Refresh Strategy: Partial Versus Full

This is the part of the project where the physics is doing the most work. Every modern e-paper panel offers two refresh modes:

A full refresh drives every pixel through a complete black-white-black cycle. It takes 1–2 seconds on a small panel, 2–5 seconds on a larger one, flashes the screen visibly while doing it, and produces a clean, ghost-free image. It is what you do at startup, when the layout completely changes, and periodically to clear accumulated ghosting.

A partial refresh updates only changed pixels, with a much shorter waveform that does not flash, completing in 200–500 milliseconds. It is what you use to change a number on a clock, a graph value, or a status line without visually disturbing the rest of the panel. The trade is ghosting: partial refreshes accumulate faint residual artifacts of previous values, and after some number of them (typically 5–20 depending on panel) you must do a full refresh to clear the build-up. The Waveshare wiki is explicit about this — “after refreshing partially several times, you need to fully refresh EPD once” — and ignoring the rule produces visibly degraded panels in days.

A workable cadence for a status dashboard:

   STARTUP                            -> full refresh, draw whole layout
   each tile update                   -> partial refresh of that tile only
   every Nth partial (e.g., N=10)     -> full refresh to clear ghosting
   every hour, or at midnight         -> full refresh anyway, as insurance

The partial-refresh “tiles” should be aligned to byte boundaries on the panel buffer if you can manage it; pushing aligned regions is faster and the panel waveforms are happier with regions whose width is a multiple of 8 pixels. For a layout designer this means snapping tile widths to 8, 16, or 32 pixels — a constraint that incidentally produces tidier-looking dashboards.

Designing layouts for an e-paper dashboard means thinking like a newspaper editor: a small number of high-information regions, generous whitespace, large readable fonts. A dashboard that tries to update a six-decimal-place number every second is the wrong shape for the medium; one that shows hour-resolution data in big type on a clean grid is exactly right.


The Layout, From Data Source to Pixels

Whether you build on a Pi or an ESP32, the pipeline has the same shape:

     home assistant / Prometheus / weather API / NWS / GTFS
                          │
                          ▼
        ESP32 or Pi pulls JSON / YAML over Wi-Fi
                          │
                          ▼
        Rendering layer composes a framebuffer image
        (Pillow on Pi, ESPHome display blocks on ESP)
                          │
                          ▼
        SPI driver pushes framebuffer to the EPD
                          │
                          ▼
        Panel refreshes (full or partial) and holds image
                          │
                          ▼
        Controller sleeps until next scheduled refresh

The data side is where you save yourself trouble by picking the right intermediary. If you already run Home Assistant, exposing every value you want on the panel as an HA sensor — even values that are not “smart home” — is the cleanest pattern. Your ESP32 then becomes a Home Assistant client via ESPHome’s HA API, and you get push updates without any custom HTTP polling logic. For a homelab built around docker compose and Home Assistant, this collapses several integration steps into the existing automation hub.

If you do not use Home Assistant, the next-best pattern is a tiny FastAPI or Flask service on your network that aggregates the values you want and returns a single JSON blob. Your panel hits that one endpoint, the service handles the messy work of talking to Prometheus, an MQTT broker, a weather API, the train API, and your homelab monitoring stack, and your firmware stays simple. This is good architecture for the same reason it is good architecture elsewhere: one device that knows how to render is a much easier piece to maintain than one device that also knows how to talk to seven external APIs.


ESPHome: The 80% Solution

If your controller is an ESP32, the path of least resistance is ESPHome — a YAML-driven firmware generator that compiles your config into a custom Arduino-based binary. It supports virtually every Waveshare and DKE e-paper panel out of the box, gives you a clean display-rendering DSL with print, image, line, rectangle, and graph primitives, integrates natively with Home Assistant for both data and OTA updates, and handles deep sleep correctly with a couple of lines of config.

A minimal config for a 7.5" Waveshare panel on an ESP32 board, pulling from Home Assistant, with 15-minute deep sleep:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
esphome:
  name: dashboard-panel

esp32:
  board: esp32dev
  framework:
    type: arduino

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

api:
  encryption:
    key: !secret api_key

ota:
  platform: esphome

deep_sleep:
  id: deep_sleep_1
  run_duration: 30s
  sleep_duration: 15min

spi:
  clk_pin: GPIO13
  mosi_pin: GPIO14

font:
  - file: "fonts/Roboto-Regular.ttf"
    id: font_lg
    size: 36
  - file: "fonts/Roboto-Regular.ttf"
    id: font_sm
    size: 16

sensor:
  - platform: homeassistant
    id: power_use
    entity_id: sensor.house_power_w

  - platform: homeassistant
    id: solar_w
    entity_id: sensor.solar_power_w

  - platform: homeassistant
    id: indoor_temp
    entity_id: sensor.living_room_temp

display:
  - platform: waveshare_epaper
    id: epaper
    model: 7.50inv2
    update_interval: never
    cs_pin: GPIO15
    dc_pin: GPIO27
    busy_pin: GPIO25
    reset_pin: GPIO26
    lambda: |-
      it.printf(20,   20, id(font_lg), "Solar: %.0f W", id(solar_w).state);
      it.printf(20,  120, id(font_lg), "Load:  %.0f W", id(power_use).state);
      it.printf(20,  220, id(font_lg), "Inside: %.1f F", id(indoor_temp).state);
      it.printf(20,  400, id(font_sm), "updated: %s", id(homeassistant_time).now().strftime("%H:%M").c_str());

time:
  - platform: homeassistant
    id: homeassistant_time

What this gives you: a panel that wakes every 15 minutes, pulls three values from Home Assistant, renders them in big and small Roboto, full-refreshes, and goes back to sleep. With a 2000 mAh lithium pack, it runs for three to six months between charges. Adding more tiles is a few more lines of lambda rendering. Adding remote OTA updates is already in the config. That is roughly the 80% solution for a panel project, and ESPHome’s e-paper components handle the partial-refresh discipline automatically when you enable it.

For projects that need more control — a graph that scrolls hourly, a custom GUI library, an unusual data source — falling back to Arduino with GxEPD2 (the de facto Arduino EPD library) is straightforward but a real step up in code volume. ESPHome until you outgrow it.


The Python Path on a Raspberry Pi

If your controller is a Pi (wall-powered, always-on, possibly slightly fancier layouts), the canonical stack is Python + Pillow + the Waveshare epd library. The Waveshare library exposes a display(image) call that takes a Pillow image at the panel’s resolution and pushes it over SPI. You write a normal Python program that:

  1. Pulls data from your sources.
  2. Composes a Pillow image with ImageDraw, importing the fonts you want.
  3. Calls epd.display(image) for a full refresh, or epd.displayPartial(image) for a partial refresh.
  4. Optionally logs metrics, exposes a /healthz endpoint, and waits for the next cycle.

A skeleton:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import time
from PIL import Image, ImageDraw, ImageFont
import requests
from waveshare_epd import epd7in5_V2

epd = epd7in5_V2.EPD()
epd.init()

font_lg = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
font_sm = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)

def fetch():
    r = requests.get("http://homelab.local:8080/dashboard.json", timeout=5)
    return r.json()

def render(data):
    img = Image.new("1", (epd.width, epd.height), 255)
    d = ImageDraw.Draw(img)
    d.text((20, 20),   f"Solar: {data['solar_w']:.0f} W",    font=font_lg, fill=0)
    d.text((20, 120),  f"Load:  {data['load_w']:.0f} W",     font=font_lg, fill=0)
    d.text((20, 220),  f"Inside: {data['indoor_f']:.1f} F",  font=font_lg, fill=0)
    d.text((20, 400),  f"updated: {time.strftime('%H:%M')}", font=font_sm, fill=0)
    return img

if __name__ == "__main__":
    counter = 0
    while True:
        try:
            data = fetch()
            img = render(data)
            if counter % 10 == 0:
                epd.init()
                epd.display(epd.getbuffer(img))   # full refresh
            else:
                epd.displayPartial(epd.getbuffer(img))  # partial
            counter += 1
        except Exception as e:
            print(f"refresh failed: {e}")
        time.sleep(60)

That is the entire daemon. Wire it up with systemd so it restarts on failure, add a log of refresh times, and the dashboard is done. The pattern scales — add tiles, add a heating-map of values over time, add a small icon library — without changing the architecture. Treat the Pi-running-the-dashboard the same way you treat the rest of your homelab nodes for fleet management: a known systemd unit, a known config in version control, a known way to roll back.


Power, Enclosure, and the Boring Stuff That Decides Whether It Lasts

The project usually works in software within a weekend. The thing that decides whether it stays on the wall for a year is the boring stuff: power, mounting, and enclosure.

Power for a wall dashboard. A Pi or wall-powered ESP32 board wants a USB-C power supply tucked behind it. Run the wire neatly down the wall through a small cable channel; you will look at it every day. A small UPS or supercapacitor on the Pi’s 5 V rail prevents power-blip filesystem corruption and is worth the extra ten dollars.

Power for a battery dashboard. A 2000–3500 mAh single-cell lithium pack with a TP4056-style charging IC and protection is the standard pattern; the dedicated EPD driver boards (Inkplate, LILYGO T5, Seeed XIAO ePaper expansion) include this. Charging is via USB-C every few months. If you want to forget the panel exists for years rather than months, a small solar cell — even an indoor one — supplementing the battery is enough to make the panel net-positive on energy. The household-scale solar-and-battery design thinking applies, scaled down.

Enclosure. A 3D-printed front frame around the panel is the right answer if you have a printer. A clamshell-style enclosure that hides the cable, the battery, and the screws and leaves only the e-paper surface visible takes a couple of design iterations to get right — the panel is fragile and thin (often a ribbon-cable connection that breaks if flexed), and the enclosure’s job is to protect that connector. If you do not have a printer, picture frames with a cut mat work well as cheap, attractive enclosures. The trick is the depth — you need 1–2 cm of standoff behind the panel for the controller board, the cable bend radius, and the battery.

Mounting. A 3M Command strip or two on a 7.5" panel holds for years; on larger panels, a small picture hook plus a foam mounting block keeps the panel flush. Avoid drywall screws unless the panel is heavy enough to need them; the project should look casual and replaceable.

The cable bend radius is the failure mode you do not expect. The ribbon cable between the EPD glass and the driver board is delicate. Repeated flexing breaks lines and produces missing rows or stuck pixels. Bend it once during assembly, secure it, and never bend it again.


Verdict

An e-paper status dashboard is the right project to learn how aggressively a display technology can shape the software around it. You stop thinking about animation, you start thinking about updates per hour and the discipline of partial-refresh-then-full-refresh, and you discover that a panel that takes two seconds to redraw enforces a layout philosophy newspapers had right a century ago: large readable values, generous whitespace, a manageable number of tiles. The hardware split is honest: a Raspberry Pi for wall-powered always-on dashboards where Python and unlimited memory make rendering trivial, an ESP32 with ESPHome for battery panels you mount once and refill every three to six months, and a few dedicated EPD driver boards that hand you the battery, charging IC, and connector ready-made if you do not want to start from breadboard. The software is closer to “scheduled cron job that writes pixels” than to any GUI framework, which is exactly the right scale for the medium. Designed well, the panel stops feeling like a screen and starts feeling like furniture — a quiet, slow, glanceable surface for the half-dozen numbers you actually want to know, at the cadence your eyes can read them. Build one for the weekend; you will end up running three.


Sources

Comments