Turning an Old Kindle Paperwhite Into a Homelab Dashboard
Rendering homelab status as a grayscale PNG on the server so a decade-old Kindle only has to download an image and put it on screen.
I have an old Kindle Paperwhite 2 sitting in a drawer, and I've been running Olympus, my home server, for a while now. At some point those two facts collided and I decided the Kindle should show me what Olympus was doing.
The PW2 is jailbroken and running KUAL, which is what makes any of this possible on a device this old. If you want to do the same thing, the current jailbreak instructions live at the Kindle Modding Wiki rather than here, since the method changes over time and this post isn't trying to be that guide.
The code for the finished project is at github.com/ZEUSGMJ/kindle-homelab-dashboard. This post is about how it got there and what actually turned out to be hard.
The first attempt was worse than no dashboard
My first pass used LinkSS to replace the Kindle's screensaver with a live image. It worked, in the sense that an image appeared. Everything around it didn't.
Starting the dashboard made it hard to get back to KUAL. Exiting usually meant a full reboot. Sometimes the Kindle just sat there showing the dashboard after I pressed power, and getting KUAL to open again could take several taps once the native interface came back. On top of that, the clock only stayed accurate if I started the dashboard at exactly second zero of a minute, because it had no way to sync itself.
None of that is a rendering problem. It's a lifecycle problem: what happens when the dashboard starts, what happens when it stops, and what the device is doing in between. So I stopped treating this as a screensaver replacement and built it as an actual KUAL application with an explicit start, an update loop, and an exit path. I also went through and removed the old Upstart jobs and cron entries the LinkSS setup had left behind, since a few of them would have quietly kept firing in the background otherwise.
What I assumed would be the hard part
I assumed making something look decent on a 758x1024 grayscale e-ink screen would be the bulk of the work. It wasn't. Pillow makes drawing text, bars, and icons on a fixed canvas pretty mechanical, and e-ink forgives a lot because there's no color to get wrong, just contrast.
The actual hard part was keeping the Kindle's native software happy about being interrupted and resumed. That part took most of the iteration.
Render on the server, not on the Kindle
The Kindle can't run a modern webpage, and I didn't want to fight its ancient browser. So the split ended up being: Olympus does all the work, the Kindle just displays the result.
A Python service on Olympus queries live metrics and weather, lays out a dashboard image with Pillow, and serves it as a grayscale PNG at the Kindle's exact resolution. A small KUAL extension on the Kindle downloads that image once a minute and puts it on screen with eips, the Kindle's framebuffer tool. That's the whole device-side job: download an image, display it. Everything that could go wrong on old, limited hardware happens on the server instead, where I can actually debug it.
Where the numbers come from
The server queries a Glances instance directly over its REST API rather than going through anything else:
| Endpoint | What it gives me |
|---|---|
/quicklook | CPU, memory, swap, load |
/system | Host and OS description |
/uptime | Host uptime |
/mem | Detailed used/total memory |
/sensors | CPU temperature |
/fs | Disk capacity and usage |
/containers | Container names and state |
Containers get sorted alphabetically before rendering, and a container counts as up if its state is running or healthy.
The one part of this that wasn't obvious going in was picking the right temperature sensor. Olympus reports a handful of them: individual CPU cores, an NVMe composite reading, ACPI and DIMM sensors, chipset temperature. If I'd just grabbed the first sensor value I found, I'd have had a real chance of labeling an NVMe temperature as the CPU temperature on the dashboard, which is a wrong number that looks completely plausible. So the renderer picks in a specific order instead:
def select_system_temperature(sensors: list[dict]) -> Optional[dict]:
"""Prefer a CPU package sensor, falling back to the hottest CPU core."""
preferred_labels = ("package id 0", "cpu package", "tctl", "tdie")
by_label = {
str(sensor.get("label") or "").lower(): sensor
for sensor in sensors
if sensor.get("value") is not None
}
for label in preferred_labels:
if label in by_label:
return by_label[label]
cores = [
sensor
for sensor in sensors
if str(sensor.get("label") or "").lower().startswith("core ")
and sensor.get("value") is not None
]
if cores:
return max(cores, key=lambda sensor: float(sensor["value"]))
return None
It's a small function, but it's what keeps an NVMe reading from quietly showing up as the CPU temperature.
Icons, baked in at build time
The dashboard uses Lucide icons for things like running/stopped containers, CPU, memory, disk, and weather conditions. Since the Kindle side just downloads a finished PNG, there was no reason to ship SVGs or an SVG renderer anywhere near the runtime image. Instead, the Docker build rasterizes them once during a build stage:
FROM debian:bookworm-slim AS icon-builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends librsvg2-bin sed \
&& rm -rf /var/lib/apt/lists/*
COPY server/assets/lucide/*.svg /icons/source/
RUN mkdir -p /icons/png \
&& for icon in /icons/source/*.svg; do \
sed -i 's/stroke-width="2"/stroke-width="2.4"/' "$icon"; \
name="$(basename "$icon" .svg)"; \
rsvg-convert --width 96 --height 96 --output "/icons/png/${name}.png" "$icon"; \
done
The stroke width gets bumped from 2 to 2.4 before conversion, which made a real difference in how the icons read on e-ink at a distance. The final runtime image only gets the rendered PNGs, not librsvg2-bin or the SVG source, so the icon toolchain never ships to production.
The part that was actually hard
Everything above this point was the easy half of the project. The hard half was making the Kindle behave when the dashboard starts, when it's running, and especially when it stops.
The start script downloads a complete frame before it touches anything else. If Olympus is unreachable, it leaves the native Kindle UI running and shows a message instead of stranding the device on a half-finished setup:
TOKEN=$(date +%s)
if ! wget -q -T 10 -t 1 -O "$TMP_PNG.new" "$URL?start=$TOKEN" || [ ! -s "$TMP_PNG.new" ]; then
rm -f "$TMP_PNG.new"
log "start failed: Olympus dashboard is unavailable"
eips 3 20 "Dashboard unavailable - Kindle UI left running"
exit 1
fi
mv "$TMP_PNG.new" "$TMP_PNG"
KUAL launches as part of the Kindle's native framework process tree, and the dashboard loop's job is to stop that same framework. If the loop stayed attached, it would kill itself along with everything else. So the start script hands off to the loop with setsid, putting it in its own session before the framework goes down.
I didn't know which input device the physical power button was on the PW2, so before writing the real logic, I worked through it with Claude in a terminal and put together a small throwaway KUAL extension whose only job was to find out. It listed every /sys/class/input/event* device and its name, checked which tools like lipc-wait-event, evtest, and hexdump actually existed on this old userspace, and then sat there capturing powerd events for 20 seconds after asking me to press the button once, along with anything matching pbpress in the system log. That gave us a real device name to target instead of guessing.
The dashboard loop scans for it the same way, then does one blocking read of a single input event:
POWER_DEVICE=""
for EVENT_PATH in /sys/class/input/event*; do
[ -e "$EVENT_PATH" ] || continue
DEVICE_NAME=$(cat "$EVENT_PATH/device/name" 2>/dev/null)
case "$DEVICE_NAME" in
*onkey*|*gpio-keys*)
POWER_DEVICE="/dev/input/${EVENT_PATH##*/}"
break
;;
esac
done
if [ -n "$POWER_DEVICE" ]; then
# A 32-bit ARM input_event is 16 bytes on the tested Paperwhite 2.
(dd if="$POWER_DEVICE" of=/dev/null bs=16 count=1 2>/dev/null && touch "$EXIT_FLAG") &
MONITOR_PID=$!
fi
One dd read of 16 bytes blocks until the button state actually changes, which is a much more direct signal than polling something else and hoping it correlates. If no such device exists, it falls back to watching for powerd's goingToScreenSaver event instead.
Getting the framework restart right took more back and forth than anything else in this project, and most of that had nothing to do with writing the shell script itself. Claude could propose a fix in seconds, but testing it meant copying the new version onto the Kindle over USB, ejecting it, launching the dashboard, and watching what the framework actually did, since none of this can be simulated off the device. A bad guess risked a stuck framework and a hard reboot, so the loop was slow because of the hardware, not because the logic was hard to write.
Earlier versions of the restore logic just started the framework and slept for a fixed number of seconds. Sometimes it came back fine, sometimes I was staring at a stale dashboard image needing an extra Home button press, or tapping KUAL two or three times before it responded. It turned out the framework's own startup job reports itself as running well before its application stack can actually launch anything, so a fixed sleep was really just guessing.
The fix was to subscribe to the readiness event before starting the framework at all, so the first real appStarted event becomes the actual signal to act on:
(lipc-wait-event -s 60 com.lab126.appmgrd appStarted >/dev/null 2>&1 && touch "$READY_FLAG") &
READY_PID=$!
initctl start framework >/dev/null 2>&1 || /etc/init.d/framework start >/dev/null 2>&1
WAITED=0
while [ "$WAITED" -lt 60 ] && [ ! -f "$READY_FLAG" ]; do
sleep 1
WAITED=$((WAITED + 1))
done
Only after that does it ask for the Home screen, and it asks twice, a few seconds apart, because the first request doesn't reliably repaint on a slow cold start. On this Paperwhite 2, the whole restoration takes something like 30 to 45 seconds. That's slower than I wanted, but it's the honest cost of waking up a framework this old cleanly instead of guessing at timing.
USB connection is treated as a second, independent way out. If a computer claims the Kindle's USB storage, the loop notices via lipc-get-prop com.lab126.volumd userstoreIsAvailable, confirms it twice to avoid a false trigger, and exits the same way the power button would. That mattered more than I expected, since it meant a bad debugging session couldn't leave me with no way off the dashboard except a hard reboot.
Keeping the time accurate
The old second-zero timing trick was fragile because it depended on my own timing, not the server's. The final loop asks Olympus for the time instead and works out the delay itself:
seconds_to_next_minute() {
EPOCH=$(wget -q -T 5 -t 1 -O - "$TIME_URL" 2>/dev/null)
case "$EPOCH" in
''|*[!0-9]*) echo 10 ;;
*)
SECOND=$((EPOCH % 60))
echo $((61 - SECOND))
;;
esac
}
That schedules the next download for one second after Olympus's clock rolls over to a new minute, which leaves enough slack for the freshly rendered image to actually reflect that minute. Because it's recalculated from the server's clock every time through the loop rather than just counted down locally, small delays from a slow render or a slow network don't add up over the course of a day. If /time is briefly unreachable, it just waits ten seconds and asks again.
Landscape without rotating the OS
The Kindle's physical framebuffer is fixed at 758x1024. I wanted a landscape mode too, mostly because the container grid reads better wide than tall, but I didn't want to depend on undocumented window manager or framebuffer rotation properties on a device this old and this poorly documented.
So the server does the rotation instead of the Kindle. It renders the landscape layout at its natural 1024x758, then rotates the whole image:
def render_landscape_for_kindle(stats, now, weather=None):
return render_landscape(stats, now, weather).rotate(-90, expand=True)
That comes back out at 758x1024, which the Kindle displays exactly like it would portrait, with no idea it's actually landscape content. You just turn the physical device so the power button ends up on the right. Portrait stayed a completely separate, working KUAL menu option the entire time I was building this, so if landscape ever broke, I had a mode I already knew was solid.


The values in these renders are demo data, not what's actually running on Olympus :P
Handling weather failures
Weather comes from Open-Meteo and is genuinely the least important part of the dashboard, which is exactly why it's the part I made sure couldn't break anything else. A successful forecast gets cached for several minutes. A failed one keeps the last good result around and retries sooner than the normal interval. If there's never been a successful result at all, the dashboard just says weather is unavailable and moves on. A third-party forecast API being down is not a reason for me to lose visibility into whether my containers are up.
Testing what actually ships
test_dashboard.py covers weather parsing and caching, the day/night/rain/snow/thunder icon mappings, memory and temperature formatting, and exact pixel dimensions for both the portrait and rotated landscape renders. I run it against the same Docker image that gets deployed, not just the source files, since packaging issues like a missing asset or a wrong path inside the container are exactly the kind of thing that source-only tests can miss.
Before rolling any change to the running dashboard, I'd build it under a separate name and expose it on a spare port so I could check the endpoints and look at the actual render before touching the production container.
What's next
The dashboard has been running for a bit now and does what I wanted: a glance at the Kindle tells me if something's down without opening a laptop. If I revisit it, the two things I'd actually want are exporting the Kindle's own battery level back to Olympus, and possibly a smart plug so I'm not just eyeballing when to unplug it. The frontlight and screen already draw almost nothing on e-ink, so most of the power cost is Wi-Fi and the once-a-minute refresh, which is modest but not zero.
Credit to jackwuwei/kindle-dashboard, which I used as a reference for the general idea of serving a Pillow-rendered image to a jailbroken Kindle before writing my own version. Full source, the README, and setup instructions are at ZEUSGMJ/kindle-homelab-dashboard.