Files
wled-screen-controller-mixed/server/src/wled_controller/api/routes/system.py
alexei.dolgolyov 9dfd2365f4
Some checks failed
Lint & Test / test (push) Failing after 28s
feat: migrate storage from JSON files to SQLite
Replace 22 individual JSON store files with a single SQLite database
(data/ledgrab.db). All entity stores now use BaseSqliteStore backed by
SQLite with WAL mode, write-through caching, and thread-safe access.

- Add Database class with SQLite backup/restore API
- Add BaseSqliteStore as drop-in replacement for BaseJsonStore
- Convert all 16 entity stores to SQLite
- Move global settings (MQTT, external URL, auto-backup) to SQLite
  settings table
- Replace JSON backup/restore with SQLite snapshot backups (.db files)
- Remove partial export/import feature (backend + frontend)
- Update demo seed to write directly to SQLite
- Add "Backup Now" button to settings UI
- Remove StorageConfig file path fields (single database_file remains)
2026-03-25 00:03:19 +03:00

307 lines
10 KiB
Python

"""System routes: health, version, displays, performance, tags, api-keys.
Backup/restore and settings routes are in backup.py and system_settings.py.
"""
import asyncio
import platform
import subprocess
import sys
from datetime import datetime, timezone
from typing import Optional
import psutil
from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired, is_auth_enabled
from wled_controller.api.dependencies import (
get_audio_source_store,
get_audio_template_store,
get_automation_store,
get_color_strip_store,
get_device_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
get_scene_preset_store,
get_sync_clock_store,
get_template_store,
get_value_source_store,
)
from wled_controller.api.schemas.system import (
DisplayInfo,
DisplayListResponse,
GpuInfo,
HealthResponse,
PerformanceResponse,
ProcessListResponse,
VersionResponse,
)
from wled_controller.config import get_config, is_demo_mode
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
# Re-export load_external_url so existing callers still work
from wled_controller.api.routes.system_settings import load_external_url # noqa: F401
logger = get_logger(__name__)
# Prime psutil CPU counter (first call always returns 0.0)
psutil.cpu_percent(interval=None)
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle # noqa: E402
def _get_cpu_name() -> str | None:
"""Get a human-friendly CPU model name (cached at module level)."""
try:
if platform.system() == "Windows":
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
)
name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
winreg.CloseKey(key)
return name.strip()
elif platform.system() == "Linux":
with open("/proc/cpuinfo") as f:
for line in f:
if "model name" in line:
return line.split(":")[1].strip()
elif platform.system() == "Darwin":
return (
subprocess.check_output(
["sysctl", "-n", "machdep.cpu.brand_string"]
)
.decode()
.strip()
)
except Exception as e:
logger.warning("CPU name detection failed: %s", e)
return platform.processor() or None
_cpu_name: str | None = _get_cpu_name()
router = APIRouter()
@router.get("/health", response_model=HealthResponse, tags=["Health"])
async def health_check():
"""Check service health status.
Returns basic health information including status, version, and timestamp.
"""
logger.debug("Health check requested")
return HealthResponse(
status="healthy",
timestamp=datetime.now(timezone.utc),
version=__version__,
demo_mode=get_config().demo,
auth_required=is_auth_enabled(),
)
@router.get("/api/v1/version", response_model=VersionResponse, tags=["Info"])
async def get_version():
"""Get version information.
Returns application version, Python version, and API version.
"""
logger.debug("Version info requested")
return VersionResponse(
version=__version__,
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
api_version="v1",
demo_mode=get_config().demo,
)
@router.get("/api/v1/tags", tags=["Tags"])
async def list_all_tags(_: AuthRequired):
"""Get all tags used across all entities."""
all_tags: set[str] = set()
store_getters = [
get_device_store, get_output_target_store, get_color_strip_store,
get_picture_source_store, get_audio_source_store, get_value_source_store,
get_sync_clock_store, get_automation_store, get_scene_preset_store,
get_template_store, get_audio_template_store, get_pp_template_store,
get_pattern_template_store,
]
for getter in store_getters:
try:
store = getter()
except RuntimeError:
continue
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
items = fn() if fn else None
if items:
for item in items:
all_tags.update(item.tags)
return {"tags": sorted(all_tags)}
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays(
_: AuthRequired,
engine_type: Optional[str] = Query(None, description="Engine type to get displays for"),
):
"""Get list of available displays.
Returns information about all available monitors/displays that can be captured.
When ``engine_type`` is provided, returns displays specific to that engine
(e.g. ``scrcpy`` returns connected Android devices instead of desktop monitors).
"""
logger.info(f"Listing available displays (engine_type={engine_type})")
try:
from wled_controller.core.capture_engines import EngineRegistry
if engine_type:
engine_cls = EngineRegistry.get_engine(engine_type)
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
elif is_demo_mode():
# In demo mode, use the best available engine (demo engine at priority 1000)
# instead of the mss-based real display detection
best = EngineRegistry.get_best_available_engine()
if best:
engine_cls = EngineRegistry.get_engine(best)
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
else:
display_dataclasses = await asyncio.to_thread(get_available_displays)
else:
display_dataclasses = await asyncio.to_thread(get_available_displays)
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
displays = [
DisplayInfo(
index=d.index,
name=d.name,
width=d.width,
height=d.height,
x=d.x,
y=d.y,
is_primary=d.is_primary,
refresh_rate=d.refresh_rate,
)
for d in display_dataclasses
]
logger.info(f"Found {len(displays)} displays")
return DisplayListResponse(
displays=displays,
count=len(displays),
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to get displays: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve display information: {str(e)}"
)
@router.get("/api/v1/system/processes", response_model=ProcessListResponse, tags=["Config"])
async def get_running_processes(_: AuthRequired):
"""Get list of currently running process names.
Returns a sorted list of unique process names for use in automation conditions.
"""
from wled_controller.core.automations.platform_detector import PlatformDetector
try:
detector = PlatformDetector()
processes = await detector.get_running_processes()
sorted_procs = sorted(processes)
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
except Exception as e:
logger.error(f"Failed to get processes: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve process list: {str(e)}"
)
@router.get(
"/api/v1/system/performance",
response_model=PerformanceResponse,
tags=["Config"],
)
def get_system_performance(_: AuthRequired):
"""Get current system performance metrics (CPU, RAM, GPU).
Uses sync ``def`` so FastAPI runs it in a thread pool — the psutil
and NVML calls are blocking and would stall the event loop if run
in an ``async def`` handler.
"""
mem = psutil.virtual_memory()
gpu = None
if _nvml_available:
try:
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle)
temp = _nvml.nvmlDeviceGetTemperature(
_nvml_handle, _nvml.NVML_TEMPERATURE_GPU
)
gpu = GpuInfo(
name=_nvml.nvmlDeviceGetName(_nvml_handle),
utilization=float(util.gpu),
memory_used_mb=round(mem_info.used / 1024 / 1024, 1),
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp),
)
except Exception as e:
logger.debug("NVML query failed: %s", e)
return PerformanceResponse(
cpu_name=_cpu_name,
cpu_percent=psutil.cpu_percent(interval=None),
ram_used_mb=round(mem.used / 1024 / 1024, 1),
ram_total_mb=round(mem.total / 1024 / 1024, 1),
ram_percent=mem.percent,
gpu=gpu,
timestamp=datetime.now(timezone.utc),
)
@router.get("/api/v1/system/metrics-history", tags=["Config"])
async def get_metrics_history(
_: AuthRequired,
manager=Depends(get_processor_manager),
):
"""Return the last ~2 minutes of system and per-target metrics.
Used by the dashboard to seed charts on page load so history
survives browser refreshes.
"""
return manager.metrics_history.get_history()
@router.get("/api/v1/system/api-keys", tags=["System"])
def list_api_keys(_: AuthRequired):
"""List API key labels (read-only; keys are defined in the YAML config file)."""
config = get_config()
keys = [
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
for label, key in config.auth.api_keys.items()
]
return {"keys": keys, "count": len(keys)}