wrolpi/controller/lib/status_worker.py

173 lines
6 KiB
Python
Raw Permalink Normal View History

"""
Background status worker for WROLPi Controller.
Periodically collects all system status and caches it in app.state
for O(1) response times on API endpoints.
"""
import asyncio
2026-03-14 10:03:11 -06:00
import logging
import multiprocessing
from datetime import datetime
from typing import Optional
2026-03-14 10:03:11 -06:00
logger = logging.getLogger(__name__)
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
from controller.lib.smart import build_smart_stats
from controller.lib.status import (
get_cpu_status,
get_disk_bandwidth_status,
get_drive_status,
get_iostat_status,
get_load_status,
get_memory_status,
get_network_status,
get_power_status,
get_processes_status,
2025-12-26 10:05:13 -07:00
get_uptime_status,
)
# Type alias for cached status dict
CachedStatus = dict
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
# SMART reads can wake spun-down USB drives, so collect them far less often
# than the fast (5s) stats.
SMART_REFRESH_SECONDS = 60
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
async def collect_all_status(smart_stats: Optional[dict] = None) -> CachedStatus:
"""
Collect all status information concurrently.
Returns a dict with all status types. Individual failures return None
for that stat type rather than failing the entire collection.
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
``smart_stats`` is collected on a slower cadence by the worker loop (see
``status_worker_loop``) and passed in so it lands in the same cached
payload as the fast 5s stats.
"""
# Run all status collections concurrently using asyncio.to_thread
# since the status functions are synchronous (use psutil)
2025-12-26 10:05:13 -07:00
cpu, memory, load, drives, network, power, iostat, processes, disk_bandwidth, uptime = await asyncio.gather(
asyncio.to_thread(get_cpu_status),
asyncio.to_thread(get_memory_status),
asyncio.to_thread(get_load_status),
asyncio.to_thread(get_drive_status),
asyncio.to_thread(get_network_status),
asyncio.to_thread(get_power_status),
asyncio.to_thread(get_iostat_status),
asyncio.to_thread(get_processes_status),
asyncio.to_thread(get_disk_bandwidth_status),
2025-12-26 10:05:13 -07:00
asyncio.to_thread(get_uptime_status),
return_exceptions=True,
)
# Handle individual failures gracefully - return None for failed stats
status = {
"cpu_stats": cpu if not isinstance(cpu, Exception) else None,
"memory_stats": memory if not isinstance(memory, Exception) else None,
"load_stats": load if not isinstance(load, Exception) else None,
"drives_stats": drives if not isinstance(drives, Exception) else [],
"nic_bandwidth_stats": network if not isinstance(network, Exception) else {},
"power_stats": power if not isinstance(power, Exception) else None,
"iostat_stats": iostat if not isinstance(iostat, Exception) else None,
"processes_stats": processes if not isinstance(processes, Exception) else [],
"disk_bandwidth_stats": disk_bandwidth if not isinstance(disk_bandwidth, Exception) else {},
2025-12-26 10:05:13 -07:00
"uptime_stats": uptime if not isinstance(uptime, Exception) else None,
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
"smart_stats": smart_stats,
"last_status": datetime.now().isoformat(),
}
return status
def get_adaptive_sleep(load_stats: Optional[dict], base_sleep: float = 5.0) -> float:
"""
Calculate adaptive sleep time based on system load.
If system is under heavy load (load_1 > cpu_count), increase sleep time
to reduce overhead on the system.
Args:
load_stats: Dict with minute_1 key (string), or None
base_sleep: Base sleep interval in seconds
Returns:
Sleep time in seconds (may be > base_sleep if system is stressed)
"""
if load_stats is None:
return base_sleep
try:
load_1 = float(load_stats.get("minute_1", 0))
cpu_count = multiprocessing.cpu_count()
if load_1 > cpu_count:
# System is stressed, slow down status updates
return (base_sleep * load_1) / cpu_count
except (ValueError, TypeError, ZeroDivisionError):
pass
return base_sleep
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
async def refresh_smart_stats(app, now: datetime) -> dict:
"""Collect SMART stats, but no more than once per SMART_REFRESH_SECONDS.
SMART reads can wake spun-down USB drives, so this runs on a slow
cadence independent of the fast status loop. The result and its
timestamp are cached on ``app.state``; between refreshes the cached
value is returned unchanged. ``build_smart_stats`` is given the prior
result so it can reuse readings for drives that are now asleep.
"""
last = getattr(app.state, "smart_last_collected", None)
cache = getattr(app.state, "smart_cache", None)
due = (
cache is None
or last is None
or (now - last).total_seconds() >= SMART_REFRESH_SECONDS
)
if due:
# build_smart_stats shells out to smartctl/hdparm; keep it off the loop.
cache = await asyncio.to_thread(build_smart_stats, cache)
app.state.smart_cache = cache
app.state.smart_last_collected = now
return cache
async def status_worker_loop(app, base_sleep: float = 5.0):
"""
Background loop that continuously collects status data.
Runs indefinitely until cancelled. Stores collected status in
app.state.cached_status for fast endpoint access.
Args:
app: FastAPI application instance
base_sleep: Base sleep interval in seconds (default 5s)
"""
2026-03-14 10:03:11 -06:00
logger.info("Status worker started")
while True:
try:
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
# SMART on its own slow cadence; everything else every cycle.
smart_stats = await refresh_smart_stats(app, datetime.now())
# Collect all status data
Warn in the navbar when a hard drive runs hot (#470) * Warn in the navbar when a hard drive runs hot. The navbar already warns about high CPU temperature; add the equivalent for drive temperature, since an overheating disk risks data loss and a user running an off-grid library won't be watching for it. - controller/lib/smart.py: build_smart_stats() returns the per-drive list plus warning thresholds (55C warn / 65C critical) so the frontend stays dumb. Reading SMART wakes a spun-down drive, so drives in standby are detected with `smartctl -n standby` (which works through the USB-SATA bridges where `hdparm -C` only reports "unknown") and NOT read; their last-known reading is reused instead. - controller/lib/status_worker.py: collect SMART on a slow 60s cadence (cached on app.state) rather than the 5s status loop, so the poll does not keep USB drives awake. The result flows into the same cached payload via a new collect_all_status(smart_stats=...) kwarg. - Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows a yellow/red `hdd` icon with a popup, mirroring the CPU warning. Verified on hardware: smart_stats reports real temps and SMART runs once a minute, not every 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Greptile review on HDD temperature warning. - _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY. smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the deeper power mode; matching only "STANDBY" meant a SLEEP drive was read and woken — the exact failure the check prevents. (TDD: added a failing SLEEP test, then fixed.) - useDriveTemperature uses ?? instead of || for the temperature fallback to express "fall back only on null/undefined". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:46:29 -06:00
status = await collect_all_status(smart_stats=smart_stats)
# Store in app.state (thread-safe for reading in single-worker uvicorn)
app.state.cached_status = status
# Calculate adaptive sleep based on load
sleep_time = get_adaptive_sleep(status.get("load_stats"), base_sleep)
except asyncio.CancelledError:
2026-03-14 10:03:11 -06:00
logger.info("Status worker cancelled")
raise
except Exception as e:
2026-03-14 10:03:11 -06:00
logger.error("Status worker error: %s", e)
sleep_time = base_sleep
await asyncio.sleep(sleep_time)