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__/ __pycache__/
*.pyo *.pyo
*.pyc *.pyc
# configs
config/
!config/alarms.sample.json

View File

@@ -6,7 +6,7 @@ connected ESP32 dashboard client on port 8766.
Protocol: Protocol:
Status image: 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 2. Binary frame: 1-bit monochrome bitmap
Alarm audio: Alarm audio:
@@ -17,6 +17,7 @@ Protocol:
import argparse import argparse
import asyncio import asyncio
import json
import logging import logging
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -68,17 +69,17 @@ async def handler(ws):
configs = load_config(_config_path) configs = load_config(_config_path)
img_idle = load_status_image(IMG_DIR / "idle.png") img_idle = load_status_image(IMG_DIR / "idle.png")
current_img = img_idle
try: alarms = [_prepare_alarm(entry) for entry in configs] if configs else []
await send_status_image(ws, img_idle)
if not configs: async def alarm_ticker():
nonlocal current_img
if not alarms:
log.info("No alarms configured — idling forever") log.info("No alarms configured — idling forever")
await asyncio.Future() await asyncio.Future()
return return
alarms = [_prepare_alarm(entry) for entry in configs]
while True: while True:
for alarm in alarms: for alarm in alarms:
if should_fire(alarm["config"]): if should_fire(alarm["config"]):
@@ -88,13 +89,30 @@ async def handler(ws):
alarm["last_fired"] = current_minute alarm["last_fired"] = current_minute
log.info("Alarm firing: %s at %s", log.info("Alarm firing: %s at %s",
alarm["config"]["alarm_time"], current_minute) 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"], await stream_alarm(ws, alarm["pcm"], alarm["sr"],
alarm["ch"], alarm["bits"]) 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) 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: except websockets.exceptions.ConnectionClosed:
log.info("Client disconnected: %s:%d", remote[0], remote[1]) log.info("Client disconnected: %s:%d", remote[0], remote[1])

View File

@@ -9,12 +9,12 @@ from PIL import Image
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
IMG_DIR = Path(__file__).parent / "assets" / "img" IMG_DIR = Path(__file__).parent / "assets" / "img"
STATUS_IMG_SIZE = 120 STATUS_IMG_SIZE = 200
MONOCHROME_THRESHOLD = 180 MONOCHROME_THRESHOLD = 180
def load_status_image(path: Path) -> bytes: 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. 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 asyncio
import json import json
import random import subprocess
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -60,17 +60,52 @@ def _get_net_throughput() -> tuple[float, float]:
return rx_kbps, tx_kbps 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]: if result.returncode != 0:
"""Mocked service status — same logic as mock_server.py.""" return []
return [
{"name": "docker", "status": random.choice(["running", "running", "running", "stopped"])}, services = []
{"name": "pihole", "status": random.choice(["running", "running", "running", "stopped"])}, for line in result.stdout.strip().splitlines():
{"name": "nginx", "status": random.choice(["running", "running", "stopped"])}, parts = line.split("\t", 1)
{"name": "sshd", "status": "running"}, if len(parts) != 2:
{"name": "ph1", "status": "running"}, continue
{"name": "ph2", "status": "stopped"}, 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: def _local_time_fields() -> dict:
@@ -98,9 +133,9 @@ def generate_stats() -> dict:
"disk_pct": round(disk.percent, 1), "disk_pct": round(disk.percent, 1),
"cpu_temp": _get_cpu_temp(), "cpu_temp": _get_cpu_temp(),
"uptime_hrs": round((time.time() - psutil.boot_time()) / 3600, 1), "uptime_hrs": round((time.time() - psutil.boot_time()) / 3600, 1),
"net_rx_kbps": rx_kbps, "net_rx_kbps": rx_kbps / 8,
"net_tx_kbps": tx_kbps, "net_tx_kbps": tx_kbps / 8, # kByte/s for humans
"services": _mock_services(), "services": _get_docker_services(),
"timestamp": int(time.time()), "timestamp": int(time.time()),
"local_time": _local_time_fields(), "local_time": _local_time_fields(),
} }