Files
TuxDMX-WebUI/backend/app/api/routes.py
T
thomas 1f110866f5
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
Update docs and screenshots
2026-07-25 10:26:29 +02:00

703 lines
23 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.core.config import get_settings
from app.core.dependencies import app_state
from app.models.schemas import (
BpmAudioConfigPayload,
BpmAudioStartPayload,
ChannelValue,
DmxOutputConfigPayload,
EffectPayload,
HealthResponse,
HomeAssistantConfigPayload,
HomeAssistantMappingPayload,
MidiBridgeEventPayload,
MidiBridgeHeartbeatPayload,
MidiBridgeTokenCreatePayload,
MidiLearnStartPayload,
MidiMappingPayload,
MidiTestMappingPayload,
ManualPatchValuesPayload,
PatchPayload,
PatchValidationPayload,
ScenePayload,
TriggerRequest,
)
router = APIRouter()
settings = get_settings()
def _extract_bearer_token(request: Request) -> str | None:
authorization = request.headers.get("Authorization", "")
if not authorization.lower().startswith("bearer "):
return None
return authorization[7:].strip() or None
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(
status="ok",
simulator_enabled=settings.simulator_enabled,
version="1.0.0",
)
@router.get("/system/status")
async def system_status() -> dict[str, object]:
return {
"app": settings.app_name,
"setup_required": False,
"engine": app_state.engine.snapshot(),
"telemetry": app_state.telemetry.snapshot(len(app_state.triggers.queue)),
"bpm": app_state.bpm.snapshot(),
}
@router.get("/system/diagnostics")
async def diagnostics() -> dict[str, object]:
return {
"engine": app_state.engine.snapshot(),
"telemetry": app_state.telemetry.snapshot(len(app_state.triggers.queue)),
"trigger_queue": app_state.triggers.snapshot(),
}
@router.post("/system/restart-service")
async def restart_service() -> dict[str, object]:
return await app_state.system.restart_service()
@router.post("/system/reboot-host")
async def reboot_host() -> dict[str, object]:
return await app_state.system.reboot_host()
@router.get("/dmx/status")
async def dmx_status() -> dict[str, object]:
return app_state.engine.snapshot()
@router.get("/dmx/devices")
async def dmx_devices() -> dict[str, object]:
status = app_state.engine.backend.get_status()
return {
"devices": [
{
"name": status.device_name,
"backend": status.backend_name,
"connected": status.connected,
"output_port": status.selected_output_port,
"universe": status.selected_universe,
}
]
}
@router.get("/dmx/config")
async def dmx_config() -> dict[str, object]:
return await app_state.dmx.get_config()
@router.put("/dmx/config")
async def dmx_config_save(payload: DmxOutputConfigPayload) -> dict[str, object]:
return await app_state.dmx.save_config(payload.model_dump())
@router.post("/dmx/artnet/discover")
async def dmx_artnet_discover(timeout_s: float = 1.0) -> dict[str, object]:
return await app_state.dmx.discover_artnet(timeout_s)
@router.get("/dmx/universes")
async def dmx_universes() -> dict[str, object]:
status = app_state.engine.backend.get_status()
return {
"universes": [
{
"id": status.selected_universe,
"frame_rate": settings.target_fps,
"output_port": status.selected_output_port,
}
]
}
@router.get("/dmx/frame")
async def dmx_frame(universe: int | None = None) -> dict[str, object]:
selected_universe = universe or app_state.engine.backend.get_status().selected_universe
frame = app_state.engine.get_frame(selected_universe)
return {
"universe": frame.universe,
"values": frame.values,
"sources": frame.source_map,
}
@router.post("/dmx/test-channel")
async def dmx_test_channel(channel: int, value: int) -> dict[str, object]:
payload = ScenePayload(
name=f"Test kanal {channel}",
slug=f"test-channel-{channel}",
values=[ChannelValue(channel=channel, value=value, precedence="htp", source="test")],
)
await app_state.scenes.save(payload)
await app_state.scenes.activate(payload.slug)
return {"status": "queued", "scene": payload}
@router.post("/dmx/blackout")
async def dmx_blackout() -> dict[str, str]:
app_state.engine.trigger_blackout()
return {"status": "blackout-active"}
@router.post("/dmx/release-blackout")
async def dmx_release_blackout() -> dict[str, str]:
app_state.engine.release_blackout()
return {"status": "blackout-released"}
@router.get("/live/mixer")
async def live_mixer() -> dict[str, object]:
return await app_state.live.snapshot()
@router.put("/live/mixer/{patch_id}")
async def live_mixer_set(patch_id: int, payload: ManualPatchValuesPayload) -> dict[str, object]:
try:
return await app_state.live.set_patch_values(
patch_id,
{int(channel): int(value) for channel, value in payload.values.items()},
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/live/mixer/{patch_id}")
async def live_mixer_clear(patch_id: int) -> dict[str, object]:
return await app_state.live.clear_patch(patch_id)
@router.put("/live/mixer/home-assistant/{mapping_id}")
async def live_mixer_set_home_assistant(mapping_id: int, payload: ManualPatchValuesPayload) -> dict[str, object]:
try:
return await app_state.live.set_home_assistant_mapping_values(
mapping_id,
{int(channel): int(value) for channel, value in payload.values.items()},
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/live/mixer/home-assistant/{mapping_id}")
async def live_mixer_clear_home_assistant(mapping_id: int) -> dict[str, object]:
try:
return await app_state.live.clear_home_assistant_mapping(mapping_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/fixtures")
async def list_fixtures() -> dict[str, object]:
return {"items": await app_state.fixtures.list_fixtures()}
@router.post("/fixtures/search-ofl")
async def search_ofl(query: str) -> dict[str, object]:
return {"results": await app_state.fixtures.search_ofl(query)}
@router.post("/fixtures/import-ofl")
async def import_ofl(manufacturer_key: str, fixture_key: str) -> dict[str, object]:
return await app_state.fixtures.import_ofl(manufacturer_key, fixture_key)
@router.post("/fixtures/preview-ofl")
async def preview_ofl(manufacturer_key: str, fixture_key: str) -> dict[str, object]:
return await app_state.fixtures.preview_ofl(manufacturer_key, fixture_key)
@router.post("/fixtures/import-file")
async def import_fixture_file(payload: dict[str, object]) -> dict[str, object]:
return await app_state.fixtures.import_payload(payload)
@router.post("/fixtures/custom")
async def create_custom_fixture(payload: dict[str, object]) -> dict[str, object]:
return await import_fixture_file(payload)
@router.get("/fixtures/{fixture_id}")
async def get_fixture(fixture_id: int) -> dict[str, object]:
try:
return await app_state.fixtures.get_fixture(fixture_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail="Fixture not found") from exc
@router.put("/fixtures/{fixture_id}")
async def update_fixture(fixture_id: int, payload: dict[str, object]) -> dict[str, object]:
try:
return await app_state.fixtures.update_fixture(fixture_id, payload)
except LookupError as exc:
raise HTTPException(status_code=404, detail="Fixture not found") from exc
@router.delete("/fixtures/{fixture_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_fixture(fixture_id: int) -> Response:
try:
await app_state.fixtures.delete_fixture(fixture_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail="Fixture not found") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/patch")
async def patch_list() -> dict[str, object]:
return {"items": await app_state.patch.list_instances()}
@router.post("/patch", status_code=status.HTTP_201_CREATED)
async def patch_create(payload: PatchPayload) -> dict[str, object]:
try:
return await app_state.patch.create_instance(payload)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.put("/patch/{patch_id}")
async def patch_update(patch_id: int, payload: PatchPayload) -> dict[str, object]:
try:
return await app_state.patch.update_instance(patch_id, payload)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.delete("/patch/{patch_id}")
async def patch_delete(patch_id: int) -> dict[str, object]:
try:
return await app_state.patch.delete_instance(patch_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/patch/validate")
async def patch_validate(payload: PatchValidationPayload) -> dict[str, object]:
try:
return await app_state.patch.validate(payload)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/scenes")
async def list_scenes() -> dict[str, object]:
return {"items": await app_state.scenes.list()}
@router.post("/scenes")
async def create_scene(payload: ScenePayload) -> dict[str, object]:
try:
return await app_state.scenes.save(payload)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/scenes/{scene_id}")
async def get_scene(scene_id: int) -> dict[str, object]:
try:
return await app_state.scenes.get(scene_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.put("/scenes/{scene_id}")
async def update_scene(scene_id: int, payload: ScenePayload) -> dict[str, object]:
try:
return await app_state.scenes.save(payload, scene_id=scene_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.delete("/scenes/{scene_id}")
async def delete_scene(scene_id: int) -> dict[str, object]:
try:
return await app_state.scenes.delete(scene_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/scenes/{scene_slug}/activate")
async def activate_scene(scene_slug: str) -> dict[str, object]:
try:
return await app_state.scenes.activate(scene_slug)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/scenes/{scene_slug}/release")
async def release_scene(scene_slug: str) -> dict[str, str]:
await app_state.scenes.release(scene_slug)
return {"status": "released"}
@router.get("/effects")
async def list_effects() -> dict[str, object]:
return {"items": [effect.model_dump() for effect in app_state.effects.list()]}
@router.post("/effects")
async def create_effect(payload: EffectPayload) -> dict[str, object]:
return app_state.effects.save(payload).model_dump()
@router.put("/effects/{effect_id}")
async def update_effect(effect_id: int, payload: EffectPayload) -> dict[str, object]:
return {"id": effect_id, **app_state.effects.save(payload).model_dump()}
@router.delete("/effects/{effect_id}")
async def delete_effect(effect_id: int) -> dict[str, object]:
effects = app_state.effects.list()
if effect_id >= len(effects):
raise HTTPException(status_code=404, detail="Effect not found")
slug = effects[effect_id].slug
app_state.effects.effects.pop(slug, None)
return {"deleted": slug}
@router.post("/effects/{effect_slug}/trigger")
async def trigger_effect(effect_slug: str) -> dict[str, object]:
return app_state.effects.trigger(effect_slug).model_dump()
@router.post("/effects/{effect_slug}/stop")
async def stop_effect(effect_slug: str) -> dict[str, str]:
app_state.effects.stop(effect_slug)
return {"status": "stopped"}
@router.get("/bpm/status")
async def bpm_status() -> dict[str, object]:
return app_state.bpm.snapshot()
@router.post("/bpm/manual")
async def bpm_manual(bpm: float) -> dict[str, object]:
return app_state.bpm.set_manual(bpm)
@router.post("/bpm/tap")
async def bpm_tap() -> dict[str, object]:
return app_state.bpm.tap()
@router.post("/bpm/audio/start")
async def bpm_audio_start(payload: BpmAudioStartPayload) -> dict[str, object]:
try:
return await app_state.bpm.start_audio(payload.device)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/bpm/audio/stop")
async def bpm_audio_stop() -> dict[str, str]:
snapshot = await app_state.bpm.stop_audio()
return {"status": "stopped", "mode": str(snapshot["mode"])}
@router.get("/bpm/config")
async def bpm_config() -> dict[str, object]:
return await app_state.bpm.get_config()
@router.put("/bpm/config")
async def bpm_config_save(payload: BpmAudioConfigPayload) -> dict[str, object]:
return await app_state.bpm.save_config(payload.preferred_device)
@router.post("/bpm/external")
async def bpm_external(bpm: float) -> dict[str, object]:
return app_state.bpm.set_manual(bpm)
@router.get("/bpm/devices")
async def bpm_devices() -> dict[str, object]:
return {"devices": await app_state.bpm.list_devices()}
@router.get("/integrations/mixitup")
async def list_mixitup_integrations() -> dict[str, object]:
return {"items": []}
@router.post("/integrations/mixitup")
async def create_mixitup_integration(payload: dict[str, object]) -> dict[str, object]:
return payload
@router.put("/integrations/mixitup/{integration_id}")
async def update_mixitup_integration(
integration_id: int,
payload: dict[str, object],
) -> dict[str, object]:
return {
"id": integration_id,
**payload,
}
@router.delete("/integrations/mixitup/{integration_id}")
async def delete_mixitup_integration(integration_id: int) -> dict[str, object]:
return {"deleted": integration_id}
@router.post("/integrations/mixitup/{integration_id}/test")
async def test_mixitup_integration(integration_id: int) -> dict[str, object]:
return {"id": integration_id, "status": "accepted", "latency_target_ms": 200}
@router.get("/integrations/home-assistant/config")
async def home_assistant_config() -> dict[str, object]:
return await app_state.home_assistant.get_config()
@router.put("/integrations/home-assistant/config")
async def save_home_assistant_config(payload: HomeAssistantConfigPayload) -> dict[str, object]:
return await app_state.home_assistant.save_config(payload.model_dump())
@router.post("/integrations/home-assistant/test-connection")
async def test_home_assistant_connection() -> dict[str, object]:
return await app_state.home_assistant.test_connection()
@router.get("/integrations/home-assistant/entities")
async def list_home_assistant_entities() -> dict[str, object]:
return await app_state.home_assistant.list_entities()
@router.get("/integrations/home-assistant/mappings")
async def list_home_assistant_mappings() -> dict[str, object]:
return await app_state.home_assistant.list_mappings()
@router.post("/integrations/home-assistant/mappings")
async def create_home_assistant_mapping(payload: HomeAssistantMappingPayload) -> dict[str, object]:
return await app_state.home_assistant.create_mapping(payload.model_dump())
@router.put("/integrations/home-assistant/mappings/{mapping_id}")
async def update_home_assistant_mapping(mapping_id: int, payload: HomeAssistantMappingPayload) -> dict[str, object]:
try:
return await app_state.home_assistant.update_mapping(mapping_id, payload.model_dump())
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/integrations/home-assistant/mappings/{mapping_id}")
async def delete_home_assistant_mapping(mapping_id: int) -> dict[str, object]:
try:
return await app_state.home_assistant.delete_mapping(mapping_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/integrations/home-assistant/mappings/{mapping_id}/test")
async def test_home_assistant_mapping(mapping_id: int) -> dict[str, object]:
try:
return await app_state.home_assistant.test_mapping(mapping_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/integrations/midi/bridges")
async def list_midi_bridges() -> dict[str, object]:
return await app_state.midi.list_bridges()
@router.get("/integrations/midi/mappings")
async def list_midi_mappings() -> dict[str, object]:
return await app_state.midi.list_mappings()
@router.post("/integrations/midi/mappings")
async def create_midi_mapping(payload: MidiMappingPayload) -> dict[str, object]:
try:
return await app_state.midi.create_mapping(payload.model_dump())
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.put("/integrations/midi/mappings/{mapping_id}")
async def update_midi_mapping(mapping_id: int, payload: MidiMappingPayload) -> dict[str, object]:
try:
return await app_state.midi.update_mapping(mapping_id, payload.model_dump())
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.delete("/integrations/midi/mappings/{mapping_id}")
async def delete_midi_mapping(mapping_id: int) -> dict[str, object]:
try:
return await app_state.midi.delete_mapping(mapping_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/integrations/midi/mappings/{mapping_id}/test")
async def test_midi_mapping(mapping_id: int, payload: MidiTestMappingPayload) -> dict[str, object]:
try:
return await app_state.midi.test_mapping(mapping_id, payload.value, payload.message_type)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/integrations/midi/tokens")
async def list_midi_tokens() -> dict[str, object]:
return await app_state.midi.list_tokens()
@router.post("/integrations/midi/tokens")
async def create_midi_token(payload: MidiBridgeTokenCreatePayload) -> dict[str, object]:
return await app_state.midi.create_token(payload.model_dump())
@router.delete("/integrations/midi/tokens/{token_id}")
async def revoke_midi_token(token_id: int) -> dict[str, object]:
try:
return await app_state.midi.revoke_token(token_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/integrations/midi/learn")
async def get_midi_learn_state() -> dict[str, object]:
return await app_state.midi.get_learn_state()
@router.post("/integrations/midi/learn/start")
async def start_midi_learn(payload: MidiLearnStartPayload) -> dict[str, object]:
return await app_state.midi.start_learn(payload.timeout_seconds, payload.allow_passthrough)
@router.post("/integrations/midi/learn/cancel")
async def cancel_midi_learn() -> dict[str, object]:
return await app_state.midi.cancel_learn()
@router.post("/integrations/midi/bridge/heartbeat")
async def midi_bridge_heartbeat(request: Request, payload: MidiBridgeHeartbeatPayload) -> dict[str, object]:
try:
await app_state.midi.verify_token(
_extract_bearer_token(request),
"midi:heartbeat",
bridge_id=payload.bridge_id,
)
return await app_state.midi.receive_heartbeat(
payload.model_dump(mode="json", exclude_none=True),
request.client.host if request.client else None,
)
except PermissionError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/integrations/midi/bridge/events")
async def midi_bridge_events(request: Request, payload: MidiBridgeEventPayload) -> dict[str, object]:
try:
await app_state.midi.verify_token(
_extract_bearer_token(request),
"midi:events",
bridge_id=payload.bridge_id,
)
return await app_state.midi.receive_event(
payload.model_dump(mode="json"),
request.client.host if request.client else None,
)
except PermissionError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/triggers/{slug}", status_code=status.HTTP_202_ACCEPTED)
async def trigger_event(slug: str, payload: TriggerRequest) -> dict[str, object]:
return app_state.triggers.enqueue(slug, payload)
@router.get("/telemetry/live")
async def telemetry_live() -> dict[str, object]:
return app_state.telemetry.snapshot(len(app_state.triggers.queue))
@router.get("/telemetry/history")
async def telemetry_history() -> dict[str, object]:
return {"samples": []}
@router.get("/events")
async def events() -> dict[str, object]:
return {"items": app_state.telemetry.snapshot()["events"]}
@router.get("/logs")
async def logs() -> dict[str, object]:
return {"items": app_state.telemetry.snapshot()["events"]}
@router.post("/backups")
async def create_backup() -> dict[str, object]:
backup = app_state.backups.create_backup()
return {
"status": "created",
"id": backup.id,
"label": backup.label,
"path": str(backup.archive_path),
"manifest": backup.manifest,
}
@router.get("/backups")
async def list_backups() -> dict[str, object]:
return {
"items": [
{
"id": backup.id,
"label": backup.label,
"path": str(backup.archive_path),
"created_at": backup.created_at.isoformat(),
"manifest": backup.manifest,
}
for backup in app_state.backups.list_backups()
]
}
@router.post("/backups/{backup_id}/restore")
async def restore_backup(backup_id: int) -> dict[str, object]:
restored = app_state.backups.restore_backup(str(backup_id))
return {
"status": "restored",
"backup_id": restored.id,
"label": restored.label,
}
@router.delete("/backups/{backup_id}")
async def delete_backup(backup_id: int) -> dict[str, object]:
app_state.backups.delete_backup(str(backup_id))
return {"deleted": backup_id}