Compare commits

...

6 Commits

Author SHA1 Message Date
72efa94a06 Updating service file 2026-02-25 00:23:08 -06:00
cbdd4a2952 Moving to offscreen Driver 2026-02-25 00:21:36 -06:00
aab94d5e96 Troubleshooting 2026-02-25 00:12:01 -06:00
80697c7da5 Named correctly, and fixed to work with Le Potato 2026-02-24 23:46:36 -06:00
d2c8079231 Updated Configs and Claud v2 2026-02-24 16:47:47 -06:00
f5ffa204c6 Update Configs and Claude 2026-02-24 16:47:35 -06:00
12 changed files with 142 additions and 48 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ venv/
*.egg-info/
dist/
build/
config.toml
.claude/

62
CLAUDE.md Normal file
View 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.

View File

@@ -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
View File

@@ -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

View File

@@ -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
View 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())

View File

@@ -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(

View File

@@ -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>

View File

@@ -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)
pygame.display.flip()
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(

View File

@@ -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)

View File

@@ -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