149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
"""
|
|
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, audio_cache: dict[Path, tuple]) -> dict:
|
|
"""Pre-resolve paths and load resources for a single alarm entry."""
|
|
alarm_img_path = _resolve_path(entry.get("alarm_image", "assets/img/on_alarm.png"))
|
|
img = load_status_image(alarm_img_path)
|
|
|
|
pcm = sr = ch = bits = None
|
|
raw_audio = entry.get("alarm_audio")
|
|
if raw_audio is not None:
|
|
audio_path = find_wav(_resolve_path(raw_audio))
|
|
if audio_path in audio_cache:
|
|
log.info("Reusing cached audio for %s", audio_path)
|
|
pcm, sr, ch, bits = audio_cache[audio_path]
|
|
else:
|
|
pcm, sr, ch, bits = read_wav(audio_path)
|
|
audio_cache[audio_path] = (pcm, sr, ch, bits)
|
|
|
|
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
|
|
|
|
audio_cache: dict[Path, tuple] = {}
|
|
alarms = [_prepare_alarm(entry, audio_cache) 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)
|
|
if alarm["pcm"] is not None:
|
|
await stream_alarm(ws, alarm["pcm"], alarm["sr"],
|
|
alarm["ch"], alarm["bits"])
|
|
# let the image persist a bit more
|
|
await asyncio.sleep(1)
|
|
else:
|
|
# longer image persistence when no audio
|
|
await asyncio.sleep(3)
|
|
|
|
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())
|