Pygame framebuffer clock for Le Potato (ARM Debian) with aiohttp webhook server. Renders 12-hour clock directly to /dev/fb0 (no X11/Wayland). Supports full-screen message overlays pushed via a browser dashboard or Bearer-token API. Includes first-run setup wizard, session-based dashboard auth, bcrypt password storage, per-IP rate limiting, and systemd service unit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
263 lines
9.9 KiB
Markdown
263 lines
9.9 KiB
Markdown
# Plan: Sahsa 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
|
||
|
||
```
|
||
sahsa_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
|
||
└── sahsa-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://<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 seconds
|
||
- `duration: 0` or `"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:
|
||
|
||
```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/sahsa-clock.service`)
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=Sahsa 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/sahsa_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 <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:
|
||
|
||
```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 <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
|
||
|
||
1. Run `python3 main.py` manually — clock appears full-screen on the TV
|
||
2. Open `http://<device-ip>: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://<device-ip>:8080/api/message -H 'Authorization: Bearer <token>' -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 sahsa-clock` → service starts, reboot device, clock appears without intervention
|