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:
2026-02-24 16:22:10 -06:00
commit 0c2392b2b3
13 changed files with 1587 additions and 0 deletions

90
config.py Normal file
View 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)