Files
pi-dashboard-server/contents_server.py

109 lines
3.4 KiB
Python
Raw Normal View History

2026-02-15 22:14:06 +09:00
"""
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"}
"""
2026-02-15 22:44:36 +09:00
import argparse
2026-02-15 22:14:06 +09:00
import asyncio
import logging
2026-02-15 22:44:36 +09:00
from datetime import datetime
from pathlib import Path
2026-02-15 22:14:06 +09:00
import websockets
2026-02-15 22:44:36 +09:00
from alarm_scheduler import DEFAULT_CONFIG_PATH, load_config, should_fire
2026-02-15 22:14:06 +09:00
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
2026-02-15 22:44:36 +09:00
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
2026-02-15 22:14:06 +09:00
async def handler(ws):
"""Handle a single WebSocket connection."""
remote = ws.remote_address
log.info("Client connected: %s:%d", remote[0], remote[1])
2026-02-15 22:44:36 +09:00
config = load_config(_config_path)
# Resolve audio and image paths from config (or defaults)
if config:
audio_path = find_wav(_resolve_path(config.get("alarm_audio", "assets/alarm/alarm_test.wav")))
alarm_img_path = _resolve_path(config.get("alarm_image", "assets/img/on_alarm.png"))
else:
audio_path = None
alarm_img_path = IMG_DIR / "on_alarm.png"
2026-02-15 22:14:06 +09:00
img_idle = load_status_image(IMG_DIR / "idle.png")
2026-02-15 22:44:36 +09:00
img_alarm = load_status_image(alarm_img_path)
2026-02-15 22:14:06 +09:00
try:
await send_status_image(ws, img_idle)
2026-02-15 22:44:36 +09:00
if not config:
log.info("No alarms configured — idling forever")
await asyncio.Future()
return
pcm, sr, ch, bits = read_wav(audio_path)
last_fired_minute = None
2026-02-15 22:14:06 +09:00
while True:
2026-02-15 22:44:36 +09:00
if should_fire(config):
current_minute = datetime.now().strftime("%Y%m%d%H%M")
if current_minute != last_fired_minute:
last_fired_minute = current_minute
log.info("Alarm firing at %s", current_minute)
await send_status_image(ws, img_alarm)
await stream_alarm(ws, pcm, sr, ch, bits)
await send_status_image(ws, img_idle)
await asyncio.sleep(TICK_INTERVAL)
2026-02-15 22:14:06 +09:00
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):
2026-02-15 22:44:36 +09:00
await asyncio.Future()
2026-02-15 22:14:06 +09:00
if __name__ == "__main__":
2026-02-15 22:44:36 +09:00
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
2026-02-15 22:14:06 +09:00
asyncio.run(main())