diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 49acdb0..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(echo:*)", - "Bash(idf.py build:*)", - "Bash(for f in DSEG14C_BI_50px.c InziuIosevka_Slab_CC_12px.c InziuIosevka_Slab_CC_16px.c InziuIosevka_Slab_CC_20px.c InziuIosevka_Slab_CC_24px.c InziuIosevka_Slab_CC_32px.c)", - "Bash(do sed -i '/\\\\.static_bitmap = 0,/d' \"$f\")", - "Bash(done)", - "Bash(file:*)", - "mcp__ide__getDiagnostics", - "Bash(python -m py_compile:*)", - "WebSearch", - "WebFetch(domain:docs.waveshare.com)", - "WebFetch(domain:www.waveshare.com)", - "WebFetch(domain:raw.githubusercontent.com)", - "Bash(npm view:*)", - "WebFetch(domain:raw.githubusercontent.com)", - "Bash(docker ps:*)", - "Bash(python3:*)" - ] - }, - "outputStyle": "iseri", - "spinnerTipsEnabled": false, - "prefersReducedMotion": true -} diff --git a/.gitignore b/.gitignore index d2e8bcb..3109838 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ sdkconfig.old dependencies.lock # vscode local settings -.vscode/ \ No newline at end of file +.vscode/ + +# claude local settings +.claude/ \ No newline at end of file diff --git a/pi/.gitignore b/pi/.gitignore deleted file mode 100644 index 469bbce..0000000 --- a/pi/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -# python artifacts -*/__pycache__ -__pycache__/ -*.pyo -*.pyc - -# configs -config/ -!config/alarms.sample.json \ No newline at end of file diff --git a/pi/PLAN.md b/pi/PLAN.md deleted file mode 100644 index f3d2880..0000000 --- a/pi/PLAN.md +++ /dev/null @@ -1,36 +0,0 @@ -# Pi Servers -- Roadmap - -## Docker Compose - -Containerize the pi servers for easier deployment. - -### Options - -1. **Single service** -- `run_all.py` as the entrypoint, both servers in one container -2. **Split services** -- separate containers for `stats_server.py` and `contents_server.py` - -Single service is simpler. Split services allow independent scaling and restarts. - -### Configuration - -- Volume mount `assets/` and `config/alarms.json` so they're editable without rebuilding -- Expose ports 8765 and 8766 -- Network mode `host` or a bridge with known IPs for ESP32 discovery -- Restart policy: `unless-stopped` - -## Repository Extraction - -The `pi/` directory will become its own git repository. - -### Steps - -1. Extract `pi/` into a standalone repo with its own `README.md`, `requirements.txt`, and CI -2. Add it back to this project as a git submodule -3. The interface contract between the two repos is the WebSocket protocol -- JSON schemas and binary frame formats documented in `README.md` - -### Benefits - -- Independent versioning and release cycle -- Pi-side contributors don't need the ESP-IDF toolchain -- CI can test the Python servers in isolation -- Cleaner separation of concerns between embedded firmware and host services diff --git a/pi/README.md b/pi/README.md deleted file mode 100644 index d098fee..0000000 --- a/pi/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# Pi Dashboard Servers - -WebSocket servers that feed system stats, alarm audio, and status images to the ESP32-S3 RLCD dashboard. - -## File Structure - -``` -pi/ - run_all.py # Launches both servers as child processes - stats_server.py # Real system stats over WebSocket (port 8765) - contents_server.py # Alarm audio + status images over WebSocket (port 8766) - mock_server.py # Drop-in replacement for stats_server with random data - audio_handler.py # WAV loading, PCM chunking, alarm streaming - image_handler.py # PNG to 1-bit monochrome conversion, alpha compositing - alarm_scheduler.py # Loads and validates alarm config, checks firing schedule - requirements.txt - config/ - alarms.json # Alarm schedule configuration - assets/ - alarm/ # WAV files for alarm audio - img/ # Status images (idle.png, on_alarm.png) -``` - -## Requirements - -Python 3.10+ - -``` -pip install -r requirements.txt -``` - -Dependencies: `websockets`, `psutil`, `Pillow` - -## Running - -Start both servers: - -``` -python run_all.py # both servers, default config -python run_all.py --config path/to.json # both servers, custom config -``` - -Or run individually: - -``` -python stats_server.py # port 8765 only -python contents_server.py --config path/to.json # port 8766, custom config -python mock_server.py # port 8765, random data (no psutil needed) -``` - -## Servers - -### stats_server.py -- port 8765 - -Pushes a JSON object every 2 seconds with real system metrics from `psutil`: - -- `cpu_pct`, `mem_pct`, `mem_used_mb`, `disk_pct` -- `cpu_temp` (reads `/sys/class/thermal/` as fallback) -- `uptime_hrs`, `net_rx_kbps`, `net_tx_kbps` -- `services` (mocked until systemd integration) -- `local_time` fields for RTC sync (`y`, `mo`, `d`, `h`, `m`, `s`) - -### contents_server.py -- port 8766 - -Serves alarm audio and status images. Protocol: - -**Status image:** -1. Text frame: `{"type":"status_image","width":120,"height":120}` -2. Binary frame: 1-bit monochrome bitmap (1800 bytes) - -**Alarm audio:** -1. Text frame: `{"type":"alarm_start","sample_rate":N,"channels":N,"bits":N}` -2. Binary frames: raw PCM chunks (4096 bytes each, paced at ~90% real-time) -3. Text frame: `{"type":"alarm_stop"}` - -Loads alarm config from `config/alarms.json` (override with `--config`). Checks schedule every 5 seconds, fires once per matched minute. If no config or empty config, sends idle image and blocks forever. On alarm: switches to alarm image, streams audio, switches back to idle. - -### mock_server.py -- port 8765 - -Same JSON schema and 2-second push interval as `stats_server.py`, but all values are randomized. No `psutil` dependency -- useful for development on non-Pi machines. - -Does not include `local_time` fields. - -## Alarm Configuration - -Config file: `config/alarms.json` -- a single alarm object or an array of alarm objects. - -Example with two alarms: - -```json -[ - { - "alarm_time": "0730", - "alarm_days": ["Mon", "Tue", "Wed", "Thu", "Fri"], - "alarm_audio": "assets/alarm/alarm_test.wav", - "alarm_image": "assets/img/on_alarm.png" - }, - { - "alarm_time": "2300", - "alarm_audio": "assets/alarm/sleep.wav", - "alarm_image": "assets/img/sleep.png" - } -] -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `alarm_time` | `string` | Yes | 4-digit HHMM, 24-hour. Fires on the matched minute. | -| `alarm_days` | `string[]` | No | 3-letter abbreviations: `Mon`–`Sun`. If omitted, fires every day. | -| `alarm_dates` | `string[]` | No | `MM/DD` strings. Ignored if `alarm_days` is also set. | -| `alarm_audio` | `string` | No | WAV path, relative to `pi/`. Default: `assets/alarm/alarm_test.wav`. | -| `alarm_image` | `string` | No | Status PNG path, relative to `pi/`. Default: `assets/img/on_alarm.png`. | - -If both `alarm_days` and `alarm_dates` are present, `alarm_days` takes priority. - -## Modules - -### audio_handler.py - -- `find_wav(path=None)` -- uses the given path if it exists, otherwise falls back to glob in `assets/alarm/` -- `read_wav(path)` -- reads WAV, returns `(pcm_bytes, sample_rate, channels, bits)` -- `stream_alarm(ws, pcm, sr, ch, bits)` -- streams one alarm cycle over WebSocket - -### image_handler.py - -- `load_status_image(path)` -- loads PNG, composites transparency onto white, converts to 1-bit 120x120 monochrome bitmap (black=1, MSB-first) -- `send_status_image(ws, img_bytes)` -- sends status image header + binary over WebSocket - -### alarm_scheduler.py - -- `load_config(path)` -- reads and validates alarm JSON; returns list of alarm dicts or `None` -- `should_fire(config)` -- checks a single alarm entry against current local time diff --git a/pi/alarm_scheduler.py b/pi/alarm_scheduler.py deleted file mode 100644 index 64e1647..0000000 --- a/pi/alarm_scheduler.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Alarm scheduler — load config and check firing schedule.""" - -import json -import logging -import re -from datetime import datetime -from pathlib import Path - -log = logging.getLogger(__name__) - -DEFAULT_CONFIG_PATH = Path(__file__).parent / "config" / "alarms.json" - -VALID_DAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} -TIME_RE = re.compile(r"^([01]\d|2[0-3])[0-5]\d$") -DATE_RE = re.compile(r"^(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])$") - - -def _validate_entry(entry: dict, index: int) -> dict | None: - """Validate a single alarm entry. Returns it if valid, None otherwise.""" - if not isinstance(entry, dict): - log.warning("Alarm #%d: expected object, got %s", index, type(entry).__name__) - return None - - alarm_time = entry.get("alarm_time") - if alarm_time is None: - log.warning("Alarm #%d: missing required field 'alarm_time'", index) - return None - if not isinstance(alarm_time, str) or not TIME_RE.match(alarm_time): - log.warning("Alarm #%d: invalid alarm_time '%s' — must be 4-digit HHMM", index, alarm_time) - return None - - alarm_days = entry.get("alarm_days") - if alarm_days is not None: - if not isinstance(alarm_days, list) or not all(isinstance(d, str) for d in alarm_days): - log.warning("Alarm #%d: alarm_days must be a list of strings", index) - return None - bad = [d for d in alarm_days if d not in VALID_DAYS] - if bad: - log.warning("Alarm #%d: invalid day abbreviations: %s", index, bad) - return None - - alarm_dates = entry.get("alarm_dates") - if alarm_dates is not None: - if not isinstance(alarm_dates, list) or not all(isinstance(d, str) for d in alarm_dates): - log.warning("Alarm #%d: alarm_dates must be a list of strings", index) - return None - bad = [d for d in alarm_dates if not DATE_RE.match(d)] - if bad: - log.warning("Alarm #%d: invalid date formats (expected MM/DD): %s", index, bad) - return None - - log.info("Alarm #%d: time=%s days=%s", index, alarm_time, alarm_days or "(every day)") - return entry - - -def load_config(path: Path) -> list[dict] | None: - """Read and validate alarm config JSON. - - Accepts either a single alarm object or an array of alarm objects. - Returns a list of valid alarm dicts, or None if the file is missing, - empty, or contains no valid entries. - Never raises — logs warnings and returns None on any problem. - """ - try: - text = path.read_text(encoding="utf-8").strip() - except FileNotFoundError: - log.warning("Config file not found: %s", path) - return None - except OSError as e: - log.warning("Cannot read config %s: %s", path, e) - return None - - if not text: - log.warning("Config file is empty: %s", path) - return None - - try: - data = json.loads(text) - except json.JSONDecodeError as e: - log.warning("Invalid JSON in %s: %s", path, e) - return None - - if not data: - log.info("Config is empty — no alarms configured") - return None - - # Normalize to list - if isinstance(data, dict): - entries = [data] - elif isinstance(data, list): - entries = data - else: - log.warning("Config must be a JSON object or array, got %s", type(data).__name__) - return None - - valid = [] - for i, entry in enumerate(entries): - result = _validate_entry(entry, i) - if result is not None: - valid.append(result) - - if not valid: - log.warning("No valid alarm entries in %s", path) - return None - - log.info("Loaded %d alarm(s) from %s", len(valid), path) - return valid - - -def should_fire(config: dict) -> bool: - """Check if a single alarm entry should fire right now. - - Rules: - - alarm_time must match current HHMM - - If alarm_days is present, today's 3-letter abbreviation must be in the list - - If alarm_days is absent but alarm_dates is present, today's MM/DD must match - - If neither alarm_days nor alarm_dates is present, fires every day - - If both are present, alarm_days wins (alarm_dates ignored) - """ - now = datetime.now() - current_hhmm = now.strftime("%H%M") - - if config["alarm_time"] != current_hhmm: - return False - - alarm_days = config.get("alarm_days") - alarm_dates = config.get("alarm_dates") - - if alarm_days is not None: - return now.strftime("%a") in alarm_days - - if alarm_dates is not None: - return now.strftime("%m/%d") in alarm_dates - - return True diff --git a/pi/assets/alarm/alarm_test.lab b/pi/assets/alarm/alarm_test.lab deleted file mode 100644 index cf732a8..0000000 --- a/pi/assets/alarm/alarm_test.lab +++ /dev/null @@ -1,55 +0,0 @@ -0 700000 j -700000 1440000 u -1440000 2370000 u -2370000 3110000 i -3110000 3710000 ch -3710000 4380000 i -4380000 4800000 g -4800000 5560000 a -5560000 6180000 ts -6180000 6850000 u -6850000 7500000 j -7500000 8210000 u -8210000 9130000 u -9130000 9880000 i -9880000 10460000 ch -10460000 11150000 i -11150000 11690000 g -11690000 12470000 a -12470000 13100000 ts -13100000 13770000 u -13770000 14420000 j -14420000 15140000 u -15140000 16070000 u -16070000 16810000 i -16810000 17420000 ch -17420000 18080000 i -18080000 18610000 g -18610000 19410000 a -19410000 20020000 ts -20020000 20680000 u -20680000 21320000 j -21320000 22030000 u -22030000 22900000 u -22900000 23640000 i -23640000 24250000 ch -24250000 24920000 i -24920000 25460000 g -25460000 26200000 a -26200000 26840000 ts -26840000 27480000 u -27480000 28130000 j -28130000 28830000 u -28830000 29720000 u -29720000 30440000 i -30440000 31040000 ch -31040000 31750000 i -31750000 32600000 by -32600000 33320000 o -33320000 34120000 o -34120000 34740000 j -34740000 35350000 a -35350000 35870000 s -35870000 36510000 u -36510000 36960000 t -36960000 38220000 o diff --git a/pi/assets/alarm/alarm_test.wav b/pi/assets/alarm/alarm_test.wav deleted file mode 100644 index 24fc245..0000000 Binary files a/pi/assets/alarm/alarm_test.wav and /dev/null differ diff --git a/pi/assets/alarm/sleep.lab b/pi/assets/alarm/sleep.lab deleted file mode 100644 index 9c3e70f..0000000 --- a/pi/assets/alarm/sleep.lab +++ /dev/null @@ -1,42 +0,0 @@ -0 850000 s -850000 1580000 u -1580000 2220000 i -2220000 2660000 m -2660000 3370000 i -3370000 4140000 N -4140000 4610000 g -4610000 5780000 a -5780000 13780000 pau -13780000 14350000 k -14350000 15010000 i -15010000 15730000 e -15730000 16160000 t -16160000 16800000 e -16800000 17260000 k -17260000 17840000 u -17840000 18390000 d -18390000 19090000 a -19090000 19700000 s -19700000 20390000 a -20390000 20830000 r -20830000 22120000 i -22120000 23620000 pau -23620000 24390000 a -24390000 24810000 r -24810000 25430000 i -25430000 25860000 g -25860000 26550000 a -26550000 27000000 t -27000000 27780000 o -27780000 28520000 o -28520000 29000000 g -29000000 29740000 o -29740000 30260000 z -30260000 31110000 a -31110000 31790000 i -31790000 32190000 m -32190000 32870000 a -32870000 33480000 sh -33480000 34040000 i -34040000 34520000 t -34520000 35950000 a diff --git a/pi/assets/alarm/sleep.wav b/pi/assets/alarm/sleep.wav deleted file mode 100644 index 7e6a1a0..0000000 Binary files a/pi/assets/alarm/sleep.wav and /dev/null differ diff --git a/pi/assets/img/idle.png b/pi/assets/img/idle.png deleted file mode 100644 index aa4f85e..0000000 Binary files a/pi/assets/img/idle.png and /dev/null differ diff --git a/pi/assets/img/on_alarm.png b/pi/assets/img/on_alarm.png deleted file mode 100644 index de8d7bc..0000000 Binary files a/pi/assets/img/on_alarm.png and /dev/null differ diff --git a/pi/assets/img/sleep.png b/pi/assets/img/sleep.png deleted file mode 100644 index 21aa875..0000000 Binary files a/pi/assets/img/sleep.png and /dev/null differ diff --git a/pi/audio_handler.py b/pi/audio_handler.py deleted file mode 100644 index 1bcecdb..0000000 --- a/pi/audio_handler.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Audio alarm functions — WAV loading and PCM streaming.""" - -import array -import asyncio -import json -import logging -import math -import wave -from pathlib import Path - -log = logging.getLogger(__name__) - -CHUNK_SIZE = 4096 -AUDIO_DIR = Path(__file__).parent / "assets" / "alarm" - - -def find_wav(path: Path | None = None) -> Path: - """Return a WAV file path. - - If *path* is given and points to an existing file, use it directly. - Otherwise fall back to the first .wav found in the alarm assets directory. - """ - if path is not None: - p = Path(path) - if p.is_file(): - log.info("Using audio file: %s", p) - return p - log.warning("Specified audio path not found: %s — falling back to glob", p) - - wavs = list(AUDIO_DIR.glob("*.wav")) - if not wavs: - raise FileNotFoundError(f"No .wav files found in {AUDIO_DIR}") - log.info("Using audio file: %s", wavs[0].name) - return wavs[0] - - -def _normalize_pcm(pcm: bytes, bits: int) -> bytes: - """Peak-normalize PCM data to 0 dBFS. - - Supports 8-bit (unsigned) and 16-bit (signed) PCM. - Returns the original bytes unchanged if already at 0 dB or silent. - """ - if bits == 16: - samples = array.array("h", pcm) # signed 16-bit - peak = max(abs(s) for s in samples) if samples else 0 - if peak == 0 or peak == 32767: - return pcm - scale = 32767 / peak - samples = array.array("h", (min(32767, max(-32768, int(s * scale))) for s in samples)) - elif bits == 8: - samples = array.array("B", pcm) # unsigned 8-bit, center=128 - peak = max(abs(s - 128) for s in samples) if samples else 0 - if peak == 0 or peak == 127: - return pcm - scale = 127 / peak - samples = array.array("B", (max(0, min(255, int((s - 128) * scale) + 128)) for s in samples)) - else: - log.warning("Normalization not supported for %d-bit audio, skipping", bits) - return pcm - - gain_db = 20 * __import__("math").log10(scale) if scale > 0 else 0 - log.info("Normalized: peak %d → 0 dBFS (gain %.1f dB)", peak, gain_db) - return samples.tobytes() - - -def read_wav(path: Path) -> tuple[bytes, int, int, int]: - """Read WAV file, normalize to 0 dBFS, return (pcm_data, sample_rate, channels, bits).""" - try: - wf = wave.open(str(path), "rb") - except wave.Error as e: - raise ValueError( - f"{path.name}: unsupported WAV format ({e}). " - "Only 8/16-bit integer PCM is supported — no 32-bit float." - ) from e - with wf: - sr = wf.getframerate() - ch = wf.getnchannels() - bits = wf.getsampwidth() * 8 - pcm = wf.readframes(wf.getnframes()) - log.info("WAV loaded: %dHz %dch %dbit, %.1fs, %d bytes", - sr, ch, bits, len(pcm) / (sr * ch * (bits // 8)), len(pcm)) - pcm = _normalize_pcm(pcm, bits) - return pcm, sr, ch, bits - - -def chunk_bytes(data: bytes, size: int): - """Yield data in fixed-size chunks.""" - for i in range(0, len(data), size): - yield data[i : i + size] - - -async def stream_alarm(ws, pcm: bytes, sr: int, ch: int, bits: int): - """Stream one alarm cycle to the connected client.""" - bytes_per_sec = sr * ch * (bits // 8) - chunk_duration = CHUNK_SIZE / bytes_per_sec - pace_delay = chunk_duration * 0.9 # 90% real-time to avoid underrun - - total_chunks = (len(pcm) + CHUNK_SIZE - 1) // CHUNK_SIZE - - start_msg = json.dumps({ - "type": "alarm_start", - "sample_rate": sr, - "channels": ch, - "bits": bits, - }) - await ws.send(start_msg) - log.info("Sent alarm_start (%d chunks, pace %.1fms)", total_chunks, pace_delay * 1000) - - for i, chunk in enumerate(chunk_bytes(pcm, CHUNK_SIZE)): - await ws.send(chunk) - await asyncio.sleep(pace_delay) - - await ws.send(json.dumps({"type": "alarm_stop"})) - log.info("Sent alarm_stop") diff --git a/pi/config/alarms.sample.json b/pi/config/alarms.sample.json deleted file mode 100644 index 35a890f..0000000 --- a/pi/config/alarms.sample.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "alarm_time": "0700", - "alarm_days": ["Mon", "Tue", "Wed", "Thu", "Fri"], - "alarm_audio": "assets/alarm/alarm_test.wav", - "alarm_image": "assets/img/on_alarm.png" - }, - { - "alarm_time": "2330", - "alarm_audio": "assets/alarm/sleep.wav", - "alarm_image": "assets/img/sleep.png" - } -] diff --git a/pi/contents_server.py b/pi/contents_server.py deleted file mode 100644 index 554d144..0000000 --- a/pi/contents_server.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Contents server — serves alarm audio and status images over WebSocket. - -Streams WAV PCM chunks and pushes 1-bit monochrome status images to the -connected ESP32 dashboard client on port 8766. - -Protocol: - Status image: - 1. Text frame: {"type":"status_image","width":200,"height":200} - 2. Binary frame: 1-bit monochrome bitmap - - Alarm audio: - 1. Text frame: {"type":"alarm_start","sample_rate":N,"channels":N,"bits":N} - 2. Binary frames: raw PCM chunks (4096 bytes each, paced at ~90% real-time) - 3. Text frame: {"type":"alarm_stop"} -""" - -import argparse -import asyncio -import json -import logging -from datetime import datetime -from pathlib import Path - -import websockets - -from alarm_scheduler import DEFAULT_CONFIG_PATH, load_config, should_fire -from audio_handler import find_wav, read_wav, stream_alarm -from image_handler import IMG_DIR, load_status_image, send_status_image - -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") -log = logging.getLogger("contents_server") - -PORT = 8766 -PI_DIR = Path(__file__).parent - -# Set by main(), read by handler() -_config_path: Path = DEFAULT_CONFIG_PATH - -TICK_INTERVAL = 5 # seconds between schedule checks - - -def _resolve_path(relative: str) -> Path: - """Resolve a config path relative to pi/ directory.""" - p = Path(relative) - if not p.is_absolute(): - p = PI_DIR / p - return p - - -def _prepare_alarm(entry: dict) -> dict: - """Pre-resolve paths and load resources for a single alarm entry.""" - audio_path = find_wav(_resolve_path(entry.get("alarm_audio", "assets/alarm/alarm_test.wav"))) - alarm_img_path = _resolve_path(entry.get("alarm_image", "assets/img/on_alarm.png")) - pcm, sr, ch, bits = read_wav(audio_path) - img = load_status_image(alarm_img_path) - return { - "config": entry, - "pcm": pcm, "sr": sr, "ch": ch, "bits": bits, - "img": img, - "last_fired": None, - } - - -async def handler(ws): - """Handle a single WebSocket connection.""" - remote = ws.remote_address - log.info("Client connected: %s:%d", remote[0], remote[1]) - - configs = load_config(_config_path) - img_idle = load_status_image(IMG_DIR / "idle.png") - current_img = img_idle - - alarms = [_prepare_alarm(entry) for entry in configs] if configs else [] - - async def alarm_ticker(): - nonlocal current_img - if not alarms: - log.info("No alarms configured — idling forever") - await asyncio.Future() - return - - while True: - for alarm in alarms: - if should_fire(alarm["config"]): - current_minute = datetime.now().strftime("%Y%m%d%H%M") - - if current_minute != alarm["last_fired"]: - alarm["last_fired"] = current_minute - log.info("Alarm firing: %s at %s", - alarm["config"]["alarm_time"], current_minute) - current_img = alarm["img"] - await send_status_image(ws, current_img) - await stream_alarm(ws, alarm["pcm"], alarm["sr"], - alarm["ch"], alarm["bits"]) - # let the image persist a bit more - await asyncio.sleep(1) - current_img = img_idle - await send_status_image(ws, current_img) - - await asyncio.sleep(TICK_INTERVAL) - - async def receiver(): - async for msg in ws: - try: - data = json.loads(msg) - except (json.JSONDecodeError, TypeError): - continue - if data.get("type") == "request_image": - log.info("Client requested image — sending current (%d bytes)", - len(current_img)) - await send_status_image(ws, current_img) - - try: - await asyncio.gather(alarm_ticker(), receiver()) - except websockets.exceptions.ConnectionClosed: - log.info("Client disconnected: %s:%d", remote[0], remote[1]) - - -async def main(): - log.info("Contents server starting on port %d", PORT) - async with websockets.serve(handler, "0.0.0.0", PORT): - await asyncio.Future() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Alarm contents server") - parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_PATH, - help="Path to alarm config JSON (default: %(default)s)") - args = parser.parse_args() - _config_path = args.config - asyncio.run(main()) diff --git a/pi/image_handler.py b/pi/image_handler.py deleted file mode 100644 index 4e00bc7..0000000 --- a/pi/image_handler.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Status image functions — loading, alpha compositing, and WS transmission.""" - -import json -import logging -from pathlib import Path - -from PIL import Image - -log = logging.getLogger(__name__) - -IMG_DIR = Path(__file__).parent / "assets" / "img" -STATUS_IMG_SIZE = 200 -MONOCHROME_THRESHOLD = 180 - - -def load_status_image(path: Path) -> bytes: - """Load a PNG, convert to 1-bit 200x200 monochrome bitmap (MSB-first, black=1). - - Transparent pixels are composited onto white so they don't render as black. - """ - img = Image.open(path) - - # Composite transparent pixels onto white background - if img.mode in ("RGBA", "LA", "PA"): - bg = Image.new("RGBA", img.size, (255, 255, 255, 255)) - bg.paste(img, mask=img.split()[-1]) - img = bg - - img = img.convert("L") - - # Resize to fit within 120x120, preserving aspect ratio - img.thumbnail((STATUS_IMG_SIZE, STATUS_IMG_SIZE), Image.LANCZOS) - - # Paste centered onto white canvas - canvas = Image.new("L", (STATUS_IMG_SIZE, STATUS_IMG_SIZE), 255) - x_off = (STATUS_IMG_SIZE - img.width) // 2 - y_off = (STATUS_IMG_SIZE - img.height) // 2 - canvas.paste(img, (x_off, y_off)) - - # Threshold to 1-bit: black (< MONOCHROME_THRESHOLD) -> 1, white -> 0 - bw = canvas.point(lambda p: 1 if p < MONOCHROME_THRESHOLD else 0, "1") - raw = bw.tobytes() - log.info("Status image loaded: %s -> %d bytes", path.name, len(raw)) - return raw - - -async def send_status_image(ws, img_bytes: bytes): - """Send a status image over the WebSocket (text header + binary payload).""" - header = json.dumps({"type": "status_image", "width": STATUS_IMG_SIZE, "height": STATUS_IMG_SIZE}) - await ws.send(header) - await ws.send(img_bytes) - log.info("Sent status image (%d bytes)", len(img_bytes)) diff --git a/pi/mock_server.py b/pi/mock_server.py deleted file mode 100644 index 4bf2450..0000000 --- a/pi/mock_server.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -"""Mock WebSocket server that sends randomized Pi stats every 2 seconds.""" - -import asyncio -import json -import random -import time - -import websockets - - -def generate_stats(): - services = [ - {"name": "docker", "status": random.choice(["running", "running", "running", "stopped"])}, - {"name": "pihole", "status": random.choice(["running", "running", "running", "stopped"])}, - {"name": "nginx", "status": random.choice(["running", "running", "stopped"])}, - {"name": "sshd", "status": "running"}, - ] - return { - "cpu_pct": round(random.uniform(5, 95), 1), - "mem_pct": round(random.uniform(30, 85), 1), - "mem_used_mb": random.randint(512, 3200), - "disk_pct": round(random.uniform(20, 80), 1), - "cpu_temp": round(random.uniform(35, 78), 1), - "uptime_hrs": round(random.uniform(1, 2000), 1), - "net_rx_kbps": round(random.uniform(0, 5000), 1), - "net_tx_kbps": round(random.uniform(0, 2000), 1), - "services": services, - "timestamp": int(time.time()), - } - - -async def handler(websocket): - addr = websocket.remote_address - print(f"Client connected: {addr}") - try: - while True: - stats = generate_stats() - await websocket.send(json.dumps(stats)) - await asyncio.sleep(2) - except websockets.ConnectionClosed: - print(f"Client disconnected: {addr}") - - -async def main(): - print("Mock Pi stats server starting on ws://0.0.0.0:8765") - async with websockets.serve(handler, "0.0.0.0", 8765): - await asyncio.Future() # run forever - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/pi/requirements.txt b/pi/requirements.txt deleted file mode 100644 index ec809d2..0000000 --- a/pi/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -websockets>=12.0 -psutil>=5.9.0 -Pillow>=10.0 diff --git a/pi/run_all.py b/pi/run_all.py deleted file mode 100644 index 2de26d7..0000000 --- a/pi/run_all.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -"""Launch stats_server and contents_server as child processes.""" -import subprocess, sys, signal -from pathlib import Path - -d = Path(__file__).parent - -# Forward any CLI args (e.g. --config) to contents_server -extra_args = sys.argv[1:] - -procs = [ - subprocess.Popen([sys.executable, d / "stats_server.py"]), - subprocess.Popen([sys.executable, d / "contents_server.py"] + extra_args), -] -signal.signal(signal.SIGINT, lambda *_: [p.terminate() for p in procs]) -signal.signal(signal.SIGTERM, lambda *_: [p.terminate() for p in procs]) -print(f"Running stats_server (PID {procs[0].pid}) + contents_server (PID {procs[1].pid})") -for p in procs: - p.wait() diff --git a/pi/stats_server.py b/pi/stats_server.py deleted file mode 100644 index 9a93757..0000000 --- a/pi/stats_server.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""WebSocket server that sends real Pi system stats every 2 seconds. - -Drop-in replacement for mock_server.py. Same port (8765), same JSON schema, -same 2s push interval. Services remain mocked until systemd integration is added. -""" - -import asyncio -import json -import subprocess -import time -from datetime import datetime -from pathlib import Path - -import psutil -import websockets - -# Prime the CPU percent counter (first call always returns 0.0) -psutil.cpu_percent(interval=None) - -# Network baseline for delta calculation -_prev_net = psutil.net_io_counters() -_prev_net_time = time.monotonic() - - -def _get_cpu_temp() -> float: - """Read CPU temperature with fallback for different Pi OS versions.""" - try: - temps = psutil.sensors_temperatures() - if "cpu_thermal" in temps and temps["cpu_thermal"]: - return round(temps["cpu_thermal"][0].current, 1) - except (AttributeError, KeyError): - pass - - # Fallback: read sysfs directly (value is in millidegrees) - thermal_path = Path("/sys/class/thermal/thermal_zone0/temp") - try: - millidegrees = int(thermal_path.read_text().strip()) - return round(millidegrees / 1000.0, 1) - except (FileNotFoundError, ValueError, PermissionError): - return 0.0 - - -def _get_net_throughput() -> tuple[float, float]: - """Calculate network rx/tx in kbps since last call.""" - global _prev_net, _prev_net_time - - now = time.monotonic() - current = psutil.net_io_counters() - elapsed = now - _prev_net_time - - if elapsed <= 0: - return 0.0, 0.0 - - rx_kbps = round((current.bytes_recv - _prev_net.bytes_recv) * 8 / (elapsed * 1000), 1) - tx_kbps = round((current.bytes_sent - _prev_net.bytes_sent) * 8 / (elapsed * 1000), 1) - - _prev_net = current - _prev_net_time = now - - return rx_kbps, tx_kbps - -# only services that matter -SERVICES_ALIASES = { - "gitea": "gitea", - "samba": "samba", - "pihole": "pihole", - "qbittorrent": "qbittorrent", - "frpc-primary": "frpc (ny)", - "pinepods": "pinepods", - "frpc-ssh": "frpc (ssh)", - "jellyfin": "jellyfin", -} -def _get_docker_services() -> list[dict]: - """Query Docker for real container statuses with ternary status model.""" - try: - result = subprocess.run( - ["docker", "ps", "-a", "--format", "{{.Names}}\t{{.Status}}"], - capture_output=True, text=True, timeout=5, - ) - except (subprocess.TimeoutExpired, FileNotFoundError, OSError): - return [] - - if result.returncode != 0: - return [] - - services = [] - for line in result.stdout.strip().splitlines(): - parts = line.split("\t", 1) - if len(parts) != 2: - continue - name, raw_status = parts - - if (name in SERVICES_ALIASES): - if raw_status.startswith("Up"): - if "unhealthy" in raw_status or "Restarting" in raw_status: - status = "warning" - else: - status = "running" - else: - status = "stopped" - services.append({"name": SERVICES_ALIASES[name], "status": status}) - - # Sort: warnings first, then stopped, then running (problems float to top) - order = {"warning": 0, "stopped": 1, "running": 2} - services.sort(key=lambda s: order.get(s["status"], 3)) - - return services - - -def _local_time_fields() -> dict: - """Current local time as broken-down fields for RTC sync.""" - now = datetime.now() - return { - "y": now.year, - "mo": now.month, - "d": now.day, - "h": now.hour, - "m": now.minute, - "s": now.second, - } - - -def generate_stats() -> dict: - mem = psutil.virtual_memory() - disk = psutil.disk_usage("/mnt/buffalo") - rx_kbps, tx_kbps = _get_net_throughput() - - return { - "cpu_pct": psutil.cpu_percent(interval=None), - "mem_pct": round(mem.percent, 1), - "mem_used_mb": int(mem.used // (1024 * 1024)), - "disk_pct": round(disk.percent, 1), - "cpu_temp": _get_cpu_temp(), - "uptime_hrs": round((time.time() - psutil.boot_time()) / 3600, 1), - "net_rx_kbps": rx_kbps / 8, - "net_tx_kbps": tx_kbps / 8, # kByte/s for humans - "services": _get_docker_services(), - "timestamp": int(time.time()), - "local_time": _local_time_fields(), - } - - -async def handler(websocket): - addr = websocket.remote_address - print(f"Client connected: {addr}") - try: - while True: - stats = generate_stats() - await websocket.send(json.dumps(stats)) - await asyncio.sleep(2) - except websockets.ConnectionClosed: - print(f"Client disconnected: {addr}") - - -async def main(): - print("Pi stats server starting on ws://0.0.0.0:8765") - async with websockets.serve(handler, "0.0.0.0", 8765): - await asyncio.Future() # run forever - - -if __name__ == "__main__": - asyncio.run(main())