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