feat(bridge): add wifi/serial bridge runtime and UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 00:38:21 +12:00
parent 2cf019079e
commit 78dc8ffc77
92 changed files with 5679 additions and 1790 deletions

View File

@@ -27,7 +27,7 @@ from microdot.session import Session # noqa: E402
from microdot.websocket import with_websocket # noqa: E402
class DummySender:
class DummyBridge:
def __init__(self):
self.sent: list[tuple[str, Optional[str]]] = []
@@ -166,7 +166,7 @@ def server(monkeypatch, tmp_path_factory):
old_cwd = os.getcwd()
os.chdir(str(SRC_PATH))
dummy_sender = DummySender()
dummy_bridge = DummyBridge()
try:
# Ensure controllers are imported fresh after our patching.
@@ -196,10 +196,10 @@ def server(monkeypatch, tmp_path_factory):
import controllers.settings as settings_ctl # noqa: E402
import controllers.device as device_ctl # noqa: E402
# Configure transport sender used by /presets/send.
from models.transport import set_sender # noqa: E402
# Configure transport bridge used by /presets/send.
from models.transport import set_bridge # noqa: E402
set_sender(dummy_sender)
set_bridge(dummy_bridge)
app = Microdot()
@@ -244,7 +244,7 @@ def server(monkeypatch, tmp_path_factory):
@app.route("/ws")
@with_websocket
async def ws(request, ws):
# Minimal websocket handler: forward raw JSON/text payloads to dummy sender.
# Minimal websocket handler: forward raw JSON/text payloads to dummy bridge.
while True:
data = await ws.receive()
if not data:
@@ -253,9 +253,9 @@ def server(monkeypatch, tmp_path_factory):
parsed = json.loads(data)
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await dummy_sender.send(payload, addr=addr)
await dummy_bridge.send(payload, addr=addr)
except Exception:
await dummy_sender.send(data)
await dummy_bridge.send(data)
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
base_url = f"http://127.0.0.1:{chosen_port}"
@@ -271,7 +271,7 @@ def server(monkeypatch, tmp_path_factory):
yield {
"base_url": base_url,
"client": client,
"sender": dummy_sender,
"bridge": dummy_bridge,
"thread": thread,
"app": app,
}
@@ -379,7 +379,7 @@ def test_settings_controller(server):
def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
bridge: DummyBridge = server["bridge"]
import controllers.device as device_ctl
@@ -436,7 +436,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
assert resp.status_code == 200
assert resp.json()["brightness"] == 77
sender.sent.clear()
bridge.sent.clear()
resp = c.post(
f"{base_url}/presets/send",
json={"preset_ids": [new_preset_id], "save": False},
@@ -444,7 +444,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
assert resp.status_code == 200
sent_result = resp.json()
assert sent_result["presets_sent"] >= 1
assert len(sender.sent) >= 1
assert len(bridge.sent) >= 1
resp = c.delete(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 200
@@ -555,7 +555,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
bridge: DummyBridge = server["bridge"]
_create_and_apply_profile(c, base_url)
@@ -667,21 +667,21 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200
assert resp.json()[dev_id].get("connected") is None
sender.sent.clear()
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(sender.sent) >= 1
first = json.loads(sender.sent[0][0])
assert len(bridge.sent) >= 1
first = json.loads(bridge.sent[0][0])
assert "presets" in first and "select" in first
assert first["presets"]["__identify"]["p"] == "blink"
assert first["presets"]["__identify"]["d"] == 50
assert first["select"] == ["__identify"]
deadline = time.monotonic() + 2.0
while len(sender.sent) < 2 and time.monotonic() < deadline:
while len(bridge.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02)
assert len(sender.sent) >= 2
second = json.loads(sender.sent[1][0])
assert len(bridge.sent) >= 2
second = json.loads(bridge.sent[1][0])
assert second.get("select") == ["off"]
resp = c.post(