mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
* 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>
152 lines
4.5 KiB
Python
152 lines
4.5 KiB
Python
"""
|
|
Pytest fixtures for Controller tests.
|
|
"""
|
|
import asyncio
|
|
import copy
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Generator
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from controller.defaults import DEFAULT_CONFIG
|
|
|
|
|
|
_FAKE_CACHED_STATUS = {
|
|
"cpu_stats": {
|
|
"percent": 0.0, "cores": 1,
|
|
"cur_frequency": 0, "max_frequency": 0, "min_frequency": 0,
|
|
"temperature": None, "high_temperature": None, "critical_temperature": None,
|
|
},
|
|
"memory_stats": {"total": 1, "used": 0, "free": 1, "cached": 0},
|
|
"load_stats": {"minute_1": "0.00", "minute_5": "0.00", "minute_15": "0.00"},
|
|
"drives_stats": [],
|
|
"nic_bandwidth_stats": {},
|
|
"power_stats": {"under_voltage": False, "over_current": False},
|
|
"iostat_stats": {"percent_iowait": None},
|
|
"processes_stats": [],
|
|
"disk_bandwidth_stats": {},
|
|
"uptime_stats": {"uptime_seconds": 0},
|
|
"smart_stats": {"drives": [], "high_temperature": 55, "critical_temperature": 65},
|
|
"last_status": "1970-01-01T00:00:00",
|
|
}
|
|
|
|
|
|
async def _noop_status_worker(app, base_sleep: float = 5.0):
|
|
"""Replacement for status_worker_loop during tests.
|
|
|
|
The real worker calls blocking samplers (psutil with interval=1.0, iostat)
|
|
via asyncio.to_thread; those threads can't be cancelled, so TestClient
|
|
teardown waits ~2s for each test. Also pre-populates cached_status so the
|
|
dashboard route doesn't fall back to direct sampling on the request path.
|
|
"""
|
|
app.state.cached_status = dict(_FAKE_CACHED_STATUS)
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_status_worker():
|
|
"""Replace the real status worker with a no-op for every test.
|
|
|
|
See _noop_status_worker for why this is necessary.
|
|
"""
|
|
with mock.patch("controller.main.status_worker_loop", _noop_status_worker):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def test_directory() -> Generator[Path, None, None]:
|
|
"""Create a temporary directory for testing."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = Path(tmp_dir).resolve()
|
|
tmp_path.chmod(0o40755)
|
|
yield tmp_path
|
|
|
|
|
|
@pytest.fixture
|
|
def test_config_directory(test_directory: Path) -> Path:
|
|
"""Create a config directory inside test_directory."""
|
|
config_dir = test_directory / "config"
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
return config_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_path(test_config_directory: Path):
|
|
"""Mock the CONFIG_PATH_ON_DRIVE to use test directory."""
|
|
config_path = test_config_directory / "controller.yaml"
|
|
with mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", config_path):
|
|
yield config_path
|
|
|
|
|
|
@pytest.fixture
|
|
def reset_runtime_config():
|
|
"""Reset the runtime config to defaults before and after each test."""
|
|
import controller.lib.config as config_module
|
|
|
|
# Save original
|
|
original = copy.deepcopy(config_module._runtime_config)
|
|
|
|
# Reset to defaults
|
|
config_module._runtime_config = copy.deepcopy(DEFAULT_CONFIG)
|
|
|
|
yield
|
|
|
|
# Restore original
|
|
config_module._runtime_config = original
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_docker_mode():
|
|
"""Mock Docker mode to be disabled."""
|
|
with mock.patch.dict("os.environ", {"DOCKERIZED": "false"}):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_docker_mode_enabled():
|
|
"""Mock Docker mode to be enabled."""
|
|
with mock.patch.dict("os.environ", {"DOCKERIZED": "true"}):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_drive_mounted(test_config_directory: Path):
|
|
"""Mock the primary drive as mounted by creating config directory."""
|
|
# test_config_directory fixture already creates it
|
|
with mock.patch(
|
|
"controller.lib.config.is_primary_drive_mounted", return_value=True
|
|
):
|
|
yield test_config_directory
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_drive_not_mounted():
|
|
"""Mock the primary drive as not mounted."""
|
|
with mock.patch(
|
|
"controller.lib.config.is_primary_drive_mounted", return_value=False
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client(reset_runtime_config, mock_docker_mode) -> TestClient:
|
|
"""Create a FastAPI TestClient for integration testing."""
|
|
from controller.main import app
|
|
|
|
with TestClient(app) as client:
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client_docker_mode(reset_runtime_config, mock_docker_mode_enabled) -> TestClient:
|
|
"""Create a FastAPI TestClient with Docker mode enabled."""
|
|
from controller.main import app
|
|
|
|
with TestClient(app) as client:
|
|
yield client
|