diff --git a/.gitignore b/.gitignore index 5de5528..2370beb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ venv/ *.egg-info/ dist/ build/ +config.toml +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..801f9d4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A Pygame framebuffer clock for a Le Potato (ARM Debian) connected to an auditorium TV. Renders directly to `/dev/fb0` — no X11/Wayland. Staff push announcements via a browser dashboard or API webhook. + +## Running locally + +**Server + dashboard only (no pygame needed — works on WSL/Windows):** +```bash +python3 main.py --no-display +``` + +**Windowed display mode (requires a graphical environment):** +```bash +python3 main.py --dev +``` +Press **Escape** to close. On WSL2 this needs WSLg or VcXsrv. + +**First run:** if `config.toml` has no `token`, one is auto-generated and saved. If `password_hash` is empty, a setup wizard appears at `http://localhost:8080/setup`. + +**Copy config from example before first run:** +```bash +cp config.toml.example config.toml +``` + +## Architecture + +The app has two concurrent execution contexts that communicate through `MessageState`: + +- **`DisplayThread`** (`display.py`) — a daemon thread running pygame. Polls `state.get()` on every frame tick. When a message is active it replaces the clock entirely; font size is binary-searched to fill 88% of the screen. +- **`ClockServer`** (`server.py`) — aiohttp running in the asyncio main thread. Updates `state` in response to dashboard/API requests. + +`MessageState` (`state.py`) is the only shared object between the two contexts. It uses a `threading.Lock` and stores expiry as a `time.monotonic()` timestamp. Expiry is checked lazily on `get()` — there is no background timer. + +`AppConfig` (`config.py`) is loaded once at startup from `config.toml`. The only post-startup writes are `save_password_hash()` (on first-run setup) and auto-generated token (on first run with empty token). Both use regex replacement on the raw TOML file to avoid reformatting. + +## Server routes + +| Route | Auth | Purpose | +|---|---|---| +| `GET /` | Session cookie | Serve dashboard `index.html` | +| `GET /setup`, `POST /setup` | None | First-run password wizard | +| `GET /login`, `POST /login` | None | Dashboard login | +| `POST /logout` | Session cookie | Dashboard logout | +| `POST /dashboard/message` | Session cookie | Send message (from dashboard JS) | +| `DELETE /dashboard/message` | Session cookie | Clear message (from dashboard JS) | +| `GET /dashboard/status` | Session cookie | Polled every 5 s by dashboard JS | +| `POST /api/message` | Bearer token + rate limit | Webhook / external integrations | +| `DELETE /api/message` | Bearer token + rate limit | Clear via API | +| `GET /api/status` | Bearer token | Check state via API | + +The dashboard (`dashboard/index.html`, `style.css`, `app.js`) calls the `/dashboard/*` routes, not `/api/*`. Static assets are served from `dashboard/` at `/static/`. + +## Key behaviours to preserve + +- `display.py` is **not imported** when `--no-display` is set — this allows the server to run without pygame installed. +- Message font layout is **cached** in `DisplayThread.run()` and only recomputed when the message text changes. +- `_update_config_field` in `config.py` uses regex on the raw TOML — it only rewrites the matched field, leaving all comments and formatting intact. +- `secrets.compare_digest` is used for bearer token comparison to prevent timing attacks. diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..068d5a3 --- /dev/null +++ b/config.toml.example @@ -0,0 +1,35 @@ +[display] +# Screen resolution. Leave commented to auto-detect from the framebuffer. +# width = 1920 +# height = 1080 + +# Display refresh rate in frames per second. 10 is plenty for a clock. +fps = 10 + +# Paths to TTF font files. Leave blank to auto-detect from system fonts. +# The clock uses a monospace font; messages use a sans-serif bold font. +clock_font_path = "" +message_font_path = "" + +[server] +port = 8080 + +# Default message duration in seconds when the caller doesn't specify one. +default_duration_seconds = 20 + +[api] +# Bearer token for /api/* routes. +# Leave blank — a token is auto-generated on first run and saved here. +token = "" + +[rate_limit] +# Maximum API requests per minute per source IP. +requests_per_minute = 20 + +[dashboard] +# Bcrypt hash of the dashboard password. +# Leave blank — a setup wizard runs on the first browser visit to set the password. +password_hash = "" + +# How many hours of inactivity before a dashboard session expires. +session_timeout_hours = 8