9.9 KiB
Plan: KH Clock — Lightweight Boot-Persistent Display with Webhook Support
Context
The existing solution is a manually-opened HTML file in a browser, which has two problems: it requires human intervention after every reboot, and a full browser engine is heavy overhead for a device whose sole job is to show a clock on a TV. The goal is a purpose-built, auto-starting application with minimal overhead that can also display messages pushed to it remotely via webhook.
Target hardware: Libre Computer AML-S905X-CC ("Le Potato") — ARM Cortex-A53, Debian-based, HDMI output to auditorium TV. Framebuffer available at /dev/fb0.
Recommended Approach: Python + Pygame on Linux Framebuffer
Pygame can render directly to /dev/fb0 (the Linux framebuffer) without any display server (no X11, no Wayland). This means the process starts faster, uses far less RAM, and has no dependency on a GUI session. An asyncio-based HTTP server (aiohttp) runs alongside the display loop to receive webhook messages.
Why this over alternatives
| Option | Overhead | Display Server Needed | Notes |
|---|---|---|---|
| Pygame on framebuffer | ~30–60 MB RAM | None | Recommended |
| Qt linuxfb/eglfs | ~80–120 MB | None | More complex setup |
| Tauri (WebView) | ~100–150 MB | Yes (or Wayland) | Good if HTML/CSS preferred |
| Chromium kiosk | ~300–500 MB | Yes | Current-ish approach, heaviest |
| Tkinter + X11 | ~50–80 MB | Yes (X11) | Simpler but needs Xorg |
Architecture
KH_Clock/
├── main.py # Entry point: asyncio event loop + pygame render loop
├── display.py # Pygame rendering: clock face, message overlay
├── server.py # aiohttp server: dashboard routes + API routes
├── dashboard/
│ ├── index.html # Dashboard UI (no framework — plain HTML/CSS/JS)
│ ├── style.css
│ └── app.js
├── config.toml # Screen resolution, colors, fonts, port, token, timeout
├── requirements.txt
└── KH-clock.service # systemd unit file
Runtime flow
main.pyinitializes pygame on the framebuffer (SDL_VIDEODRIVER=fbcon)- Shared
asyncio.Lock-protectedMessageStateobject holds current message text, expiry time, and persist flag aiohttpserver starts as a backgroundasynciotask with two route groups (see below)- Main pygame loop renders the clock and, if
MessageStatehas an active message, renders the overlay banner - A background asyncio task ticks the message expiry and clears it when time runs out
Route groups
Dashboard routes (no auth — LAN-trusted)
GET /→ servedashboard/index.htmlPOST /dashboard/message→ update message (freeform text, duration/persist)DELETE /dashboard/message→ clear current messageGET /dashboard/status→ return current message JSON (for live UI refresh)
API routes (bearer token required)
POST /api/message→ same as dashboard POST, for programmatic/webhook callersDELETE /api/message→ same as dashboard DELETEGET /api/status→ same as dashboard status
Both route groups write to the same MessageState object.
Dashboard UI
Simple, large-element page — usable by non-technical staff:
- Multiline textarea for message text
- Duration picker: radio buttons (30 sec / 1 min / 5 min / Until cleared)
- "Send to Screen" button (POST /dashboard/message)
- "Clear Screen" button (DELETE /dashboard/message)
- Status bar showing what is currently displayed (polls GET /dashboard/status every 5s)
Webhook / programmatic API
POST http://<device-ip>:8080/api/message
Authorization: Bearer <token>
Content-Type: application/json
{"text": "Service starts in 5 minutes", "duration": 60}
duration(seconds): auto-dismiss after this many secondsduration: 0or"persist": true: message stays until replaced or cleared- Config default (
config.toml) applies when neither field is provided
DELETE http://<device-ip>:8080/api/message
Authorization: Bearer <token>
Security
Bearer token auth on /api/* — aiohttp middleware validates Authorization: Bearer <token> for all /api/ routes only. Token is a randomly generated 32-byte hex string stored in config.toml. On first run, if no token is present, one is auto-generated and printed to stdout once for the operator to save.
Rate limiting on /api/* — max configurable requests/minute per source IP; returns 429 on excess. Prevents flooding from programmatic callers.
Dashboard password auth — the dashboard requires a password before any controls are accessible. On first visit, the server returns a login page. On correct password submission, the server sets a signed session cookie (using aiohttp's built-in cookie response with a server-side session store). Subsequent requests check for a valid session cookie; invalid or missing cookies redirect back to the login page. Sessions expire after a configurable idle timeout (default: 8 hours).
The password is stored in config.toml as a bcrypt hash, never in plaintext. A helper command generates the hash:
python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
Paste the output into config.toml:
[dashboard]
password_hash = "$2b$12$..." # bcrypt hash only — never store plaintext here
session_timeout_hours = 8
API routes still use bearer token — separate from the dashboard password. The two auth systems are independent.
No TLS required — LAN-only. If ever internet-facing, add nginx in front with TLS.
systemd service (/etc/systemd/system/KH-clock.service)
[Unit]
Description=KH Clock Display
After=network.target
DefaultDependencies=no
[Service]
Type=simple
User=pi # or whatever the device user is
Environment=SDL_VIDEODRIVER=fbcon
Environment=SDL_FBDEV=/dev/fb0
ExecStart=/usr/bin/python3 /opt/KH_Clock/main.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target means it starts without needing a graphical session.
Screen blanking
The service file should also disable console blanking so the TV stays on:
ExecStartPre=/bin/sh -c 'echo -ne "\033[9;0]" > /dev/tty1'
System Prep
These steps are done once on the Le Potato before deploying the application. SSH in from another machine to do this — you won't have a desktop after step 1.
1. Confirm what's running
systemctl get-default
- If it returns
graphical.target→ a desktop environment is configured to start at boot. - If it returns
multi-user.target→ already headless, skip to step 3.
To see which display manager is running (GDM for GNOME, LightDM for Ubuntu/XFCE, etc.):
systemctl status display-manager
2. Disable the desktop environment
Switch the default boot target to headless:
sudo systemctl set-default multi-user.target
Then disable the display manager so it won't start even if manually invoked:
# Ubuntu Desktop uses GDM3:
sudo systemctl disable gdm3
# If it's LightDM (common on lighter Ubuntu variants):
sudo systemctl disable lightdm
If you're unsure which one, run both — the one that isn't installed will just print "not found" harmlessly.
You do not need to uninstall the desktop packages. Disabling the service is enough. The desktop software stays on disk in case you ever need it back.
3. Add the service user to the video group
The clock process needs permission to write to /dev/fb0:
sudo usermod -aG video <your-username>
Log out and back in (or reboot) for the group change to take effect.
4. Disable console blanking permanently
By default, Linux blanks the console after ~10 minutes of inactivity, turning the TV off. Prevent this by adding a kernel parameter:
sudo nano /etc/default/grub
Find the line GRUB_CMDLINE_LINUX_DEFAULT and append consoleblank=0:
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash consoleblank=0"
Then apply it:
sudo update-grub
Note
: The Le Potato may use a non-GRUB bootloader (U-Boot with extlinux). If
update-grubisn't available, the equivalent is editing/boot/extlinux/extlinux.confand appendingconsoleblank=0to theAPPENDline.
5. Reboot and verify
sudo reboot
After reboot, SSH back in and confirm:
systemctl get-default # should return multi-user.target
systemctl status gdm3 # should show disabled/inactive
ls -la /dev/fb0 # should exist; note the group (usually 'video')
The TV should show a plain text console at this point — that's expected. The clock service will replace it once deployed.
Key implementation notes
- Font rendering: pygame.font can load system TTF fonts (e.g., DejaVu, or a custom downloaded digital-style font). Font path is configurable.
- Resolution: Read from
config.tomlor auto-detected via pygame display info. - Framebuffer access: The service user needs to be in the
videogroup (usermod -aG video <user>). - No internet required: All rendering is local; webhook server only needs LAN access.
- Dependencies:
pygame,aiohttp,tomllib(stdlib in Python 3.11+). Install via pip into a venv or system packages.
Verification
- Run
python3 main.pymanually — clock appears full-screen on the TV - Open
http://<device-ip>:8080on a laptop on the same network — dashboard loads, staff UI is visible - Type a message in the dashboard, pick a duration, hit "Send" — banner appears on the TV
- "Clear Screen" removes the banner
- Status bar in dashboard reflects the current message within 5 seconds
curl -X POST http://<device-ip>:8080/api/message -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"text":"API test", "duration": 30}'— message appears- Same curl without the header →
401 Unauthorized sudo systemctl enable --now KH-clock→ service starts, reboot device, clock appears without intervention