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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user