Update docs and screenshots
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.dependencies import app_state
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _create_token(client: TestClient, bridge_id: str | None = None) -> str:
|
||||
response = client.post(
|
||||
"/api/v1/integrations/midi/tokens",
|
||||
json={
|
||||
"label": f"pytest-{uuid4().hex[:8]}",
|
||||
"bridge_id": bridge_id,
|
||||
"scopes": ["midi:connect", "midi:events", "midi:heartbeat"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
def _create_scene(client: TestClient, slug: str) -> int:
|
||||
response = client.post(
|
||||
"/api/v1/scenes",
|
||||
json={
|
||||
"name": slug,
|
||||
"slug": slug,
|
||||
"priority": 25,
|
||||
"values": [{"channel": 10, "value": 255, "precedence": "htp", "source": "scene"}],
|
||||
"targets": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return int(response.json()["id"])
|
||||
|
||||
|
||||
def test_midi_heartbeat_updates_bridge_status() -> None:
|
||||
with TestClient(app) as client:
|
||||
token = _create_token(client, bridge_id="bridge-heartbeat")
|
||||
heartbeat = client.post(
|
||||
"/api/v1/integrations/midi/bridge/heartbeat",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"type": "heartbeat",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": "bridge-heartbeat",
|
||||
"device": "USB MIDI Test",
|
||||
"timestamp": "2026-07-24T20:30:00+00:00",
|
||||
},
|
||||
)
|
||||
assert heartbeat.status_code == 200
|
||||
bridges = client.get("/api/v1/integrations/midi/bridges")
|
||||
assert bridges.status_code == 200
|
||||
item = next(entry for entry in bridges.json()["items"] if entry["bridge_id"] == "bridge-heartbeat")
|
||||
assert item["device_name"] == "USB MIDI Test"
|
||||
assert item["online"] is True
|
||||
assert item["last_heartbeat_at"] is not None
|
||||
|
||||
|
||||
def test_midi_event_can_activate_scene_through_mapping() -> None:
|
||||
slug = f"midi-scene-{uuid4().hex[:8]}"
|
||||
with TestClient(app) as client:
|
||||
scene_id = _create_scene(client, slug)
|
||||
token = _create_token(client, bridge_id="bridge-scenes")
|
||||
mapping = client.post(
|
||||
"/api/v1/integrations/midi/mappings",
|
||||
json={
|
||||
"name": "Scene trigger",
|
||||
"enabled": True,
|
||||
"bridge_id": "bridge-scenes",
|
||||
"device_name": "USB*",
|
||||
"message_type": "note_on",
|
||||
"channel": 0,
|
||||
"number": 36,
|
||||
"action": "activate_scene",
|
||||
"target_type": "scene",
|
||||
"target_id": slug,
|
||||
"mode": "trigger",
|
||||
"minimum_value": 1,
|
||||
"maximum_value": 127,
|
||||
},
|
||||
)
|
||||
assert mapping.status_code == 200
|
||||
|
||||
event = client.post(
|
||||
"/api/v1/integrations/midi/bridge/events",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"type": "midi_event",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": "bridge-scenes",
|
||||
"device": "USB MIDI Controller",
|
||||
"timestamp": "2026-07-24T20:30:00+00:00",
|
||||
"message": {"type": "note_on", "channel": 0, "number": 36, "value": 127},
|
||||
},
|
||||
)
|
||||
assert event.status_code == 200
|
||||
assert event.json()["executed"] == 1
|
||||
assert f"scene:{slug}" in app_state.engine.layers
|
||||
|
||||
client.delete(f"/api/v1/integrations/midi/mappings/{mapping.json()['id']}")
|
||||
client.delete(f"/api/v1/scenes/{scene_id}")
|
||||
|
||||
|
||||
def test_midi_flash_hold_applies_and_releases_scene_layer() -> None:
|
||||
slug = f"midi-flash-{uuid4().hex[:8]}"
|
||||
with TestClient(app) as client:
|
||||
scene_id = _create_scene(client, slug)
|
||||
token = _create_token(client, bridge_id="bridge-flash")
|
||||
mapping_response = client.post(
|
||||
"/api/v1/integrations/midi/mappings",
|
||||
json={
|
||||
"name": "Flash scene",
|
||||
"enabled": True,
|
||||
"bridge_id": "bridge-flash",
|
||||
"device_name": "*",
|
||||
"message_type": "note_on",
|
||||
"channel": 0,
|
||||
"number": 40,
|
||||
"action": "flash_scene",
|
||||
"target_type": "scene",
|
||||
"target_id": slug,
|
||||
"mode": "hold",
|
||||
"minimum_value": 1,
|
||||
"maximum_value": 127,
|
||||
},
|
||||
)
|
||||
assert mapping_response.status_code == 200
|
||||
mapping_id = mapping_response.json()["id"]
|
||||
layer_name = f"midi:flash:{mapping_id}:{slug}"
|
||||
|
||||
note_on = client.post(
|
||||
"/api/v1/integrations/midi/bridge/events",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"type": "midi_event",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": "bridge-flash",
|
||||
"device": "USB MIDI Controller",
|
||||
"timestamp": "2026-07-24T20:30:01+00:00",
|
||||
"message": {"type": "note_on", "channel": 0, "number": 40, "value": 127},
|
||||
},
|
||||
)
|
||||
assert note_on.status_code == 200
|
||||
assert layer_name in app_state.engine.layers
|
||||
|
||||
note_off = client.post(
|
||||
"/api/v1/integrations/midi/bridge/events",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"type": "midi_event",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": "bridge-flash",
|
||||
"device": "USB MIDI Controller",
|
||||
"timestamp": "2026-07-24T20:30:02+00:00",
|
||||
"message": {"type": "note_off", "channel": 0, "number": 40, "value": 0},
|
||||
},
|
||||
)
|
||||
assert note_off.status_code == 200
|
||||
assert layer_name not in app_state.engine.layers
|
||||
|
||||
client.delete(f"/api/v1/integrations/midi/mappings/{mapping_id}")
|
||||
client.delete(f"/api/v1/scenes/{scene_id}")
|
||||
|
||||
|
||||
def test_midi_continuous_master_and_scene_intensity_are_scaled() -> None:
|
||||
slug = f"midi-intensity-{uuid4().hex[:8]}"
|
||||
with TestClient(app) as client:
|
||||
scene_id = _create_scene(client, slug)
|
||||
token = _create_token(client, bridge_id="bridge-cc")
|
||||
master_mapping = client.post(
|
||||
"/api/v1/integrations/midi/mappings",
|
||||
json={
|
||||
"name": "Master dimmer",
|
||||
"enabled": True,
|
||||
"bridge_id": "bridge-cc",
|
||||
"device_name": "*",
|
||||
"message_type": "control_change",
|
||||
"channel": 0,
|
||||
"number": 14,
|
||||
"action": "set_master_dimmer",
|
||||
"target_type": "global",
|
||||
"target_id": None,
|
||||
"mode": "continuous",
|
||||
"minimum_value": 0,
|
||||
"maximum_value": 127,
|
||||
},
|
||||
)
|
||||
assert master_mapping.status_code == 200
|
||||
|
||||
intensity_mapping = client.post(
|
||||
"/api/v1/integrations/midi/mappings",
|
||||
json={
|
||||
"name": "Scene intensity",
|
||||
"enabled": True,
|
||||
"bridge_id": "bridge-cc",
|
||||
"device_name": "*",
|
||||
"message_type": "control_change",
|
||||
"channel": 0,
|
||||
"number": 15,
|
||||
"action": "set_scene_intensity",
|
||||
"target_type": "scene",
|
||||
"target_id": slug,
|
||||
"mode": "continuous",
|
||||
"minimum_value": 0,
|
||||
"maximum_value": 127,
|
||||
},
|
||||
)
|
||||
assert intensity_mapping.status_code == 200
|
||||
|
||||
master_event = client.post(
|
||||
"/api/v1/integrations/midi/bridge/events",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"type": "midi_event",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": "bridge-cc",
|
||||
"device": "USB MIDI Controller",
|
||||
"timestamp": "2026-07-24T20:31:00+00:00",
|
||||
"message": {"type": "control_change", "channel": 0, "number": 14, "value": 64},
|
||||
},
|
||||
)
|
||||
assert master_event.status_code == 200
|
||||
assert 120 <= app_state.engine.master <= 132
|
||||
|
||||
intensity_event = client.post(
|
||||
"/api/v1/integrations/midi/bridge/events",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"type": "midi_event",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": "bridge-cc",
|
||||
"device": "USB MIDI Controller",
|
||||
"timestamp": "2026-07-24T20:31:01+00:00",
|
||||
"message": {"type": "control_change", "channel": 0, "number": 15, "value": 64},
|
||||
},
|
||||
)
|
||||
assert intensity_event.status_code == 200
|
||||
layer_name = f"midi:intensity:{intensity_mapping.json()['id']}:{slug}"
|
||||
layer = app_state.engine.layers[layer_name]
|
||||
assert 120 <= layer.values_by_universe[1][10] <= 132
|
||||
|
||||
client.delete(f"/api/v1/integrations/midi/mappings/{master_mapping.json()['id']}")
|
||||
client.delete(f"/api/v1/integrations/midi/mappings/{intensity_mapping.json()['id']}")
|
||||
client.delete(f"/api/v1/scenes/{scene_id}")
|
||||
|
||||
|
||||
def test_midi_learn_captures_next_event_without_triggering_existing_mapping() -> None:
|
||||
slug = f"midi-learn-{uuid4().hex[:8]}"
|
||||
with TestClient(app) as client:
|
||||
scene_id = _create_scene(client, slug)
|
||||
token = _create_token(client, bridge_id="bridge-learn")
|
||||
mapping = client.post(
|
||||
"/api/v1/integrations/midi/mappings",
|
||||
json={
|
||||
"name": "Learn target",
|
||||
"enabled": True,
|
||||
"bridge_id": "bridge-learn",
|
||||
"device_name": "*",
|
||||
"message_type": "note_on",
|
||||
"channel": 0,
|
||||
"number": 41,
|
||||
"action": "activate_scene",
|
||||
"target_type": "scene",
|
||||
"target_id": slug,
|
||||
"mode": "trigger",
|
||||
"minimum_value": 1,
|
||||
"maximum_value": 127,
|
||||
},
|
||||
)
|
||||
assert mapping.status_code == 200
|
||||
|
||||
start = client.post(
|
||||
"/api/v1/integrations/midi/learn/start",
|
||||
json={"timeout_seconds": 15, "allow_passthrough": False},
|
||||
)
|
||||
assert start.status_code == 200
|
||||
assert start.json()["active"] is True
|
||||
|
||||
event = client.post(
|
||||
"/api/v1/integrations/midi/bridge/events",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"type": "midi_event",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": "bridge-learn",
|
||||
"device": "USB MIDI Controller",
|
||||
"timestamp": "2026-07-24T20:32:00+00:00",
|
||||
"message": {"type": "note_on", "channel": 0, "number": 41, "value": 127},
|
||||
},
|
||||
)
|
||||
assert event.status_code == 200
|
||||
assert event.json()["captured_for_learning"] is True
|
||||
assert f"scene:{slug}" not in app_state.engine.layers
|
||||
|
||||
state = client.get("/api/v1/integrations/midi/learn")
|
||||
assert state.status_code == 200
|
||||
assert state.json()["active"] is False
|
||||
assert state.json()["captured_event"]["message"]["number"] == 41
|
||||
|
||||
client.delete(f"/api/v1/integrations/midi/mappings/{mapping.json()['id']}")
|
||||
client.delete(f"/api/v1/scenes/{scene_id}")
|
||||
|
||||
|
||||
def test_midi_bridge_token_can_be_restricted_to_bridge_id() -> None:
|
||||
with TestClient(app) as client:
|
||||
token = _create_token(client, bridge_id="bridge-locked")
|
||||
response = client.post(
|
||||
"/api/v1/integrations/midi/bridge/events",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"type": "midi_event",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": "wrong-bridge",
|
||||
"device": "USB MIDI Controller",
|
||||
"timestamp": "2026-07-24T20:33:00+00:00",
|
||||
"message": {"type": "note_on", "channel": 0, "number": 50, "value": 127},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
Reference in New Issue
Block a user