pi features docs
This commit is contained in:
@@ -273,6 +273,8 @@ static void create_main_section(lv_obj_t *parent)
|
|||||||
img_status = lv_img_create(parent);
|
img_status = lv_img_create(parent);
|
||||||
lv_obj_set_pos(img_status, 280, 156);
|
lv_obj_set_pos(img_status, 280, 156);
|
||||||
lv_obj_set_size(img_status, 120, 120);
|
lv_obj_set_size(img_status, 120, 120);
|
||||||
|
lv_obj_set_style_bg_color(img_status, lv_color_white(), 0);
|
||||||
|
lv_obj_set_style_bg_opa(img_status, LV_OPA_COVER, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void create_bottom_bar(lv_obj_t *parent)
|
static void create_bottom_bar(lv_obj_t *parent)
|
||||||
|
|||||||
71
pi/PLAN.md
Normal file
71
pi/PLAN.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 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` | Path to WAV file. Relative paths resolve from `pi/`. |
|
||||||
|
| `alarm_image` | `string` | Path to status PNG shown during alarm. Relative paths resolve from `pi/`. If not supplied, default 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.
|
||||||
|
|
||||||
|
### 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 `alarm_config.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 `docs/ALARM_PROTOCOL.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
|
||||||
91
pi/README.md
Normal file
91
pi/README.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Pi Dashboard Servers
|
||||||
|
|
||||||
|
WebSocket servers that feed system stats, alarm audio, and status images to the ESP32-S3 RLCD dashboard.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pi/
|
||||||
|
run_all.py # Launches both servers as child processes
|
||||||
|
stats_server.py # Real system stats over WebSocket (port 8765)
|
||||||
|
contents_server.py # Alarm audio + status images over WebSocket (port 8766)
|
||||||
|
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
|
||||||
|
requirements.txt
|
||||||
|
assets/
|
||||||
|
alarm/ # WAV files for alarm audio
|
||||||
|
img/ # Status images (idle.png, on_alarm.png)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Python 3.10+
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies: `websockets`, `psutil`, `Pillow`
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
Start both servers:
|
||||||
|
|
||||||
|
```
|
||||||
|
python run_all.py
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Servers
|
||||||
|
|
||||||
|
### stats_server.py -- port 8765
|
||||||
|
|
||||||
|
Pushes a JSON object every 2 seconds with real system metrics from `psutil`:
|
||||||
|
|
||||||
|
- `cpu_pct`, `mem_pct`, `mem_used_mb`, `disk_pct`
|
||||||
|
- `cpu_temp` (reads `/sys/class/thermal/` as fallback)
|
||||||
|
- `uptime_hrs`, `net_rx_kbps`, `net_tx_kbps`
|
||||||
|
- `services` (mocked until systemd integration)
|
||||||
|
- `local_time` fields for RTC sync (`y`, `mo`, `d`, `h`, `m`, `s`)
|
||||||
|
|
||||||
|
### contents_server.py -- port 8766
|
||||||
|
|
||||||
|
Serves alarm audio and status images. Protocol:
|
||||||
|
|
||||||
|
**Status image:**
|
||||||
|
1. Text frame: `{"type":"status_image","width":120,"height":120}`
|
||||||
|
2. Binary frame: 1-bit monochrome bitmap (1800 bytes)
|
||||||
|
|
||||||
|
**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"}`
|
||||||
|
|
||||||
|
On connect, sends `idle.png` as the status image. Alarm cycles switch to `on_alarm.png` during playback, then back to `idle.png`.
|
||||||
|
|
||||||
|
### mock_server.py -- port 8765
|
||||||
|
|
||||||
|
Same JSON schema and 2-second push interval as `stats_server.py`, but all values are randomized. No `psutil` dependency -- useful for development on non-Pi machines.
|
||||||
|
|
||||||
|
Does not include `local_time` fields.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
### audio_handler.py
|
||||||
|
|
||||||
|
- `find_wav()` -- finds the first `.wav` 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
|
||||||
|
|
||||||
|
### image_handler.py
|
||||||
|
|
||||||
|
- `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
|
||||||
64
pi/audio_handler.py
Normal file
64
pi/audio_handler.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Audio alarm functions — WAV loading and PCM streaming."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHUNK_SIZE = 4096
|
||||||
|
AUDIO_DIR = Path(__file__).parent / "assets" / "alarm"
|
||||||
|
|
||||||
|
|
||||||
|
def find_wav() -> Path:
|
||||||
|
"""Find the first .wav file in the alarm assets directory."""
|
||||||
|
wavs = list(AUDIO_DIR.glob("*.wav"))
|
||||||
|
if not wavs:
|
||||||
|
raise FileNotFoundError(f"No .wav files found in {AUDIO_DIR}")
|
||||||
|
log.info("Using audio file: %s", wavs[0].name)
|
||||||
|
return wavs[0]
|
||||||
|
|
||||||
|
|
||||||
|
def read_wav(path: Path) -> tuple[bytes, int, int, int]:
|
||||||
|
"""Read WAV file and return (pcm_data, sample_rate, channels, bits_per_sample)."""
|
||||||
|
with wave.open(str(path), "rb") as wf:
|
||||||
|
sr = wf.getframerate()
|
||||||
|
ch = wf.getnchannels()
|
||||||
|
bits = wf.getsampwidth() * 8
|
||||||
|
pcm = wf.readframes(wf.getnframes())
|
||||||
|
log.info("WAV loaded: %dHz %dch %dbit, %.1fs, %d bytes",
|
||||||
|
sr, ch, bits, len(pcm) / (sr * ch * (bits // 8)), len(pcm))
|
||||||
|
return pcm, sr, ch, bits
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_bytes(data: bytes, size: int):
|
||||||
|
"""Yield data in fixed-size chunks."""
|
||||||
|
for i in range(0, len(data), size):
|
||||||
|
yield data[i : i + size]
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_alarm(ws, pcm: bytes, sr: int, ch: int, bits: int):
|
||||||
|
"""Stream one alarm cycle to the connected client."""
|
||||||
|
bytes_per_sec = sr * ch * (bits // 8)
|
||||||
|
chunk_duration = CHUNK_SIZE / bytes_per_sec
|
||||||
|
pace_delay = chunk_duration * 0.9 # 90% real-time to avoid underrun
|
||||||
|
|
||||||
|
total_chunks = (len(pcm) + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||||
|
|
||||||
|
start_msg = json.dumps({
|
||||||
|
"type": "alarm_start",
|
||||||
|
"sample_rate": sr,
|
||||||
|
"channels": ch,
|
||||||
|
"bits": bits,
|
||||||
|
})
|
||||||
|
await ws.send(start_msg)
|
||||||
|
log.info("Sent alarm_start (%d chunks, pace %.1fms)", total_chunks, pace_delay * 1000)
|
||||||
|
|
||||||
|
for i, chunk in enumerate(chunk_bytes(pcm, CHUNK_SIZE)):
|
||||||
|
await ws.send(chunk)
|
||||||
|
await asyncio.sleep(pace_delay)
|
||||||
|
|
||||||
|
await ws.send(json.dumps({"type": "alarm_stop"}))
|
||||||
|
log.info("Sent alarm_stop")
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""
|
|
||||||
Alarm audio streaming test server.
|
|
||||||
|
|
||||||
Streams a WAV file as raw PCM chunks over WebSocket on port 8766.
|
|
||||||
Repeats every 30-60 seconds to exercise the ESP32 audio pipeline.
|
|
||||||
|
|
||||||
Protocol:
|
|
||||||
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 asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import struct
|
|
||||||
import wave
|
|
||||||
from pathlib import Path
|
|
||||||
from random import randint
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
||||||
log = logging.getLogger("audio_server")
|
|
||||||
|
|
||||||
PORT = 8766
|
|
||||||
CHUNK_SIZE = 4096
|
|
||||||
AUDIO_DIR = Path(__file__).parent / "assets" / "alarm"
|
|
||||||
IMG_DIR = Path(__file__).parent / "assets" / "img"
|
|
||||||
STATUS_IMG_SIZE = 120
|
|
||||||
|
|
||||||
|
|
||||||
def find_wav() -> Path:
|
|
||||||
"""Find the first .wav file in the alarm assets directory."""
|
|
||||||
wavs = list(AUDIO_DIR.glob("*.wav"))
|
|
||||||
if not wavs:
|
|
||||||
raise FileNotFoundError(f"No .wav files found in {AUDIO_DIR}")
|
|
||||||
log.info("Using audio file: %s", wavs[0].name)
|
|
||||||
return wavs[0]
|
|
||||||
|
|
||||||
|
|
||||||
def read_wav(path: Path) -> tuple[bytes, int, int, int]:
|
|
||||||
"""Read WAV file and return (pcm_data, sample_rate, channels, bits_per_sample)."""
|
|
||||||
with wave.open(str(path), "rb") as wf:
|
|
||||||
sr = wf.getframerate()
|
|
||||||
ch = wf.getnchannels()
|
|
||||||
bits = wf.getsampwidth() * 8
|
|
||||||
pcm = wf.readframes(wf.getnframes())
|
|
||||||
log.info("WAV loaded: %dHz %dch %dbit, %.1fs, %d bytes",
|
|
||||||
sr, ch, bits, len(pcm) / (sr * ch * (bits // 8)), len(pcm))
|
|
||||||
return pcm, sr, ch, bits
|
|
||||||
|
|
||||||
|
|
||||||
def load_status_image(path: Path) -> bytes:
|
|
||||||
"""Load a PNG, convert to 1-bit 120x120 monochrome bitmap (MSB-first, black=1)."""
|
|
||||||
img = Image.open(path).convert("L")
|
|
||||||
|
|
||||||
# Resize to fit within 120x120, preserving aspect ratio
|
|
||||||
img.thumbnail((STATUS_IMG_SIZE, STATUS_IMG_SIZE), Image.LANCZOS)
|
|
||||||
|
|
||||||
# Paste centered onto white canvas
|
|
||||||
canvas = Image.new("L", (STATUS_IMG_SIZE, STATUS_IMG_SIZE), 255)
|
|
||||||
x_off = (STATUS_IMG_SIZE - img.width) // 2
|
|
||||||
y_off = (STATUS_IMG_SIZE - img.height) // 2
|
|
||||||
canvas.paste(img, (x_off, y_off))
|
|
||||||
|
|
||||||
# Threshold to 1-bit: black (< 128) → 1, white → 0
|
|
||||||
bw = canvas.point(lambda p: 1 if p < 128 else 0, "1")
|
|
||||||
raw = bw.tobytes()
|
|
||||||
log.info("Status image loaded: %s → %d bytes", path.name, len(raw))
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
async def send_status_image(ws, img_bytes: bytes):
|
|
||||||
"""Send a status image over the WebSocket (text header + binary payload)."""
|
|
||||||
header = json.dumps({"type": "status_image", "width": STATUS_IMG_SIZE, "height": STATUS_IMG_SIZE})
|
|
||||||
await ws.send(header)
|
|
||||||
await ws.send(img_bytes)
|
|
||||||
log.info("Sent status image (%d bytes)", len(img_bytes))
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_bytes(data: bytes, size: int):
|
|
||||||
"""Yield data in fixed-size chunks."""
|
|
||||||
for i in range(0, len(data), size):
|
|
||||||
yield data[i : i + size]
|
|
||||||
|
|
||||||
|
|
||||||
async def stream_alarm(ws, pcm: bytes, sr: int, ch: int, bits: int):
|
|
||||||
"""Stream one alarm cycle to the connected client."""
|
|
||||||
# Compute pacing: how long each chunk represents in seconds
|
|
||||||
bytes_per_sec = sr * ch * (bits // 8)
|
|
||||||
chunk_duration = CHUNK_SIZE / bytes_per_sec
|
|
||||||
pace_delay = chunk_duration * 0.9 # 90% real-time to avoid underrun
|
|
||||||
|
|
||||||
total_chunks = (len(pcm) + CHUNK_SIZE - 1) // CHUNK_SIZE
|
|
||||||
|
|
||||||
# Start
|
|
||||||
start_msg = json.dumps({
|
|
||||||
"type": "alarm_start",
|
|
||||||
"sample_rate": sr,
|
|
||||||
"channels": ch,
|
|
||||||
"bits": bits,
|
|
||||||
})
|
|
||||||
await ws.send(start_msg)
|
|
||||||
log.info("Sent alarm_start (%d chunks, pace %.1fms)", total_chunks, pace_delay * 1000)
|
|
||||||
|
|
||||||
# Stream PCM chunks
|
|
||||||
for i, chunk in enumerate(chunk_bytes(pcm, CHUNK_SIZE)):
|
|
||||||
await ws.send(chunk) # bytes → binary frame
|
|
||||||
await asyncio.sleep(pace_delay)
|
|
||||||
|
|
||||||
# Stop
|
|
||||||
await ws.send(json.dumps({"type": "alarm_stop"}))
|
|
||||||
log.info("Sent alarm_stop")
|
|
||||||
|
|
||||||
|
|
||||||
async def handler(ws):
|
|
||||||
"""Handle a single WebSocket connection."""
|
|
||||||
remote = ws.remote_address
|
|
||||||
log.info("Client connected: %s:%d", remote[0], remote[1])
|
|
||||||
|
|
||||||
wav_path = find_wav()
|
|
||||||
pcm, sr, ch, bits = read_wav(wav_path)
|
|
||||||
|
|
||||||
# Load status images
|
|
||||||
img_idle = load_status_image(IMG_DIR / "idle.png")
|
|
||||||
img_alarm = load_status_image(IMG_DIR / "on_alarm.png")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Send idle image on connect
|
|
||||||
await send_status_image(ws, img_idle)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
delay = randint(30, 60)
|
|
||||||
log.info("Next alarm in %ds", delay)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
# Switch to alarm image before audio
|
|
||||||
await send_status_image(ws, img_alarm)
|
|
||||||
await stream_alarm(ws, pcm, sr, ch, bits)
|
|
||||||
# Switch back to idle after alarm
|
|
||||||
await send_status_image(ws, img_idle)
|
|
||||||
except websockets.exceptions.ConnectionClosed:
|
|
||||||
log.info("Client disconnected: %s:%d", remote[0], remote[1])
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
log.info("Audio server starting on port %d", PORT)
|
|
||||||
async with websockets.serve(handler, "0.0.0.0", PORT):
|
|
||||||
await asyncio.Future() # run forever
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main_loop = asyncio.run(main())
|
|
||||||
70
pi/contents_server.py
Normal file
70
pi/contents_server.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
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"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from random import randint
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def handler(ws):
|
||||||
|
"""Handle a single WebSocket connection."""
|
||||||
|
remote = ws.remote_address
|
||||||
|
log.info("Client connected: %s:%d", remote[0], remote[1])
|
||||||
|
|
||||||
|
wav_path = find_wav()
|
||||||
|
pcm, sr, ch, bits = read_wav(wav_path)
|
||||||
|
|
||||||
|
# Load status images
|
||||||
|
img_idle = load_status_image(IMG_DIR / "idle.png")
|
||||||
|
img_alarm = load_status_image(IMG_DIR / "on_alarm.png")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send idle image on connect
|
||||||
|
await send_status_image(ws, img_idle)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
delay = randint(30, 60)
|
||||||
|
log.info("Next alarm in %ds", delay)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
# Switch to alarm image before audio
|
||||||
|
await send_status_image(ws, img_alarm)
|
||||||
|
await stream_alarm(ws, pcm, sr, ch, bits)
|
||||||
|
# Switch back to idle after alarm
|
||||||
|
await send_status_image(ws, img_idle)
|
||||||
|
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() # run forever
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
52
pi/image_handler.py
Normal file
52
pi/image_handler.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Status image functions — loading, alpha compositing, and WS transmission."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IMG_DIR = Path(__file__).parent / "assets" / "img"
|
||||||
|
STATUS_IMG_SIZE = 120
|
||||||
|
MONOCHROME_THRESHOLD = 120
|
||||||
|
|
||||||
|
|
||||||
|
def load_status_image(path: Path) -> bytes:
|
||||||
|
"""Load a PNG, convert to 1-bit 120x120 monochrome bitmap (MSB-first, black=1).
|
||||||
|
|
||||||
|
Transparent pixels are composited onto white so they don't render as black.
|
||||||
|
"""
|
||||||
|
img = Image.open(path)
|
||||||
|
|
||||||
|
# Composite transparent pixels onto white background
|
||||||
|
if img.mode in ("RGBA", "LA", "PA"):
|
||||||
|
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
||||||
|
bg.paste(img, mask=img.split()[-1])
|
||||||
|
img = bg
|
||||||
|
|
||||||
|
img = img.convert("L")
|
||||||
|
|
||||||
|
# Resize to fit within 120x120, preserving aspect ratio
|
||||||
|
img.thumbnail((STATUS_IMG_SIZE, STATUS_IMG_SIZE), Image.LANCZOS)
|
||||||
|
|
||||||
|
# Paste centered onto white canvas
|
||||||
|
canvas = Image.new("L", (STATUS_IMG_SIZE, STATUS_IMG_SIZE), 255)
|
||||||
|
x_off = (STATUS_IMG_SIZE - img.width) // 2
|
||||||
|
y_off = (STATUS_IMG_SIZE - img.height) // 2
|
||||||
|
canvas.paste(img, (x_off, y_off))
|
||||||
|
|
||||||
|
# Threshold to 1-bit: black (< 128) -> 1, white -> 0
|
||||||
|
bw = canvas.point(lambda p: 1 if p < MONOCHROME_THRESHOLD else 0, "1")
|
||||||
|
raw = bw.tobytes()
|
||||||
|
log.info("Status image loaded: %s -> %d bytes", path.name, len(raw))
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
async def send_status_image(ws, img_bytes: bytes):
|
||||||
|
"""Send a status image over the WebSocket (text header + binary payload)."""
|
||||||
|
header = json.dumps({"type": "status_image", "width": STATUS_IMG_SIZE, "height": STATUS_IMG_SIZE})
|
||||||
|
await ws.send(header)
|
||||||
|
await ws.send(img_bytes)
|
||||||
|
log.info("Sent status image (%d bytes)", len(img_bytes))
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Launch stats_server and audio_server as child processes."""
|
"""Launch stats_server and contents_server as child processes."""
|
||||||
import subprocess, sys, signal
|
import subprocess, sys, signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
d = Path(__file__).parent
|
d = Path(__file__).parent
|
||||||
procs = [
|
procs = [
|
||||||
subprocess.Popen([sys.executable, d / "stats_server.py"]),
|
subprocess.Popen([sys.executable, d / "stats_server.py"]),
|
||||||
subprocess.Popen([sys.executable, d / "audio_server.py"]),
|
subprocess.Popen([sys.executable, d / "contents_server.py"]),
|
||||||
]
|
]
|
||||||
signal.signal(signal.SIGINT, lambda *_: [p.terminate() for p in procs])
|
signal.signal(signal.SIGINT, lambda *_: [p.terminate() for p in procs])
|
||||||
signal.signal(signal.SIGTERM, lambda *_: [p.terminate() for p in procs])
|
signal.signal(signal.SIGTERM, lambda *_: [p.terminate() for p in procs])
|
||||||
print(f"Running stats_server (PID {procs[0].pid}) + audio_server (PID {procs[1].pid})")
|
print(f"Running stats_server (PID {procs[0].pid}) + contents_server (PID {procs[1].pid})")
|
||||||
for p in procs:
|
for p in procs:
|
||||||
p.wait()
|
p.wait()
|
||||||
|
|||||||
Reference in New Issue
Block a user