From 0c2392b2b325d99bffa1279eb20d8f1fa15e60ae Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 24 Feb 2026 16:22:10 -0600 Subject: [PATCH] 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 --- .gitignore | 8 + PLAN.md | 262 ++++++++++++++++++++++++++++ config.py | 90 ++++++++++ config.toml | 35 ++++ dashboard/app.js | 130 ++++++++++++++ dashboard/index.html | 73 ++++++++ dashboard/style.css | 206 ++++++++++++++++++++++ display.py | 259 ++++++++++++++++++++++++++++ main.py | 47 +++++ requirements.txt | 5 + sahsa-clock.service | 27 +++ server.py | 401 +++++++++++++++++++++++++++++++++++++++++++ state.py | 44 +++++ 13 files changed, 1587 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 config.py create mode 100644 config.toml create mode 100644 dashboard/app.js create mode 100644 dashboard/index.html create mode 100644 dashboard/style.css create mode 100644 display.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 sahsa-clock.service create mode 100644 server.py create mode 100644 state.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5de5528 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ +dist/ +build/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..6b532af --- /dev/null +++ b/PLAN.md @@ -0,0 +1,262 @@ +# 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://: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/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 +``` + +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 sahsa-clock` → service starts, reboot device, clock appears without intervention diff --git a/config.py b/config.py new file mode 100644 index 0000000..7b7a53b --- /dev/null +++ b/config.py @@ -0,0 +1,90 @@ +import re +import secrets +import sys +from dataclasses import dataclass +from pathlib import Path + +try: + import tomllib +except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + print("Error: Python 3.11+ is required (for tomllib), or install tomli: pip install tomli") + sys.exit(1) + +CONFIG_PATH = Path("config.toml") + + +@dataclass +class AppConfig: + width: int | None + height: int | None + fps: int + clock_font_path: str + message_font_path: str + port: int + default_duration: float + api_token: str + rate_limit: int + password_hash: str + session_timeout_hours: int + config_path: Path + + @classmethod + def load(cls, path: str | Path = CONFIG_PATH) -> "AppConfig": + p = Path(path) + if not p.exists(): + print(f"Error: Config file not found: {p}") + print("Copy config.toml to your working directory and edit it.") + sys.exit(1) + + with open(p, "rb") as f: + raw = tomllib.load(f) + + display = raw.get("display", {}) + server = raw.get("server", {}) + api = raw.get("api", {}) + rate = raw.get("rate_limit", {}) + dashboard = raw.get("dashboard", {}) + + token = api.get("token", "").strip() + if not token: + token = secrets.token_hex(32) + _update_config_field(p, "token", token) + print(f"\n[sahsa-clock] Generated API bearer token (saved to config.toml):") + print(f" {token}\n") + + return cls( + width=display.get("width"), + height=display.get("height"), + fps=display.get("fps", 10), + clock_font_path=display.get("clock_font_path", "").strip(), + message_font_path=display.get("message_font_path", "").strip(), + port=server.get("port", 8080), + default_duration=float(server.get("default_duration_seconds", 20)), + api_token=token, + rate_limit=rate.get("requests_per_minute", 20), + password_hash=dashboard.get("password_hash", "").strip(), + session_timeout_hours=dashboard.get("session_timeout_hours", 8), + config_path=p, + ) + + def save_password_hash(self, hashed: str) -> None: + _update_config_field(self.config_path, "password_hash", hashed) + self.password_hash = hashed + + +def _update_config_field(config_path: Path, key: str, value: str) -> None: + """Update a quoted string field in config.toml using regex replacement.""" + content = config_path.read_text() + pattern = rf'^({re.escape(key)}\s*=\s*)"[^"]*"' + + def replacer(m: re.Match) -> str: + return f'{m.group(1)}"{value}"' + + new_content = re.sub(pattern, replacer, content, flags=re.MULTILINE) + if new_content == content: + print(f"Warning: Could not update '{key}' in {config_path}. Field not found.") + return + config_path.write_text(new_content) diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..068d5a3 --- /dev/null +++ b/config.toml @@ -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 diff --git a/dashboard/app.js b/dashboard/app.js new file mode 100644 index 0000000..0be6fac --- /dev/null +++ b/dashboard/app.js @@ -0,0 +1,130 @@ +'use strict'; + +const msgTextarea = document.getElementById('msg'); +const charNum = document.getElementById('char-num'); +const sendBtn = document.getElementById('send-btn'); +const clearBtn = document.getElementById('clear-btn'); +const statusDot = document.getElementById('status-dot'); +const statusText = document.getElementById('status-text'); + +// ── Character counter ──────────────────────────────────────────────────────── + +msgTextarea.addEventListener('input', () => { + charNum.textContent = msgTextarea.value.length; +}); + +// ── Duration helper ────────────────────────────────────────────────────────── + +function getSelectedDuration() { + const checked = document.querySelector('input[name="duration"]:checked'); + return checked ? parseInt(checked.value, 10) : 60; +} + +// ── Auth redirect helper ───────────────────────────────────────────────────── + +function handleUnauth(status) { + if (status === 401) { + window.location.href = '/login'; + return true; + } + return false; +} + +// ── Send message ───────────────────────────────────────────────────────────── + +async function sendMessage() { + const text = msgTextarea.value.trim(); + if (!text) { + msgTextarea.focus(); + return; + } + + const duration = getSelectedDuration(); + const body = { text }; + if (duration === 0) { + body.persist = true; + } else { + body.duration = duration; + } + + sendBtn.disabled = true; + try { + const res = await fetch('/dashboard/message', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (handleUnauth(res.status)) return; + + if (res.ok) { + msgTextarea.value = ''; + charNum.textContent = '0'; + await refreshStatus(); + } else { + const err = await res.text(); + setStatus(false, `Error: ${err}`); + } + } catch { + setStatus(false, 'Could not reach server.'); + } finally { + sendBtn.disabled = false; + } +} + +// ── Clear message ──────────────────────────────────────────────────────────── + +async function clearMessage() { + try { + const res = await fetch('/dashboard/message', { method: 'DELETE' }); + if (handleUnauth(res.status)) return; + if (res.ok) { + await refreshStatus(); + } + } catch { + setStatus(false, 'Could not reach server.'); + } +} + +// ── Status polling ─────────────────────────────────────────────────────────── + +function setStatus(active, message) { + statusDot.className = 'status-dot' + (active ? ' active' : ''); + statusText.textContent = message; +} + +async function refreshStatus() { + try { + const res = await fetch('/dashboard/status'); + if (handleUnauth(res.status)) return; + + const data = await res.json(); + + if (data.active) { + let detail = ''; + if (data.persistent) { + detail = ' — until cleared'; + } else if (data.remaining_seconds !== null) { + const secs = Math.round(data.remaining_seconds); + const mins = Math.floor(secs / 60); + detail = mins > 0 + ? ` — ${mins}m ${secs % 60}s remaining` + : ` — ${secs}s remaining`; + } + setStatus(true, `Showing: "${data.text}"${detail}`); + } else { + setStatus(false, 'Screen is showing the clock.'); + } + } catch { + setStatus(false, 'Could not reach server.'); + } +} + +// ── Wire up events ─────────────────────────────────────────────────────────── + +sendBtn.addEventListener('click', sendMessage); +clearBtn.addEventListener('click', clearMessage); + +// Initial status fetch, then poll every 5 seconds +refreshStatus(); +setInterval(refreshStatus, 5000); diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..a60df37 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,73 @@ + + + + + + Sahsa Clock + + + +
+ +
+

Sahsa Clock

+
+ +
+
+ +
+ +
+

Send Message

+ +
+ + +
0 / 300
+
+ +
+ +
+ + + + +
+
+ +
+ + +
+
+ +
+
+ Checking… +
+ +
+
+ + + + diff --git a/dashboard/style.css b/dashboard/style.css new file mode 100644 index 0000000..addd327 --- /dev/null +++ b/dashboard/style.css @@ -0,0 +1,206 @@ +/* ── Reset & tokens ────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f0f0f; + --surface: #1a1a1a; + --surface2: #242424; + --border: #333; + --text: #f0f0f0; + --muted: #888; + --primary: #2563eb; + --primary-h: #1d4ed8; + --secondary-h: #2f2f2f; + --danger: #dc2626; + --success: #16a34a; + --radius: 10px; +} + +html { font-size: 16px; } + +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, sans-serif; + min-height: 100vh; + line-height: 1.5; +} + +/* ── Layout ────────────────────────────────────────────────────────────────── */ +.container { + max-width: 640px; + margin: 0 auto; + padding: 1.25rem 1rem; +} + +/* ── Header ────────────────────────────────────────────────────────────────── */ +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +h1 { font-size: 1.4rem; font-weight: 700; } + +.btn-signout { + background: none; + border: 1px solid var(--border); + color: var(--muted); + padding: 0.4rem 0.9rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: border-color 0.15s, color 0.15s; +} +.btn-signout:hover { border-color: #666; color: var(--text); } + +/* ── Cards ─────────────────────────────────────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + margin-bottom: 1rem; +} + +h2 { + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 1.25rem; +} + +/* ── Form fields ───────────────────────────────────────────────────────────── */ +.field { margin-bottom: 1.25rem; } + +label { + display: block; + margin-bottom: 0.45rem; + color: var(--muted); + font-size: 0.9rem; + font-weight: 500; +} + +textarea { + width: 100%; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 0.75rem; + font-size: 1rem; + font-family: inherit; + resize: vertical; + line-height: 1.5; + transition: border-color 0.15s; +} +textarea:focus { outline: none; border-color: var(--primary); } + +.char-count { + text-align: right; + color: var(--muted); + font-size: 0.8rem; + margin-top: 0.3rem; +} + +/* ── Duration picker ───────────────────────────────────────────────────────── */ +.duration-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +@media (min-width: 440px) { + .duration-grid { grid-template-columns: repeat(4, 1fr); } +} + +.dur-option { + display: flex; + align-items: center; + justify-content: center; + background: var(--surface2); + border: 2px solid var(--border); + border-radius: 6px; + padding: 0.65rem 0.25rem; + cursor: pointer; + user-select: none; + transition: border-color 0.15s, background 0.15s; + text-align: center; +} +.dur-option input[type=radio] { display: none; } +.dur-option span { font-size: 0.95rem; font-weight: 500; } +.dur-option:hover { border-color: #555; } +.dur-option:has(input:checked) { + border-color: var(--primary); + background: #1e2d4a; + color: #93c5fd; +} + +/* ── Action buttons ────────────────────────────────────────────────────────── */ +.action-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +@media (max-width: 360px) { + .action-row { grid-template-columns: 1fr; } +} + +.btn { + padding: 0.9rem 1rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + width: 100%; + transition: background 0.15s; + /* Minimum touch target */ + min-height: 48px; +} + +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-h); } +.btn-primary:disabled { background: #374151; color: #6b7280; cursor: not-allowed; } + +.btn-secondary { + background: var(--surface2); + color: var(--text); + border: 1px solid var(--border); +} +.btn-secondary:hover { background: var(--secondary-h); border-color: #555; } + +/* ── Status bar ────────────────────────────────────────────────────────────── */ +.status-bar { + display: flex; + align-items: center; + gap: 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + background: var(--muted); + transition: background 0.3s, box-shadow 0.3s; +} +.status-dot.active { + background: var(--success); + box-shadow: 0 0 7px var(--success); +} + +#status-text { + font-size: 0.95rem; + color: var(--muted); +} diff --git a/display.py b/display.py new file mode 100644 index 0000000..5387356 --- /dev/null +++ b/display.py @@ -0,0 +1,259 @@ +import os +import threading +import time +from pathlib import Path + +import pygame + +from config import AppConfig +from state import MessageState + + +# ── Font discovery ──────────────────────────────────────────────────────────── + +# Monospace for the clock so digit widths are consistent (no layout shift) +_CLOCK_FONT_CANDIDATES = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf", + "/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf", + "/usr/share/fonts/truetype/noto/NotoSansMono-Bold.ttf", +] + +# Bold sans-serif for messages — maximises readability of arbitrary text +_MESSAGE_FONT_CANDIDATES = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", +] + +WHITE = (255, 255, 255) +BLACK = (0, 0, 0) + + +def _find_font(configured: str, candidates: list[str]) -> str | None: + if configured and Path(configured).exists(): + return configured + for p in candidates: + if Path(p).exists(): + return p + return None # fall back to pygame's built-in bitmap font + + +def _load_font(path: str | None, size: int) -> pygame.font.Font: + if path: + try: + return pygame.font.Font(path, size) + except Exception: + pass + return pygame.font.Font(None, size) # pygame built-in fallback + + +# ── Text layout helpers ─────────────────────────────────────────────────────── + +def _wrap_text(font: pygame.font.Font, text: str, max_width: int) -> list[str]: + """Word-wrap text to fit within max_width pixels. Returns list of lines.""" + words = text.split() + lines: list[str] = [] + current: list[str] = [] + + for word in words: + test = " ".join(current + [word]) + if font.size(test)[0] <= max_width: + current.append(word) + else: + if current: + lines.append(" ".join(current)) + current = [word] + else: + lines.append(word) # single word too wide — add it anyway + + if current: + lines.append(" ".join(current)) + + return lines or [""] + + +def _fit_message_font( + font_path: str | None, + text: str, + max_w: int, + max_h: int, +) -> tuple[pygame.font.Font, list[str]]: + """ + Binary-search for the largest font size where the word-wrapped text fits + within (max_w, max_h). Returns (font, wrapped_lines). + """ + lo, hi = 16, 500 + best_font = _load_font(font_path, lo) + best_lines = _wrap_text(best_font, text, max_w) + + while lo <= hi: + mid = (lo + hi) // 2 + f = _load_font(font_path, mid) + lines = _wrap_text(f, text, max_w) + total_h = len(lines) * f.get_linesize() + max_line_w = max((f.size(ln)[0] for ln in lines), default=0) + + if total_h <= max_h and max_line_w <= max_w: + best_font, best_lines = f, lines + lo = mid + 1 + else: + hi = mid - 1 + + return best_font, best_lines + + +def _build_clock_font(font_path: str | None, screen_w: int, screen_h: int) -> pygame.font.Font: + """ + Find the largest font size where the widest possible time string fits within + 88 % of the screen width and 50 % of the screen height. + """ + target_w = int(screen_w * 0.88) + target_h = int(screen_h * 0.50) + sample = "12:00:00 AM" # widest realistic string + + lo, hi = 16, 600 + best = lo + + while lo <= hi: + mid = (lo + hi) // 2 + f = _load_font(font_path, mid) + tw, th = f.size(sample) + if tw <= target_w and th <= target_h: + best = mid + lo = mid + 1 + else: + hi = mid - 1 + + return _load_font(font_path, best) + + +# ── Display thread ──────────────────────────────────────────────────────────── + +class DisplayThread(threading.Thread): + def __init__(self, state: MessageState, config: AppConfig, dev_mode: bool = False): + super().__init__(daemon=True, name="display") + self.state = state + self.config = config + self.dev_mode = dev_mode + self._stop_event = threading.Event() + + def stop(self) -> None: + self._stop_event.set() + + def run(self) -> None: + # SDL environment must be set before pygame.init() + if self.dev_mode: + os.environ.setdefault("SDL_VIDEODRIVER", "x11") + else: + os.environ["SDL_VIDEODRIVER"] = "fbcon" + os.environ["SDL_FBDEV"] = "/dev/fb0" + + try: + pygame.init() + except Exception as e: + print(f"[display] pygame.init() failed: {e}") + return + + try: + screen = self._create_surface() + except Exception as e: + print(f"[display] Failed to create display surface: {e}") + pygame.quit() + return + + pygame.mouse.set_visible(False) + clock = pygame.time.Clock() + screen_w, screen_h = screen.get_size() + + clock_font_path = _find_font(self.config.clock_font_path, _CLOCK_FONT_CANDIDATES) + msg_font_path = _find_font(self.config.message_font_path, _MESSAGE_FONT_CANDIDATES) + + if not clock_font_path: + print("[display] Warning: no TTF font found; falling back to pygame built-in (may look pixelated at large sizes)") + if not msg_font_path: + msg_font_path = clock_font_path # use same font if none found + + clock_font = _build_clock_font(clock_font_path, screen_w, screen_h) + + # Cache message layout — recomputed only when message text changes + last_msg_text: str | None = None + msg_font: pygame.font.Font | None = None + msg_lines: list[str] = [] + + while not self._stop_event.is_set(): + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self._stop_event.set() + break + # Allow Escape to exit in dev mode + if self.dev_mode and event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: + self._stop_event.set() + break + + text, _ = self.state.get() + screen.fill(BLACK) + + if text: + if text != last_msg_text: + max_w = int(screen_w * 0.88) + max_h = int(screen_h * 0.88) + msg_font, msg_lines = _fit_message_font(msg_font_path, text, max_w, max_h) + last_msg_text = text + self._draw_message(screen, msg_font, msg_lines, screen_w, screen_h) + else: + last_msg_text = None + self._draw_clock(screen, clock_font, screen_w, screen_h) + + pygame.display.flip() + clock.tick(self.config.fps) + + pygame.quit() + + def _create_surface(self) -> pygame.Surface: + if self.dev_mode: + w = self.config.width or 1280 + h = self.config.height or 720 + surface = pygame.display.set_mode((w, h)) + pygame.display.set_caption("Sahsa Clock [DEV]") + return surface + + flags = pygame.FULLSCREEN | pygame.NOFRAME + if self.config.width and self.config.height: + return pygame.display.set_mode((self.config.width, self.config.height), flags) + return pygame.display.set_mode((0, 0), flags) + + @staticmethod + def _draw_clock( + screen: pygame.Surface, + font: pygame.font.Font, + screen_w: int, + screen_h: int, + ) -> None: + now = time.localtime() + hour = now.tm_hour % 12 or 12 + ampm = "AM" if now.tm_hour < 12 else "PM" + time_str = f"{hour}:{now.tm_min:02d}:{now.tm_sec:02d} {ampm}" + + surf = font.render(time_str, True, WHITE) + rect = surf.get_rect(center=(screen_w // 2, screen_h // 2)) + screen.blit(surf, rect) + + @staticmethod + def _draw_message( + screen: pygame.Surface, + font: pygame.font.Font, + lines: list[str], + screen_w: int, + screen_h: int, + ) -> None: + line_h = font.get_linesize() + total_h = len(lines) * line_h + y = (screen_h - total_h) // 2 + + for line in lines: + surf = font.render(line, True, WHITE) + x = (screen_w - surf.get_width()) // 2 + screen.blit(surf, (x, y)) + y += line_h diff --git a/main.py b/main.py new file mode 100644 index 0000000..7cf6628 --- /dev/null +++ b/main.py @@ -0,0 +1,47 @@ +import argparse +import asyncio +import sys + +from config import AppConfig +from server import run_server +from state import MessageState + + +def main() -> None: + parser = argparse.ArgumentParser(description="Sahsa Clock Display") + parser.add_argument( + "--dev", + action="store_true", + help="Windowed dev mode — renders to an x11 window instead of the framebuffer", + ) + parser.add_argument( + "--no-display", + action="store_true", + help="Start the HTTP server only, with no display output (useful for testing the dashboard/API)", + ) + parser.add_argument( + "--config", + default="config.toml", + metavar="PATH", + help="Path to config.toml (default: config.toml)", + ) + args = parser.parse_args() + + config = AppConfig.load(args.config) + state = MessageState() + + if not args.no_display: + # Import display only when needed — keeps --no-display working without pygame + from display import DisplayThread + display = DisplayThread(state, config, dev_mode=args.dev) + display.start() + + try: + asyncio.run(run_server(state, config)) + except KeyboardInterrupt: + print("\n[sahsa-clock] Shutting down.") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8883e1b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pygame>=2.5.0 +aiohttp>=3.9.0 +bcrypt>=4.0.0 +# Uncomment if using Python < 3.11 (tomllib is stdlib in 3.11+): +# tomli>=2.0.0 diff --git a/sahsa-clock.service b/sahsa-clock.service new file mode 100644 index 0000000..176cb4b --- /dev/null +++ b/sahsa-clock.service @@ -0,0 +1,27 @@ +[Unit] +Description=Sahsa Clock Display +After=network.target +DefaultDependencies=no + +[Service] +Type=simple +User=pi +WorkingDirectory=/opt/sahsa_clock + +# Tell SDL to render directly to the Linux framebuffer (no display server needed) +Environment=SDL_VIDEODRIVER=fbcon +Environment=SDL_FBDEV=/dev/fb0 + +# Disable console blanking so the TV stays on +ExecStartPre=/bin/sh -c 'echo -ne "\033[9;0]" > /dev/tty1' + +# If using a virtualenv (recommended): +ExecStart=/opt/sahsa_clock/venv/bin/python3 /opt/sahsa_clock/main.py +# If using system Python instead, replace the line above with: +# ExecStart=/usr/bin/python3 /opt/sahsa_clock/main.py + +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/server.py b/server.py new file mode 100644 index 0000000..55e5b33 --- /dev/null +++ b/server.py @@ -0,0 +1,401 @@ +import asyncio +import json +import secrets +import threading +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path + +import bcrypt +from aiohttp import web + +from config import AppConfig +from state import MessageState + +DASHBOARD_DIR = Path(__file__).parent / "dashboard" +SESSION_COOKIE = "sahsa_session" + + +# ── Session store ───────────────────────────────────────────────────────────── + +@dataclass +class _Session: + last_active: float = field(default_factory=time.time) + + +class SessionStore: + def __init__(self, timeout_hours: int): + self._sessions: dict[str, _Session] = {} + self._lock = threading.Lock() + self._timeout = timeout_hours * 3600.0 + + def create(self) -> str: + sid = str(uuid.uuid4()) + with self._lock: + self._sessions[sid] = _Session() + return sid + + def validate(self, sid: str) -> bool: + with self._lock: + s = self._sessions.get(sid) + if s is None: + return False + now = time.time() + if now - s.last_active > self._timeout: + del self._sessions[sid] + return False + s.last_active = now + return True + + def delete(self, sid: str) -> None: + with self._lock: + self._sessions.pop(sid, None) + + +# ── Rate limiter ────────────────────────────────────────────────────────────── + +class RateLimiter: + def __init__(self, max_per_minute: int): + self._max = max_per_minute + self._data: dict[str, list[float]] = {} + self._lock = threading.Lock() + + def is_allowed(self, ip: str) -> bool: + now = time.time() + cutoff = now - 60.0 + with self._lock: + ts = [t for t in self._data.get(ip, []) if t > cutoff] + if len(ts) >= self._max: + self._data[ip] = ts + return False + ts.append(now) + self._data[ip] = ts + return True + + +# ── Inline HTML pages ───────────────────────────────────────────────────────── + +_PAGE_STYLE = """ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + background: #111; color: #f0f0f0; + font-family: system-ui, -apple-system, sans-serif; + display: flex; justify-content: center; align-items: center; + min-height: 100vh; padding: 1rem; +} +.card { + background: #1a1a1a; border: 1px solid #333; border-radius: 10px; + padding: 2rem; width: 100%; max-width: 420px; +} +h1 { font-size: 1.5rem; margin-bottom: 0.5rem; text-align: center; } +.subtitle { color: #888; text-align: center; margin-bottom: 1.75rem; font-size: 0.9rem; } +label { display: block; margin-bottom: 0.4rem; color: #aaa; font-size: 0.9rem; } +input[type=password] { + width: 100%; padding: 0.75rem; border-radius: 6px; + border: 1px solid #444; background: #242424; color: #f0f0f0; + font-size: 1.05rem; margin-bottom: 1.1rem; +} +input[type=password]:focus { outline: none; border-color: #2563eb; } +.btn { + width: 100%; padding: 0.875rem; border: none; border-radius: 8px; + font-size: 1.05rem; font-weight: 600; cursor: pointer; +} +.btn-blue { background: #2563eb; color: #fff; } +.btn-blue:hover { background: #1d4ed8; } +.btn-green { background: #16a34a; color: #fff; } +.btn-green:hover { background: #15803d; } +.error { color: #f87171; margin-bottom: 1rem; text-align: center; font-size: 0.95rem; } +""" + + +def _login_page(error: str = "") -> str: + err = f'

{error}

' if error else "" + return f""" + + + + + Sahsa Clock — Sign In + + + +
+

Sahsa Clock

+

Sign in to access the dashboard.

+ {err} +
+ + + +
+
+ +""" + + +def _setup_page(error: str = "") -> str: + err = f'

{error}

' if error else "" + return f""" + + + + + Sahsa Clock — First Run Setup + + + +
+

First Run Setup

+

Create a password to protect the dashboard.

+ {err} +
+ + + + + +
+
+ +""" + + +# ── Message body parsing ────────────────────────────────────────────────────── + +def _parse_message_body(body: dict, default_duration: float) -> tuple[str, float | None]: + """ + Returns (text, duration_seconds). + duration=None means persistent. + """ + text = body.get("text", "").strip() + if body.get("persist") or body.get("duration") == 0: + duration: float | None = None + elif "duration" in body: + duration = float(body["duration"]) + else: + duration = default_duration + return text, duration + + +# ── Server ──────────────────────────────────────────────────────────────────── + +class ClockServer: + def __init__(self, state: MessageState, config: AppConfig): + self.state = state + self.config = config + self.sessions = SessionStore(config.session_timeout_hours) + self.rate_limiter = RateLimiter(config.rate_limit) + self.app = self._build_app() + + def _build_app(self) -> web.Application: + app = web.Application() + + # Static dashboard assets (CSS, JS) — no auth required + app.router.add_static("/static", DASHBOARD_DIR, show_index=False) + + # First-run setup + app.router.add_get("/setup", self._handle_setup_get) + app.router.add_post("/setup", self._handle_setup_post) + + # Authentication + app.router.add_get("/login", self._handle_login_get) + app.router.add_post("/login", self._handle_login_post) + app.router.add_post("/logout", self._handle_logout) + + # Dashboard (session-protected) + app.router.add_get("/", self._handle_root) + app.router.add_post("/dashboard/message", self._handle_dashboard_set) + app.router.add_delete("/dashboard/message", self._handle_dashboard_clear) + app.router.add_get("/dashboard/status", self._handle_dashboard_status) + + # API (bearer token + rate limit) + app.router.add_post("/api/message", self._handle_api_set) + app.router.add_delete("/api/message", self._handle_api_clear) + app.router.add_get("/api/status", self._handle_api_status) + + return app + + # ── Auth helpers ────────────────────────────────────────────────────────── + + def _setup_needed(self) -> bool: + return not self.config.password_hash + + def _check_session(self, request: web.Request) -> bool: + sid = request.cookies.get(SESSION_COOKIE) + return bool(sid and self.sessions.validate(sid)) + + def _session_redirect(self) -> web.Response: + """Redirect to setup or login depending on configuration state.""" + if self._setup_needed(): + return web.HTTPFound("/setup") + return web.HTTPFound("/login") + + def _set_session_cookie(self, response: web.Response, sid: str) -> None: + response.set_cookie( + SESSION_COOKIE, + sid, + httponly=True, + samesite="Lax", + max_age=self.config.session_timeout_hours * 3600, + ) + + def _require_bearer(self, request: web.Request) -> web.Response | None: + """Return 401 if bearer token is missing or wrong, else None.""" + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return web.Response(status=401, text="Unauthorized") + token = auth[7:].strip() + if not secrets.compare_digest(token, self.config.api_token): + return web.Response(status=401, text="Unauthorized") + return None + + def _require_rate_limit(self, request: web.Request) -> web.Response | None: + """Return 429 if this IP has exceeded the rate limit, else None.""" + ip = request.remote or "unknown" + if not self.rate_limiter.is_allowed(ip): + return web.Response(status=429, text="Too Many Requests") + return None + + # ── Setup handlers ──────────────────────────────────────────────────────── + + async def _handle_setup_get(self, request: web.Request) -> web.Response: + if not self._setup_needed(): + return web.HTTPFound("/") + return web.Response(content_type="text/html", text=_setup_page()) + + async def _handle_setup_post(self, request: web.Request) -> web.Response: + if not self._setup_needed(): + return web.HTTPFound("/") + + data = await request.post() + password = data.get("password", "") + confirm = data.get("confirm", "") + + if len(password) < 6: + return web.Response( + content_type="text/html", + text=_setup_page("Password must be at least 6 characters."), + ) + if password != confirm: + return web.Response( + content_type="text/html", + text=_setup_page("Passwords do not match."), + ) + + hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + self.config.save_password_hash(hashed) + return web.HTTPFound("/login") + + # ── Login / logout handlers ─────────────────────────────────────────────── + + async def _handle_login_get(self, request: web.Request) -> web.Response: + if self._setup_needed(): + return web.HTTPFound("/setup") + return web.Response(content_type="text/html", text=_login_page()) + + async def _handle_login_post(self, request: web.Request) -> web.Response: + if self._setup_needed(): + return web.HTTPFound("/setup") + + data = await request.post() + password = data.get("password", "").encode() + + if not bcrypt.checkpw(password, self.config.password_hash.encode()): + return web.Response( + content_type="text/html", + text=_login_page("Incorrect password."), + ) + + sid = self.sessions.create() + response = web.HTTPFound("/") + self._set_session_cookie(response, sid) + return response + + async def _handle_logout(self, request: web.Request) -> web.Response: + sid = request.cookies.get(SESSION_COOKIE) + if sid: + self.sessions.delete(sid) + response = web.HTTPFound("/login") + response.del_cookie(SESSION_COOKIE) + return response + + # ── Dashboard handlers ──────────────────────────────────────────────────── + + async def _handle_root(self, request: web.Request) -> web.Response: + if not self._check_session(request): + return self._session_redirect() + return web.FileResponse(DASHBOARD_DIR / "index.html") + + async def _handle_dashboard_set(self, request: web.Request) -> web.Response: + if not self._check_session(request): + return web.Response(status=401, text="Unauthorized") + try: + body = await request.json() + except Exception: + return web.Response(status=400, text="Invalid JSON") + text, duration = _parse_message_body(body, self.config.default_duration) + if not text: + return web.Response(status=400, text="Missing or empty 'text' field") + self.state.set(text, duration) + return web.Response(content_type="application/json", text=json.dumps({"ok": True})) + + async def _handle_dashboard_clear(self, request: web.Request) -> web.Response: + if not self._check_session(request): + return web.Response(status=401, text="Unauthorized") + self.state.clear() + return web.Response(content_type="application/json", text=json.dumps({"ok": True})) + + async def _handle_dashboard_status(self, request: web.Request) -> web.Response: + if not self._check_session(request): + return web.Response(status=401, text="Unauthorized") + return web.Response( + content_type="application/json", + text=json.dumps(self.state.to_dict()), + ) + + # ── API handlers ────────────────────────────────────────────────────────── + + async def _handle_api_set(self, request: web.Request) -> web.Response: + if err := self._require_bearer(request): + return err + if err := self._require_rate_limit(request): + return err + try: + body = await request.json() + except Exception: + return web.Response(status=400, text="Invalid JSON") + text, duration = _parse_message_body(body, self.config.default_duration) + if not text: + return web.Response(status=400, text="Missing or empty 'text' field") + self.state.set(text, duration) + return web.Response(content_type="application/json", text=json.dumps({"ok": True})) + + async def _handle_api_clear(self, request: web.Request) -> web.Response: + if err := self._require_bearer(request): + return err + if err := self._require_rate_limit(request): + return err + self.state.clear() + return web.Response(content_type="application/json", text=json.dumps({"ok": True})) + + async def _handle_api_status(self, request: web.Request) -> web.Response: + if err := self._require_bearer(request): + return err + return web.Response( + content_type="application/json", + text=json.dumps(self.state.to_dict()), + ) + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +async def run_server(state: MessageState, config: AppConfig) -> None: + server = ClockServer(state, config) + runner = web.AppRunner(server.app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", config.port) + await site.start() + print(f"[sahsa-clock] Dashboard: http://0.0.0.0:{config.port}") + print(f"[sahsa-clock] API: http://0.0.0.0:{config.port}/api/") + await asyncio.Event().wait() # run forever diff --git a/state.py b/state.py new file mode 100644 index 0000000..0974bc3 --- /dev/null +++ b/state.py @@ -0,0 +1,44 @@ +import threading +import time +from dataclasses import dataclass, field + + +@dataclass +class MessageState: + _text: str | None = field(default=None) + _expires_at: float | None = field(default=None) # monotonic timestamp + _lock: threading.Lock = field(default_factory=threading.Lock) + + def set(self, text: str, duration: float | None) -> None: + """Set message. duration=None means persistent (no expiry).""" + with self._lock: + self._text = text + self._expires_at = time.monotonic() + duration if duration else None + + def clear(self) -> None: + with self._lock: + self._text = None + self._expires_at = None + + def get(self) -> tuple[str | None, float | None]: + """Return (text, remaining_seconds). Both None if no active message.""" + with self._lock: + if self._text is None: + return None, None + if self._expires_at is not None and time.monotonic() >= self._expires_at: + self._text = None + self._expires_at = None + return None, None + remaining: float | None = None + if self._expires_at is not None: + remaining = max(0.0, self._expires_at - time.monotonic()) + return self._text, remaining + + def to_dict(self) -> dict: + text, remaining = self.get() + return { + "active": text is not None, + "text": text, + "remaining_seconds": round(remaining, 1) if remaining is not None else None, + "persistent": text is not None and remaining is None, + }