Compare commits
6 Commits
7cd683722b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a9e8ba7ff | |||
| a20a8f3552 | |||
| df6c7ea4bc | |||
| 595f9916ac | |||
| 37a09ef66b | |||
| 7ec68fec84 |
@@ -70,7 +70,7 @@ Pushes a JSON object every 2 seconds with real system metrics from `psutil`:
|
|||||||
- `cpu_pct`, `mem_pct`, `mem_used_mb`, `disk_pct`
|
- `cpu_pct`, `mem_pct`, `mem_used_mb`, `disk_pct`
|
||||||
- `cpu_temp` (reads `/sys/class/thermal/` as fallback)
|
- `cpu_temp` (reads `/sys/class/thermal/` as fallback)
|
||||||
- `uptime_hrs`, `net_rx_kbps`, `net_tx_kbps` (values are in kB/s despite the field names)
|
- `uptime_hrs`, `net_rx_kbps`, `net_tx_kbps` (values are in kB/s despite the field names)
|
||||||
- `services` — live Docker container statuses via `docker ps -a`, with a ternary status model (`running`, `warning`, `stopped`). Monitored containers: gitea, samba, pihole, qbittorrent, frpc (ny), pinepods, frpc (ssh), jellyfin.
|
- `services` — live Docker container statuses via `docker ps -a`, with a ternary status model (`running`, `warning`, `stopped`). Monitored containers: gitea, samba, pihole, qbittorrent, frpc (ny), pinepods, frpc (ssh), jellyfin, kc-start2, kc-monitor. A configured container Docker doesn't report at all (deleted, or a typo in `SERVICES_ALIASES`) is reported as `stopped` and logged once to stdout.
|
||||||
- `local_time` fields for RTC sync (`y`, `mo`, `d`, `h`, `m`, `s`)
|
- `local_time` fields for RTC sync (`y`, `mo`, `d`, `h`, `m`, `s`)
|
||||||
|
|
||||||
### contents_server.py -- port 8766
|
### contents_server.py -- port 8766
|
||||||
@@ -121,7 +121,7 @@ Example with two alarms:
|
|||||||
| `alarm_time` | `string` | Yes | 4-digit HHMM, 24-hour. Fires on the matched minute. |
|
| `alarm_time` | `string` | Yes | 4-digit HHMM, 24-hour. Fires on the matched minute. |
|
||||||
| `alarm_days` | `string[]` | No | 3-letter abbreviations: `Mon`–`Sun`. If omitted, fires every day. |
|
| `alarm_days` | `string[]` | No | 3-letter abbreviations: `Mon`–`Sun`. If omitted, fires every day. |
|
||||||
| `alarm_dates` | `string[]` | No | `MM/DD` strings. Ignored if `alarm_days` is also set. |
|
| `alarm_dates` | `string[]` | No | `MM/DD` strings. Ignored if `alarm_days` is also set. |
|
||||||
| `alarm_audio` | `string` | No | WAV path, relative to project root. Default: `assets/alarm/alarm_test.wav`. |
|
| `alarm_audio` | `string` | No | WAV path, relative to project root. Silent if not set. "default" (case-insensitive) uses `assets/alarm/alarm.wav`. |
|
||||||
| `alarm_image` | `string` | No | Status PNG path, relative to project root. Default: `assets/img/on_alarm.png`. |
|
| `alarm_image` | `string` | No | Status PNG path, relative to project root. Default: `assets/img/on_alarm.png`. |
|
||||||
|
|
||||||
If both `alarm_days` and `alarm_dates` are present, `alarm_days` takes priority.
|
If both `alarm_days` and `alarm_dates` are present, `alarm_days` takes priority.
|
||||||
|
|||||||
@@ -9,5 +9,10 @@
|
|||||||
"alarm_time": "2330",
|
"alarm_time": "2330",
|
||||||
"alarm_audio": "assets/alarm/sleep.wav",
|
"alarm_audio": "assets/alarm/sleep.wav",
|
||||||
"alarm_image": "assets/img/sleep.png"
|
"alarm_image": "assets/img/sleep.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alarm_time": "0800",
|
||||||
|
"alarm_days": ["Sat", "Sun"],
|
||||||
|
"alarm_image": "assets/img/on_alarm.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+20
-4
@@ -48,12 +48,22 @@ def _resolve_path(relative: str) -> Path:
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
def _prepare_alarm(entry: dict) -> dict:
|
def _prepare_alarm(entry: dict, audio_cache: dict[Path, tuple]) -> dict:
|
||||||
"""Pre-resolve paths and load resources for a single alarm entry."""
|
"""Pre-resolve paths and load resources for a single alarm entry."""
|
||||||
audio_path = find_wav(_resolve_path(entry.get("alarm_audio", "assets/alarm/alarm_test.wav")))
|
|
||||||
alarm_img_path = _resolve_path(entry.get("alarm_image", "assets/img/on_alarm.png"))
|
alarm_img_path = _resolve_path(entry.get("alarm_image", "assets/img/on_alarm.png"))
|
||||||
pcm, sr, ch, bits = read_wav(audio_path)
|
|
||||||
img = load_status_image(alarm_img_path)
|
img = load_status_image(alarm_img_path)
|
||||||
|
|
||||||
|
pcm = sr = ch = bits = None
|
||||||
|
raw_audio = entry.get("alarm_audio")
|
||||||
|
if raw_audio is not None:
|
||||||
|
audio_path = find_wav(_resolve_path(raw_audio))
|
||||||
|
if audio_path in audio_cache:
|
||||||
|
log.info("Reusing cached audio for %s", audio_path)
|
||||||
|
pcm, sr, ch, bits = audio_cache[audio_path]
|
||||||
|
else:
|
||||||
|
pcm, sr, ch, bits = read_wav(audio_path)
|
||||||
|
audio_cache[audio_path] = (pcm, sr, ch, bits)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"config": entry,
|
"config": entry,
|
||||||
"pcm": pcm, "sr": sr, "ch": ch, "bits": bits,
|
"pcm": pcm, "sr": sr, "ch": ch, "bits": bits,
|
||||||
@@ -71,7 +81,8 @@ async def handler(ws):
|
|||||||
img_idle = load_status_image(IMG_DIR / "idle.png")
|
img_idle = load_status_image(IMG_DIR / "idle.png")
|
||||||
current_img = img_idle
|
current_img = img_idle
|
||||||
|
|
||||||
alarms = [_prepare_alarm(entry) for entry in configs] if configs else []
|
audio_cache: dict[Path, tuple] = {}
|
||||||
|
alarms = [_prepare_alarm(entry, audio_cache) for entry in configs] if configs else []
|
||||||
|
|
||||||
async def alarm_ticker():
|
async def alarm_ticker():
|
||||||
nonlocal current_img
|
nonlocal current_img
|
||||||
@@ -91,10 +102,15 @@ async def handler(ws):
|
|||||||
alarm["config"]["alarm_time"], current_minute)
|
alarm["config"]["alarm_time"], current_minute)
|
||||||
current_img = alarm["img"]
|
current_img = alarm["img"]
|
||||||
await send_status_image(ws, current_img)
|
await send_status_image(ws, current_img)
|
||||||
|
if alarm["pcm"] is not None:
|
||||||
await stream_alarm(ws, alarm["pcm"], alarm["sr"],
|
await stream_alarm(ws, alarm["pcm"], alarm["sr"],
|
||||||
alarm["ch"], alarm["bits"])
|
alarm["ch"], alarm["bits"])
|
||||||
# let the image persist a bit more
|
# let the image persist a bit more
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
else:
|
||||||
|
# longer image persistence when no audio
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
current_img = img_idle
|
current_img = img_idle
|
||||||
await send_status_image(ws, current_img)
|
await send_status_image(ws, current_img)
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,14 @@ SERVICES_ALIASES = {
|
|||||||
"pinepods": "pinepods",
|
"pinepods": "pinepods",
|
||||||
"frpc-ssh": "frpc (ssh)",
|
"frpc-ssh": "frpc (ssh)",
|
||||||
"jellyfin": "jellyfin",
|
"jellyfin": "jellyfin",
|
||||||
|
"kancolle-start2-server": "kc-start2",
|
||||||
|
"kancolle-kcmonitor": "kc-monitor",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Configured names Docker didn't report, so a typo doesn't reprint every 2s
|
||||||
|
_missing_containers: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
def _get_docker_services() -> list[dict]:
|
def _get_docker_services() -> list[dict]:
|
||||||
"""Query Docker for real container statuses with ternary status model."""
|
"""Query Docker for real container statuses with ternary status model."""
|
||||||
try:
|
try:
|
||||||
@@ -84,7 +91,10 @@ def _get_docker_services() -> list[dict]:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
global _missing_containers
|
||||||
|
|
||||||
services = []
|
services = []
|
||||||
|
seen = set()
|
||||||
for line in result.stdout.strip().splitlines():
|
for line in result.stdout.strip().splitlines():
|
||||||
parts = line.split("\t", 1)
|
parts = line.split("\t", 1)
|
||||||
if len(parts) != 2:
|
if len(parts) != 2:
|
||||||
@@ -100,6 +110,20 @@ def _get_docker_services() -> list[dict]:
|
|||||||
else:
|
else:
|
||||||
status = "stopped"
|
status = "stopped"
|
||||||
services.append({"name": SERVICES_ALIASES[name], "status": status})
|
services.append({"name": SERVICES_ALIASES[name], "status": status})
|
||||||
|
seen.add(name)
|
||||||
|
|
||||||
|
# A configured name Docker never reported is either a typo or a deleted
|
||||||
|
# container — show it as stopped instead of dropping it off the dashboard
|
||||||
|
missing = set(SERVICES_ALIASES) - seen
|
||||||
|
for name in sorted(missing):
|
||||||
|
services.append({"name": SERVICES_ALIASES[name], "status": "stopped"})
|
||||||
|
|
||||||
|
if missing != _missing_containers:
|
||||||
|
for name in sorted(missing - _missing_containers):
|
||||||
|
print(f"No container named {name!r} — reporting as stopped")
|
||||||
|
for name in sorted(_missing_containers - missing):
|
||||||
|
print(f"Container {name!r} is back")
|
||||||
|
_missing_containers = missing
|
||||||
|
|
||||||
# Sort: warnings first, then stopped, then running (problems float to top)
|
# Sort: warnings first, then stopped, then running (problems float to top)
|
||||||
order = {"warning": 0, "stopped": 1, "running": 2}
|
order = {"warning": 0, "stopped": 1, "running": 2}
|
||||||
|
|||||||
Reference in New Issue
Block a user