Compare commits
4 Commits
e05773450a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 595f9916ac | |||
| 37a09ef66b | |||
| 7ec68fec84 | |||
| 7cd683722b |
36
PLAN.md
36
PLAN.md
@@ -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
|
||||
@@ -121,7 +121,7 @@ Example with two alarms:
|
||||
| `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 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`. |
|
||||
|
||||
If both `alarm_days` and `alarm_dates` are present, `alarm_days` takes priority.
|
||||
|
||||
@@ -9,5 +9,10 @@
|
||||
"alarm_time": "2330",
|
||||
"alarm_audio": "assets/alarm/sleep.wav",
|
||||
"alarm_image": "assets/img/sleep.png"
|
||||
},
|
||||
{
|
||||
"alarm_time": "0800",
|
||||
"alarm_days": ["Sat", "Sun"],
|
||||
"alarm_image": "assets/img/on_alarm.png"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -48,12 +48,22 @@ def _resolve_path(relative: str) -> Path:
|
||||
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."""
|
||||
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)
|
||||
|
||||
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,
|
||||
@@ -71,7 +81,8 @@ async def handler(ws):
|
||||
img_idle = load_status_image(IMG_DIR / "idle.png")
|
||||
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():
|
||||
nonlocal current_img
|
||||
@@ -91,10 +102,15 @@ async def handler(ws):
|
||||
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"])
|
||||
# let the image persist a bit more
|
||||
await asyncio.sleep(1)
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user