import json import time import uuid from typing import Any, Dict import pytest from api_server import ( # noqa: E402 DummyBridge, bridge_sent_envelope, device_body_from_envelope, ) def _json(resp) -> Dict[str, Any]: # Many endpoints already set Content-Type; but be tolerant for now. return resp.json() # pragma: no cover def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) -> str: for obj_id, data in list_resp_json.items(): if isinstance(data, dict) and data.get(field) == value: return str(obj_id) raise AssertionError(f"Could not find id for {field}={value!r}") def _create_and_apply_profile(c, base_url: str) -> str: """Sequences/scenes/presets need an active profile in session.""" unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}" resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name}) assert resp.status_code == 201 profile_id = next(iter(resp.json().keys())) resp = c.post(f"{base_url}/profiles/{profile_id}/apply") assert resp.status_code == 200 return str(profile_id) def test_main_routes(server): c = server["client"] base_url: str = server["base_url"] resp = c.get(f"{base_url}/") assert resp.status_code == 200 assert "LED Controller" in resp.text resp = c.get(f"{base_url}/favicon.ico") assert resp.status_code == 204 resp = c.get(f"{base_url}/static/style.css") assert resp.status_code == 200 resp = c.get(f"{base_url}/settings/page") assert resp.status_code == 200 assert "LED Controller" in resp.text with c.websocket_connect("/ws") as ws: ws.send_text('{"v":"1","select":["off"]}') def test_settings_controller(server): c = server["client"] base_url: str = server["base_url"] resp = c.get(f"{base_url}/settings") assert resp.status_code == 200 data = resp.json() assert isinstance(data, dict) assert "wifi_channel" in data resp = c.get(f"{base_url}/settings/wifi/ap") assert resp.status_code == 200 ap = resp.json() assert "saved_ssid" in ap assert "active" in ap unique_ssid = f"pytest-ssid-{uuid.uuid4().hex[:8]}" resp = c.post( f"{base_url}/settings/wifi/ap", json={"ssid": unique_ssid, "password": "secret", "channel": 1}, ) assert resp.status_code == 200 msg = resp.json() assert msg["ssid"] == unique_ssid assert msg["channel"] == 1 resp = c.post( f"{base_url}/settings/wifi/ap", json={"ssid": "bad-ssid", "password": "secret", "channel": 12}, ) assert resp.status_code == 400 resp = c.put(f"{base_url}/settings", json={"wifi_channel": 11}) assert resp.status_code == 200 resp = c.put(f"{base_url}/settings", json={"wifi_channel": 12}) assert resp.status_code == 400 resp = c.put(f"{base_url}/settings", json={"global_brightness": 42}) assert resp.status_code == 200 resp = c.get(f"{base_url}/settings") assert resp.status_code == 200 assert resp.json().get("global_brightness") == 42 resp = c.put( f"{base_url}/settings", json={"sequence_switch_wait": "downbeat"}, ) assert resp.status_code == 200 resp = c.get(f"{base_url}/settings") assert resp.json().get("sequence_switch_wait") == "downbeat" resp = c.put(f"{base_url}/settings", json={"global_brightness": 300}) assert resp.status_code == 400 def test_profiles_presets_zones_endpoints(server, monkeypatch): c = server["client"] base_url: str = server["base_url"] bridge: DummyBridge = server["bridge"] import controllers.device as device_ctl monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05) unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}" resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name}) assert resp.status_code == 201 created = resp.json() assert isinstance(created, dict) profile_id = next(iter(created.keys())) resp = c.post(f"{base_url}/profiles/{profile_id}/apply") assert resp.status_code == 200 resp = c.get(f"{base_url}/profiles/current") assert resp.status_code == 200 current = resp.json() assert str(current["id"]) == str(profile_id) # Presets CRUD (scoped to current profile session). resp = c.get(f"{base_url}/presets") assert resp.status_code == 200 presets = resp.json() assert isinstance(presets, dict) assert presets # seeded presets should exist first_preset_id = next(iter(presets.keys())) resp = c.get(f"{base_url}/presets/{first_preset_id}") assert resp.status_code == 200 assert resp.json() # dict unique_preset_name = f"pytest-preset-{uuid.uuid4().hex[:8]}" resp = c.post( f"{base_url}/presets", json={ "name": unique_preset_name, "pattern": "on", "colors": ["#ff0000"], "brightness": 123, "delay": 100, }, ) assert resp.status_code == 201 created_preset = resp.json() new_preset_id = next(iter(created_preset.keys())) assert created_preset[new_preset_id]["profile_id"] == str(profile_id) resp = c.put( f"{base_url}/presets/{new_preset_id}", json={"brightness": 77}, ) assert resp.status_code == 200 assert resp.json()["brightness"] == 77 bridge.sent.clear() resp = c.post( f"{base_url}/presets/send", json={"preset_ids": [new_preset_id], "save": False}, ) assert resp.status_code == 200 sent_result = resp.json() assert sent_result["presets_sent"] >= 1 assert len(bridge.sent) >= 1 resp = c.delete(f"{base_url}/presets/{new_preset_id}") assert resp.status_code == 200 resp = c.get(f"{base_url}/presets/{new_preset_id}") assert resp.status_code == 404 # Zones CRUD (scoped to current profile session). unique_zone_name = f"pytest-zone-{uuid.uuid4().hex[:8]}" resp = c.post( f"{base_url}/zones", json={"name": unique_zone_name, "names": ["1", "2"]}, ) assert resp.status_code == 201 created_zones = resp.json() zone_id = next(iter(created_zones.keys())) resp = c.get(f"{base_url}/zones/{zone_id}") assert resp.status_code == 200 assert resp.json()["name"] == unique_zone_name resp = c.post(f"{base_url}/zones/{zone_id}/set-current") assert resp.status_code == 200 resp = c.get(f"{base_url}/zones/current") assert resp.status_code == 200 assert resp.json()["zone_id"] == str(zone_id) resp = c.put( f"{base_url}/zones/{zone_id}", json={"name": f"{unique_zone_name}-updated", "names": ["3"]}, ) assert resp.status_code == 200 assert resp.json()["names"] == ["3"] resp = c.post(f"{base_url}/zones/{zone_id}/clone", json={"name": "pytest-zone-clone"}) assert resp.status_code == 201 clone_payload = resp.json() clone_id = next(iter(clone_payload.keys())) resp = c.get(f"{base_url}/zones/{clone_id}") assert resp.status_code == 200 resp = c.delete(f"{base_url}/zones/{clone_id}") assert resp.status_code == 200 resp = c.delete(f"{base_url}/zones/{zone_id}") assert resp.status_code == 200 resp = c.get(f"{base_url}/profiles/{profile_id}/export") assert resp.status_code == 200 bundle = resp.json() assert bundle.get("kind") == "profile" assert isinstance(bundle.get("presets"), dict) import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}" resp = c.post( f"{base_url}/profiles/import", json={"bundle": bundle, "name": import_name, "apply": False}, ) assert resp.status_code == 201 imported_profile_id = resp.json().get("id") or next( k for k in resp.json().keys() if k != "id" ) resp = c.delete(f"{base_url}/profiles/{imported_profile_id}") assert resp.status_code == 200 resp = c.get(f"{base_url}/presets/{first_preset_id}/export") assert resp.status_code == 200 assert resp.json().get("kind") == "preset" resp = c.post( f"{base_url}/presets/import", json={"bundle": resp.json()}, ) assert resp.status_code == 201 imported_preset_id = next(iter(resp.json().keys())) resp = c.delete(f"{base_url}/presets/{imported_preset_id}") assert resp.status_code == 200 # Profile clone + update endpoints. clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}" resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name}) assert resp.status_code == 201 cloned = resp.json() clone_profile_id = next(iter(cloned.keys())) resp = c.post(f"{base_url}/profiles/{clone_profile_id}/apply") assert resp.status_code == 200 resp = c.put( f"{base_url}/profiles/current", json={"name": f"{clone_name}-updated"}, ) assert resp.status_code == 200 resp = c.put( f"{base_url}/profiles/{clone_profile_id}", json={"name": f"{clone_name}-updated-2"}, ) assert resp.status_code == 200 resp = c.delete(f"{base_url}/profiles/{clone_profile_id}") assert resp.status_code == 200 resp = c.delete(f"{base_url}/profiles/{profile_id}") assert resp.status_code == 200 def test_groups_sequences_scenes_palettes_patterns_endpoints(server): c = server["client"] base_url: str = server["base_url"] bridge: DummyBridge = server["bridge"] _create_and_apply_profile(c, base_url) # Groups. unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}" resp = c.post(f"{base_url}/groups", json={"name": unique_group_name}) assert resp.status_code == 201 groups_list = c.get(f"{base_url}/groups").json() group_id = _find_id_by_field(groups_list, "name", unique_group_name) resp = c.get(f"{base_url}/groups/{group_id}") assert resp.status_code == 200 assert resp.json()["name"] == unique_group_name resp = c.put(f"{base_url}/groups/{group_id}", json={"brightness": 10}) assert resp.status_code == 200 assert resp.json()["brightness"] == 10 resp = c.delete(f"{base_url}/groups/{group_id}") assert resp.status_code == 200 # Sequences. unique_seq_name = f"pytest-seq-{uuid.uuid4().hex[:8]}" resp = c.post( f"{base_url}/sequences", json={ "name": unique_seq_name, "steps": [{"preset_id": "1", "group_ids": []}], }, ) assert resp.status_code == 201 sequences_list = c.get(f"{base_url}/sequences").json() seq_id = _find_id_by_field(sequences_list, "name", unique_seq_name) resp = c.get(f"{base_url}/sequences/{seq_id}") assert resp.status_code == 200 resp = c.put(f"{base_url}/sequences/{seq_id}", json={"step_duration_ms": 1234}) assert resp.status_code == 200 assert resp.json()["step_duration_ms"] == 1234 resp = c.delete(f"{base_url}/sequences/{seq_id}") assert resp.status_code == 200 # Scenes. unique_scene_name = f"pytest-scene-{uuid.uuid4().hex[:8]}" resp = c.post(f"{base_url}/scenes", json={"name": unique_scene_name}) assert resp.status_code == 201 scenes_list = c.get(f"{base_url}/scenes").json() scene_id = _find_id_by_field(scenes_list, "name", unique_scene_name) resp = c.get(f"{base_url}/scenes/{scene_id}") assert resp.status_code == 200 resp = c.put(f"{base_url}/scenes/{scene_id}", json={"name": unique_scene_name + "-updated"}) assert resp.status_code == 200 assert resp.json()["name"].endswith("-updated") resp = c.delete(f"{base_url}/scenes/{scene_id}") assert resp.status_code == 200 # Palettes. colors = ["#112233", "#445566"] resp = c.post(f"{base_url}/palettes", json={"colors": colors}) assert resp.status_code == 201 palette_payload = resp.json() palette_id = str(palette_payload["id"]) resp = c.get(f"{base_url}/palettes/{palette_id}") assert resp.status_code == 200 assert resp.json()["id"] == palette_id resp = c.put(f"{base_url}/palettes/{palette_id}", json={"colors": ["#000000"]}) assert resp.status_code == 200 assert resp.json()["colors"] == ["#000000"] resp = c.delete(f"{base_url}/palettes/{palette_id}") assert resp.status_code == 200 # Devices (LED driver registry). resp = c.get(f"{base_url}/devices") assert resp.status_code == 200 assert resp.json() == {} resp = c.post(f"{base_url}/devices", json={}) assert resp.status_code == 400 resp = c.post( f"{base_url}/devices", json={"name": "pytest-dev", "address": "aa:bb:cc:dd:ee:ff"}, ) assert resp.status_code == 201 dev_map = resp.json() dev_id = next(iter(dev_map.keys())) assert dev_id == "aabbccddeeff" assert dev_map[dev_id]["name"] == "pytest-dev" assert dev_map[dev_id]["id"] == dev_id assert dev_map[dev_id]["type"] == "led" assert dev_map[dev_id]["transport"] == "espnow" assert dev_map[dev_id]["address"] == "aabbccddeeff" resp = c.get(f"{base_url}/devices/{dev_id}") assert resp.status_code == 200 assert resp.json()["name"] == "pytest-dev" assert resp.json()["type"] == "led" assert resp.json().get("connected") is None resp = c.get(f"{base_url}/devices") assert resp.status_code == 200 assert resp.json()[dev_id].get("connected") is None bridge.sent.clear() resp = c.post(f"{base_url}/devices/{dev_id}/identify") assert resp.status_code == 200 assert resp.json().get("message") assert len(bridge.sent) >= 1 first = bridge_sent_envelope(bridge, 0) assert first["v"] == "1" first_body = device_body_from_envelope(first, dev_id) assert first_body["p"]["__identify"]["p"] == "blink" assert first_body["p"]["__identify"]["d"] == 50 assert first_body["s"] == ["__identify"] deadline = time.monotonic() + 2.0 while len(bridge.sent) < 2 and time.monotonic() < deadline: time.sleep(0.02) assert len(bridge.sent) >= 2 second = bridge_sent_envelope(bridge, 1) second_body = device_body_from_envelope(second, dev_id) assert second_body["s"] == ["off"] resp = c.post( f"{base_url}/devices", json={ "name": "pytest-wifi", "type": "led", "transport": "wifi", "address": "192.168.50.10", "mac": "102030405060", }, ) assert resp.status_code == 201 wid = "102030405060" assert wid in resp.json() assert resp.json()[wid]["transport"] == "wifi" assert resp.json()[wid]["address"] == "192.168.50.10" resp = c.get(f"{base_url}/devices/{wid}") assert resp.status_code == 200 assert resp.json().get("connected") is None resp = c.post( f"{base_url}/devices", json={ "name": "pytest-wifi", "transport": "wifi", "address": "192.168.50.11", "mac": "102030405061", }, ) assert resp.status_code == 201 wid2 = "102030405061" assert wid2 in resp.json() assert resp.json()[wid2]["name"] == "pytest-wifi" resp = c.post( f"{base_url}/devices", json={ "name": "pytest-wifi-dupmac", "transport": "wifi", "address": "192.168.50.99", "mac": "102030405060", }, ) assert resp.status_code == 409 resp = c.post( f"{base_url}/devices", json={"name": "no-mac-wifi", "transport": "wifi", "address": "192.168.50.12"}, ) assert resp.status_code == 400 resp = c.post( f"{base_url}/devices", json={"name": "bad-tr", "transport": "serial"}, ) assert resp.status_code == 400 resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": " "}) assert resp.status_code == 400 resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": "renamed"}) assert resp.status_code == 200 assert resp.json()["name"] == "renamed" resp = c.put(f"{base_url}/devices/{wid}", json={"name": "renamed"}) assert resp.status_code == 200 assert resp.json()["name"] == "renamed" resp = c.delete(f"{base_url}/devices/{wid2}") assert resp.status_code == 200 resp = c.delete(f"{base_url}/devices/{wid}") assert resp.status_code == 200 resp = c.delete(f"{base_url}/devices/{dev_id}") assert resp.status_code == 200 # Patterns. resp = c.get(f"{base_url}/patterns/definitions") assert resp.status_code == 200 definitions = resp.json() assert isinstance(definitions, dict) assert "colour_cycle" in definitions cc_mode = definitions["colour_cycle"].get("mode") assert isinstance(cc_mode, dict) assert "0" in cc_mode and "1" in cc_mode assert "blink" in definitions blink_mode = definitions["blink"].get("mode") assert not isinstance(blink_mode, dict) or len(blink_mode) < 2 pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}" resp = c.post( f"{base_url}/patterns", json={"name": pattern_id, "data": {"foo": "bar"}}, ) assert resp.status_code == 201 assert resp.json()["foo"] == "bar" resp = c.get(f"{base_url}/patterns") assert resp.status_code == 200 patterns_list = resp.json() assert isinstance(patterns_list, dict) # Runtime list merges repo ``db/pattern.json`` + driver ``.py`` names; test DB # entries are still exposed on GET /patterns/ after POST. assert "blink" in patterns_list or len(patterns_list) >= 1 resp = c.get(f"{base_url}/patterns/{pattern_id}") assert resp.status_code == 200 assert resp.json()["foo"] == "bar" resp = c.put(f"{base_url}/patterns/{pattern_id}", json={"baz": 1}) assert resp.status_code == 200 assert resp.json()["baz"] == 1 resp = c.delete(f"{base_url}/patterns/{pattern_id}") assert resp.status_code == 200 # on/off are firmware-only in presets.py — no OTA file; API rejects serve/send/upload/driver. resp = c.get(f"{base_url}/patterns/ota/file/on.py") assert resp.status_code == 400 assert "error" in resp.json() resp = c.post(f"{base_url}/patterns/off/send", json={}) assert resp.status_code == 400 assert "error" in resp.json() resp = c.post( f"{base_url}/patterns/upload", json={"name": "on.py", "code": "class On:\n def run(self, p):\n yield\n"}, ) assert resp.status_code == 400 resp = c.post( f"{base_url}/patterns/driver", json={ "name": "off", "code": "class Off:\n def run(self, p):\n yield\n", }, ) assert resp.status_code == 400 def test_audio_api(server): c = server["client"] base_url = server["base_url"] resp = c.get(f"{base_url}/api/audio/status") assert resp.status_code == 200 body = resp.json() assert "status" in body assert "audio_run" in body["status"] resp = c.get(f"{base_url}/api/audio/devices") assert resp.status_code == 200 assert "devices" in resp.json() resp = c.put( f"{base_url}/api/audio/device", json={"device_select": "default", "device_override": ""}, ) assert resp.status_code == 200 assert resp.json().get("ok") is True resp = c.post(f"{base_url}/api/audio/reset") assert resp.status_code == 409 resp = c.post(f"{base_url}/api/audio/stop") assert resp.status_code == 200 assert resp.json().get("ok") is True def test_bridge_settings_api(server, monkeypatch): c = server["client"] base_url = server["base_url"] import controllers.wifi_bridge as wifi_bridge_ctl # noqa: E402 monkeypatch.setattr(wifi_bridge_ctl, "nmcli_available", lambda: True) monkeypatch.setattr( wifi_bridge_ctl, "list_wifi_interfaces", lambda: [{"device": "wlan0", "type": "wifi", "state": "connected"}], ) async def _fake_scan(device): _ = device return [{"ssid": "bridge-test", "signal": 80}] monkeypatch.setattr(wifi_bridge_ctl, "scan_wifi", _fake_scan) resp = c.get(f"{base_url}/settings/wifi/interfaces") assert resp.status_code == 200 assert resp.json().get("ok") is True assert resp.json()["interfaces"][0]["device"] == "wlan0" resp = c.get(f"{base_url}/settings/wifi/scan", params={"device": "wlan0"}) assert resp.status_code == 200 assert resp.json()["networks"][0]["ssid"] == "bridge-test" resp = c.get(f"{base_url}/settings/wifi/bridges") assert resp.status_code == 200 payload = resp.json() assert payload.get("ok") is True assert "bridge_transport" in payload assert "bridges" in payload resp = c.put( f"{base_url}/settings/wifi/bridges", json={ "bridge_transport": "serial", "bridge_serial_port": "/dev/ttyUSB0", "bridges": [], }, ) assert resp.status_code == 200 assert resp.json().get("ok") is True resp = c.get(f"{base_url}/settings/wifi/bridges") assert resp.json().get("bridge_transport") == "serial" def test_group_identify(server, monkeypatch): c = server["client"] base_url = server["base_url"] bridge: DummyBridge = server["bridge"] import controllers.device as device_ctl # noqa: E402 monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05) _create_and_apply_profile(c, base_url) resp = c.post(f"{base_url}/groups", json={"name": "pytest-identify-group"}) assert resp.status_code == 201 groups_list = c.get(f"{base_url}/groups").json() group_id = _find_id_by_field(groups_list, "name", "pytest-identify-group") resp = c.post( f"{base_url}/devices", json={"name": "identify-dev", "address": "aabbccddeeff"}, ) assert resp.status_code == 201 dev_id = "aabbccddeeff" resp = c.put( f"{base_url}/groups/{group_id}", json={"devices": [dev_id]}, ) assert resp.status_code == 200 bridge.sent.clear() resp = c.post(f"{base_url}/groups/{group_id}/identify") assert resp.status_code == 200 assert resp.json().get("sent", 0) >= 1 assert len(bridge.sent) >= 1