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

9.9 KiB
Raw Blame History

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.


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:

python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"

Paste the output into config.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)

[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

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.):

systemctl status display-manager

2. Disable the desktop environment

Switch the default boot target to headless:

sudo systemctl set-default multi-user.target

Then disable the display manager so it won't start even if manually invoked:

# 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:

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:

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:

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

sudo reboot

After reboot, SSH back in and confirm:

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