diff --git a/PLAN.md b/PLAN.md index 8df4053..f3d2880 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,40 +1,5 @@ # 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` | (Optional) Path to WAV file. Relative paths resolve from `pi/`. If not supplied, defaults to `assets/alarm/alarm_test.wav`. | -| `alarm_image` | `string` | (Optional) Path to status PNG shown during alarm. Relative paths resolve from `pi/`. If not supplied, defaults 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. @@ -48,7 +13,7 @@ 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 +- 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` @@ -61,7 +26,7 @@ The `pi/` directory will become its own git repository. 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` +3. The interface contract between the two repos is the WebSocket protocol -- JSON schemas and binary frame formats documented in `README.md` ### Benefits diff --git a/README.md b/README.md index a3c9b6a..d098fee 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ pi/ 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) @@ -33,15 +36,16 @@ Dependencies: `websockets`, `psutil`, `Pillow` Start both servers: ``` -python run_all.py +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 # port 8766 only -python mock_server.py # port 8765, random data (no psutil needed) +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 @@ -69,7 +73,7 @@ Serves alarm audio and status images. Protocol: 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`. +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 @@ -77,11 +81,43 @@ Same JSON schema and 2-second push interval as `stats_server.py`, but all values 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()` -- finds the first `.wav` in `assets/alarm/` +- `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 @@ -89,3 +125,8 @@ Does not include `local_time` fields. - `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/stats_server.py b/stats_server.py index cc7963f..7ec2408 100644 --- a/stats_server.py +++ b/stats_server.py @@ -68,6 +68,8 @@ def _mock_services() -> list[dict]: {"name": "pihole", "status": random.choice(["running", "running", "running", "stopped"])}, {"name": "nginx", "status": random.choice(["running", "running", "stopped"])}, {"name": "sshd", "status": "running"}, + {"name": "ph1", "status": "running"}, + {"name": "ph2", "status": "stopped"}, ] @@ -86,7 +88,7 @@ def _local_time_fields() -> dict: def generate_stats() -> dict: mem = psutil.virtual_memory() - disk = psutil.disk_usage("/") + disk = psutil.disk_usage("/mnt/buffalo") rx_kbps, tx_kbps = _get_net_throughput() return {