# 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 1. `main.py` initializes pygame on the framebuffer (`SDL_VIDEODRIVER=fbcon`) 2. Shared `asyncio.Lock`-protected `MessageState` object holds current message text, expiry time, and persist flag 3. `aiohttp` server starts as a background `asyncio` task with two route groups (see below) 4. Main pygame loop renders the clock and, if `MessageState` has an active message, renders the overlay banner 5. A background asyncio task ticks the message expiry and clears it when time runs out ### Route groups **Dashboard routes** (no auth — LAN-trusted) - `GET /` → serve `dashboard/index.html` - `POST /dashboard/message` → update message (freeform text, duration/persist) - `DELETE /dashboard/message` → clear current message - `GET /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 callers - `DELETE /api/message` → same as dashboard DELETE - `GET /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://:8080/api/message Authorization: Bearer Content-Type: application/json {"text": "Service starts in 5 minutes", "duration": 60} ``` - `duration` (seconds): auto-dismiss after this many seconds - `duration: 0` or `"persist": true`: message stays until replaced or cleared - Config default (`config.toml`) applies when neither field is provided ``` DELETE http://:8080/api/message Authorization: Bearer ``` ### Security **Bearer token auth on `/api/*`** — `aiohttp` middleware validates `Authorization: Bearer ` 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: ```bash python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())" ``` Paste the output into `config.toml`: ```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`) ```ini [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 ```bash 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.): ```bash systemctl status display-manager ``` ### 2. Disable the desktop environment Switch the default boot target to headless: ```bash sudo systemctl set-default multi-user.target ``` Then disable the display manager so it won't start even if manually invoked: ```bash # 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`: ```bash sudo usermod -aG video ``` 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: ```bash 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: ```bash sudo update-grub ``` > **Note**: The Le Potato may use a non-GRUB bootloader (U-Boot with extlinux). If `update-grub` isn't available, the equivalent is editing `/boot/extlinux/extlinux.conf` and appending `consoleblank=0` to the `APPEND` line. ### 5. Reboot and verify ```bash sudo reboot ``` After reboot, SSH back in and confirm: ```bash 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.toml` or auto-detected via pygame display info. - **Framebuffer access**: The service user needs to be in the `video` group (`usermod -aG video `). - **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 1. Run `python3 main.py` manually — clock appears full-screen on the TV 2. Open `http://:8080` on a laptop on the same network — dashboard loads, staff UI is visible 3. Type a message in the dashboard, pick a duration, hit "Send" — banner appears on the TV 4. "Clear Screen" removes the banner 5. Status bar in dashboard reflects the current message within 5 seconds 6. `curl -X POST http://:8080/api/message -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{"text":"API test", "duration": 30}'` — message appears 7. Same curl without the header → `401 Unauthorized` 8. `sudo systemctl enable --now KH-clock` → service starts, reboot device, clock appears without intervention