Back to blog
FILE 0xE2·A BYOS E-INK DASHBOARD THAT WAKES ONCE A DAY

A BYOS e-ink dashboard that wakes once a day

April 27, 2026 · homelab, eink, dashboard

I wanted a tiny always-on display in my office that showed the day's useful information and lasted a season on a single battery charge. The constraint was self-imposed: I didn't want to plug it in. That meant e-ink, very few wakes per day, and a server that did all the work.

What was happening

Web dashboards are great until you have to leave a tab open on a tablet all day, or the screen blanks, or it bricks itself from sleep states. A dedicated little display that just shows today's info would be more useful than a thing I have to remember to glance at.

The hardware I picked is a 7.5" 800×480 1-bit e-paper panel on an ESP32-S3 with the open TRMNL firmware. In BYOS mode the device just fetches an image from a URL on a polling interval. All the layout work happens server-side.

What I found

A few things have to line up for "wakes once a day, lasts 3 months on a battery":

The fix

The server is a single Python file using http.server, fronted by nginx with a Let's Encrypt cert. The device hits four BYOS endpoints (/api/setup, /api/display, /api/log, plus a static image at /display.bmp). The aggregator pulls from about a dozen sources and caches each according to how often it actually changes:

Source Cache TTL
Sobriety counter 60s
Weather + 7-day 30 min
Todoist tasks 5 min
qBittorrent stats 5 min
Speedtest + storage hourly (pushed by cron)
WAN bandwidth this month 30 min
Journal "on this day" 6h
Homelab status probes 60s

The "wake once a day at 5 AM local time" bit returns a refresh-rate value computed against the local timezone:

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

LOCAL_TZ = ZoneInfo("America/Chicago")

def seconds_until_next_5am():
    now_utc = datetime.now(timezone.utc)
    now_local = now_utc.astimezone(LOCAL_TZ)
    next_wake_local = now_local.replace(hour=5, minute=0, second=0, microsecond=0)
    if next_wake_local <= now_local:
        next_wake_local += timedelta(days=1)
    next_wake_utc = next_wake_local.astimezone(timezone.utc)
    return max(int((next_wake_utc - now_utc).total_seconds()), 60)

ZoneInfo handles the spring-forward / fall-back transitions automatically; the wake stays at 5 AM local across DST shifts, and the UTC equivalent moves by an hour as appropriate.

The layout splits the 800×480 canvas into bands: a 52 px header with date and weather, a sobriety counter beside a 7-day forecast, a two-column body with Todoist on the left and a "this day five years ago" journal entry on the right, a stats band with torrents / internet / storage, and a footer with overall homelab status and the last-sync time.

The device also supports manual wake — the middle button on the back fetches the latest image within ~5 seconds. So the 5 AM cadence is the "automatic" mode but I can always force a refresh when I want one.

One small detail that's worth doing on day one: lock the public URL to your home IP and your LAN ranges with nginx geo. The dashboard contains personal data and there's no reason for it to be globally addressable.

geo $dash_allowed {
    default 0;
    <home-wan-ip>/32 1;
    <lan-subnet>     1;
}
server {
    server_name dash.example.com;
    if ($dash_allowed = 0) { return 403; }
    # ...
}

What I'd do differently

I'd skip the cask version of any "Tailscale-like" daemon I install during the setup phase. (Generalization from a parallel mistake on the Mac mini: the cask version is a GUI app requiring menu-bar interaction; the formula is a CLI daemon that works over SSH. Always pick the one that doesn't require a mouse.)

For the dashboard itself, the only thing I'd build differently is the push side. Instead of having the dashboard pull from a dozen APIs on a schedule, I'd have the sources push when they have new data, and let the dashboard read from a single cached blob. Pulls are fine at this scale but the failure mode of one slow API blocking the whole render is exactly the failure I hit on another dashboard a few weeks later. Push topology avoids it.