An always-on e-ink desk panel built from a Kindle Paperwhite 3, driven by a small render service on a homelab NUC.
The Kindle sleeps for almost all of its life, waking on a timer to fetch a pre-rendered image and paint it. All layout, typography and dithering happen server-side; the device does an HTTP request and a framebuffer write.
┌──────────────────┐ HTTP(S) ┌────────────────────────────┐
│ Kindle PW3 │ ───────────────────▶ │ NUC │
│ FW 5.16.2.1.1 │ HEAD + If-None-Match │ kindleframe container │
│ │ ◀─────────────────── │ │
│ wake → fetch │ 304, or a PNG │ Satori → SVG → PNG │
│ → paint → sleep │ │ → 16-level dither │
└──────────────────┘ └────────────────────────────┘
▲ ▲ │
│ power button → menu │ └─ Open-Meteo
│ POST /status
(phone, script, HA)
The Kindle's software is from 2015: an ancient WebKit, a slow CPU, few fonts. Anything laid out on the device looks worse and takes longer than the same work done on the NUC. Rendering server-side inverts that — the Kindle's job shrinks to a fetch and a blit, which is the one thing it does cheaply and reliably.
The renderer is Satori + resvg, not headless Chromium. A browser buys real
CSS layout, but costs ~400MB of image, 150–300MB of RSS per render, a 256MB
shm_size workaround, zombie process reaping, and output that drifts when
Chromium changes its font hinting. Satori renders a fixed subset
deterministically from fonts the image ships itself, in ~9MB of dependencies.
The trade is a smaller CSS subset — flexbox, absolute positioning, borders, text properties; no stylesheets, no grid, no selectors. For a fixed-size panel with a designed layout that is not a real constraint.
E-ink holds its image with zero power, and the whole design leans on that. The device wakes on an RTC alarm, fetches, paints, and suspends to RAM. Measured on the hardware: a 60-second alarm produced a 61-second gap with uptime continuous, so this is a genuine suspend/resume, not a reboot.
The cost is latency — content appears within one poll interval, not instantly. At a 15-minute cadence that costs nothing real.
Most polls find nothing changed. A conditional HEAD carrying the stored ETag
returns 304 in a few hundred bytes, and the screen is never touched: no image
transfer, no refresh, no ghosting, no power.
This is why the ETag deliberately excludes the current time. Including it
would change the frame every minute, so every poll would return 200 and repaint
— defeating the whole design. It also excludes weather.fetchedAt, which moves
on every cache refresh even when the readings are identical.
A consequence worth understanding: the rendered "updated" stamp shows when the frame was last genuinely re-rendered, not when it was last requested. If the server dies, the panel keeps showing its old stamp and the staleness is visible. That is the more useful behaviour — e-ink fails silently, and stale content presented as current is the worst available failure mode.
Two paths, and the distinction is the point of the design.
| Device menu | Requests | Who decides |
|---|---|---|
| In office | nothing — paints a local asset | The device. A local override. |
| Out of office | nothing — paints a local asset | The device. A local override. |
| Show server | ?mode=server&orient=… |
The server, from its display state. |
In and out never touch the network, so they still work with the server down.
Show server is the useful one for an in/out board: you decide you are working
from home on the way in, not while standing at the panel.
curl -X POST -H "Authorization: Bearer $WRITE_TOKEN" \
https://kindleframe.example/status/outThe panel follows within one poll. That works because the ETag hashes the resolved display and the store's version counter, so a status change invalidates the device's cached frame and it stops 304ing.
display is one of in, out or text, plus a weekendMode
toggle and a short customText string.
weekend is deliberately not a selectable display. It is a rule, driven by
the toggle: when on, Saturday and Sunday override whatever is selected. Making
it a value as well would mean two ways to say the same thing and an obvious
question about which wins. Turn the toggle off if you work weekends.
Setting text shows the custom message — capped at 120 characters, sized to
its length, wrapping rather than truncating. Sending text without a display
switches to it, since that is almost always what was meant.
GET /status reports the selection and the outcome, so a caller can always
explain what is on the wall:
{
"display": "in", // what was selected
"customText": "",
"weekendMode": true, // the toggle
"effective": "weekend", // what the panel actually shows
"autoWeekend": true, // the rule is overriding the selection
"version": 12
}The day is decided in the display timezone, always server-side: the Kindle's
clock is UTC and it ships no tzdata, so TZ=… date is silently ignored there.
GET / serves a single page — two status buttons, a custom-text field, the
weekend toggle, and a live preview of the frame in both orientations. The
device chooses which orientation it asks for, so showing one would be a guess.
No token is typed in. GET / issues an httpOnly SameSite=Strict session
cookie and the page's writes ride on that, so it can simply be opened and used.
Be clear about what that is. It is not authentication — anyone who can reach the page can drive the panel. The cookie buys CSRF protection: another site cannot make your browser POST here. Protect the page at the proxy. The server says so at startup rather than leaving it implied.
The device holds a read token. It sits unattended, possibly somewhere you
do not control, and could be lost — so it must be able to fetch frames and
nothing else. Every write requires a separate write token the Kindle never
sees, and returns 403 to the read token. Verified against the real device,
not just in tests.
Behind Cloudflare Access, use two applications on the same hostname:
| Application | Policy action | Who |
|---|---|---|
kindleframe.example/frame.png |
Service Auth | the Kindle, via a service token |
kindleframe.example |
Allow | you, via SSO |
Cloudflare matches the more specific path first, so the device's credential
only unlocks /frame.png — it cannot reach the control page or any write
endpoint. The policy action must be Service Auth; with Allow, Access
still demands an interactive login and the Kindle just receives a login page.
Set CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET in the device's
local/env.sh and the fetch script sends them alongside the bearer token.
They are checked by different things: the Access headers get the request
through Cloudflare, the bearer token gets it past the application.
The result is a decent position if the Kindle is stolen: the attacker holds a credential that can fetch a picture of the weather. They cannot reach the control page, because Cloudflare will not route them there, and they cannot write, because the read token is refused. That holds even if one layer is misconfigured — which is why the tokens stay even with Access in front.
TLS is fine here despite the device's age: xh is built against rustls
(TLS 1.2/1.3), not the 2015-era OpenSSL. The real constraint is that rustls
refuses HTTPS to bare IPs, so the endpoint must be a hostname.
Setting the display is deliberately easy to call from anything that can make an authenticated POST. Four request shapes are accepted, because integrations vary in what they can send and a webhook that fails opaquely is close to undebuggable from the sending end.
| Shape | Example | Suits |
|---|---|---|
| Path, no body | POST /status/out |
Anything. Shortcuts, HA, cron |
| JSON | {"display":"out"} |
Home Assistant rest_command |
| Form-encoded | display=out, or Slack's text=out |
Slack slash commands |
| Bare string | out |
Minimal webhook senders |
POST /status takes any subset of display, text and weekendMode:
# custom message
-d '{"text":"Back at 3pm"}'
# stop overriding weekends
-d '{"weekendMode":false}'All of them need Authorization: Bearer <write token>.
An unknown value returns 400 and says what it will accept, rather than
failing silently:
{ "error": "unknown display", "allowed": ["in","out","text"], "received": "lunch" }Home Assistant runs on the same NUC, so it talks to the container directly over the Docker network and never touches Cloudflare — no service token, no Access policy, nothing to expire. Only the Kindle needs to come in from outside.
rest_command:
frame_status:
# Container name on the shared Docker network, or the NUC's LAN address.
url: "http://kindleframe:8080/status/{{ display }}"
method: POST
headers:
Authorization: !secret frame_write_token
# Then, from an automation, script or dashboard button:
# service: rest_command.frame_status
# data: { display: out }A device tracker makes it automatic:
automation:
- alias: Frame follows presence
trigger:
- platform: state
entity_id: person.jj
action:
- service: rest_command.frame_status
data:
display: "{{ 'in' if trigger.to_state.state == 'home' else 'out' }}"A slash command posts form-encoded with the argument in text, which is read
directly — so /frame out works with the command URL pointed at /status.
Two caveats, since Slack reaches you from the internet rather than the LAN:
it cannot set an Authorization header on slash commands, and it would have to
get through Access. Both point at the same answer — a small proxy or workflow
step that adds the header, with an Access policy to match.
curl -X POST -H "Authorization: Bearer $WRITE_TOKEN" \
https://kindleframe.example/status/weekendThat is the whole integration surface: one URL, one header, no body.
LanguageBreak, which supports firmware ≤ 5.16.2.1.1. Nothing exists above that, so if the device takes an OTA update the project ends permanently. OTA blocking is mandatory rather than hardening.
Blocking is done by renameotabin (renames /usr/bin/otaupd and otav3) and
verified at the filesystem level — only the .bck copies should exist.
Verification matters: there is a documented case of someone running the
blocker, assuming it worked, connecting anyway and losing the jailbreak.
| Component | Purpose |
|---|---|
| Universal Hotfix 2.5.0 | Includes sh_integration, without which .sh files never appear in the library |
| KUAL | Extension launcher |
| MRPI | Package installer |
| USBNetwork | SSH over Wi-Fi, key auth |
| renameotabin | OTA blocking |
sh_integration is the non-obvious one: KUAL is itself a shell script in
documents/, and something has to make the Kindle index .sh files as library
items. The hotfix bundled with LanguageBreak predates it, so KUAL silently
never appears.
Frame appears in the Kindle library. Tap it to start; press the power button
for the menu; Exit hands the screen back.
Library → Frame ──▶ menu ──▶ Show server / In office / Out of office
▲ │
power button ▼
└──────────── content on screen
│
Exit ─┴──▶ Kindle home screen
Five options, each in both orientations: Show server, In office,
Out of office, Rotate screen, Exit.
Menu taps get on-screen acknowledgement — Fetching from server…, or a named
error. Scheduled refreshes stay silent: nobody is watching, and a flash every
cycle would defeat the 304 path.
The RTC node. kindle-dash upstream targets a Kindle 4 and writes relative
seconds to /sys/devices/platform/mxc_rtc.0/wakeup_enable. The PW3 has no such
node — it uses /sys/class/rtc/rtc0/wakealarm, which takes an absolute epoch.
Upstream would suspend with no alarm armed and never wake. dash.sh probes for
both and fails loudly if neither is writable, before taking over the screen.
Stopping the UI. Upstream calls /etc/init.d/framework stop, which does not
exist on 5.x, and a bare initctl, which is not on PATH (/usr/bin:/bin
only). Both fail silently, so the Kindle keeps drawing its status bar over the
frame.
Worse, anything that does kill cvm — stop framework, stop lab126_gui —
cannot be undone without a reboot; every restart path reports success while the
panel stays frozen. So Frame follows KOReader's default and never kills
anything: it disables pillow over lipc, SIGSTOPs awesome, and stops the
separate statusbar upstart job. Handing back is a SIGCONT and a lipc call
to show the home screen.
Touch cannot wake the device. The touch controller has no power/wakeup
node; only the RTC, the PMIC onkey and USB can wake it. So interaction is:
press power to wake early, then tap. There is no kernel wake-reason to read, so
dash.sh infers it from the clock — an RTC wakeup lands on its target, a button
press arrives before it.
Reachability probes are the wrong tool. An earlier version pinged a host before fetching. Pinging something public succeeds before the route to the server is usable; pinging the server fails permanently against any host filtering ICMP, which includes a Mac with stealth mode on. The fetch now simply retries with a short timeout — testing the one connection that has to work.
The framebuffer is always 1072×1448 portrait and touch always reports in device coordinates, however you hold the device. Landscape therefore means composing at 1448×1072, rotating 90° into the portrait buffer, and pushing the hit regions through the same transform.
All of that lives in render/. It emits a PNG and a .regions file already
in device coordinates, so the on-device code never does rotation maths — it just
loads menu-landscape.png alongside menu-landscape.regions.
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/ |
none | Control page; reveals nothing without a token |
GET |
/frame.png?mode=&orient= |
read | The rendered frame, ETag, 304 |
HEAD |
/frame.png?mode=&orient= |
read | What the device actually polls with |
GET |
/status |
read | display, effective, autoWeekend, version |
POST |
/status |
write | display, text, weekendMode — any subset |
POST |
/status/:display |
write | Path form, no body |
POST |
/content |
write | Markdown, stored but not yet displayed |
GET |
/content |
read | Current markdown and version |
GET |
/healthz |
none | Liveness |
mode is server, in, out, text or weekend; orient is portrait or
landscape. Both
are validated against an allow-list and fall back to defaults. They select a
rendering only — they change no server state, and the device's read token could
not change anything if they did.
Markdown is stored and rendered but not yet reachable: mode=server resolves to
in/out only. The pipeline is kept wired and tested so adding a text
display later is a small change rather than a rebuild.
markdown ──▶ markdown-it tokens ──▶ Satori element tree
│
Satori ──▶ SVG
│
resvg ──▶ PNG
│
rotate if landscape ─┤
│
greyscale + Floyd–Steinberg
│
remap to 16 fixed grey levels
│
force PNG colour-type 0 ──▶ device
Two steps are easy to get subtly wrong, and both fail silently on the device:
-colors 16is wrong. It picks an adaptive palette from image content — 18 content-derived levels when measured. The panel has 16 fixed levels (i*255/15); feed it anything else and it re-quantises with a worse algorithm. We-remaponto an explicit ramp instead.eipsrefuses palette-indexed PNGs withpaint_image> … 8bit only. Remapping produces one by default, so the output is forced back to true greyscale withpng:color-type=0.
Both are asserted in the test suite.
Landscape is two columns (55/45, status | weather) with a vertical rule. Portrait stacks them and drops the horizontal rules — stacked sections are already separated by whitespace and a change of type size, so the lines read as clutter there.
Weather glyphs are inline SVG, not Unicode. ☀ ☁ ☂ render inconsistently
across fonts and come out spindly once dithered; drawn strokes stay crisp
because we control the weight.
Satori has no font fallback. It renders only from the faces handed to it, so any character the shipped font lacks becomes a tofu box — which is how the high/low arrows first rendered, since Georgia has no U+2191. They are drawn as SVG now, removing the dependency on glyph coverage.
Type is Charis SIL in the container, Georgia locally — both Matthew
Carter designs for low-resolution screens, which is exactly the e-ink problem.
Charter itself is a .ttc collection, which Satori cannot read.
Open-Meteo, because it needs no API key and so the service deploys without
secret management. Cached 15 minutes. weather.js returns a provider-agnostic
shape, so swapping to Home Assistant means rewriting one function.
A weather outage does not take the frame down. getWeather() returns null
rather than throwing, the template renders a visible "weather unavailable" gap,
and stale readings are preferred over none. A panel showing the date and
someone's status beats a blank panel.
Setting this up on a new machine, or moving it, is a separate runbook: DEPLOY.md — what must be carried across, what goes on the NUC, and which credentials the Kindle needs.
cd server
npm install
npm test # 52 tests, no setup needed
npm run preview # renders all 6 frames to ./out
OFFLINE=1 npm run preview # fixture weather, no network
FRAME_READ_TOKEN=r FRAME_WRITE_TOKEN=w npm startpreview asserts every frame against the constraints the device enforces —
dimensions, grey levels, PNG colour type — so a layout change that eips would
reject fails on your laptop instead of silently on the wall. It also writes the
intermediate SVG, which opens in any browser for iterating on layout without a
render cycle.
The font differs between local and container (Georgia vs Charis SIL). Metrics
are close but not identical, so long text can break lines differently. Set
FONT_REGULAR and FONT_BOLD if you need an exact match.
cp .env.example .env # set the two tokens and your location
docker compose up -d --buildThe image needs only ImageMagick and a font — no browser, no shm_size, no
process reaping. Because Satori renders from fonts installed in the image
rather than discovering system fonts, output is reproducible.
./render/make-screens.sh # generate assets
rsync -avz --exclude=local/state --exclude=local/env.sh \
kindle/ kindle:/mnt/us/dashboard/
rsync -avz render/screens/ kindle:/mnt/us/dashboard/screens/
cp kindle/tools/Frame.sh /Volumes/Kindle/documents/ # library launcherThen tap Frame in the library. Config is local/env.sh, where FRAME_TOKEN
must hold the read token. It is gitignored and excluded from the deploy, so
it survives an update rather than being replaced by the placeholders in
local/env.sh.example - copy that across by hand on the first deploy only.
Over TLS the endpoint must be a hostname, not an IP — xh is built against
rustls, which refuses HTTPS to bare IP addresses. Plain HTTP to an IP is fine
for local testing.
kindle/ on-device runtime
dash.sh state machine, RTC suspend, UI takeover, menu
modal.sh paints a screen, reads touch, reports the region hit
start.sh stop.sh launch and hand back
local/env.sh endpoint, token, schedule
local/fetch-*.sh conditional HEAD, retrying GET, PNG validation
tools/Frame.sh library launcher (needs sh_integration)
tools/Block.OTA.sh on-device OTA blocker with reporting
xh next-wakeup bundled ARM binaries from kindle-dash
render/ generates the on-device screens
make-screens.sh menu + status cards, both orientations, with hit regions
lib.sh rotation, quantisation, region transforms
server/ the NUC service
src/ config, fonts, weather, store, render, templates
src/templates/ui the control page
scripts/preview render every frame and check device constraints
test/ 52 tests: ETag, auth boundary, weekend rule, integrations
secrets/ SSH key and tokens (gitignored)
WPA3. The PW3 supports WPA2 only. WPA3 mandates Protected Management Frames, which needs driver and firmware support a 2015 device on Linux 3.0.35 does not have — not solvable in software. A travel router in repeater mode is the practical fix, and has the side benefit of restoring network-level OTA blocking.
Device in/out cards carry no weather. They are static PNGs so they work offline; the server renders richer versions of the same two modes. Whether the device should prefer the server's version with the static one as a fallback is undecided.
Battery. A 2015 lithium cell held permanently at 100% will swell. With poll-and-suspend the device should run for weeks per charge, so charging periodically rather than continuously is both sufficient and kinder to the cell.
No git history. .gitignore excludes secrets/, but the repository is not
initialised — worth doing before anything else, so the key and tokens never have
a chance to land in a commit.