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
+103 -1
View File
@@ -47,6 +47,13 @@ def output_target_store(_route_db):
return OutputTargetStore(_route_db)
@pytest.fixture
def mqtt_source_store(_route_db):
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
return MQTTSourceStore(_route_db)
@pytest.fixture
def processor_manager():
"""A mock ProcessorManager — avoids real hardware."""
@@ -60,7 +67,7 @@ def processor_manager():
@pytest.fixture
def client(device_store, output_target_store, processor_manager):
def client(device_store, output_target_store, processor_manager, mqtt_source_store):
app = _make_app()
# Override auth to always pass
@@ -72,6 +79,7 @@ def client(device_store, output_target_store, processor_manager):
app.dependency_overrides[deps.get_device_store] = lambda: device_store
app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store
app.dependency_overrides[deps.get_processor_manager] = lambda: processor_manager
app.dependency_overrides[deps.get_mqtt_store] = lambda: mqtt_source_store
return TestClient(app, raise_server_exceptions=False)
@@ -428,6 +436,100 @@ class TestWLEDSchemeInference:
assert device_store.get_device(existing.id).url == "http://10.0.0.5"
class TestMqttSourceId:
"""Regression coverage for the device ``mqtt_source_id`` field.
The store + ``device_config.MQTTConfig`` already carried the field, but
the API schema/route layer dropped it (DeviceCreate/Update/Response never
declared it, and the route never threaded it). These pin the create +
update round-trip and the referenced-source validation so it can't
silently regress.
"""
@pytest.fixture
def _stub_mqtt_validate(self, monkeypatch):
async def fake_validate(self, url): # noqa: ARG001 — provider self
return {"led_count": 60}
from ledgrab.core.devices.mqtt_provider import MQTTDeviceProvider
monkeypatch.setattr(MQTTDeviceProvider, "validate_device", fake_validate)
return fake_validate
def test_create_mqtt_device_persists_source_id(
self, client, device_store, mqtt_source_store, _stub_mqtt_validate
):
src = mqtt_source_store.create_source(name="Broker A", broker_host="192.168.1.10")
resp = client.post(
"/api/v1/devices",
json={
"name": "Living Room MQTT",
"device_type": "mqtt",
"url": "mqtt://ledgrab/device/living-room",
"led_count": 60,
"mqtt_source_id": src.id,
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["mqtt_source_id"] == src.id
assert device_store.get_device(body["id"]).mqtt_source_id == src.id
def test_create_mqtt_device_rejects_unknown_source(self, client, _stub_mqtt_validate):
resp = client.post(
"/api/v1/devices",
json={
"name": "Bad Broker Ref",
"device_type": "mqtt",
"url": "mqtt://ledgrab/device/x",
"led_count": 60,
"mqtt_source_id": "mqs_doesnotexist",
},
)
assert resp.status_code == 422, resp.text
assert "not found" in resp.json()["detail"]
def test_update_device_sets_mqtt_source_id(self, client, device_store, mqtt_source_store):
src = mqtt_source_store.create_source(name="Broker B", broker_host="10.0.0.2")
dev = device_store.create_device(
name="MQTT dev",
url="mqtt://ledgrab/device/a",
led_count=10,
device_type="mqtt",
)
resp = client.put(f"/api/v1/devices/{dev.id}", json={"mqtt_source_id": src.id})
assert resp.status_code == 200, resp.text
assert resp.json()["mqtt_source_id"] == src.id
assert device_store.get_device(dev.id).mqtt_source_id == src.id
def test_update_device_can_clear_mqtt_source(self, client, device_store, mqtt_source_store):
"""An empty string unsets the broker (back to 'first available'). The
store's None-means-skip rule means '' is a real value that persists."""
src = mqtt_source_store.create_source(name="Broker C", broker_host="10.0.0.3")
dev = device_store.create_device(
name="MQTT dev3",
url="mqtt://ledgrab/device/c",
led_count=10,
device_type="mqtt",
mqtt_source_id=src.id,
)
resp = client.put(f"/api/v1/devices/{dev.id}", json={"mqtt_source_id": ""})
assert resp.status_code == 200, resp.text
assert resp.json()["mqtt_source_id"] == ""
assert device_store.get_device(dev.id).mqtt_source_id == ""
def test_update_device_rejects_unknown_mqtt_source(self, client, device_store):
dev = device_store.create_device(
name="MQTT dev2",
url="mqtt://ledgrab/device/b",
led_count=10,
device_type="mqtt",
)
resp = client.put(f"/api/v1/devices/{dev.id}", json={"mqtt_source_id": "mqs_nope"})
assert resp.status_code == 422, resp.text
assert "not found" in resp.json()["detail"]
class TestPairThenCreateFlow:
"""End-to-end coverage: pair, then persist; assert the token is
encrypted at rest and decrypted in to_config(), and that the API