Compare commits

...

4 Commits

Author SHA1 Message Date
595f9916ac minor qol 2026-02-18 10:17:30 +09:00
37a09ef66b silent alarms available 2026-02-17 12:45:21 +09:00
7ec68fec84 new alarm scheme 2026-02-17 12:39:14 +09:00
7cd683722b removed PLAN.md 2026-02-17 12:37:31 +09:00
6 changed files with 30 additions and 45 deletions

36
PLAN.md
View File

@@ -1,36 +0,0 @@
# Pi Servers -- Roadmap
## 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 `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`
## 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 `README.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

View File

@@ -121,7 +121,7 @@ Example with two alarms:
| `alarm_time` | `string` | Yes | 4-digit HHMM, 24-hour. Fires on the matched minute. | | `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_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_dates` | `string[]` | No | `MM/DD` strings. Ignored if `alarm_days` is also set. |
| `alarm_audio` | `string` | No | WAV path, relative to project root. Default: `assets/alarm/alarm_test.wav`. | | `alarm_audio` | `string` | No | WAV path, relative to project root. Silent if not set. "default" (case-insensitive) uses `assets/alarm/alarm.wav`. |
| `alarm_image` | `string` | No | Status PNG path, relative to project root. Default: `assets/img/on_alarm.png`. | | `alarm_image` | `string` | No | Status PNG path, relative to project root. Default: `assets/img/on_alarm.png`. |
If both `alarm_days` and `alarm_dates` are present, `alarm_days` takes priority. If both `alarm_days` and `alarm_dates` are present, `alarm_days` takes priority.

View File

@@ -9,5 +9,10 @@
"alarm_time": "2330", "alarm_time": "2330",
"alarm_audio": "assets/alarm/sleep.wav", "alarm_audio": "assets/alarm/sleep.wav",
"alarm_image": "assets/img/sleep.png" "alarm_image": "assets/img/sleep.png"
},
{
"alarm_time": "0800",
"alarm_days": ["Sat", "Sun"],
"alarm_image": "assets/img/on_alarm.png"
} }
] ]

View File

@@ -48,12 +48,22 @@ def _resolve_path(relative: str) -> Path:
return p return p
def _prepare_alarm(entry: dict) -> dict: def _prepare_alarm(entry: dict, audio_cache: dict[Path, tuple]) -> dict:
"""Pre-resolve paths and load resources for a single alarm entry.""" """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")) 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) 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 { return {
"config": entry, "config": entry,
"pcm": pcm, "sr": sr, "ch": ch, "bits": bits, "pcm": pcm, "sr": sr, "ch": ch, "bits": bits,
@@ -71,7 +81,8 @@ async def handler(ws):
img_idle = load_status_image(IMG_DIR / "idle.png") img_idle = load_status_image(IMG_DIR / "idle.png")
current_img = img_idle current_img = img_idle
alarms = [_prepare_alarm(entry) for entry in configs] if configs else [] audio_cache: dict[Path, tuple] = {}
alarms = [_prepare_alarm(entry, audio_cache) for entry in configs] if configs else []
async def alarm_ticker(): async def alarm_ticker():
nonlocal current_img nonlocal current_img
@@ -91,10 +102,15 @@ async def handler(ws):
alarm["config"]["alarm_time"], current_minute) alarm["config"]["alarm_time"], current_minute)
current_img = alarm["img"] current_img = alarm["img"]
await send_status_image(ws, current_img) await send_status_image(ws, current_img)
if alarm["pcm"] is not None:
await stream_alarm(ws, alarm["pcm"], alarm["sr"], await stream_alarm(ws, alarm["pcm"], alarm["sr"],
alarm["ch"], alarm["bits"]) alarm["ch"], alarm["bits"])
# let the image persist a bit more # let the image persist a bit more
await asyncio.sleep(1) await asyncio.sleep(1)
else:
# longer image persistence when no audio
await asyncio.sleep(3)
current_img = img_idle current_img = img_idle
await send_status_image(ws, current_img) await send_status_image(ws, current_img)