""" 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"]) 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())