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}