feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers

Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
  presets and system into one payload for the HA coordinator, collapsing
  the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
  unit-tested graph_schema engine — one authoritative connectable-field
  registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
  the routes for multi-broker MQTT; shared validate_mqtt_source_exists
  (_mqtt_validation.py) reused by device + output-target routes; stop
  update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
  (shared by __main__, android_entry, demo) so a lingering events WebSocket
  can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
  its authenticated token label (never the secret); uvicorn access_log off.

Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
  connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
  and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.

Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
This commit is contained in:
2026-05-28 22:51:04 +03:00
parent b83a72e63f
commit a5effba553
37 changed files with 3068 additions and 145 deletions
+33 -1
View File
@@ -12,7 +12,12 @@ import signal
import threading
from types import SimpleNamespace
from ledgrab.__main__ import _install_signal_handlers, _request_shutdown
from ledgrab.__main__ import (
_build_server,
_install_signal_handlers,
_request_shutdown,
)
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
def test_request_shutdown_sets_should_exit() -> None:
@@ -21,6 +26,33 @@ def test_request_shutdown_sets_should_exit() -> None:
assert server.should_exit is True
def test_build_server_bounds_graceful_shutdown() -> None:
"""uvicorn defaults ``timeout_graceful_shutdown`` to ``None`` (wait
forever). A lingering events WebSocket then blocks ``Server.shutdown()``
from ever reaching the lifespan shutdown, so LED targets are never
stopped and the process can't exit. The bound is what guarantees the
lifespan shutdown runs — assert we never regress to the unbounded default.
"""
fake_config = SimpleNamespace(
server=SimpleNamespace(host="127.0.0.1", port=8080, log_level="INFO")
)
server = _build_server(fake_config) # type: ignore[arg-type]
assert server.config.timeout_graceful_shutdown == GRACEFUL_SHUTDOWN_TIMEOUT
assert server.config.timeout_graceful_shutdown is not None
assert server.config.timeout_graceful_shutdown > 0
def test_graceful_shutdown_timeout_fits_os_budget() -> None:
"""The graceful wait runs BEFORE the lifespan's own ~16 s shutdown budget,
and OS shutdown gives the whole process only ~20 s before a force-kill.
Keep the bound small so target restore + DB checkpoint still fit.
"""
assert isinstance(GRACEFUL_SHUTDOWN_TIMEOUT, int)
assert 0 < GRACEFUL_SHUTDOWN_TIMEOUT <= 5
def test_install_signal_handlers_installs_for_known_signals() -> None:
"""Tray path runs uvicorn on a background thread, so our handlers must
actually survive — verify each catchable signal is replaced.