Compare commits
6 Commits
ace1b3bd27
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 72efa94a06 | |||
| cbdd4a2952 | |||
| aab94d5e96 | |||
| 80697c7da5 | |||
| d2c8079231 | |||
| f5ffa204c6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
config.toml
|
||||
.claude/
|
||||
|
||||
62
CLAUDE.md
Normal file
62
CLAUDE.md
Normal file
@@ -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.
|
||||
@@ -1,24 +1,24 @@
|
||||
[Unit]
|
||||
Description=Sahsa Clock Display
|
||||
Description=KH Clock Display
|
||||
After=network.target
|
||||
DefaultDependencies=no
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/opt/sahsa_clock
|
||||
User=sasha
|
||||
WorkingDirectory=/opt/KH_Clock
|
||||
|
||||
# Tell SDL to render directly to the Linux framebuffer (no display server needed)
|
||||
Environment=SDL_VIDEODRIVER=fbcon
|
||||
Environment=SDL_FBDEV=/dev/fb0
|
||||
# SDL renders offscreen; pixels are blit directly to /dev/fb0 (Amlogic kmsdrm is unsupported)
|
||||
Environment=SDL_VIDEODRIVER=offscreen
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
# 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
|
||||
ExecStart=/opt/KH_Clock/venv/bin/python3 /opt/KH_Clock/main.py
|
||||
# If using system Python instead, replace the line above with:
|
||||
# ExecStart=/usr/bin/python3 /opt/sahsa_clock/main.py
|
||||
# ExecStart=/usr/bin/python3 /opt/KH_Clock/main.py
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
14
PLAN.md
14
PLAN.md
@@ -1,4 +1,4 @@
|
||||
# Plan: Sahsa Clock — Lightweight Boot-Persistent Display with Webhook Support
|
||||
# Plan: KH Clock — Lightweight Boot-Persistent Display with Webhook Support
|
||||
|
||||
## Context
|
||||
|
||||
@@ -27,7 +27,7 @@ Pygame can render directly to `/dev/fb0` (the Linux framebuffer) without any dis
|
||||
## Architecture
|
||||
|
||||
```
|
||||
sahsa_clock/
|
||||
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
|
||||
@@ -37,7 +37,7 @@ sahsa_clock/
|
||||
│ └── app.js
|
||||
├── config.toml # Screen resolution, colors, fonts, port, token, timeout
|
||||
├── requirements.txt
|
||||
└── sahsa-clock.service # systemd unit file
|
||||
└── KH-clock.service # systemd unit file
|
||||
```
|
||||
|
||||
### Runtime flow
|
||||
@@ -117,11 +117,11 @@ session_timeout_hours = 8
|
||||
|
||||
**No TLS required** — LAN-only. If ever internet-facing, add nginx in front with TLS.
|
||||
|
||||
### systemd service (`/etc/systemd/system/sahsa-clock.service`)
|
||||
### systemd service (`/etc/systemd/system/KH-clock.service`)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Sahsa Clock Display
|
||||
Description=KH Clock Display
|
||||
After=network.target
|
||||
DefaultDependencies=no
|
||||
|
||||
@@ -130,7 +130,7 @@ 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
|
||||
ExecStart=/usr/bin/python3 /opt/KH_Clock/main.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -259,4 +259,4 @@ The TV should show a plain text console at this point — that's expected. The c
|
||||
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
|
||||
8. `sudo systemctl enable --now KH-clock` → service starts, reboot device, clock appears without intervention
|
||||
|
||||
28
README.md
28
README.md
@@ -1,4 +1,4 @@
|
||||
# Sahsa Clock
|
||||
# KH Clock
|
||||
|
||||
A lightweight, boot-persistent clock display for a TV, running on a [Libre Computer AML-S905X-CC ("Le Potato")](https://libre.computer/products/aml-s905x-cc/). Renders directly to the Linux framebuffer — no desktop environment, no browser, no X11. Staff can push announcements to the screen from any phone or laptop on the same network.
|
||||
|
||||
@@ -17,7 +17,7 @@ A lightweight, boot-persistent clock display for a TV, running on a [Libre Compu
|
||||
## File structure
|
||||
|
||||
```
|
||||
sahsa_clock/
|
||||
KH_Clock/
|
||||
├── main.py # Entry point
|
||||
├── display.py # Pygame rendering (clock + messages)
|
||||
├── server.py # aiohttp web server (dashboard + API)
|
||||
@@ -29,7 +29,7 @@ sahsa_clock/
|
||||
│ ├── style.css
|
||||
│ └── app.js
|
||||
├── requirements.txt
|
||||
└── sahsa-clock.service # systemd unit file
|
||||
└── KH-clock.service # systemd unit file
|
||||
```
|
||||
|
||||
---
|
||||
@@ -276,14 +276,14 @@ ls -la /dev/fb0 # should exist, group should be 'video'
|
||||
### 2. Install the application
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/sahsa_clock
|
||||
sudo chown <your-username>: /opt/sahsa_clock
|
||||
sudo mkdir -p /opt/KH_Clock
|
||||
sudo chown <your-username>: /opt/KH_Clock
|
||||
|
||||
# Copy files (from your dev machine):
|
||||
scp -r . <user>@<ip>:/opt/sahsa_clock/
|
||||
scp -r . <user>@<ip>:/opt/KH_Clock/
|
||||
|
||||
# On the Le Potato — create a venv and install dependencies:
|
||||
cd /opt/sahsa_clock
|
||||
cd /opt/KH_Clock
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
@@ -297,7 +297,7 @@ sudo apt install fonts-dejavu
|
||||
### 4. Configure
|
||||
|
||||
```bash
|
||||
nano /opt/sahsa_clock/config.toml
|
||||
nano /opt/KH_Clock/config.toml
|
||||
```
|
||||
|
||||
You only need to verify the `[server]` port and `[rate_limit]` values. Everything else is handled automatically on first run (token generation, password setup).
|
||||
@@ -305,21 +305,21 @@ You only need to verify the `[server]` port and `[rate_limit]` values. Everythin
|
||||
If the username on your device is not `pi`, open the service file and update the `User=` line:
|
||||
|
||||
```bash
|
||||
nano /opt/sahsa_clock/sahsa-clock.service
|
||||
nano /opt/KH_Clock/KH-clock.service
|
||||
```
|
||||
|
||||
### 5. Install and start the systemd service
|
||||
|
||||
```bash
|
||||
sudo cp /opt/sahsa_clock/sahsa-clock.service /etc/systemd/system/
|
||||
sudo cp /opt/KH_Clock/KH-clock.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now sahsa-clock
|
||||
sudo systemctl enable --now KH-clock
|
||||
```
|
||||
|
||||
**Check that it started:**
|
||||
|
||||
```bash
|
||||
sudo systemctl status sahsa-clock
|
||||
sudo systemctl status KH-clock
|
||||
```
|
||||
|
||||
The TV should now show the clock. Open `http://<device-ip>:8080` from a laptop or phone on the same network to reach the dashboard.
|
||||
@@ -340,7 +340,7 @@ The TV should now show the clock. Open `http://<device-ip>:8080` from a laptop o
|
||||
|
||||
**Clock doesn't appear after reboot:**
|
||||
```bash
|
||||
sudo journalctl -u sahsa-clock -n 50
|
||||
sudo journalctl -u KH-clock -n 50
|
||||
```
|
||||
Common causes: wrong `User=` in the service file, user not in the `video` group, `/dev/fb0` not present.
|
||||
|
||||
@@ -354,7 +354,7 @@ sudo usermod -aG video <user> # add if missing, then log out and back in
|
||||
The desktop environment may still be running. Confirm `systemctl get-default` returns `multi-user.target` and that the display manager is disabled.
|
||||
|
||||
**Dashboard unreachable:**
|
||||
Check the service is running (`systemctl status sahsa-clock`) and that `port = 8080` in `config.toml` isn't blocked by a firewall.
|
||||
Check the service is running (`systemctl status KH-clock`) and that `port = 8080` in `config.toml` isn't blocked by a firewall.
|
||||
|
||||
**Font looks pixelated:**
|
||||
DejaVu fonts aren't installed. Run `sudo apt install fonts-dejavu` on the device and restart the service.
|
||||
|
||||
7
check_sdl.py
Normal file
7
check_sdl.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import ctypes
|
||||
sdl = ctypes.CDLL('libSDL2-2.0.so.0')
|
||||
sdl.SDL_GetVideoDriver.restype = ctypes.c_char_p
|
||||
n = sdl.SDL_GetNumVideoDrivers()
|
||||
print('Available SDL2 drivers:')
|
||||
for i in range(n):
|
||||
print(' ', sdl.SDL_GetVideoDriver(i).decode())
|
||||
@@ -52,7 +52,7 @@ class AppConfig:
|
||||
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"\n[KH-clock] Generated API bearer token (saved to config.toml):")
|
||||
print(f" {token}\n")
|
||||
|
||||
return cls(
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sahsa Clock</title>
|
||||
<title>KH Clock</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<h1>Sahsa Clock</h1>
|
||||
<h1>KH Clock</h1>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="btn-signout">Sign out</button>
|
||||
</form>
|
||||
|
||||
37
display.py
37
display.py
@@ -1,3 +1,4 @@
|
||||
import mmap
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
@@ -147,8 +148,9 @@ class DisplayThread(threading.Thread):
|
||||
if self.dev_mode:
|
||||
os.environ.setdefault("SDL_VIDEODRIVER", "x11")
|
||||
else:
|
||||
os.environ["SDL_VIDEODRIVER"] = "fbcon"
|
||||
os.environ["SDL_FBDEV"] = "/dev/fb0"
|
||||
# SDL kmsdrm is incompatible with the Amlogic MESON DRM driver.
|
||||
# Use offscreen rendering and blit pixels directly to /dev/fb0.
|
||||
os.environ["SDL_VIDEODRIVER"] = "offscreen"
|
||||
|
||||
try:
|
||||
pygame.init()
|
||||
@@ -167,6 +169,19 @@ class DisplayThread(threading.Thread):
|
||||
clock = pygame.time.Clock()
|
||||
screen_w, screen_h = screen.get_size()
|
||||
|
||||
# Open /dev/fb0 for direct pixel writes (framebuffer mode only)
|
||||
fb0_mmap = None
|
||||
fb0_file = None
|
||||
if not self.dev_mode:
|
||||
try:
|
||||
bpp = int(Path("/sys/class/graphics/fb0/bits_per_pixel").read_text())
|
||||
fb0_file = open("/dev/fb0", "rb+")
|
||||
fb0_mmap = mmap.mmap(fb0_file.fileno(), screen_w * screen_h * (bpp // 8))
|
||||
except Exception as e:
|
||||
print(f"[display] Failed to open /dev/fb0: {e}")
|
||||
pygame.quit()
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
@@ -206,9 +221,17 @@ class DisplayThread(threading.Thread):
|
||||
last_msg_text = None
|
||||
self._draw_clock(screen, clock_font, screen_w, screen_h)
|
||||
|
||||
if fb0_mmap is not None:
|
||||
fb0_mmap.seek(0)
|
||||
fb0_mmap.write(pygame.image.tostring(screen, "BGRA"))
|
||||
else:
|
||||
pygame.display.flip()
|
||||
clock.tick(self.config.fps)
|
||||
|
||||
if fb0_mmap:
|
||||
fb0_mmap.close()
|
||||
if fb0_file:
|
||||
fb0_file.close()
|
||||
pygame.quit()
|
||||
|
||||
def _create_surface(self) -> pygame.Surface:
|
||||
@@ -216,13 +239,13 @@ class DisplayThread(threading.Thread):
|
||||
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]")
|
||||
pygame.display.set_caption("KH 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)
|
||||
# Read actual framebuffer dimensions from sysfs
|
||||
size_str = Path("/sys/class/graphics/fb0/virtual_size").read_text().strip()
|
||||
w, h = map(int, size_str.split(","))
|
||||
return pygame.display.set_mode((w, h))
|
||||
|
||||
@staticmethod
|
||||
def _draw_clock(
|
||||
|
||||
4
main.py
4
main.py
@@ -8,7 +8,7 @@ from state import MessageState
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Sahsa Clock Display")
|
||||
parser = argparse.ArgumentParser(description="KH Clock Display")
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
action="store_true",
|
||||
@@ -39,7 +39,7 @@ def main() -> None:
|
||||
try:
|
||||
asyncio.run(run_server(state, config))
|
||||
except KeyboardInterrupt:
|
||||
print("\n[sahsa-clock] Shutting down.")
|
||||
print("\n[KH-clock] Shutting down.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
|
||||
12
server.py
12
server.py
@@ -14,7 +14,7 @@ from config import AppConfig
|
||||
from state import MessageState
|
||||
|
||||
DASHBOARD_DIR = Path(__file__).parent / "dashboard"
|
||||
SESSION_COOKIE = "sahsa_session"
|
||||
SESSION_COOKIE = "kh_session"
|
||||
|
||||
|
||||
# ── Session store ─────────────────────────────────────────────────────────────
|
||||
@@ -116,12 +116,12 @@ def _login_page(error: str = "") -> str:
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sahsa Clock — Sign In</title>
|
||||
<title>KH Clock — Sign In</title>
|
||||
<style>{_PAGE_STYLE}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Sahsa Clock</h1>
|
||||
<h1>KH Clock</h1>
|
||||
<p class="subtitle">Sign in to access the dashboard.</p>
|
||||
{err}
|
||||
<form method="post" action="/login">
|
||||
@@ -141,7 +141,7 @@ def _setup_page(error: str = "") -> str:
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sahsa Clock — First Run Setup</title>
|
||||
<title>KH Clock — First Run Setup</title>
|
||||
<style>{_PAGE_STYLE}</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -396,6 +396,6 @@ async def run_server(state: MessageState, config: AppConfig) -> None:
|
||||
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/")
|
||||
print(f"[KH-clock] Dashboard: http://0.0.0.0:{config.port}")
|
||||
print(f"[KH-clock] API: http://0.0.0.0:{config.port}/api/")
|
||||
await asyncio.Event().wait() # run forever
|
||||
|
||||
Reference in New Issue
Block a user