A BYOS e-ink dashboard that wakes once a day
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 device gets one image per wake. Whatever the server cares about, it has to render into that image. No JS, no live updates.
- The image format has to be 1-bit. 256 levels of gray dithered down is the right rendering pipeline; trying to feed it color images produces a muddy mess.
- The poll interval is what determines battery life. 15 minutes is ~3 months. 5 minutes is ~5 weeks. 1 minute is ~10 days.
- DST exists. If you want it to wake at "5 AM local time every day," you have to compute the next wake against the local timezone, not UTC, or it'll drift an hour twice a year.
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.