diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..3c85d2f --- /dev/null +++ b/PLAN.md @@ -0,0 +1,71 @@ +# Pi Servers -- Roadmap + +## Alarm Configuration + +Replace the current `randint(30, 60)` test loop in `contents_server.py` with real schedule-driven alarms. + +### Config schema + +File: `pi/alarm_config.json` + +```json +{ + "alarm_time": "0700", + "alarm_days": ["Mon", "Tue", "Wed", "Thu", "Fri"], + "alarm_dates": ["07/20", "10/21"], + "alarm_audio": "assets/alarm/alarm_test.wav", + "alarm_image": "assets/img/on_alarm.png" +} +``` + +### Field definitions + +| Field | Type | Description | +|-------|------|-------------| +| `alarm_time` | `string` | 4-digit HHMM (24-hour). Triggers on the 0th second of the minute, or as soon as Python detects it. | +| `alarm_days` | `string[]` | (Optional) 3-letter day abbreviations: `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`, `Sun`. Alarm only fires on listed days. If not supplied, alarms every day. | +| `alarm_dates` | `string[]` | (Optional) Strings of `MM/DD` format. If both `alarm_days` and `alarm_dates` are set, only `alarm_days` is effective. | +| `alarm_audio` | `string` | Path to WAV file. Relative paths resolve from `pi/`. | +| `alarm_image` | `string` | Path to status PNG shown during alarm. Relative paths resolve from `pi/`. If not supplied, default to `assets/img/on_alarm.png` | + +### Behavior + +- On startup, load and validate `alarm_config.json` +- Each tick (~5s), check if current local time matches `alarm_time` and today's day name is in `alarm_days`, or if today's date is in `alarm_dates`. +- Fire alarm once per matched minute (debounce so it doesn't re-trigger within the same minute) +- After alarm completes, return to idle image and resume schedule checking + +## 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 `alarm_config.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 `docs/ALARM_PROTOCOL.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/README.md b/README.md new file mode 100644 index 0000000..a3c9b6a --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# 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 + requirements.txt + 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 +``` + +Or run individually: + +``` +python stats_server.py # port 8765 only +python contents_server.py # port 8766 only +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"}` + +On connect, sends `idle.png` as the status image. Alarm cycles switch to `on_alarm.png` during playback, then back to `idle.png`. + +### 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. + +## Modules + +### audio_handler.py + +- `find_wav()` -- finds the first `.wav` 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 diff --git a/audio_handler.py b/audio_handler.py new file mode 100644 index 0000000..444c876 --- /dev/null +++ b/audio_handler.py @@ -0,0 +1,64 @@ +"""Audio alarm functions — WAV loading and PCM streaming.""" + +import asyncio +import json +import logging +import wave +from pathlib import Path + +log = logging.getLogger(__name__) + +CHUNK_SIZE = 4096 +AUDIO_DIR = Path(__file__).parent / "assets" / "alarm" + + +def find_wav() -> Path: + """Find the first .wav file in the alarm assets directory.""" + 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 read_wav(path: Path) -> tuple[bytes, int, int, int]: + """Read WAV file and return (pcm_data, sample_rate, channels, bits_per_sample).""" + with wave.open(str(path), "rb") as 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)) + 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/audio_server.py b/audio_server.py deleted file mode 100644 index e41d226..0000000 --- a/audio_server.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Alarm audio streaming test server. - -Streams a WAV file as raw PCM chunks over WebSocket on port 8766. -Repeats every 30-60 seconds to exercise the ESP32 audio pipeline. - -Protocol: - 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 asyncio -import json -import logging -import struct -import wave -from pathlib import Path -from random import randint - -import websockets -from PIL import Image - -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") -log = logging.getLogger("audio_server") - -PORT = 8766 -CHUNK_SIZE = 4096 -AUDIO_DIR = Path(__file__).parent / "assets" / "alarm" -IMG_DIR = Path(__file__).parent / "assets" / "img" -STATUS_IMG_SIZE = 120 - - -def find_wav() -> Path: - """Find the first .wav file in the alarm assets directory.""" - 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 read_wav(path: Path) -> tuple[bytes, int, int, int]: - """Read WAV file and return (pcm_data, sample_rate, channels, bits_per_sample).""" - with wave.open(str(path), "rb") as 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)) - return pcm, sr, ch, bits - - -def load_status_image(path: Path) -> bytes: - """Load a PNG, convert to 1-bit 120x120 monochrome bitmap (MSB-first, black=1).""" - img = Image.open(path).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 (< 128) → 1, white → 0 - bw = canvas.point(lambda p: 1 if p < 128 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)) - - -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.""" - # Compute pacing: how long each chunk represents in seconds - 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 - 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) - - # Stream PCM chunks - for i, chunk in enumerate(chunk_bytes(pcm, CHUNK_SIZE)): - await ws.send(chunk) # bytes → binary frame - await asyncio.sleep(pace_delay) - - # Stop - await ws.send(json.dumps({"type": "alarm_stop"})) - log.info("Sent alarm_stop") - - -async def handler(ws): - """Handle a single WebSocket connection.""" - remote = ws.remote_address - log.info("Client connected: %s:%d", remote[0], remote[1]) - - wav_path = find_wav() - pcm, sr, ch, bits = read_wav(wav_path) - - # Load status images - img_idle = load_status_image(IMG_DIR / "idle.png") - img_alarm = load_status_image(IMG_DIR / "on_alarm.png") - - try: - # Send idle image on connect - await send_status_image(ws, img_idle) - - while True: - delay = randint(30, 60) - log.info("Next alarm in %ds", delay) - await asyncio.sleep(delay) - - # Switch to alarm image before audio - await send_status_image(ws, img_alarm) - await stream_alarm(ws, pcm, sr, ch, bits) - # Switch back to idle after alarm - await send_status_image(ws, img_idle) - except websockets.exceptions.ConnectionClosed: - log.info("Client disconnected: %s:%d", remote[0], remote[1]) - - -async def main(): - log.info("Audio server starting on port %d", PORT) - async with websockets.serve(handler, "0.0.0.0", PORT): - await asyncio.Future() # run forever - - -if __name__ == "__main__": - main_loop = asyncio.run(main()) diff --git a/contents_server.py b/contents_server.py new file mode 100644 index 0000000..f1272e5 --- /dev/null +++ b/contents_server.py @@ -0,0 +1,70 @@ +""" +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":120,"height":120} + 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 asyncio +import logging +from random import randint + +import websockets + +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 + + +async def handler(ws): + """Handle a single WebSocket connection.""" + remote = ws.remote_address + log.info("Client connected: %s:%d", remote[0], remote[1]) + + wav_path = find_wav() + pcm, sr, ch, bits = read_wav(wav_path) + + # Load status images + img_idle = load_status_image(IMG_DIR / "idle.png") + img_alarm = load_status_image(IMG_DIR / "on_alarm.png") + + try: + # Send idle image on connect + await send_status_image(ws, img_idle) + + while True: + delay = randint(30, 60) + log.info("Next alarm in %ds", delay) + await asyncio.sleep(delay) + + # Switch to alarm image before audio + await send_status_image(ws, img_alarm) + await stream_alarm(ws, pcm, sr, ch, bits) + # Switch back to idle after alarm + await send_status_image(ws, img_idle) + 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() # run forever + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/image_handler.py b/image_handler.py new file mode 100644 index 0000000..3faccb5 --- /dev/null +++ b/image_handler.py @@ -0,0 +1,52 @@ +"""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 = 120 +MONOCHROME_THRESHOLD = 120 + + +def load_status_image(path: Path) -> bytes: + """Load a PNG, convert to 1-bit 120x120 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 (< 128) -> 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/run_all.py b/run_all.py index a6ba7e9..a4e5151 100644 --- a/run_all.py +++ b/run_all.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 -"""Launch stats_server and audio_server as child processes.""" +"""Launch stats_server and contents_server as child processes.""" import subprocess, sys, signal from pathlib import Path d = Path(__file__).parent procs = [ subprocess.Popen([sys.executable, d / "stats_server.py"]), - subprocess.Popen([sys.executable, d / "audio_server.py"]), + subprocess.Popen([sys.executable, d / "contents_server.py"]), ] 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}) + audio_server (PID {procs[1].pid})") +print(f"Running stats_server (PID {procs[0].pid}) + contents_server (PID {procs[1].pid})") for p in procs: p.wait()