Files
KH_Clock/PLAN.md
Spencer 0c2392b2b3 Initial implementation of Sahsa Clock
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>
2026-02-24 16:22:10 -06:00

263 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** | ~3060 MB RAM | None | Recommended |
| Qt linuxfb/eglfs | ~80120 MB | None | More complex setup |
| Tauri (WebView) | ~100150 MB | Yes (or Wayland) | Good if HTML/CSS preferred |
| Chromium kiosk | ~300500 MB | Yes | Current-ish approach, heaviest |
| Tkinter + X11 | ~5080 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