Compare commits

...

10 Commits

Author SHA1 Message Date
60d99a993f alarm image persist for 1 second longer 2026-02-16 23:42:16 +09:00
Mikkeli Matlock
c602cfde1a pi gitignore 2026-02-16 23:35:09 +09:00
Mikkeli Matlock
a836ee43ec pi gitignore 2026-02-16 23:33:18 +09:00
Mikkeli Matlock
8fc7ed1327 untracked alarm config 2026-02-16 23:32:32 +09:00
Mikkeli Matlock
ce13fb23a8 gitignore 2026-02-16 22:37:38 +09:00
c2fbb2b69a new client connection logic
- esp32 requests for image when ready to receive
- server serves initial image on request
2026-02-16 21:56:28 +09:00
6e633c9367 pi status server update 2026-02-16 21:08:40 +09:00
71c79eb3ab docker services real 2026-02-16 20:47:44 +09:00
Mikkeli Matlock
9ace7be32b changed rx/tx to kByte/s 2026-02-16 16:40:28 +09:00
Mikkeli Matlock
227f66dbff new layout + 200px status images 2026-02-16 14:52:20 +09:00
5 changed files with 81 additions and 24 deletions

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@
__pycache__/
*.pyo
*.pyc
# configs
config/
!config/alarms.sample.json

View File

@@ -6,7 +6,7 @@ connected ESP32 dashboard client on port 8766.
Protocol:
Status image:
1. Text frame: {"type":"status_image","width":120,"height":120}
1. Text frame: {"type":"status_image","width":200,"height":200}
2. Binary frame: 1-bit monochrome bitmap
Alarm audio:
@@ -17,6 +17,7 @@ Protocol:
import argparse
import asyncio
import json
import logging
from datetime import datetime
from pathlib import Path
@@ -68,17 +69,17 @@ async def handler(ws):
configs = load_config(_config_path)
img_idle = load_status_image(IMG_DIR / "idle.png")
current_img = img_idle
try:
await send_status_image(ws, img_idle)
alarms = [_prepare_alarm(entry) for entry in configs] if configs else []
if not configs:
async def alarm_ticker():
nonlocal current_img
if not alarms:
log.info("No alarms configured — idling forever")
await asyncio.Future()
return
alarms = [_prepare_alarm(entry) for entry in configs]
while True:
for alarm in alarms:
if should_fire(alarm["config"]):
@@ -88,13 +89,30 @@ async def handler(ws):
alarm["last_fired"] = current_minute
log.info("Alarm firing: %s at %s",
alarm["config"]["alarm_time"], current_minute)
await send_status_image(ws, alarm["img"])
current_img = alarm["img"]
await send_status_image(ws, current_img)
await stream_alarm(ws, alarm["pcm"], alarm["sr"],
alarm["ch"], alarm["bits"])
await send_status_image(ws, img_idle)
# let the image persist a bit more
await asyncio.sleep(1)
current_img = img_idle
await send_status_image(ws, current_img)
await asyncio.sleep(TICK_INTERVAL)
async def receiver():
async for msg in ws:
try:
data = json.loads(msg)
except (json.JSONDecodeError, TypeError):
continue
if data.get("type") == "request_image":
log.info("Client requested image — sending current (%d bytes)",
len(current_img))
await send_status_image(ws, current_img)
try:
await asyncio.gather(alarm_ticker(), receiver())
except websockets.exceptions.ConnectionClosed:
log.info("Client disconnected: %s:%d", remote[0], remote[1])

View File

@@ -9,12 +9,12 @@ from PIL import Image
log = logging.getLogger(__name__)
IMG_DIR = Path(__file__).parent / "assets" / "img"
STATUS_IMG_SIZE = 120
STATUS_IMG_SIZE = 200
MONOCHROME_THRESHOLD = 180
def load_status_image(path: Path) -> bytes:
"""Load a PNG, convert to 1-bit 120x120 monochrome bitmap (MSB-first, black=1).
"""Load a PNG, convert to 1-bit 200x200 monochrome bitmap (MSB-first, black=1).
Transparent pixels are composited onto white so they don't render as black.
"""

View File

@@ -7,7 +7,7 @@ same 2s push interval. Services remain mocked until systemd integration is added
import asyncio
import json
import random
import subprocess
import time
from datetime import datetime
from pathlib import Path
@@ -60,17 +60,52 @@ def _get_net_throughput() -> tuple[float, float]:
return rx_kbps, tx_kbps
# only services that matter
SERVICES_ALIASES = {
"gitea": "gitea",
"samba": "samba",
"pihole": "pihole",
"qbittorrent": "qbittorrent",
"frpc-primary": "frpc (ny)",
"pinepods": "pinepods",
"frpc-ssh": "frpc (ssh)",
"jellyfin": "jellyfin",
}
def _get_docker_services() -> list[dict]:
"""Query Docker for real container statuses with ternary status model."""
try:
result = subprocess.run(
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.Status}}"],
capture_output=True, text=True, timeout=5,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return []
def _mock_services() -> list[dict]:
"""Mocked service status — same logic as mock_server.py."""
return [
{"name": "docker", "status": random.choice(["running", "running", "running", "stopped"])},
{"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"},
]
if result.returncode != 0:
return []
services = []
for line in result.stdout.strip().splitlines():
parts = line.split("\t", 1)
if len(parts) != 2:
continue
name, raw_status = parts
if (name in SERVICES_ALIASES):
if raw_status.startswith("Up"):
if "unhealthy" in raw_status or "Restarting" in raw_status:
status = "warning"
else:
status = "running"
else:
status = "stopped"
services.append({"name": SERVICES_ALIASES[name], "status": status})
# Sort: warnings first, then stopped, then running (problems float to top)
order = {"warning": 0, "stopped": 1, "running": 2}
services.sort(key=lambda s: order.get(s["status"], 3))
return services
def _local_time_fields() -> dict:
@@ -98,9 +133,9 @@ def generate_stats() -> dict:
"disk_pct": round(disk.percent, 1),
"cpu_temp": _get_cpu_temp(),
"uptime_hrs": round((time.time() - psutil.boot_time()) / 3600, 1),
"net_rx_kbps": rx_kbps,
"net_tx_kbps": tx_kbps,
"services": _mock_services(),
"net_rx_kbps": rx_kbps / 8,
"net_tx_kbps": tx_kbps / 8, # kByte/s for humans
"services": _get_docker_services(),
"timestamp": int(time.time()),
"local_time": _local_time_fields(),
}