164 lines
4.8 KiB
Python
164 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""WebSocket server that sends real Pi system stats every 2 seconds.
|
|
|
|
Drop-in replacement for mock_server.py. Same port (8765), same JSON schema,
|
|
same 2s push interval. Services remain mocked until systemd integration is added.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import psutil
|
|
import websockets
|
|
|
|
# Prime the CPU percent counter (first call always returns 0.0)
|
|
psutil.cpu_percent(interval=None)
|
|
|
|
# Network baseline for delta calculation
|
|
_prev_net = psutil.net_io_counters()
|
|
_prev_net_time = time.monotonic()
|
|
|
|
|
|
def _get_cpu_temp() -> float:
|
|
"""Read CPU temperature with fallback for different Pi OS versions."""
|
|
try:
|
|
temps = psutil.sensors_temperatures()
|
|
if "cpu_thermal" in temps and temps["cpu_thermal"]:
|
|
return round(temps["cpu_thermal"][0].current, 1)
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
# Fallback: read sysfs directly (value is in millidegrees)
|
|
thermal_path = Path("/sys/class/thermal/thermal_zone0/temp")
|
|
try:
|
|
millidegrees = int(thermal_path.read_text().strip())
|
|
return round(millidegrees / 1000.0, 1)
|
|
except (FileNotFoundError, ValueError, PermissionError):
|
|
return 0.0
|
|
|
|
|
|
def _get_net_throughput() -> tuple[float, float]:
|
|
"""Calculate network rx/tx in kbps since last call."""
|
|
global _prev_net, _prev_net_time
|
|
|
|
now = time.monotonic()
|
|
current = psutil.net_io_counters()
|
|
elapsed = now - _prev_net_time
|
|
|
|
if elapsed <= 0:
|
|
return 0.0, 0.0
|
|
|
|
rx_kbps = round((current.bytes_recv - _prev_net.bytes_recv) * 8 / (elapsed * 1000), 1)
|
|
tx_kbps = round((current.bytes_sent - _prev_net.bytes_sent) * 8 / (elapsed * 1000), 1)
|
|
|
|
_prev_net = current
|
|
_prev_net_time = now
|
|
|
|
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 []
|
|
|
|
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:
|
|
"""Current local time as broken-down fields for RTC sync."""
|
|
now = datetime.now()
|
|
return {
|
|
"y": now.year,
|
|
"mo": now.month,
|
|
"d": now.day,
|
|
"h": now.hour,
|
|
"m": now.minute,
|
|
"s": now.second,
|
|
}
|
|
|
|
|
|
def generate_stats() -> dict:
|
|
mem = psutil.virtual_memory()
|
|
disk = psutil.disk_usage("/mnt/buffalo")
|
|
rx_kbps, tx_kbps = _get_net_throughput()
|
|
|
|
return {
|
|
"cpu_pct": psutil.cpu_percent(interval=None),
|
|
"mem_pct": round(mem.percent, 1),
|
|
"mem_used_mb": int(mem.used // (1024 * 1024)),
|
|
"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 / 8,
|
|
"net_tx_kbps": tx_kbps / 8, # kByte/s for humans
|
|
"services": _get_docker_services(),
|
|
"timestamp": int(time.time()),
|
|
"local_time": _local_time_fields(),
|
|
}
|
|
|
|
|
|
async def handler(websocket):
|
|
addr = websocket.remote_address
|
|
print(f"Client connected: {addr}")
|
|
try:
|
|
while True:
|
|
stats = generate_stats()
|
|
await websocket.send(json.dumps(stats))
|
|
await asyncio.sleep(2)
|
|
except websockets.ConnectionClosed:
|
|
print(f"Client disconnected: {addr}")
|
|
|
|
|
|
async def main():
|
|
print("Pi stats server starting on ws://0.0.0.0:8765")
|
|
async with websockets.serve(handler, "0.0.0.0", 8765):
|
|
await asyncio.Future() # run forever
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|