KH Clock
A lightweight, boot-persistent clock display for a TV, running on a Libre Computer AML-S905X-CC ("Le Potato"). Renders directly to the Linux framebuffer — no desktop environment, no browser, no X11. Staff can push announcements to the screen from any phone or laptop on the same network.
How it works
- Display: Python + Pygame renders a full-screen 12-hour clock (
12:00:00 AM) directly to/dev/fb0. No display server required. - Messages: When a message is active, it replaces the clock entirely — large, centered, auto-sized text fills the screen for easy reading from across the room.
- Dashboard: A browser-based control panel (password-protected) lets staff type a message, pick a duration, and send it to the screen.
- API: A Bearer-token-protected HTTP API accepts webhook calls from other systems.
- Auto-start: A systemd service starts the clock at boot without any human intervention.
File structure
KH_Clock/
├── main.py # Entry point
├── display.py # Pygame rendering (clock + messages)
├── server.py # aiohttp web server (dashboard + API)
├── state.py # Thread-safe shared message state
├── config.py # Config loading and saving
├── config.toml # Your configuration (edit this)
├── dashboard/
│ ├── index.html # Dashboard UI
│ ├── style.css
│ └── app.js
├── requirements.txt
└── KH-clock.service # systemd unit file
Requirements
- Python 3.11+
- pip packages:
pygame,aiohttp,bcrypt
pip install -r requirements.txt
If you're on Python 3.10 or older, also install
tomli:pip install tomli
Local development and testing
You do not need the Le Potato to develop or test. The server and dashboard work on any machine; the display can be tested in a window.
Test the server and dashboard (no display needed)
This works on any machine, including Windows/WSL, with no pygame or display required:
pip install aiohttp bcrypt
python3 main.py --no-display
Open http://localhost:8080 in a browser. You will be directed to the first-run setup wizard to create a dashboard password. After that you can use the full dashboard.
The API bearer token is auto-generated on first run, printed to the terminal, and saved to config.toml.
Test the display in a window (requires a graphical environment)
pip install -r requirements.txt
python3 main.py --dev
This opens a 1280×720 window showing the clock. Use it alongside the server to test message rendering. Press Escape to close the window.
WSL2 note:
--devmode requires a display server. On Windows 11 with WSL2, WSLg provides one automatically. On older setups you may need VcXsrv andexport DISPLAY=:0before running. If you can't get a display working locally,--no-displayis sufficient to develop and test everything except the visual rendering.
Test the API with curl
After starting the server (with or without --no-display), copy the token from config.toml and run:
# Send a message for 30 seconds
curl -X POST http://localhost:8080/api/message \
-H 'Authorization: Bearer <your-token>' \
-H 'Content-Type: application/json' \
-d '{"text": "Service starts in 5 minutes", "duration": 30}'
# Send a persistent message (stays until cleared)
curl -X POST http://localhost:8080/api/message \
-H 'Authorization: Bearer <your-token>' \
-H 'Content-Type: application/json' \
-d '{"text": "Please move to the fellowship hall", "persist": true}'
# Clear the message
curl -X DELETE http://localhost:8080/api/message \
-H 'Authorization: Bearer <your-token>'
# Check current state
curl http://localhost:8080/api/status \
-H 'Authorization: Bearer <your-token>'
Configuration
Edit config.toml before deploying. All settings have sensible defaults and are documented inline.
[display]
# Uncomment to fix resolution instead of auto-detecting:
# width = 1920
# height = 1080
fps = 10 # 10 FPS is plenty for a clock
clock_font_path = "" # Leave blank to auto-detect a system monospace font
message_font_path = "" # Leave blank to auto-detect a system sans-serif font
[server]
port = 8080
default_duration_seconds = 20 # Used when a caller doesn't specify a duration
[api]
token = "" # Leave blank — auto-generated on first run and saved here
[rate_limit]
requests_per_minute = 20 # Per source IP, applies to /api/* only
[dashboard]
password_hash = "" # Leave blank — set via the first-run setup wizard
session_timeout_hours = 8
Font notes
On Debian, the auto-detection checks for DejaVu, Liberation, FreeFont, and Noto families. If none are found, pygame's built-in bitmap font is used as a fallback (functional but less sharp at large sizes).
To install good fonts on the Le Potato:
sudo apt install fonts-dejavu
To use a custom font, provide the full path to a .ttf file:
clock_font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"
message_font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
Dashboard usage
Open http://<device-ip>:8080 in any browser on the same network.
- First visit: You'll be directed to a one-time setup page to create a password. This is saved as a bcrypt hash in
config.toml— the plaintext password is never stored. - After login: The dashboard has a message text area, a duration picker, and two buttons.
- Send to Screen: Displays the message immediately. The clock disappears; the message fills the screen.
- Clear Screen: Removes the message and returns to the clock.
- The status bar at the bottom shows what is currently displayed and updates every 5 seconds.
- Sessions expire after 8 hours of inactivity (configurable).
To change the dashboard password later, generate a new hash and paste it into config.toml:
python3 -c "import bcrypt; print(bcrypt.hashpw(b'newpassword', bcrypt.gensalt()).decode())"
Then update config.toml:
[dashboard]
password_hash = "$2b$12$..."
And restart the service.
API reference
All /api/* routes require a Authorization: Bearer <token> header. The token is in config.toml under [api] token.
POST /api/message
Send a message to the screen.
Body (JSON):
| Field | Type | Description |
|---|---|---|
text |
string | Required. The message to display. |
duration |
number | Seconds to display. 0 means persistent. |
persist |
boolean | true means display until cleared. |
If neither duration nor persist is provided, default_duration_seconds from config.toml is used.
Example:
curl -X POST http://<ip>:8080/api/message \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{"text": "Doors open in 10 minutes", "duration": 60}'
DELETE /api/message
Clear the current message.
curl -X DELETE http://<ip>:8080/api/message \
-H 'Authorization: Bearer <token>'
GET /api/status
Returns the current display state as JSON.
{
"active": true,
"text": "Doors open in 10 minutes",
"remaining_seconds": 42.3,
"persistent": false
}
Rate limiting: /api/* routes are limited to requests_per_minute per source IP (default: 20). Excess requests receive 429 Too Many Requests.
Deploying to the Le Potato
1. Prepare the system (one time only)
SSH into the Le Potato from another machine. You won't need a monitor for this step.
Switch to headless boot (if a desktop is currently configured):
systemctl get-default # returns graphical.target? then continue
sudo systemctl set-default multi-user.target
sudo systemctl disable gdm3 # or lightdm — run both, the wrong one is harmless
Add your user to the video group (needed to write to /dev/fb0):
sudo usermod -aG video <your-username>
Disable console blanking (prevents the TV from going to sleep):
sudo nano /etc/default/grub
# Find GRUB_CMDLINE_LINUX_DEFAULT and append: consoleblank=0
sudo update-grub
If the Le Potato uses U-Boot/extlinux instead of GRUB, edit
/boot/extlinux/extlinux.confand appendconsoleblank=0to theAPPENDline instead.
Reboot and verify:
sudo reboot
# After reconnecting:
systemctl get-default # should print multi-user.target
ls -la /dev/fb0 # should exist, group should be 'video'
2. Install the application
sudo mkdir -p /opt/KH_Clock
sudo chown <your-username>: /opt/KH_Clock
# Copy files (from your dev machine):
scp -r . <user>@<ip>:/opt/KH_Clock/
# On the Le Potato — create a venv and install dependencies:
cd /opt/KH_Clock
python3 -m venv venv
venv/bin/pip install -r requirements.txt
3. Install fonts (if not already present)
sudo apt install fonts-dejavu
4. Configure
nano /opt/KH_Clock/config.toml
You only need to verify the [server] port and [rate_limit] values. Everything else is handled automatically on first run (token generation, password setup).
If the username on your device is not pi, open the service file and update the User= line:
nano /opt/KH_Clock/KH-clock.service
5. Install and start the systemd service
sudo cp /opt/KH_Clock/KH-clock.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now KH-clock
Check that it started:
sudo systemctl status KH-clock
The TV should now show the clock. Open http://<device-ip>:8080 from a laptop or phone on the same network to reach the dashboard.
6. Verify end-to-end
- Clock appears on the TV at boot — no keyboard/mouse/monitor needed
- Dashboard loads at
http://<device-ip>:8080 - Type a message, pick a duration, press Send to Screen — message appears
- Clear Screen returns to the clock
- Status bar reflects the current state within 5 seconds
curlAPI test works (see Test the API above)- Reboot the device — clock comes back on its own
Troubleshooting
Clock doesn't appear after reboot:
sudo journalctl -u KH-clock -n 50
Common causes: wrong User= in the service file, user not in the video group, /dev/fb0 not present.
/dev/fb0 permission denied:
groups # check 'video' is listed
sudo usermod -aG video <user> # add if missing, then log out and back in
Framebuffer not found (/dev/fb0 doesn't exist):
The desktop environment may still be running. Confirm systemctl get-default returns multi-user.target and that the display manager is disabled.
Dashboard unreachable:
Check the service is running (systemctl status KH-clock) and that port = 8080 in config.toml isn't blocked by a firewall.
Font looks pixelated:
DejaVu fonts aren't installed. Run sudo apt install fonts-dejavu on the device and restart the service.