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>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
262
PLAN.md
Normal file
262
PLAN.md
Normal file
@@ -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://<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:
|
||||
|
||||
```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 <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:
|
||||
|
||||
```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 <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
|
||||
90
config.py
Normal file
90
config.py
Normal file
@@ -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)
|
||||
35
config.toml
Normal file
35
config.toml
Normal file
@@ -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
|
||||
130
dashboard/app.js
Normal file
130
dashboard/app.js
Normal file
@@ -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);
|
||||
73
dashboard/index.html
Normal file
73
dashboard/index.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sahsa Clock</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<h1>Sahsa Clock</h1>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="btn-signout">Sign out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<section class="card">
|
||||
<h2>Send Message</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="msg">Message</label>
|
||||
<textarea
|
||||
id="msg"
|
||||
rows="4"
|
||||
maxlength="300"
|
||||
placeholder="Type a message to display on the screen…"
|
||||
></textarea>
|
||||
<div class="char-count"><span id="char-num">0</span> / 300</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Duration</label>
|
||||
<div class="duration-grid">
|
||||
<label class="dur-option">
|
||||
<input type="radio" name="duration" value="30">
|
||||
<span>30 sec</span>
|
||||
</label>
|
||||
<label class="dur-option">
|
||||
<input type="radio" name="duration" value="60" checked>
|
||||
<span>1 min</span>
|
||||
</label>
|
||||
<label class="dur-option">
|
||||
<input type="radio" name="duration" value="300">
|
||||
<span>5 min</span>
|
||||
</label>
|
||||
<label class="dur-option">
|
||||
<input type="radio" name="duration" value="0">
|
||||
<span>Until cleared</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button id="send-btn" class="btn btn-primary">Send to Screen</button>
|
||||
<button id="clear-btn" class="btn btn-secondary">Clear Screen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="status-bar" id="status-bar">
|
||||
<div class="status-dot" id="status-dot"></div>
|
||||
<span id="status-text">Checking…</span>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
206
dashboard/style.css
Normal file
206
dashboard/style.css
Normal file
@@ -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);
|
||||
}
|
||||
259
display.py
Normal file
259
display.py
Normal file
@@ -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
|
||||
47
main.py
Normal file
47
main.py
Normal file
@@ -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()
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -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
|
||||
27
sahsa-clock.service
Normal file
27
sahsa-clock.service
Normal file
@@ -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
|
||||
401
server.py
Normal file
401
server.py
Normal file
@@ -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'<p class="error">{error}</p>' if error else ""
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sahsa Clock — Sign In</title>
|
||||
<style>{_PAGE_STYLE}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Sahsa Clock</h1>
|
||||
<p class="subtitle">Sign in to access the dashboard.</p>
|
||||
{err}
|
||||
<form method="post" action="/login">
|
||||
<label for="pw">Password</label>
|
||||
<input type="password" id="pw" name="password" autofocus autocomplete="current-password">
|
||||
<button type="submit" class="btn btn-blue">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _setup_page(error: str = "") -> str:
|
||||
err = f'<p class="error">{error}</p>' if error else ""
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sahsa Clock — First Run Setup</title>
|
||||
<style>{_PAGE_STYLE}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>First Run Setup</h1>
|
||||
<p class="subtitle">Create a password to protect the dashboard.</p>
|
||||
{err}
|
||||
<form method="post" action="/setup">
|
||||
<label for="pw">Password (6+ characters)</label>
|
||||
<input type="password" id="pw" name="password" autofocus minlength="6" autocomplete="new-password">
|
||||
<label for="pw2">Confirm Password</label>
|
||||
<input type="password" id="pw2" name="confirm" autocomplete="new-password">
|
||||
<button type="submit" class="btn btn-green">Set Password & Continue</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ── 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
|
||||
44
state.py
Normal file
44
state.py
Normal file
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user