Compare commits
10 Commits
8fd0201777
...
60d99a993f
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d99a993f | |||
|
|
c602cfde1a | ||
|
|
a836ee43ec | ||
|
|
8fc7ed1327 | ||
|
|
ce13fb23a8 | ||
| c2fbb2b69a | |||
| 6e633c9367 | |||
| 71c79eb3ab | |||
|
|
9ace7be32b | ||
|
|
227f66dbff |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# configs
|
||||||
|
config/
|
||||||
|
!config/alarms.sample.json
|
||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user