Compare commits

1 Commits
Author SHA1 Message Date
thomas 1f110866f5 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
2026-07-25 10:26:29 +02:00
170 changed files with 24657 additions and 3 deletions
+17
View File
@@ -0,0 +1,17 @@
TUXDMX_ENV=development
TUXDMX_APP_NAME=TuxDMX
TUXDMX_SECRET_KEY=change-me-before-production
TUXDMX_DATABASE_URL=sqlite+aiosqlite:///./data/tuxdmx.db
TUXDMX_SIMULATOR_ENABLED=true
TUXDMX_OLA_UNIVERSE=1
TUXDMX_OLA_OUTPUT_PORT=
TUXDMX_OLA_SEND_TIMEOUT_MS=1000
TUXDMX_DATA_DIR=./data
TUXDMX_BACKUP_DIR=./data/backups
TUXDMX_FIXTURE_CACHE_DIR=./data/fixtures
TUXDMX_DIAGNOSTICS_DIR=./data/diagnostics
TUXDMX_UPLOADS_DIR=./data/uploads
TUXDMX_LOG_DIR=./data
TUXDMX_FRONTEND_DIST=frontend/dist
TUXDMX_LOG_LEVEL=INFO
TUXDMX_ALLOW_LOCAL_DOCS=true
+81
View File
@@ -0,0 +1,81 @@
name: CI
on:
push:
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.lock
- name: Pip check
run: pip check
- name: Import greenlet
run: python -c "import greenlet; print(greenlet.__version__)"
- name: Ruff
run: ruff check backend
- name: Mypy
run: mypy backend/app
- name: Pytest
run: pytest
shell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install ShellCheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Bash syntax check
run: |
bash -n scripts/install-pi.sh
bash -n scripts/update-pi.sh
bash -n scripts/install-linux.sh
bash -n scripts/update-linux.sh
bash -n scripts/uninstall-linux.sh
bash -n scripts/lib/install-common.sh
- name: ShellCheck
run: |
shellcheck scripts/install-pi.sh
shellcheck scripts/update-pi.sh
shellcheck scripts/install-linux.sh
shellcheck scripts/update-linux.sh
shellcheck scripts/uninstall-linux.sh
shellcheck scripts/lib/install-common.sh
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.28.2
- uses: actions/setup-node@v4
with:
node-version: "20.19.2"
cache: "pnpm"
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Frontend lint
run: pnpm lint
- name: Frontend typecheck
run: pnpm typecheck
- name: Frontend tests
run: pnpm test
- name: Frontend build
run: pnpm build
arm64-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- name: ARM64 compile check
run: docker run --rm --platform linux/arm64/v8 -v "$PWD":/workspace -w /workspace python:3.11-slim python -m compileall backend
+22
View File
@@ -0,0 +1,22 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.pnpm-store/
node_modules/
frontend/dist/
data/
backend/.coverage
backend/htmlcov/
backend/.pytest_cache/
*.pyc
*.pyo
*.sqlite3
*.db
*.log
*.zip
*.tar
*.tar.gz
.env
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
TuxDMX
Copyright (c) Thomas / TuxiNet
All rights reserved.
This repository and all accompanying source code, documentation, scripts, builds,
and assets are proprietary and confidential unless Thomas / TuxiNet states
otherwise in writing.
No permission is granted to copy, modify, distribute, sublicense, publish,
sell, or use this material outside the private scope approved by the copyright
holder.
Third-party components remain subject to their own licenses. See
THIRD_PARTY_NOTICES.md and docs/DEPENDENCIES.md.
+2
View File
@@ -0,0 +1,2 @@
All rights reserved - Thomas / TuxiNet
+29
View File
@@ -66,10 +66,13 @@ pnpm dev
- [HARDWARE-VALIDATION.md](HARDWARE-VALIDATION.md)
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
- [SECURITY.md](SECURITY.md)
- [docs/system-manual/README.md](docs/system-manual/README.md)
- [docs/system-manual/MANUAL.md](docs/system-manual/MANUAL.md)
- [docs/API-ENDPOINTS.md](docs/API-ENDPOINTS.md)
- [docs/MIXITUP.md](docs/MIXITUP.md)
- [docs/OFL.md](docs/OFL.md)
- [docs/BPM.md](docs/BPM.md)
- [docs/MIDI_BRIDGE.md](docs/MIDI_BRIDGE.md)
- [docs/DEPENDENCIES.md](docs/DEPENDENCIES.md)
- [docs/IMPLEMENTATION-STATUS.md](docs/IMPLEMENTATION-STATUS.md)
@@ -110,6 +113,32 @@ powershell -ExecutionPolicy Bypass -File .\scripts\sync-windows-deploy.ps1 -Pack
Standard-stien for pakken er `Desktop\TuxDMXWebUI-update`, og den kan aendres med `-StageRoot <sti>`.
## Dokumentation og screenshots
- Den opdaterede systemmanual ligger i `docs/system-manual/`.
- Screenshot-saettet er regenereret den 25. juli 2026 fra lokal `mock=1` preview og matcher den nuvaerende kompakte navigation med grupperede menuer.
- Saettet indeholder ogsaa `MIDI`-siden, saa hele den aktuelle WebUI er dækket.
## Git upload via PowerShell
Hvis du vil stage, committe og pushe direkte til TuxiNet Git fra PowerShell, kan du bruge helper-scriptet:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\push-git-tuxinet.ps1 -InitIfNeeded -SetOrigin -StageAll -Commit -CommitMessage "Update docs and screenshots" -Push
```
Standard-remote er:
```text
https://git.tuxinet.dk/thomas/TuxDMX-WebUI.git
```
Hvis du kun vil se status og remote uden at pushe endnu:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\push-git-tuxinet.ps1 -SetOrigin
```
## Produktionsnoter
- Debian 13 amd64 er nu førsteborger-platform for Linux-installation.
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
[alembic]
script_location = %(here)s/alembic
sqlalchemy.url = sqlite+aiosqlite:///./data/tuxdmx.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from app.core.config import get_alembic_database_url, get_settings # noqa: E402
from app.models import entities # noqa: F401, E402
from app.models.base import Base # noqa: E402
config = context.config
settings = get_settings()
alembic_url = get_alembic_database_url(settings.database_url)
config.set_main_option("sqlalchemy.url", alembic_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=alembic_url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,88 @@
"""Initial TuxDMX schema."""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260712_0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(length=100), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=30), nullable=False),
sa.Column("disabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_users_username", "users", ["username"], unique=True)
op.create_table(
"settings",
sa.Column("key", sa.String(length=120), primary_key=True),
sa.Column("value", sa.JSON(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"fixture_definitions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("slug", sa.String(length=160), nullable=False),
sa.Column("manufacturer", sa.String(length=120), nullable=False),
sa.Column("model", sa.String(length=160), nullable=False),
sa.Column("short_name", sa.String(length=120), nullable=True),
sa.Column("categories", sa.JSON(), nullable=False),
sa.Column("normalized_data", sa.JSON(), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_fixture_definitions_slug", "fixture_definitions", ["slug"], unique=True)
op.create_table(
"scenes",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("slug", sa.String(length=160), nullable=False),
sa.Column("color", sa.String(length=20), nullable=False),
sa.Column("icon", sa.String(length=40), nullable=True),
sa.Column("priority", sa.Integer(), nullable=False),
sa.Column("fade_in_ms", sa.Integer(), nullable=False),
sa.Column("fade_out_ms", sa.Integer(), nullable=False),
sa.Column("hold_ms", sa.Integer(), nullable=False),
sa.Column("master_limit", sa.Integer(), nullable=False),
sa.Column("values", sa.JSON(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_scenes_slug", "scenes", ["slug"], unique=True)
op.create_table(
"effects",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("slug", sa.String(length=160), nullable=False),
sa.Column("effect_type", sa.String(length=80), nullable=False),
sa.Column("parameters", sa.JSON(), nullable=False),
sa.Column("priority", sa.Integer(), nullable=False),
sa.Column("cooldown_ms", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_effects_slug", "effects", ["slug"], unique=True)
def downgrade() -> None:
op.drop_index("ix_effects_slug", table_name="effects")
op.drop_table("effects")
op.drop_index("ix_scenes_slug", table_name="scenes")
op.drop_table("scenes")
op.drop_index("ix_fixture_definitions_slug", table_name="fixture_definitions")
op.drop_table("fixture_definitions")
op.drop_table("settings")
op.drop_index("ix_users_username", table_name="users")
op.drop_table("users")
@@ -0,0 +1,68 @@
"""Add persisted fixture sources and patch instances."""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260719_0002"
down_revision = "20260712_0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "fixture_sources" not in tables:
op.create_table(
"fixture_sources",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("manufacturer_key", sa.String(length=100), nullable=False),
sa.Column("fixture_key", sa.String(length=150), nullable=False),
sa.Column("schema_ref", sa.String(length=255), nullable=False),
sa.Column("source_url", sa.String(length=255), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("payload_hash", sa.String(length=64), nullable=False),
sa.Column("imported_at", sa.DateTime(timezone=True), nullable=False),
)
if "fixture_instances" not in tables:
op.create_table(
"fixture_instances",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("universe", sa.Integer(), nullable=False, server_default="1"),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("definition_id", sa.Integer(), nullable=False),
sa.Column("mode_key", sa.String(length=120), nullable=False),
sa.Column("start_address", sa.Integer(), nullable=False),
sa.Column("channel_count", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("group_names", sa.JSON(), nullable=False),
sa.Column("position", sa.JSON(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_fixture_instances_definition_id", "fixture_instances", ["definition_id"], unique=False)
else:
columns = {column["name"] for column in inspector.get_columns("fixture_instances")}
if "universe" not in columns:
with op.batch_alter_table("fixture_instances") as batch_op:
batch_op.add_column(sa.Column("universe", sa.Integer(), nullable=False, server_default="1"))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "fixture_instances" in tables:
columns = {column["name"] for column in inspector.get_columns("fixture_instances")}
if "universe" in columns:
with op.batch_alter_table("fixture_instances") as batch_op:
batch_op.drop_column("universe")
if "fixture_sources" in tables:
op.drop_table("fixture_sources")
@@ -0,0 +1,44 @@
"""Add persisted scene targets."""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260722_0003"
down_revision = "20260719_0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "scenes" not in tables:
return
columns = {column["name"] for column in inspector.get_columns("scenes")}
if "targets" not in columns:
with op.batch_alter_table("scenes") as batch_op:
batch_op.add_column(
sa.Column(
"targets",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'"),
)
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "scenes" not in tables:
return
columns = {column["name"] for column in inspector.get_columns("scenes")}
if "targets" in columns:
with op.batch_alter_table("scenes") as batch_op:
batch_op.drop_column("targets")
@@ -0,0 +1,87 @@
"""Add MIDI bridge, token and mapping tables."""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260724_0004"
down_revision = "20260722_0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "midi_bridges" not in tables:
op.create_table(
"midi_bridges",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("bridge_id", sa.String(length=160), nullable=False),
sa.Column("device_name", sa.String(length=255), nullable=True),
sa.Column("ip_address", sa.String(length=64), nullable=True),
sa.Column("protocol_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("online", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_event_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("last_event", sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_midi_bridges_bridge_id", "midi_bridges", ["bridge_id"], unique=True)
if "midi_bridge_tokens" not in tables:
op.create_table(
"midi_bridge_tokens",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("label", sa.String(length=120), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("bridge_id", sa.String(length=160), nullable=True),
sa.Column("scopes", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_midi_bridge_tokens_token_hash", "midi_bridge_tokens", ["token_hash"], unique=True)
if "midi_mappings" not in tables:
op.create_table(
"midi_mappings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("bridge_id", sa.String(length=160), nullable=True),
sa.Column("device_name", sa.String(length=255), nullable=True),
sa.Column("message_type", sa.String(length=40), nullable=False),
sa.Column("channel", sa.Integer(), nullable=True),
sa.Column("number", sa.Integer(), nullable=False),
sa.Column("action", sa.String(length=60), nullable=False),
sa.Column("target_type", sa.String(length=40), nullable=False),
sa.Column("target_id", sa.String(length=160), nullable=True),
sa.Column("mode", sa.String(length=40), nullable=False, server_default="trigger"),
sa.Column("minimum_value", sa.Integer(), nullable=False, server_default="0"),
sa.Column("maximum_value", sa.Integer(), nullable=False, server_default="127"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "midi_mappings" in tables:
op.drop_table("midi_mappings")
if "midi_bridge_tokens" in tables:
op.drop_index("ix_midi_bridge_tokens_token_hash", table_name="midi_bridge_tokens")
op.drop_table("midi_bridge_tokens")
if "midi_bridges" in tables:
op.drop_index("ix_midi_bridges_bridge_id", table_name="midi_bridges")
op.drop_table("midi_bridges")
+2
View File
@@ -0,0 +1,2 @@
"""TuxDMX backend package."""
+2
View File
@@ -0,0 +1,2 @@
"""API modules."""
+7
View File
@@ -0,0 +1,7 @@
from fastapi import APIRouter
from app.api.routes import router
api_router = APIRouter()
api_router.include_router(router)
+702
View File
@@ -0,0 +1,702 @@
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}
+2
View File
@@ -0,0 +1,2 @@
"""Authentication services."""
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
@dataclass(slots=True)
class SessionData:
session_id: str
csrf_token: str
expires_at: datetime
role: str
username: str
class AuthService:
def __init__(self) -> None:
self._hasher = PasswordHasher()
self._sessions: dict[str, SessionData] = {}
def hash_password(self, password: str) -> str:
return self._hasher.hash(password)
def verify_password(self, password: str, password_hash: str) -> bool:
try:
return self._hasher.verify(password_hash, password)
except Exception:
return False
def create_session(self, username: str, role: str) -> SessionData:
session_id = secrets.token_hex(24)
csrf_token = secrets.token_hex(16)
expires_at = datetime.now(UTC) + timedelta(hours=12)
session = SessionData(
session_id=session_id,
csrf_token=csrf_token,
expires_at=expires_at,
role=role,
username=username,
)
self._sessions[session_id] = session
return session
def get_session(self, session_id: str | None) -> SessionData | None:
if session_id is None:
return None
session = self._sessions.get(session_id)
if session is None:
return None
if session.expires_at < datetime.now(UTC):
self._sessions.pop(session_id, None)
return None
return session
+2
View File
@@ -0,0 +1,2 @@
"""Backup and restore services."""
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import json
import shutil
import zipfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@dataclass(slots=True)
class BackupRecordData:
id: str
label: str
archive_path: Path
manifest: dict[str, object]
created_at: datetime
class BackupService:
def __init__(self, data_dir: Path, backup_dir: Path) -> None:
self.data_dir = data_dir
self.backup_dir = backup_dir
self.backup_dir.mkdir(parents=True, exist_ok=True)
def create_backup(self, label: str | None = None) -> BackupRecordData:
created_at = datetime.now(UTC)
backup_id = created_at.strftime("%Y%m%d%H%M%S")
safe_label = label or f"backup-{backup_id}"
archive_path = self.backup_dir / f"{safe_label}.zip"
manifest: dict[str, Any] = {
"id": backup_id,
"label": safe_label,
"created_at": created_at.isoformat(),
"format_version": 1,
"included_paths": [],
}
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for file_path in sorted(self.data_dir.rglob("*")):
if not file_path.is_file():
continue
if self.backup_dir in file_path.parents:
continue
relative = file_path.relative_to(self.data_dir)
archive.write(file_path, arcname=f"data/{relative.as_posix()}")
manifest["included_paths"].append(f"data/{relative.as_posix()}")
archive.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=True))
return BackupRecordData(
id=backup_id,
label=safe_label,
archive_path=archive_path,
manifest=manifest,
created_at=created_at,
)
def list_backups(self) -> list[BackupRecordData]:
backups: list[BackupRecordData] = []
for archive_path in sorted(self.backup_dir.glob("*.zip"), reverse=True):
try:
with zipfile.ZipFile(archive_path) as archive:
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
except (KeyError, zipfile.BadZipFile, json.JSONDecodeError):
continue
backups.append(
BackupRecordData(
id=str(manifest["id"]),
label=str(manifest["label"]),
archive_path=archive_path,
manifest=manifest,
created_at=datetime.fromisoformat(str(manifest["created_at"])),
)
)
return backups
def restore_backup(self, backup_id: str) -> BackupRecordData:
target = next((item for item in self.list_backups() if item.id == backup_id), None)
if target is None:
raise FileNotFoundError(f"Backup {backup_id} ikke fundet")
self.create_backup(label=f"pre-restore-{backup_id}")
temp_dir = self.backup_dir / f"restore-{backup_id}"
if temp_dir.exists():
shutil.rmtree(temp_dir)
temp_dir.mkdir(parents=True, exist_ok=True)
try:
with zipfile.ZipFile(target.archive_path) as archive:
archive.extractall(temp_dir)
restored_data_dir = temp_dir / "data"
self._clear_runtime_files()
if restored_data_dir.exists():
for file_path in sorted(restored_data_dir.rglob("*")):
if not file_path.is_file():
continue
relative = file_path.relative_to(restored_data_dir)
destination = self.data_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(file_path, destination)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
return target
def _clear_runtime_files(self) -> None:
for file_path in sorted(self.data_dir.rglob("*"), reverse=True):
if self.backup_dir == file_path or self.backup_dir in file_path.parents:
continue
if file_path.is_file():
file_path.unlink()
elif file_path.is_dir():
file_path.rmdir()
def delete_backup(self, backup_id: str) -> None:
target = next((item for item in self.list_backups() if item.id == backup_id), None)
if target is None:
raise FileNotFoundError(f"Backup {backup_id} ikke fundet")
target.archive_path.unlink(missing_ok=True)
+2
View File
@@ -0,0 +1,2 @@
"""BPM services."""
+619
View File
@@ -0,0 +1,619 @@
from __future__ import annotations
import asyncio
import math
import sys
from array import array
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from statistics import fmean, median
from time import monotonic
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.models.entities import Setting
BPM_AUDIO_SETTING_KEY = "bpm_audio"
AUTO_ALSA_DEVICE = "alsa:auto"
SB_ALIAS_DEVICE = "alsa:plughw:CARD=SB,DEV=0"
SB_FALLBACK_DEVICE = "alsa:plughw:1,0"
DEFAULT_ALSA_DEVICE = "alsa:default"
DEFAULT_SAMPLE_RATE = 44_100
DEFAULT_CHANNELS = 1
DEFAULT_AUDIO_FORMAT = "S16_LE"
@dataclass(slots=True)
class AudioDevice:
id: str
name: str
backend: str
is_default: bool = False
recommended: bool = False
class BeatAnalyzer:
def __init__(self, sample_rate: int = DEFAULT_SAMPLE_RATE, window_size: int = 1024) -> None:
self.sample_rate = sample_rate
self.window_size = window_size
self._buffer = array("h")
self._processed_samples = 0
self._last_trigger_sample = -sample_rate
self._energy_history: deque[float] = deque(maxlen=48)
self._beat_times: deque[float] = deque(maxlen=16)
self.bpm = 120.0
self.confidence = 0.0
self.last_beat_at: float | None = None
self.current_level = 0.0
self.peak_level = 0.0
self.clipping = False
def feed_pcm16(self, chunk: bytes) -> tuple[float, float] | None:
samples = array("h")
samples.frombytes(chunk[: len(chunk) - (len(chunk) % 2)])
if sys.byteorder != "little":
samples.byteswap()
self._buffer.extend(samples)
updated = False
while len(self._buffer) >= self.window_size:
window = self._buffer[: self.window_size]
del self._buffer[: self.window_size]
updated = self._process_window(window) or updated
self._processed_samples += len(window)
if updated:
return self.bpm, self.confidence
return None
def _process_window(self, window: array[int]) -> bool:
if not window:
return False
energy = math.sqrt(sum(sample * sample for sample in window) / len(window)) / 32768.0
peak = max(abs(sample) for sample in window) / 32768.0
self.current_level = round(max(max(0.0, min(1.0, energy)), self.current_level * 0.82), 4)
self.peak_level = round(max(peak, self.peak_level * 0.92), 4)
self.clipping = peak >= 0.985
baseline = fmean(self._energy_history) if self._energy_history else 0.03
threshold = max(0.08, baseline * 2.4)
self._energy_history.append(energy)
min_interval = int(self.sample_rate * 0.2)
if energy < threshold or self._processed_samples - self._last_trigger_sample < min_interval:
return False
timestamp = self._processed_samples / self.sample_rate
self._beat_times.append(timestamp)
self._last_trigger_sample = self._processed_samples
self.last_beat_at = timestamp
if len(self._beat_times) < 2:
return False
intervals = [
self._beat_times[index] - self._beat_times[index - 1]
for index in range(1, len(self._beat_times))
]
beat_seconds = median(intervals)
if beat_seconds <= 0:
return False
bpm = self._normalize_bpm(60.0 / beat_seconds)
jitter = 0.0
if len(intervals) > 1:
average = fmean(intervals)
jitter = fmean(abs(interval - average) for interval in intervals)
self.bpm = round(bpm, 2)
regularity = max(0.0, 1.0 - min(jitter / max(beat_seconds, 0.001), 1.0))
self.confidence = round(min(1.0, regularity * min(1.0, len(intervals) / 6)), 3)
return True
def _normalize_bpm(self, bpm: float) -> float:
while bpm < 70.0:
bpm *= 2.0
while bpm > 180.0:
bpm /= 2.0
return bpm
class BpmService:
def __init__(self, session_factory: async_sessionmaker[AsyncSession] = SessionLocal) -> None:
self._session_factory = session_factory
self.mode = "manual"
self.current_bpm = 120.0
self.confidence = 1.0
self.last_error: str | None = None
self.audio_connected = False
self.current_device = "manual"
self.selected_device = AUTO_ALSA_DEVICE
self.recommended_device = SB_ALIAS_DEVICE
self.beat_counter = 0
self.last_beat_detected_at: float | None = None
self._tap_times: list[float] = []
self._manual_bpm = 120.0
self._audio_task: asyncio.Task[None] | None = None
self._audio_process: asyncio.subprocess.Process | None = None
self._analyzer = BeatAnalyzer()
self._device_cache: list[dict[str, object]] = [
self._serialize_device(
AudioDevice(
id=AUTO_ALSA_DEVICE,
name="Auto (SB -> plughw:CARD=SB,DEV=0 -> plughw:1,0 -> default)",
backend="alsa",
is_default=True,
)
),
self._serialize_device(AudioDevice(id=DEFAULT_ALSA_DEVICE, name="ALSA default", backend="alsa")),
self._serialize_device(
AudioDevice(id="synthetic-click-track", name="Syntetisk click track", backend="synthetic")
),
]
async def startup(self) -> None:
await self._load_config()
await self.list_devices()
async def shutdown(self) -> None:
await self.stop_audio()
def set_manual(self, bpm: float) -> dict[str, object]:
self._manual_bpm = float(bpm)
self.mode = "manual"
self.current_device = "manual"
self.current_bpm = round(float(bpm), 2)
self.confidence = 1.0
self.audio_connected = False
self.last_error = None
self._cancel_audio_runtime()
return self.snapshot()
def tap(self) -> dict[str, object]:
now = monotonic()
self._tap_times = [value for value in self._tap_times if now - value < 4.0]
self._tap_times.append(now)
if len(self._tap_times) >= 2:
intervals = [
self._tap_times[index] - self._tap_times[index - 1]
for index in range(1, len(self._tap_times))
]
beat_seconds = median(intervals)
if beat_seconds > 0:
self.current_bpm = round(60 / beat_seconds, 2)
self._manual_bpm = self.current_bpm
self.mode = "tap"
self.current_device = "tap"
self.confidence = min(1.0, len(intervals) / 4)
self.audio_connected = False
self.last_error = None
self._mark_beat_event()
return self.snapshot()
async def start_audio(self, device: str = AUTO_ALSA_DEVICE) -> dict[str, object]:
await self.stop_audio()
self._analyzer = BeatAnalyzer()
normalized_device = self._normalize_device_id(device)
self.mode = f"audio:{normalized_device}"
self.current_device = "pending"
self.confidence = 0.0
self.audio_connected = False
self.last_error = None
if normalized_device == "synthetic-click-track":
self._audio_task = asyncio.create_task(self._synthetic_click_loop(), name="tuxdmx-bpm-synthetic")
return self.snapshot()
if normalized_device.startswith("alsa:"):
candidates = await self._resolve_alsa_candidates(normalized_device)
self._audio_task = asyncio.create_task(
self._alsa_capture_loop(candidates),
name="tuxdmx-bpm-alsa",
)
return self.snapshot()
raise LookupError(f"Ukendt BPM-device: {normalized_device}")
async def stop_audio(self) -> dict[str, object]:
self._cancel_audio_runtime()
if self._audio_task is not None:
try:
await self._audio_task
except asyncio.CancelledError:
pass
if self._audio_process is not None:
try:
await self._audio_process.wait()
except ProcessLookupError:
pass
self._audio_task = None
self._audio_process = None
self.audio_connected = False
if self.mode.startswith("audio:"):
self.mode = "manual"
self.current_device = "manual"
self.current_bpm = self._manual_bpm
self.confidence = 1.0
return self.snapshot()
async def get_config(self) -> dict[str, object]:
return {
"preferred_device": self.selected_device,
"recommended_device": self.recommended_device,
"sample_rate": DEFAULT_SAMPLE_RATE,
"channels": DEFAULT_CHANNELS,
"format": DEFAULT_AUDIO_FORMAT,
}
async def save_config(self, preferred_device: str) -> dict[str, object]:
self.selected_device = self._normalize_device_id(preferred_device)
await self.list_devices()
payload = await self.get_config()
async with self._session_factory() as session:
setting = await session.get(Setting, BPM_AUDIO_SETTING_KEY)
if setting is None:
setting = Setting(key=BPM_AUDIO_SETTING_KEY, value=payload, updated_at=datetime.now(UTC))
session.add(setting)
else:
setting.value = payload
setting.updated_at = datetime.now(UTC)
await session.commit()
return await self.get_config()
async def list_devices(self) -> list[dict[str, object]]:
devices: list[AudioDevice] = []
self._append_device(
devices,
AudioDevice(
id=AUTO_ALSA_DEVICE,
name="Auto (SB -> plughw:CARD=SB,DEV=0 -> plughw:1,0 -> default)",
backend="alsa",
is_default=True,
),
)
self._append_device(
devices,
AudioDevice(id="synthetic-click-track", name="Syntetisk click track", backend="synthetic"),
)
self._append_device(devices, AudioDevice(id=DEFAULT_ALSA_DEVICE, name="ALSA default", backend="alsa"))
for device in await self._list_alsa_hardware_devices():
self._append_device(devices, device)
try:
process = await asyncio.create_subprocess_exec(
"arecord",
"-L",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except (FileNotFoundError, NotImplementedError):
self.recommended_device = self._pick_recommended_device(devices)
for device in devices:
device.recommended = device.id == self.recommended_device
self._device_cache = [self._serialize_device(device) for device in devices]
return self._device_cache
stdout, _stderr = await process.communicate()
if process.returncode == 0:
for raw_line in stdout.decode("utf-8", errors="ignore").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or raw_line.startswith(" "):
continue
self._append_device(devices, AudioDevice(id=f"alsa:{line}", name=line, backend="alsa"))
self.recommended_device = self._pick_recommended_device(devices)
for device in devices:
device.recommended = device.id == self.recommended_device
self._device_cache = [self._serialize_device(device) for device in devices]
return self._device_cache
def snapshot(self) -> dict[str, object]:
return {
"mode": self.mode,
"bpm": round(self.current_bpm, 2),
"confidence": round(self.confidence, 3),
"devices": [str(device["id"]) for device in self._device_cache],
"device_details": self._device_cache,
"current_device": self.current_device,
"selected_device": self.selected_device,
"recommended_device": self.recommended_device,
"audio_connected": self.audio_connected,
"last_error": self.last_error,
"last_beat_at": self._analyzer.last_beat_at,
"last_beat_detected_at": self.last_beat_detected_at,
"beat_counter": self.beat_counter,
"input_level": round(self._analyzer.current_level, 4),
"peak_level": round(self._analyzer.peak_level, 4),
"clipping": self._analyzer.clipping,
"sample_rate": DEFAULT_SAMPLE_RATE,
"channels": DEFAULT_CHANNELS,
"format": DEFAULT_AUDIO_FORMAT,
}
async def _alsa_capture_loop(self, candidates: list[str]) -> None:
last_error: str | None = None
for candidate in candidates:
alsa_device = candidate.split(":", 1)[1]
try:
process = await asyncio.create_subprocess_exec(
"arecord",
"-D",
alsa_device,
"-q",
"-f",
DEFAULT_AUDIO_FORMAT,
"-c",
str(DEFAULT_CHANNELS),
"-r",
str(DEFAULT_SAMPLE_RATE),
"-t",
"raw",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
self._fallback_to_manual("arecord blev ikke fundet på systemet.")
return
self._audio_process = process
self.current_device = candidate
self.mode = f"audio:{candidate}"
connected_once = False
stderr_output = b""
try:
if process.stdout is None:
last_error = "ALSA-capture kunne ikke starte."
continue
while True:
chunk = await process.stdout.read(4096)
if not chunk:
break
connected_once = True
self.audio_connected = True
result = self._analyzer.feed_pcm16(chunk)
if result is None:
continue
bpm, confidence = result
self.current_bpm = bpm
self.confidence = confidence
self._mark_beat_event()
except asyncio.CancelledError:
if process.returncode is None:
process.terminate()
await process.wait()
raise
finally:
if process.returncode is None:
process.terminate()
await process.wait()
if process.stderr is not None:
stderr_output = await process.stderr.read()
self._audio_process = None
error = self._humanize_alsa_error(
candidate,
stderr_output.decode("utf-8", errors="ignore").strip(),
)
if connected_once:
if self.mode.startswith("audio:"):
self._fallback_to_manual(error or "Audio-input stoppede.")
return
last_error = error or f"Kunne ikke åbne ALSA-input {alsa_device}."
self.audio_connected = False
if self.mode.startswith("audio:"):
self._fallback_to_manual(last_error or "Audio-input stoppede.")
async def _synthetic_click_loop(self) -> None:
self.audio_connected = True
click_interval = 0.5
sample_rate = self._analyzer.sample_rate
click_width = 2048
silence_width = int(sample_rate * click_interval) - click_width
amplitude = 22000
click = array("h", [amplitude if index < 128 else 0 for index in range(click_width)])
silence = array("h", [0] * max(0, silence_width))
try:
while True:
self._feed_array(click)
self._feed_array(silence)
await asyncio.sleep(click_interval)
except asyncio.CancelledError:
raise
finally:
self.audio_connected = False
def _feed_array(self, values: array[int]) -> None:
if not values:
return
result = self._analyzer.feed_pcm16(values.tobytes())
if result is None:
return
bpm, confidence = result
self.current_bpm = bpm
self.confidence = confidence
self._mark_beat_event()
def _cancel_audio_runtime(self) -> None:
if self._audio_task is not None:
self._audio_task.cancel()
if self._audio_process is not None and self._audio_process.returncode is None:
self._audio_process.terminate()
def _fallback_to_manual(self, error: str) -> None:
self.mode = "manual"
self.current_device = "manual"
self.current_bpm = self._manual_bpm
self.confidence = 1.0
self.audio_connected = False
self.last_error = error
self._analyzer.current_level = 0.0
self._analyzer.peak_level = 0.0
self._analyzer.clipping = False
def _mark_beat_event(self) -> None:
self.beat_counter += 1
self.last_beat_detected_at = monotonic()
def _serialize_device(self, device: AudioDevice) -> dict[str, Any]:
return {
"id": device.id,
"name": device.name,
"backend": device.backend,
"is_default": device.is_default,
"recommended": device.recommended,
}
async def _list_alsa_hardware_devices(self) -> list[AudioDevice]:
try:
process = await asyncio.create_subprocess_exec(
"arecord",
"-l",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except (FileNotFoundError, NotImplementedError):
return []
stdout, _stderr = await process.communicate()
if process.returncode != 0:
return []
devices: list[AudioDevice] = []
for raw_line in stdout.decode("utf-8", errors="ignore").splitlines():
line = raw_line.strip()
if not line or not line.startswith("card "):
continue
card = self._parse_card_line(line)
device = self._parse_device_from_card_line(line)
if card is None or device is None:
continue
card_index, card_key, card_name = card
device_index, _device_key, device_name = device
label = f"{card_name} / {device_name}" if device_name else card_name
self._append_device(
devices,
AudioDevice(id=f"alsa:hw:{card_index},{device_index}", name=label, backend="alsa"),
)
self._append_device(
devices,
AudioDevice(
id=f"alsa:plughw:{card_index},{device_index}",
name=f"{label} (plughw)",
backend="alsa",
),
)
if card_key:
self._append_device(
devices,
AudioDevice(
id=f"alsa:plughw:CARD={card_key},DEV={device_index}",
name=f"{label} (stabil alias)",
backend="alsa",
),
)
return devices
async def _load_config(self) -> None:
async with self._session_factory() as session:
setting = await session.get(Setting, BPM_AUDIO_SETTING_KEY)
if setting is None or not isinstance(setting.value, dict):
return
preferred = setting.value.get("preferred_device")
if isinstance(preferred, str) and preferred.strip():
self.selected_device = self._normalize_device_id(preferred)
async def _resolve_alsa_candidates(self, requested_device: str) -> list[str]:
await self.list_devices()
candidates: list[str] = []
if requested_device == AUTO_ALSA_DEVICE:
if self.selected_device != AUTO_ALSA_DEVICE:
candidates.append(self.selected_device)
candidates.extend([self.recommended_device, SB_ALIAS_DEVICE, SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE])
else:
candidates.append(requested_device)
if requested_device == SB_ALIAS_DEVICE:
candidates.extend([SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE])
elif requested_device == SB_FALLBACK_DEVICE:
candidates.append(DEFAULT_ALSA_DEVICE)
unique_candidates: list[str] = []
for candidate in candidates:
normalized = self._normalize_device_id(candidate)
if not normalized.startswith("alsa:"):
continue
if normalized not in unique_candidates:
unique_candidates.append(normalized)
return unique_candidates or [DEFAULT_ALSA_DEVICE]
def _append_device(self, devices: list[AudioDevice], device: AudioDevice) -> None:
if any(existing.id == device.id for existing in devices):
return
devices.append(device)
def _pick_recommended_device(self, devices: list[AudioDevice]) -> str:
preferred_ids = [SB_ALIAS_DEVICE, SB_FALLBACK_DEVICE, DEFAULT_ALSA_DEVICE]
for preferred_id in preferred_ids:
if any(device.id == preferred_id for device in devices):
return preferred_id
for device in devices:
if "SB" in device.id.upper():
return device.id
return DEFAULT_ALSA_DEVICE
def _normalize_device_id(self, device: str) -> str:
normalized = device.strip() if device else AUTO_ALSA_DEVICE
if not normalized:
return AUTO_ALSA_DEVICE
if normalized == "default":
return DEFAULT_ALSA_DEVICE
if normalized.startswith("alsa:") or normalized == "synthetic-click-track":
return normalized
if normalized.startswith("plughw:") or normalized.startswith("hw:"):
return f"alsa:{normalized}"
return f"alsa:{normalized}"
def _humanize_alsa_error(self, candidate: str, error: str) -> str:
if not error:
return f"Audio-input {candidate.split(':', 1)[1]} stoppede."
normalized = error.lower()
if "device or resource busy" in normalized:
return (
"Mikrofonen er optaget af en anden proces. Luk PipeWire, PulseAudio eller en "
"anden TuxDMX/arecord-proces, eller vælg et andet ALSA-input."
)
if "cannot get card index" in normalized or "no such file or directory" in normalized:
return f"ALSA-input {candidate.split(':', 1)[1]} blev ikke fundet."
if "audio open error" in normalized or "unable to open slave" in normalized:
return f"Kunne ikke åbne ALSA-input {candidate.split(':', 1)[1]}."
return error
def _parse_card_line(self, line: str) -> tuple[str, str, str] | None:
try:
after_prefix = line.split("card ", 1)[1]
card_index = after_prefix.split(":", 1)[0].strip()
after_index = after_prefix.split(":", 1)[1]
card_key = after_index.split("[", 1)[0].split(",", 1)[0].strip()
card_name = after_index.split("[", 1)[1].split("]", 1)[0].strip()
return card_index, card_key, card_name
except IndexError:
return None
def _parse_device_from_card_line(self, line: str) -> tuple[str, str, str] | None:
try:
after_device = line.split("device ", 1)[1]
device_index = after_device.split(":", 1)[0].strip()
after_index = after_device.split(":", 1)[1]
device_key = after_index.split("[", 1)[0].strip().rstrip(",")
device_name = after_index.split("[", 1)[1].split("]", 1)[0].strip()
return device_index, device_key, device_name
except IndexError:
return None
+2
View File
@@ -0,0 +1,2 @@
"""Core application services."""
+60
View File
@@ -0,0 +1,60 @@
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="TUXDMX_", env_file=".env", extra="ignore")
app_name: str = "TuxDMX"
env: str = "development"
secret_key: str = "development-secret-change-me"
database_url: str = "sqlite+aiosqlite:///./data/tuxdmx.db"
simulator_enabled: bool = True
allow_local_docs: bool = True
log_level: str = "INFO"
target_fps: int = Field(default=30, ge=1, le=44)
ola_universe: int = Field(default=1, ge=1, le=63999)
ola_output_port: str | None = None
ola_send_timeout_ms: int = Field(default=1000, ge=100, le=10000)
system_control_enabled: bool = True
system_service_name: str = "tuxdmx.service"
system_control_helper: str = "/opt/tuxdmx/scripts/control-system.sh"
frontend_dist: Path = Path("frontend/dist")
data_dir: Path = Path("data")
backup_dir: Path = Path("data/backups")
fixture_cache_dir: Path = Path("data/fixtures")
diagnostics_dir: Path = Path("data/diagnostics")
uploads_dir: Path = Path("data/uploads")
log_dir: Path = Path("data")
def resolve_path(path: Path) -> Path:
return path.expanduser().resolve(strict=False)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
settings = Settings()
settings.frontend_dist = resolve_path(settings.frontend_dist)
settings.data_dir = resolve_path(settings.data_dir)
settings.backup_dir = resolve_path(settings.backup_dir)
settings.fixture_cache_dir = resolve_path(settings.fixture_cache_dir)
settings.diagnostics_dir = resolve_path(settings.diagnostics_dir)
settings.uploads_dir = resolve_path(settings.uploads_dir)
settings.log_dir = resolve_path(settings.log_dir)
settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.backup_dir.mkdir(parents=True, exist_ok=True)
settings.fixture_cache_dir.mkdir(parents=True, exist_ok=True)
settings.diagnostics_dir.mkdir(parents=True, exist_ok=True)
settings.uploads_dir.mkdir(parents=True, exist_ok=True)
settings.log_dir.mkdir(parents=True, exist_ok=True)
return settings
def get_alembic_database_url(database_url: str) -> str:
if database_url.startswith("sqlite+aiosqlite:///"):
return database_url.replace("sqlite+aiosqlite:///", "sqlite:///", 1)
return database_url
+45
View File
@@ -0,0 +1,45 @@
from collections.abc import AsyncIterator
from sqlalchemy import event, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import get_settings
from app.models.base import Base
settings = get_settings()
engine = create_async_engine(settings.database_url, future=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
@event.listens_for(engine.sync_engine, "connect")
def _enable_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # type: ignore[no-untyped-def]
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA foreign_keys=ON;")
cursor.close()
async def init_database() -> None:
async with engine.begin() as connection:
await connection.execute(text("PRAGMA journal_mode=WAL;"))
await connection.run_sync(Base.metadata.create_all)
await _ensure_compatible_schema(connection)
async def get_db_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
async def _ensure_compatible_schema(connection) -> None: # type: ignore[no-untyped-def]
def sync_ensure(sync_connection) -> None: # type: ignore[no-untyped-def]
scene_columns = {
row[1]
for row in sync_connection.exec_driver_sql("PRAGMA table_info('scenes')").fetchall()
}
if "targets" not in scene_columns:
sync_connection.exec_driver_sql(
"ALTER TABLE scenes ADD COLUMN targets JSON NOT NULL DEFAULT '[]'"
)
await connection.run_sync(sync_ensure)
+62
View File
@@ -0,0 +1,62 @@
from app.auth.service import AuthService
from app.backup.service import BackupService
from app.bpm.service import BpmService
from app.core.config import get_settings
from app.dmx.backends import OlaDmxBackend, SimulatorDmxBackend
from app.dmx.engine import DmxEngine
from app.dmx.output_service import DmxOutputService
from app.effects.service import EffectService
from app.fixtures.service import FixtureService
from app.homeassistant.service import HomeAssistantService
from app.live.service import LiveDeskService
from app.midi.service import MidiService
from app.mixitup.service import TriggerService
from app.patch.service import PatchService
from app.scenes.service import SceneService
from app.system.service import SystemControlService
from app.telemetry.service import TelemetryService
class ApplicationState:
def __init__(self) -> None:
settings = get_settings()
self.telemetry = TelemetryService()
backend = (
SimulatorDmxBackend(universe=settings.ola_universe)
if settings.simulator_enabled
else OlaDmxBackend(
universe=settings.ola_universe,
output_port=settings.ola_output_port,
send_timeout_s=settings.ola_send_timeout_ms / 1000,
)
)
self.engine = DmxEngine(self.telemetry, backend=backend)
self.auth = AuthService()
self.fixtures = FixtureService()
self.patch = PatchService()
self.scenes = SceneService(self.engine)
self.bpm = BpmService()
self.effects = EffectService(self.engine, self.bpm)
self.dmx = DmxOutputService(self.engine)
self.home_assistant = HomeAssistantService(self.engine)
self.live = LiveDeskService(self.engine, home_assistant=self.home_assistant)
self.triggers = TriggerService(self.engine, self.effects, self.scenes)
self.midi = MidiService(self.engine, self.scenes, self.effects)
self.backups = BackupService(settings.data_dir, settings.backup_dir)
self.system = SystemControlService()
async def startup(self) -> None:
await self.dmx.startup()
await self.home_assistant.startup()
await self.midi.startup()
await self.bpm.startup()
await self.engine.start()
async def shutdown(self) -> None:
await self.effects.shutdown()
await self.home_assistant.shutdown()
await self.bpm.shutdown()
await self.engine.stop()
app_state = ApplicationState()
+32
View File
@@ -0,0 +1,32 @@
import json
import logging
from datetime import UTC, datetime
from app.core.config import get_settings
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
return json.dumps(payload, ensure_ascii=True)
def configure_logging() -> None:
settings = get_settings()
root = logging.getLogger()
if root.handlers:
return
root.setLevel(settings.log_level.upper())
console = logging.StreamHandler()
console.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
root.addHandler(console)
json_handler = logging.FileHandler(settings.log_dir / "tuxdmx.json.log", encoding="utf-8")
json_handler.setFormatter(JsonFormatter())
root.addHandler(json_handler)
+2
View File
@@ -0,0 +1,2 @@
"""DMX runtime services."""
+496
View File
@@ -0,0 +1,496 @@
from __future__ import annotations
import asyncio
import socket
import struct
import threading
from array import array
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, Protocol
from app.dmx.frame import DmxFrame
def utc_now() -> datetime:
return datetime.now(UTC)
@dataclass(slots=True)
class BackendStatus:
connected: bool = True
degraded: bool = False
last_error: str | None = None
reconnect_count: int = 0
latency_ms: int = 5
device_name: str = "Simulator universe 1"
connected_since: datetime = field(default_factory=utc_now)
backend_name: str = "simulator"
last_successful_frame: datetime | None = None
frames_sent: int = 0
send_errors: int = 0
selected_universe: int = 1
selected_output_port: str | None = None
class DmxBackend:
async def startup(self) -> None:
return None
async def shutdown(self) -> None:
return None
async def send_frame(self, frame: DmxFrame) -> None: # pragma: no cover - interface
raise NotImplementedError
def get_status(self) -> BackendStatus: # pragma: no cover - interface
raise NotImplementedError
@dataclass(slots=True)
class ArtNetNode:
ip: str
short_name: str
long_name: str
net: int
sub_switch: int
port_count: int
raw_port_address: int
@property
def label(self) -> str:
name = self.long_name or self.short_name or self.ip
return f"{name} ({self.ip})"
class OlaClientAdapter(Protocol):
def open(self) -> None: ...
def close(self) -> None: ...
def send_frame(self, universe: int, values: list[int]) -> None: ...
class PythonOlaClientAdapter:
def __init__(self, timeout_s: float = 1.0) -> None:
self.timeout_s = timeout_s
self._wrapper_class: type[Any] | None = None
def open(self) -> None:
if self._wrapper_class is not None:
return
try:
from ola.ClientWrapper import ClientWrapper
except ImportError as exc: # pragma: no cover - depends on Pi runtime
raise RuntimeError(
"Python OLA bindings blev ikke fundet. "
"Installer ola-python eller OLA's Python-modul."
) from exc
self._wrapper_class = ClientWrapper
def close(self) -> None:
self._wrapper_class = None
def send_frame(self, universe: int, values: list[int]) -> None:
self.open()
assert self._wrapper_class is not None
wrapper = self._wrapper_class()
client = wrapper.Client()
dmx_buffer = array("B", values)
completed = threading.Event()
result: dict[str, object] = {
"success": False,
"error": f"Timeout under afsendelse til olad efter {self.timeout_s:.2f}s",
}
def stop_wrapper() -> None:
try:
wrapper.Stop()
except Exception:
return
def on_timeout() -> None:
completed.set()
stop_wrapper()
def callback(state: object | None = None) -> None:
if completed.is_set():
return
result["success"] = self._state_succeeded(state)
if not result["success"]:
result["error"] = self._state_message(state)
completed.set()
stop_wrapper()
timer = threading.Timer(self.timeout_s, on_timeout)
timer.daemon = True
timer.start()
try:
client.SendDmx(universe, dmx_buffer, callback)
wrapper.Run()
except Exception as exc:
raise RuntimeError(f"OLA SendDmx fejlede: {exc}") from exc
finally:
timer.cancel()
if not bool(result["success"]):
raise RuntimeError(str(result["error"]))
def _state_succeeded(self, state: object | None) -> bool:
if state is None:
return True
if isinstance(state, bool):
return state
for attribute in ("Succeeded", "succeeded", "Ok", "ok", "success"):
member = getattr(state, attribute, None)
if callable(member):
try:
return bool(member())
except Exception:
continue
if member is not None:
return bool(member)
return True
def _state_message(self, state: object | None) -> str:
if state is None:
return "Ukendt OLA-fejl"
for attribute in ("message", "error", "status"):
member = getattr(state, attribute, None)
if callable(member):
try:
value = member()
except Exception:
continue
if value:
return str(value)
elif member:
return str(member)
return str(state)
class OlaDmxBackend(DmxBackend):
def __init__(
self,
*,
universe: int = 1,
output_port: str | None = None,
send_timeout_s: float = 1.0,
adapter_factory: Callable[[], OlaClientAdapter] | None = None,
) -> None:
self._adapter_factory = adapter_factory or (
lambda: PythonOlaClientAdapter(timeout_s=send_timeout_s)
)
self._adapter: OlaClientAdapter | None = None
self._has_connected_once = False
self._status = BackendStatus(
connected=False,
degraded=True,
last_error="Afventer forbindelse til olad",
backend_name="ola",
device_name="OLA DMX output",
selected_universe=universe,
selected_output_port=output_port,
)
async def startup(self) -> None:
return None
async def shutdown(self) -> None:
await self._disconnect()
async def send_frame(self, frame: DmxFrame) -> None:
await self._ensure_connected()
assert self._adapter is not None
try:
await asyncio.to_thread(
self._adapter.send_frame,
self._status.selected_universe,
frame.values.copy(),
)
except Exception as exc:
self._status.connected = False
self._status.degraded = True
self._status.last_error = str(exc)
self._status.send_errors += 1
await self._disconnect()
raise
self._status.connected = True
self._status.degraded = False
self._status.last_error = None
self._status.frames_sent += 1
self._status.last_successful_frame = utc_now()
def get_status(self) -> BackendStatus:
return self._status
async def _ensure_connected(self) -> None:
if self._adapter is not None:
return
adapter = self._adapter_factory()
await asyncio.to_thread(adapter.open)
self._adapter = adapter
self._status.connected = True
self._status.degraded = False
self._status.last_error = None
if self._has_connected_once:
self._status.reconnect_count += 1
else:
self._has_connected_once = True
self._status.connected_since = utc_now()
async def _disconnect(self) -> None:
if self._adapter is None:
return
adapter = self._adapter
self._adapter = None
await asyncio.to_thread(adapter.close)
ARTNET_PORT = 6454
ARTNET_HEADER = b"Art-Net\x00"
ARTNET_OPCODE_POLL = 0x2000
ARTNET_OPCODE_POLL_REPLY = 0x2100
ARTNET_OPCODE_DMX = 0x5000
def build_artnet_dmx_packet(universe: int, values: list[int], sequence: int = 1) -> bytes:
dmx_values = bytes(array("B", values[:512] + [0] * max(0, 512 - len(values))))
port_address = max(0, universe - 1)
sub_uni = port_address & 0xFF
net = (port_address >> 8) & 0x7F
length = len(dmx_values)
return b"".join(
[
ARTNET_HEADER,
struct.pack("<H", ARTNET_OPCODE_DMX),
bytes([0x00, 0x0E]),
bytes([sequence & 0xFF, 0x00]),
bytes([sub_uni, net]),
struct.pack(">H", length),
dmx_values,
]
)
def build_artnet_poll_packet() -> bytes:
return b"".join(
[
ARTNET_HEADER,
struct.pack("<H", ARTNET_OPCODE_POLL),
bytes([0x00, 0x0E]),
bytes([0x00, 0x00]),
]
)
def parse_artnet_poll_reply(packet: bytes) -> ArtNetNode | None:
if len(packet) < 190 or not packet.startswith(ARTNET_HEADER):
return None
opcode = struct.unpack_from("<H", packet, 8)[0]
if opcode != ARTNET_OPCODE_POLL_REPLY:
return None
ip = ".".join(str(part) for part in packet[10:14])
net = packet[18]
sub_switch = packet[19]
short_name = packet[26:44].split(b"\x00", 1)[0].decode("utf-8", errors="ignore").strip()
long_name = packet[44:108].split(b"\x00", 1)[0].decode("utf-8", errors="ignore").strip()
port_count = struct.unpack_from(">H", packet, 172)[0]
sw_out_0 = packet[190] if len(packet) > 190 else 0
raw_port_address = (net << 8) | sw_out_0
return ArtNetNode(
ip=ip,
short_name=short_name,
long_name=long_name,
net=net,
sub_switch=sub_switch,
port_count=port_count,
raw_port_address=raw_port_address,
)
def discover_artnet_nodes(timeout_s: float = 1.0) -> list[ArtNetNode]:
nodes: dict[str, ArtNetNode] = {}
poll_packet = build_artnet_poll_packet()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("", 0))
sock.settimeout(max(0.05, timeout_s / 5))
sock.sendto(poll_packet, ("255.255.255.255", ARTNET_PORT))
deadline = datetime.now(UTC).timestamp() + timeout_s
while datetime.now(UTC).timestamp() < deadline:
try:
packet, _addr = sock.recvfrom(1024)
except socket.timeout:
continue
node = parse_artnet_poll_reply(packet)
if node is not None:
nodes[node.ip] = node
finally:
sock.close()
return sorted(nodes.values(), key=lambda item: (item.long_name or item.short_name or item.ip, item.ip))
class ArtNetDmxBackend(DmxBackend):
def __init__(
self,
*,
universe: int = 1,
target_host: str = "255.255.255.255",
output_port: str | None = None,
) -> None:
self._target_host = target_host
self._sequence = 0
self._socket: socket.socket | None = None
self._status = BackendStatus(
connected=False,
degraded=True,
last_error="Afventer Artnet socket",
backend_name="artnet",
device_name=f"Art-Net output {target_host}",
selected_universe=universe,
selected_output_port=output_port or target_host,
)
async def startup(self) -> None:
if self._socket is not None:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
self._socket = sock
self._status.connected = True
self._status.degraded = False
self._status.last_error = None
self._status.connected_since = utc_now()
async def shutdown(self) -> None:
if self._socket is not None:
self._socket.close()
self._socket = None
self._status.connected = False
async def send_frame(self, frame: DmxFrame) -> None:
if self._socket is None:
await self.startup()
assert self._socket is not None
self._sequence = (self._sequence + 1) % 256
packet = build_artnet_dmx_packet(self._status.selected_universe, frame.values.copy(), self._sequence)
try:
self._socket.sendto(packet, (self._target_host, ARTNET_PORT))
except OSError as exc:
self._status.connected = False
self._status.degraded = True
self._status.last_error = str(exc)
self._status.send_errors += 1
raise RuntimeError(str(exc)) from exc
self._status.connected = True
self._status.degraded = False
self._status.last_error = None
self._status.frames_sent += 1
self._status.last_successful_frame = utc_now()
def get_status(self) -> BackendStatus:
return self._status
class SimulatorDmxBackend(DmxBackend):
def __init__(self, universe: int = 1) -> None:
self._status = BackendStatus(
selected_universe=universe,
selected_output_port="simulator",
device_name=f"Simulator universe {universe}",
)
self._history: deque[DmxFrame] = deque(maxlen=120)
self._faults: set[str] = set()
def inject_fault(self, fault: str, enabled: bool) -> None:
if enabled:
self._faults.add(fault)
else:
self._faults.discard(fault)
def history(self) -> list[DmxFrame]:
return list(self._history)
async def send_frame(self, frame: DmxFrame) -> None:
if "ola_unavailable" in self._faults:
self._record_error("OLA utilgaengelig i simulator")
raise RuntimeError(self._status.last_error or "Simulatorfejl")
if "usb_disconnected" in self._faults:
self._record_error("USB-DMX er frakoblet i simulator")
raise RuntimeError(self._status.last_error or "Simulatorfejl")
if "send_timeout" in self._faults:
await asyncio.sleep(0.25)
self._record_error("Simuleret send timeout")
raise TimeoutError(self._status.last_error or "Simuleret send timeout")
await asyncio.sleep(self._status.latency_ms / 1000)
was_connected = self._status.connected
self._status.connected = True
self._status.degraded = "slow_backend" in self._faults
self._status.last_error = None
self._status.frames_sent += 1
self._status.last_successful_frame = utc_now()
if not was_connected:
self._status.connected_since = utc_now()
self._status.reconnect_count += 1
self._history.append(frame.copy())
def get_status(self) -> BackendStatus:
return self._status
def _record_error(self, message: str) -> None:
self._status.connected = False
self._status.degraded = True
self._status.last_error = message
self._status.send_errors += 1
class FakeOlaClientAdapter:
def __init__(
self,
*,
output_port: str = "mock-port-1",
open_results: list[Exception | None] | None = None,
send_results: list[Exception | None] | None = None,
) -> None:
self.output_port = output_port
self.open_results: deque[Exception | None] = deque(open_results or [])
self.send_results: deque[Exception | None] = deque(send_results or [])
self.is_open = False
self.open_calls = 0
self.close_calls = 0
self.sent_frames: list[tuple[int, list[int]]] = []
def open(self) -> None:
self.open_calls += 1
if self.open_results:
outcome = self.open_results.popleft()
if outcome is not None:
raise outcome
self.is_open = True
def close(self) -> None:
self.close_calls += 1
self.is_open = False
def send_frame(self, universe: int, values: list[int]) -> None:
if not self.is_open:
raise RuntimeError("Mock OLA-adapter er ikke aaben")
self.sent_frames.append((universe, values.copy()))
if self.send_results:
outcome = self.send_results.popleft()
if outcome is not None:
raise outcome
FakeOlaAdapter = FakeOlaClientAdapter
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import asyncio
import logging
import time
from collections import deque
from datetime import UTC, datetime
from app.core.config import get_settings
from app.dmx.backends import DmxBackend, SimulatorDmxBackend
from app.dmx.frame import DmxFrame, FrameLayer, merge_layers
from app.telemetry.service import TelemetryService
logger = logging.getLogger(__name__)
class DmxEngine:
def __init__(self, telemetry: TelemetryService, backend: DmxBackend | None = None) -> None:
self._settings = get_settings()
self.telemetry = telemetry
self.backend = backend or SimulatorDmxBackend()
self.layers: dict[str, FrameLayer] = {}
self.blackout = False
self.freeze = False
self.master = 255
self.current_frame = DmxFrame()
self.current_frames: dict[int, DmxFrame] = {1: DmxFrame()}
self._task: asyncio.Task[None] | None = None
self._running = False
self._frame_times: deque[float] = deque(maxlen=120)
@property
def is_running(self) -> bool:
return self._running
async def start(self) -> None:
if self._running:
return
await self.backend.startup()
self._running = True
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._running = False
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
await self.backend.shutdown()
def configure_backend(self, backend: DmxBackend) -> None:
self.backend = backend
async def replace_backend(self, backend: DmxBackend) -> None:
was_running = self._running
if was_running:
await self.stop()
self.backend = backend
selected_universe = self.backend.get_status().selected_universe
self.current_frame = DmxFrame(universe=selected_universe)
self.current_frames = {selected_universe: self.current_frame.copy()}
if was_running:
await self.start()
async def _loop(self) -> None:
interval = 1 / self._settings.target_fps
while self._running:
started = time.perf_counter()
try:
if not self.freeze:
frames = merge_layers(list(self.layers.values()), blackout=self.blackout)
if self.master < 255:
for frame in frames.values():
frame.values = [int((value / 255) * self.master) for value in frame.values]
self.current_frames = {universe: frame.copy() for universe, frame in frames.items()}
selected_universe = self.backend.get_status().selected_universe
frame = self.current_frames.get(selected_universe, DmxFrame(universe=selected_universe))
self.current_frame = frame.copy()
await self.backend.send_frame(frame)
self.telemetry.record_send_success(self.backend.get_status())
self._frame_times.append(time.perf_counter() - started)
except Exception as exc:
logger.exception("DMX send failed")
self.telemetry.record_send_failure(str(exc), self.backend.get_status())
elapsed = time.perf_counter() - started
await asyncio.sleep(max(0, interval - elapsed))
def set_layer(self, layer: FrameLayer) -> None:
self.layers[layer.name] = layer
def remove_layer(self, name: str) -> None:
self.layers.pop(name, None)
def trigger_blackout(self) -> None:
self.blackout = True
def release_blackout(self) -> None:
self.blackout = False
def get_frame(self, universe: int) -> DmxFrame:
frame = self.current_frames.get(universe)
if frame is None:
return DmxFrame(universe=universe)
return frame.copy()
def snapshot(self) -> dict[str, object]:
status = self.backend.get_status()
fps = 0.0
if self._frame_times:
average = sum(self._frame_times) / len(self._frame_times)
if average > 0:
fps = 1 / average
return {
"backend": status.backend_name,
"connected": status.connected,
"degraded": status.degraded,
"last_error": status.last_error,
"last_successful_frame": status.last_successful_frame.isoformat()
if status.last_successful_frame is not None
else None,
"frames_sent": status.frames_sent,
"send_errors": status.send_errors,
"reconnect_count": status.reconnect_count,
"selected_universe": status.selected_universe,
"selected_output_port": status.selected_output_port,
"available_universes": sorted(self.current_frames),
"blackout": self.blackout,
"freeze": self.freeze,
"master": self.master,
"fps": round(fps, 2),
"frame": self.current_frame.values,
"source_map": self.current_frame.source_map,
"updated_at": datetime.now(UTC).isoformat(),
}
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
def clamp_channel(value: int) -> int:
return max(0, min(255, int(value)))
@dataclass(slots=True)
class FrameLayer:
name: str
priority: int
values_by_universe: dict[int, dict[int, int]]
precedence_map_by_universe: dict[int, dict[int, str]] = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
@property
def values(self) -> dict[int, int]:
return self.values_by_universe.get(1, {})
@property
def precedence_map(self) -> dict[int, str]:
return self.precedence_map_by_universe.get(1, {})
@classmethod
def from_channel_values(
cls,
name: str,
priority: int,
values: dict[int, int],
precedence_map: dict[int, str] | None = None,
*,
universe: int = 1,
) -> "FrameLayer":
return cls(
name=name,
priority=priority,
values_by_universe={universe: values},
precedence_map_by_universe={universe: precedence_map or {}},
)
@dataclass(slots=True)
class DmxFrame:
universe: int = 1
values: list[int] = field(default_factory=lambda: [0] * 512)
source_map: list[str] = field(default_factory=lambda: ["idle"] * 512)
def set_channel(self, channel: int, value: int, source: str = "manual") -> None:
if not 1 <= channel <= 512:
raise ValueError("Channel must be in range 1..512")
self.values[channel - 1] = clamp_channel(value)
self.source_map[channel - 1] = source
def copy(self) -> DmxFrame:
return DmxFrame(self.universe, self.values.copy(), self.source_map.copy())
def merge_layers(layers: list[FrameLayer], blackout: bool = False) -> dict[int, DmxFrame]:
universes = sorted(
{
int(universe)
for layer in layers
for universe in layer.values_by_universe
}
) or [1]
if blackout:
return {universe: DmxFrame(universe=universe) for universe in universes}
ordered = sorted(layers, key=lambda layer: (layer.priority, layer.created_at))
frames = {universe: DmxFrame(universe=universe) for universe in universes}
for universe, frame in frames.items():
for channel in range(1, 513):
channel_candidates: list[tuple[FrameLayer, int]] = []
for layer in ordered:
values = layer.values_by_universe.get(universe, {})
if channel in values:
channel_candidates.append((layer, clamp_channel(values[channel])))
if not channel_candidates:
continue
precedence_map = channel_candidates[-1][0].precedence_map_by_universe.get(universe, {})
precedence = precedence_map.get(channel, "ltp").lower()
if precedence == "htp":
chosen_layer, chosen_value = max(channel_candidates, key=lambda pair: pair[1])
else:
chosen_layer, chosen_value = channel_candidates[-1]
frame.set_channel(channel, chosen_value, chosen_layer.name)
return frames
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Literal
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.config import get_settings
from app.core.database import SessionLocal
from app.dmx.backends import (
ArtNetDmxBackend,
ArtNetNode,
OlaDmxBackend,
SimulatorDmxBackend,
discover_artnet_nodes,
)
from app.dmx.engine import DmxEngine
from app.models.entities import Setting
DMX_OUTPUT_SETTING_KEY = "dmx_output"
BackendKind = Literal["simulator", "ola", "artnet"]
class DmxOutputService:
def __init__(
self,
engine: DmxEngine,
session_factory: async_sessionmaker[AsyncSession] = SessionLocal,
) -> None:
self.engine = engine
self._session_factory = session_factory
self._settings = get_settings()
self._config = self._default_config()
self._artnet_nodes: list[ArtNetNode] = []
async def startup(self) -> None:
await self._load_config()
self.engine.configure_backend(self._build_backend(self._config))
async def get_config(self) -> dict[str, object]:
return {
**self._config,
"artnet_nodes": [self._serialize_node(node) for node in self._artnet_nodes],
}
async def save_config(self, payload: dict[str, object]) -> dict[str, object]:
normalized = self._normalize_config(payload)
self._config = normalized
await self._persist_config(normalized)
backend = self._build_backend(normalized)
if self.engine.is_running:
await self.engine.replace_backend(backend)
else:
self.engine.configure_backend(backend)
return await self.get_config()
async def discover_artnet(self, timeout_s: float = 1.0) -> dict[str, object]:
nodes = await self._discover(timeout_s)
self._artnet_nodes = nodes
return {
"items": [self._serialize_node(node) for node in nodes],
"count": len(nodes),
}
async def _discover(self, timeout_s: float) -> list[ArtNetNode]:
return await self._to_thread_discovery(timeout_s)
async def _to_thread_discovery(self, timeout_s: float) -> list[ArtNetNode]:
import asyncio
return await asyncio.to_thread(discover_artnet_nodes, timeout_s)
async def _load_config(self) -> None:
async with self._session_factory() as session:
setting = await session.get(Setting, DMX_OUTPUT_SETTING_KEY)
if setting is None or not isinstance(setting.value, dict):
return
self._config = self._normalize_config(setting.value)
async def _persist_config(self, config: dict[str, object]) -> None:
async with self._session_factory() as session:
setting = await session.get(Setting, DMX_OUTPUT_SETTING_KEY)
if setting is None:
setting = Setting(
key=DMX_OUTPUT_SETTING_KEY,
value=config,
updated_at=datetime.now(UTC),
)
session.add(setting)
else:
setting.value = config
setting.updated_at = datetime.now(UTC)
await session.commit()
def _default_config(self) -> dict[str, object]:
backend: BackendKind = "simulator" if self._settings.simulator_enabled else "ola"
return {
"backend": backend,
"universe": self._settings.ola_universe,
"output_port": self._settings.ola_output_port or "",
"target_host": "",
}
def _normalize_config(self, payload: dict[str, object]) -> dict[str, object]:
backend = str(payload.get("backend", self._default_config()["backend"])).lower()
if backend not in {"simulator", "ola", "artnet"}:
backend = "simulator"
universe = int(payload.get("universe", self._settings.ola_universe) or self._settings.ola_universe)
output_port = str(payload.get("output_port", "") or "").strip()
target_host = str(payload.get("target_host", "") or "").strip()
if backend == "artnet" and not target_host:
target_host = "255.255.255.255"
if backend == "simulator":
output_port = "simulator"
return {
"backend": backend,
"universe": max(1, min(63999, universe)),
"output_port": output_port,
"target_host": target_host,
}
def _build_backend(self, config: dict[str, object]):
backend = str(config["backend"])
universe = int(config["universe"])
output_port = str(config.get("output_port", "") or "") or None
if backend == "ola":
return OlaDmxBackend(
universe=universe,
output_port=output_port,
send_timeout_s=self._settings.ola_send_timeout_ms / 1000,
)
if backend == "artnet":
target_host = str(config.get("target_host", "") or "255.255.255.255")
return ArtNetDmxBackend(
universe=universe,
target_host=target_host,
output_port=output_port or target_host,
)
return SimulatorDmxBackend(universe=universe)
def _serialize_node(self, node: ArtNetNode) -> dict[str, object]:
return {
"ip": node.ip,
"short_name": node.short_name,
"long_name": node.long_name,
"label": node.label,
"net": node.net,
"sub_switch": node.sub_switch,
"port_count": node.port_count,
"raw_port_address": node.raw_port_address,
}
+2
View File
@@ -0,0 +1,2 @@
"""Effect runtime services."""
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from time import monotonic
from app.bpm.service import BpmService
from app.dmx.engine import DmxEngine
from app.dmx.frame import FrameLayer
from app.models.schemas import EffectPayload
@dataclass(slots=True)
class ActiveEffect:
slug: str
started_at: float
duration_ms: int
sync_mode: str = "manual"
class EffectService:
def __init__(self, engine: DmxEngine, bpm: BpmService) -> None:
self.engine = engine
self.bpm = bpm
self.effects: dict[str, EffectPayload] = {}
self.active_effects: dict[str, ActiveEffect] = {}
self._effect_tasks: dict[str, asyncio.Task[None]] = {}
def save(self, payload: EffectPayload) -> EffectPayload:
self.effects[payload.slug] = payload
return payload
def list(self) -> list[EffectPayload]:
return list(self.effects.values())
def trigger(self, slug: str) -> EffectPayload:
effect = self.effects[slug]
self.stop(slug)
if effect.effect_type == "beat-flash":
self._effect_tasks[slug] = asyncio.create_task(
self._run_beat_flash(effect),
name=f"tuxdmx-effect-{slug}",
)
self.active_effects[slug] = ActiveEffect(
slug=slug,
started_at=monotonic(),
duration_ms=self._coerce_duration_ms(effect.parameters.get("duration_ms"), fallback=180),
sync_mode="bpm",
)
return effect
values_by_universe, precedence_map_by_universe = self._resolve_channel_maps(effect)
duration_ms = self._coerce_duration_ms(effect.parameters.get("duration_ms"), fallback=5000)
self.engine.set_layer(
FrameLayer(
name=f"effect:{slug}",
priority=effect.priority,
values_by_universe=values_by_universe,
precedence_map_by_universe=precedence_map_by_universe,
)
)
self.active_effects[slug] = ActiveEffect(
slug=slug,
started_at=monotonic(),
duration_ms=duration_ms,
sync_mode="static",
)
return effect
def stop(self, slug: str) -> None:
task = self._effect_tasks.pop(slug, None)
if task is not None:
task.cancel()
self.engine.remove_layer(f"effect:{slug}")
self.active_effects.pop(slug, None)
async def shutdown(self) -> None:
for slug in list(self._effect_tasks):
self.stop(slug)
tasks = list(self._effect_tasks.values())
self._effect_tasks.clear()
for task in tasks:
try:
await task
except asyncio.CancelledError:
pass
async def _run_beat_flash(self, effect: EffectPayload) -> None:
slug = effect.slug
values_by_universe, precedence_map_by_universe = self._resolve_channel_maps(effect)
if not any(values_by_universe.values()):
return
pulse_ms = self._coerce_duration_ms(effect.parameters.get("duration_ms"), fallback=180)
pulse_seconds = max(0.05, pulse_ms / 1000)
last_seen_beat = self.bpm.beat_counter
next_manual_pulse_at = monotonic()
try:
while True:
bpm_value = max(40.0, min(220.0, float(self.bpm.current_bpm)))
beat_interval = max(0.2, 60.0 / bpm_value)
should_pulse = False
if self.bpm.audio_connected:
if self.bpm.beat_counter != last_seen_beat:
last_seen_beat = self.bpm.beat_counter
should_pulse = True
else:
await asyncio.sleep(0.02)
continue
else:
now = monotonic()
if now >= next_manual_pulse_at:
next_manual_pulse_at = now + beat_interval
should_pulse = True
else:
await asyncio.sleep(min(0.05, next_manual_pulse_at - now))
continue
if not should_pulse:
continue
self.engine.set_layer(
FrameLayer(
name=f"effect:{slug}",
priority=effect.priority,
values_by_universe=values_by_universe,
precedence_map_by_universe=precedence_map_by_universe,
)
)
await asyncio.sleep(min(pulse_seconds, beat_interval * 0.8))
self.engine.remove_layer(f"effect:{slug}")
except asyncio.CancelledError:
self.engine.remove_layer(f"effect:{slug}")
raise
def _resolve_channel_maps(self, effect: EffectPayload) -> tuple[dict[int, dict[int, int]], dict[int, dict[int, str]]]:
raw_channels = effect.parameters.get("channels", {})
raw_precedence = effect.parameters.get("precedence", {})
channels = raw_channels if isinstance(raw_channels, dict) else {}
precedence = raw_precedence if isinstance(raw_precedence, dict) else {}
values = {
int(channel): int(value)
for channel, value in channels.items()
}
precedence_map = {
int(channel): str(value)
for channel, value in precedence.items()
}
universe = int(effect.parameters.get("universe", 1) or 1)
return {universe: values}, {universe: precedence_map}
def _coerce_duration_ms(self, raw_value: object, fallback: int) -> int:
if isinstance(raw_value, (int, float)):
return max(50, int(raw_value))
if isinstance(raw_value, str):
try:
return max(50, int(float(raw_value)))
except ValueError:
return fallback
return fallback
+2
View File
@@ -0,0 +1,2 @@
"""Fixture import and normalization."""
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
def normalize_fixture(
payload: dict[str, object], manufacturer_key: str, fixture_key: str
) -> dict[str, object]:
modes = payload.get("modes", [])
available_channels = payload.get("availableChannels", {})
normalized_modes: list[dict[str, object]] = []
for mode in modes if isinstance(modes, list) else []:
channels = mode.get("channels", []) if isinstance(mode, dict) else []
normalized_channels: list[dict[str, object]] = []
for index, channel in enumerate(channels, start=1):
if isinstance(channel, str):
channel_definition = (
available_channels.get(channel, {})
if isinstance(available_channels, dict)
else {}
)
capability = (
channel_definition.get("capability")
if isinstance(channel_definition, dict)
else None
)
capabilities = (
channel_definition.get("capabilities")
if isinstance(channel_definition, dict)
else None
)
channel_capabilities = (
capabilities
if isinstance(capabilities, list)
else [capability]
if capability is not None
else []
)
capability_types = [
str(item.get("type", "")).lower()
for item in channel_capabilities
if isinstance(item, dict)
]
normalized_channels.append(
{
"index": index,
"key": channel,
"display_name": channel.replace("-", " ").title(),
"precedence": (
"htp"
if "dim" in channel.lower() or "intensity" in capability_types
else "ltp"
),
"resolution": 8,
"capabilities": channel_capabilities,
}
)
elif isinstance(channel, dict):
key = str(
channel.get("name", channel.get("key", channel.get("display_name", f"channel-{index}")))
)
capability = channel.get("capability")
capabilities = channel.get("capabilities")
channel_capabilities = (
capabilities
if isinstance(capabilities, list)
else [capability]
if capability is not None
else []
)
normalized_channels.append(
{
"index": index,
"key": key,
"display_name": str(channel.get("display_name", key)),
"precedence": str(
channel.get(
"precedence",
"htp" if "dim" in key.lower() else "ltp",
)
).lower(),
"resolution": int(channel.get("resolution", 8)),
"capabilities": channel_capabilities,
}
)
normalized_modes.append(
{
"key": (
str(mode.get("name", mode.get("key", "default")))
if isinstance(mode, dict)
else "default"
),
"channel_count": len(normalized_channels),
"channels": normalized_channels,
}
)
return {
"manufacturer": manufacturer_key,
"model": payload.get("name", fixture_key),
"short_name": payload.get("shortName"),
"categories": payload.get("categories", []),
"modes": normalized_modes,
"schema_version": payload.get("$schema", "unknown"),
"source": {
"manufacturer_key": manufacturer_key,
"fixture_key": fixture_key,
},
"warnings": [],
}
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import json
from pathlib import Path
import httpx
class OflClient:
search_url = "https://open-fixture-library.org/api/v1/get-search-results"
fixture_base = "https://open-fixture-library.org"
async def search(self, query: str) -> list[dict[str, object]]:
fallback = self._search_local(query)
try:
async with httpx.AsyncClient(timeout=6.0) as client:
response = await client.post(
self.search_url,
json={
"searchQuery": query,
"manufacturersQuery": [],
"categoriesQuery": [],
},
)
response.raise_for_status()
keys = response.json()
return [{"fixture_key": key, "cached": False} for key in keys]
except Exception:
return fallback
async def fetch_fixture(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]:
local_path = Path("test-data/fixtures") / f"{manufacturer_key}__{fixture_key}.json"
if local_path.exists():
return json.loads(local_path.read_text(encoding="utf-8"))
url = f"{self.fixture_base}/{manufacturer_key}/{fixture_key}.json"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
def _search_local(self, query: str) -> list[dict[str, object]]:
results: list[dict[str, object]] = []
lowered = query.lower()
for path in Path("test-data/fixtures").glob("*.json"):
if lowered in path.stem.lower():
results.append({"fixture_key": path.stem.replace("__", "/"), "cached": True})
return results
+289
View File
@@ -0,0 +1,289 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.fixtures.normalize import normalize_fixture
from app.fixtures.ofl import OflClient
from app.models.entities import FixtureDefinition, FixtureInstance, FixtureSource
class FixtureService:
def __init__(
self,
session_factory: async_sessionmaker[AsyncSession] = SessionLocal,
client: OflClient | None = None,
) -> None:
self._session_factory = session_factory
self.client = client or OflClient()
self.test_data_path = Path("test-data/fixtures")
async def search_ofl(self, query: str) -> list[dict[str, object]]:
if not query.strip():
return []
return await self.client.search(query)
async def preview_ofl(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]:
fixture = await self.client.fetch_fixture(manufacturer_key, fixture_key)
return normalize_fixture(fixture, manufacturer_key, fixture_key)
async def import_ofl(self, manufacturer_key: str, fixture_key: str) -> dict[str, object]:
source_payload = await self.client.fetch_fixture(manufacturer_key, fixture_key)
normalized = normalize_fixture(source_payload, manufacturer_key, fixture_key)
return await self._upsert_fixture(normalized, source_payload)
async def import_payload(self, payload: dict[str, object]) -> dict[str, object]:
normalized = self._normalize_import_payload(payload)
return await self._upsert_fixture(normalized, payload)
async def list_fixtures(self) -> list[dict[str, object]]:
async with self._session_factory() as session:
result = await session.execute(select(FixtureDefinition).order_by(FixtureDefinition.created_at, FixtureDefinition.id))
definitions = result.scalars().all()
return [self._serialize_definition(definition) for definition in definitions]
async def get_fixture(self, fixture_id: int) -> dict[str, object]:
async with self._session_factory() as session:
definition = await session.get(FixtureDefinition, fixture_id)
if definition is None:
raise LookupError("Fixture not found")
return self._serialize_definition(definition)
async def update_fixture(self, fixture_id: int, payload: dict[str, object]) -> dict[str, object]:
async with self._session_factory() as session:
definition = await session.get(FixtureDefinition, fixture_id)
if definition is None:
raise LookupError("Fixture not found")
existing = dict(definition.normalized_data)
merged = dict(existing)
for key in ("manufacturer", "model", "short_name", "categories", "modes", "schema_version", "source", "warnings"):
if key in payload:
merged[key] = payload[key]
definition.manufacturer = str(merged.get("manufacturer", definition.manufacturer))
definition.model = str(merged.get("model", definition.model))
definition.short_name = self._optional_str(merged.get("short_name"))
definition.categories = self._string_list(merged.get("categories", []))
definition.normalized_data = merged
await session.commit()
await session.refresh(definition)
return self._serialize_definition(definition)
async def delete_fixture(self, fixture_id: int) -> None:
async with self._session_factory() as session:
definition = await session.get(FixtureDefinition, fixture_id)
if definition is None:
raise LookupError("Fixture not found")
await session.execute(delete(FixtureInstance).where(FixtureInstance.definition_id == fixture_id))
source_id = definition.source_id
await session.delete(definition)
await session.flush()
if source_id is not None:
result = await session.execute(
select(FixtureDefinition).where(
FixtureDefinition.source_id == source_id,
FixtureDefinition.id != fixture_id,
).limit(1)
)
if result.scalar_one_or_none() is None:
source = await session.get(FixtureSource, source_id)
if source is not None:
await session.delete(source)
await session.commit()
async def _upsert_fixture(
self,
normalized: dict[str, object],
source_payload: dict[str, object],
) -> dict[str, object]:
source = normalized.get("source", {})
manufacturer_key = str(source.get("manufacturer_key", "custom")) if isinstance(source, dict) else "custom"
fixture_key = str(source.get("fixture_key", "custom")) if isinstance(source, dict) else "custom"
slug = f"{manufacturer_key}/{fixture_key}"
payload_hash = hashlib.sha256(json.dumps(source_payload, sort_keys=True).encode("utf-8")).hexdigest()
schema_ref = str(normalized.get("schema_version", "unknown"))
source_url = str(source_payload.get("oflURL", "")) if isinstance(source_payload, dict) else ""
async with self._session_factory() as session:
fixture_source = await self._upsert_source(
session,
manufacturer_key=manufacturer_key,
fixture_key=fixture_key,
schema_ref=schema_ref,
source_url=source_url,
payload=source_payload,
payload_hash=payload_hash,
)
result = await session.execute(select(FixtureDefinition).where(FixtureDefinition.slug == slug))
definition = result.scalar_one_or_none()
if definition is None:
definition = FixtureDefinition(
slug=slug,
manufacturer=str(normalized.get("manufacturer", manufacturer_key)),
model=str(normalized.get("model", fixture_key)),
short_name=self._optional_str(normalized.get("short_name")),
categories=self._string_list(normalized.get("categories", [])),
normalized_data=normalized,
source_id=fixture_source.id,
)
session.add(definition)
else:
definition.manufacturer = str(normalized.get("manufacturer", definition.manufacturer))
definition.model = str(normalized.get("model", definition.model))
definition.short_name = self._optional_str(normalized.get("short_name"))
definition.categories = self._string_list(normalized.get("categories", []))
definition.normalized_data = normalized
definition.source_id = fixture_source.id
await session.commit()
await session.refresh(definition)
return self._serialize_definition(definition)
async def _upsert_source(
self,
session: AsyncSession,
*,
manufacturer_key: str,
fixture_key: str,
schema_ref: str,
source_url: str,
payload: dict[str, object],
payload_hash: str,
) -> FixtureSource:
result = await session.execute(
select(FixtureSource).where(
FixtureSource.manufacturer_key == manufacturer_key,
FixtureSource.fixture_key == fixture_key,
)
)
fixture_source = result.scalar_one_or_none()
if fixture_source is None:
fixture_source = FixtureSource(
manufacturer_key=manufacturer_key,
fixture_key=fixture_key,
schema_ref=schema_ref,
source_url=source_url,
payload=payload,
payload_hash=payload_hash,
)
session.add(fixture_source)
await session.flush()
return fixture_source
fixture_source.schema_ref = schema_ref
fixture_source.source_url = source_url
fixture_source.payload = payload
fixture_source.payload_hash = payload_hash
await session.flush()
return fixture_source
def _serialize_definition(self, definition: FixtureDefinition) -> dict[str, object]:
normalized = dict(definition.normalized_data)
return {
"id": definition.id,
"slug": definition.slug,
"manufacturer": definition.manufacturer,
"model": definition.model,
"short_name": definition.short_name,
"categories": definition.categories,
"modes": normalized.get("modes", []),
"schema_version": normalized.get("schema_version", "unknown"),
"source": normalized.get("source", {}),
"warnings": normalized.get("warnings", []),
}
def _normalize_import_payload(self, payload: dict[str, object]) -> dict[str, object]:
if self._looks_like_ofl_fixture(payload):
source_payload = payload.get("source", {})
source = source_payload if isinstance(source_payload, dict) else {}
manufacturer_key = str(source.get("manufacturer_key", payload.get("manufacturer_key", "custom")))
fixture_key = str(
source.get(
"fixture_key",
payload.get("fixture_key", str(payload.get("name", "custom-fixture")).lower().replace(" ", "-")),
)
)
normalized = normalize_fixture(payload, manufacturer_key, fixture_key)
manufacturer_name = payload.get("manufacturer")
if manufacturer_name not in (None, ""):
normalized["manufacturer"] = str(manufacturer_name)
return normalized
return self._normalize_custom_payload(payload)
def _normalize_custom_payload(self, payload: dict[str, object]) -> dict[str, object]:
manufacturer = str(payload.get("manufacturer", "custom"))
model = str(payload.get("model", payload.get("name", "Custom fixture")))
source_payload = payload.get("source", {})
source = source_payload if isinstance(source_payload, dict) else {}
manufacturer_key = str(source.get("manufacturer_key", manufacturer.lower().replace(" ", "-")))
fixture_key = str(source.get("fixture_key", model.lower().replace(" ", "-")))
raw_modes = payload.get("modes", [])
modes: list[dict[str, object]] = []
for index, mode in enumerate(raw_modes if isinstance(raw_modes, list) else [], start=1):
if not isinstance(mode, dict):
continue
raw_channels = mode.get("channels", [])
channels: list[dict[str, object]] = []
for channel_index, channel in enumerate(raw_channels if isinstance(raw_channels, list) else [], start=1):
if isinstance(channel, dict):
channels.append(
{
"index": int(channel.get("index", channel_index)),
"key": str(channel.get("key", channel.get("display_name", f"channel-{channel_index}"))),
"display_name": str(channel.get("display_name", channel.get("key", f"Channel {channel_index}"))),
"precedence": str(channel.get("precedence", "ltp")).lower(),
"resolution": int(channel.get("resolution", 8)),
"capabilities": channel.get("capabilities", []),
}
)
elif isinstance(channel, str):
channels.append(
{
"index": channel_index,
"key": channel,
"display_name": channel,
"precedence": "htp" if "dim" in channel.lower() else "ltp",
"resolution": 8,
"capabilities": [],
}
)
modes.append(
{
"key": str(mode.get("key", mode.get("name", f"mode-{index}"))),
"channel_count": int(mode.get("channel_count", len(channels))),
"channels": channels,
}
)
return {
"manufacturer": manufacturer,
"model": model,
"short_name": payload.get("short_name"),
"categories": self._string_list(payload.get("categories", [])),
"modes": modes,
"schema_version": str(payload.get("schema_version", "custom")),
"source": {
"manufacturer_key": manufacturer_key,
"fixture_key": fixture_key,
},
"warnings": payload.get("warnings", []),
}
def _string_list(self, values: object) -> list[str]:
if not isinstance(values, list):
return []
return [str(value) for value in values]
def _optional_str(self, value: object) -> str | None:
return str(value) if value not in (None, "") else None
def _looks_like_ofl_fixture(self, payload: dict[str, object]) -> bool:
return isinstance(payload.get("availableChannels"), dict) and isinstance(payload.get("modes"), list)
+869
View File
@@ -0,0 +1,869 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from time import monotonic
from typing import Any, Protocol
import httpx
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.dmx.engine import DmxEngine
from app.models.entities import Setting
logger = logging.getLogger(__name__)
HA_CONFIG_KEY = "home_assistant_config"
HA_MAPPINGS_KEY = "home_assistant_mappings"
SWITCH_ON_THRESHOLD = 140
SWITCH_OFF_THRESHOLD = 115
EDGE_TRIGGER_THRESHOLD = 128
ON_OFF_ENTITY_DOMAINS = {"light", "switch", "input_boolean"}
TRIGGER_ENTITY_DOMAINS = {"scene", "automation"}
class HomeAssistantAdapter(Protocol):
async def get_api_root(self, base_url: str, token: str) -> dict[str, object]: ...
async def get_config(self, base_url: str, token: str) -> dict[str, object]: ...
async def list_entities(self, base_url: str, token: str) -> list[dict[str, object]]: ...
async def call_service(
self,
base_url: str,
token: str,
domain: str,
service: str,
data: dict[str, object],
) -> dict[str, object]: ...
class HttpxHomeAssistantAdapter:
def __init__(self, timeout_s: float = 2.0) -> None:
self.timeout_s = timeout_s
async def get_api_root(self, base_url: str, token: str) -> dict[str, object]:
return await self._get_json(base_url, token, "/api/")
async def get_config(self, base_url: str, token: str) -> dict[str, object]:
return await self._get_json(base_url, token, "/api/config")
async def list_entities(self, base_url: str, token: str) -> list[dict[str, object]]:
payload = await self._get_json(base_url, token, "/api/states")
if not isinstance(payload, list):
return []
return [item for item in payload if isinstance(item, dict)]
async def call_service(
self,
base_url: str,
token: str,
domain: str,
service: str,
data: dict[str, object],
) -> dict[str, object]:
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
response = await client.post(
f"{base_url.rstrip('/')}/api/services/{domain}/{service}",
headers=self._headers(token),
json=data,
)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else {"result": payload}
async def _get_json(self, base_url: str, token: str, path: str) -> dict[str, object] | list[object]:
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
response = await client.get(
f"{base_url.rstrip('/')}{path}",
headers=self._headers(token),
)
response.raise_for_status()
payload = response.json()
if isinstance(payload, (dict, list)):
return payload
return {}
def _headers(self, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
@dataclass(slots=True)
class MappingRuntime:
last_dmx_values: list[int]
last_sent_raw_values: list[int] | None = None
last_sent_summary: str | None = None
last_success_at: datetime | None = None
last_error: str | None = None
last_error_at: datetime | None = None
last_service: str | None = None
in_flight: bool = False
pending_raw_values: list[int] | None = None
resync_required: bool = False
switch_state: bool | None = None
class HomeAssistantService:
def __init__(
self,
engine: DmxEngine,
session_factory: async_sessionmaker[AsyncSession] = SessionLocal,
adapter: HomeAssistantAdapter | None = None,
) -> None:
self.engine = engine
self._session_factory = session_factory
self._adapter = adapter or HttpxHomeAssistantAdapter()
self._config = self._default_config()
self._token = ""
self._mappings: list[dict[str, object]] = []
self._entities_by_id: dict[str, dict[str, object]] = {}
self._mapping_runtime: dict[int, MappingRuntime] = {}
self._mapping_tasks: dict[int, asyncio.Task[None]] = {}
self._running = False
self._task: asyncio.Task[None] | None = None
self._dispatch_count = 0
self._error_count = 0
self._last_error: str | None = None
self._last_successful_call_at: datetime | None = None
self._last_connection_success_at: datetime | None = None
self._last_connection_error: str | None = None
self._ha_version: str | None = None
self._auth_ok = False
self._reachable = False
async def startup(self) -> None:
await self._load()
self._running = True
self._task = asyncio.create_task(self._loop(), name="tuxdmx-home-assistant-bridge")
async def shutdown(self) -> None:
self._running = False
for task in list(self._mapping_tasks.values()):
task.cancel()
for task in list(self._mapping_tasks.values()):
try:
await task
except asyncio.CancelledError:
pass
self._mapping_tasks.clear()
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def get_config(self) -> dict[str, object]:
return {
**self._config,
"has_token": bool(self._token),
"token_mask": "********" if self._token else "",
"mapping_count": len(self._mappings),
"dispatch_count": self._dispatch_count,
"error_count": self._error_count,
"last_error": self._last_error,
"last_successful_call_at": self._iso(self._last_successful_call_at),
"last_connection_success_at": self._iso(self._last_connection_success_at),
"last_connection_error": self._last_connection_error,
"ha_version": self._ha_version,
"auth_ok": self._auth_ok,
"reachable": self._reachable,
}
async def save_config(self, payload: dict[str, object]) -> dict[str, object]:
normalized = self._normalize_config(payload)
incoming_token = str(payload.get("token", "") or "").strip()
if incoming_token:
self._token = incoming_token
self._config = normalized
await self._persist_setting(
HA_CONFIG_KEY,
{
**normalized,
"token": self._token,
},
)
return await self.get_config()
async def test_connection(self) -> dict[str, object]:
base_url = str(self._config["base_url"])
if not base_url or not self._token:
self._reachable = False
self._auth_ok = False
self._last_connection_error = "Base URL eller token mangler."
return {
"reachable": False,
"auth_ok": False,
"ha_version": self._ha_version,
"last_error": self._last_connection_error,
"last_successful_call_at": self._iso(self._last_successful_call_at),
"last_connection_success_at": self._iso(self._last_connection_success_at),
}
try:
await self._adapter.get_api_root(base_url, self._token)
config = await self._adapter.get_config(base_url, self._token)
except httpx.HTTPStatusError as exc:
self._reachable = True
self._auth_ok = exc.response.status_code != 401
self._last_connection_error = self._summarize_exception(exc)
return {
"reachable": self._reachable,
"auth_ok": False,
"ha_version": self._ha_version,
"last_error": self._last_connection_error,
"last_successful_call_at": self._iso(self._last_successful_call_at),
"last_connection_success_at": self._iso(self._last_connection_success_at),
}
except Exception as exc:
self._reachable = False
self._auth_ok = False
self._last_connection_error = self._summarize_exception(exc)
return {
"reachable": False,
"auth_ok": False,
"ha_version": self._ha_version,
"last_error": self._last_connection_error,
"last_successful_call_at": self._iso(self._last_successful_call_at),
"last_connection_success_at": self._iso(self._last_connection_success_at),
}
self._reachable = True
self._auth_ok = True
self._last_connection_error = None
self._last_connection_success_at = datetime.now(UTC)
self._ha_version = str(config.get("version", "") or self._ha_version or "")
return {
"reachable": True,
"auth_ok": True,
"ha_version": self._ha_version,
"last_error": None,
"last_successful_call_at": self._iso(self._last_successful_call_at),
"last_connection_success_at": self._iso(self._last_connection_success_at),
}
async def list_entities(self) -> dict[str, object]:
if not self._config["base_url"] or not self._token:
return {"items": []}
entities = await self._adapter.list_entities(str(self._config["base_url"]), self._token)
items: list[dict[str, object]] = []
self._entities_by_id.clear()
for entity in entities:
normalized = self._normalize_entity(entity)
if normalized is None:
continue
self._entities_by_id[str(normalized["entity_id"])] = normalized
items.append(normalized)
items.sort(key=lambda item: (str(item["domain"]), str(item["friendly_name"]), str(item["entity_id"])))
self._last_connection_success_at = datetime.now(UTC)
self._reachable = True
self._auth_ok = True
return {"items": items}
async def list_mappings(self) -> dict[str, object]:
return {"items": [self._serialize_mapping(mapping) for mapping in self._mappings]}
async def create_mapping(self, payload: dict[str, object]) -> dict[str, object]:
mapping = self._normalize_mapping(payload)
mapping["id"] = self._next_mapping_id()
self._mappings.append(mapping)
self._ensure_runtime(int(mapping["id"]), self._channel_span(mapping))
await self._persist_mappings()
return self._serialize_mapping(mapping)
async def update_mapping(self, mapping_id: int, payload: dict[str, object]) -> dict[str, object]:
mapping = self._find_mapping(mapping_id)
updated = self._normalize_mapping({**mapping, **payload, "id": mapping_id})
updated["id"] = mapping_id
index = next(index for index, item in enumerate(self._mappings) if int(item["id"]) == mapping_id)
self._mappings[index] = updated
runtime = self._ensure_runtime(mapping_id, self._channel_span(updated))
runtime.resync_required = True
await self._persist_mappings()
return self._serialize_mapping(updated)
async def delete_mapping(self, mapping_id: int) -> dict[str, object]:
mapping = self._find_mapping(mapping_id)
task = self._mapping_tasks.pop(mapping_id, None)
if task is not None:
task.cancel()
self._mappings = [item for item in self._mappings if int(item["id"]) != mapping_id]
self._mapping_runtime.pop(mapping_id, None)
await self._persist_mappings()
return {"deleted": mapping_id, "entity_id": mapping["entity_id"]}
async def test_mapping(self, mapping_id: int) -> dict[str, object]:
mapping = self._find_mapping(mapping_id)
raw_values = self._build_test_values(mapping)
runtime = self._ensure_runtime(mapping_id, self._channel_span(mapping))
result = await self._dispatch_mapping(mapping, raw_values, runtime, force=True)
runtime.resync_required = True
return {
"status": "sent" if result else "skipped",
"mapping_id": mapping_id,
"entity_id": mapping["entity_id"],
}
async def dispatch_once(self) -> None:
await self._dispatch_enabled_mappings()
await self._drain_mapping_tasks()
async def _loop(self) -> None:
try:
while self._running:
await self._dispatch_enabled_mappings()
await asyncio.sleep(0.05)
except asyncio.CancelledError:
raise
async def _dispatch_enabled_mappings(self) -> None:
if not bool(self._config["enabled"]):
return
if not self._config["base_url"] or not self._token:
return
self._prune_orphaned_runtimes()
for mapping in self._mappings:
mapping_id = int(mapping["id"])
runtime = self._ensure_runtime(mapping_id, self._channel_span(mapping))
frame = self.engine.get_frame(int(mapping["universe"]))
raw_values = self._read_raw_values(mapping, frame.values)
previous_observed = runtime.last_dmx_values.copy()
runtime.last_dmx_values = raw_values.copy()
if not bool(mapping["enabled"]):
continue
if runtime.in_flight:
runtime.pending_raw_values = raw_values.copy()
continue
if runtime.resync_required:
runtime.resync_required = False
self._schedule_dispatch(mapping, runtime, raw_values, force=True)
continue
if not self._should_dispatch(mapping, runtime, previous_observed, raw_values):
continue
self._schedule_dispatch(mapping, runtime, raw_values)
def _schedule_dispatch(
self,
mapping: dict[str, object],
runtime: MappingRuntime,
raw_values: list[int],
*,
force: bool = False,
) -> None:
mapping_id = int(mapping["id"])
runtime.in_flight = True
runtime.pending_raw_values = None
task = asyncio.create_task(
self._dispatch_task(mapping_id, raw_values.copy(), force=force),
name=f"tuxdmx-ha-mapping-{mapping_id}",
)
self._mapping_tasks[mapping_id] = task
async def _dispatch_task(self, mapping_id: int, raw_values: list[int], *, force: bool = False) -> None:
try:
mapping = self._find_mapping(mapping_id)
except LookupError:
return
runtime = self._ensure_runtime(mapping_id, self._channel_span(mapping))
try:
await self._dispatch_mapping(mapping, raw_values, runtime, force=force)
finally:
runtime.in_flight = False
self._mapping_tasks.pop(mapping_id, None)
pending = runtime.pending_raw_values.copy() if runtime.pending_raw_values is not None else None
runtime.pending_raw_values = None
if pending is not None and bool(mapping.get("enabled", True)):
if runtime.resync_required:
runtime.resync_required = False
self._schedule_dispatch(mapping, runtime, pending, force=True)
elif runtime.last_sent_raw_values != pending:
self._schedule_dispatch(mapping, runtime, pending)
async def _dispatch_mapping(
self,
mapping: dict[str, object],
raw_values: list[int],
runtime: MappingRuntime,
*,
force: bool = False,
) -> bool:
call = self._build_service_call(mapping, raw_values, runtime, force=force)
if call is None:
return False
domain, service, data, summary = call
try:
await self._adapter.call_service(str(self._config["base_url"]), self._token, domain, service, data)
except Exception as exc:
message = self._summarize_exception(exc)
runtime.last_error = message
runtime.last_error_at = datetime.now(UTC)
self._error_count += 1
self._last_error = message
self._last_connection_error = message
self._reachable = not isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout))
if isinstance(exc, httpx.HTTPStatusError):
self._auth_ok = exc.response.status_code != 401
logger.warning("Home Assistant dispatch failed: %s", message)
return False
runtime.last_error = None
runtime.last_service = f"{domain}.{service}"
runtime.last_sent_summary = summary
runtime.last_success_at = datetime.now(UTC)
runtime.last_sent_raw_values = raw_values.copy()
if mapping["fixture_type"] == "switch":
normalized = self._normalized_value(raw_values[0], mapping)
if normalized >= SWITCH_ON_THRESHOLD:
runtime.switch_state = True
elif normalized <= SWITCH_OFF_THRESHOLD:
runtime.switch_state = False
self._dispatch_count += 1
self._last_error = None
self._last_successful_call_at = runtime.last_success_at
self._last_connection_success_at = runtime.last_success_at
self._last_connection_error = None
self._reachable = True
self._auth_ok = True
return True
def _should_dispatch(
self,
mapping: dict[str, object],
runtime: MappingRuntime,
previous_observed: list[int],
raw_values: list[int],
) -> bool:
fixture_type = str(mapping["fixture_type"])
if fixture_type in {"scene", "automation"}:
previous = previous_observed[0] if previous_observed else 0
current = raw_values[0] if raw_values else 0
return previous < EDGE_TRIGGER_THRESHOLD <= current
if fixture_type == "switch":
current = self._normalized_value(raw_values[0], mapping)
if runtime.switch_state is None:
if current >= SWITCH_ON_THRESHOLD:
return True
if current <= SWITCH_OFF_THRESHOLD:
return True
return False
if not runtime.switch_state and current >= SWITCH_ON_THRESHOLD:
return True
if runtime.switch_state and current <= SWITCH_OFF_THRESHOLD:
return True
return False
previous_sent = runtime.last_sent_raw_values
if previous_sent is None:
return True
deadband = max(0, int(mapping["deadband"]))
if max(abs(current - old) for current, old in zip(raw_values, previous_sent, strict=False)) < deadband:
return False
rate_limit_hz = max(0.1, float(mapping["rate_limit_hz"]))
interval = 1.0 / rate_limit_hz
if runtime.last_success_at is None:
return True
return (monotonic() - self._datetime_to_monotonic_reference(runtime.last_success_at)) >= interval
def _datetime_to_monotonic_reference(self, value: datetime) -> float:
delta = datetime.now(UTC) - value
return monotonic() - max(0.0, delta.total_seconds())
def _build_service_call(
self,
mapping: dict[str, object],
raw_values: list[int],
runtime: MappingRuntime,
*,
force: bool = False,
) -> tuple[str, str, dict[str, object], str] | None:
fixture_type = str(mapping["fixture_type"])
entity_id = str(mapping["entity_id"])
transition = max(0.0, int(mapping["fade_ms"]) / 1000)
entity_domain = self._entity_domain(entity_id)
if fixture_type == "switch":
normalized = self._normalized_value(raw_values[0], mapping)
if entity_domain not in ON_OFF_ENTITY_DOMAINS:
runtime.last_error = f"Entity {entity_id} understoetter ikke on/off-routing for fixturetype switch."
runtime.last_error_at = datetime.now(UTC)
return None
if normalized >= SWITCH_ON_THRESHOLD or force:
return (entity_domain, "turn_on", {"entity_id": entity_id}, f"{entity_domain}.turn_on {entity_id}")
if normalized <= SWITCH_OFF_THRESHOLD:
return (entity_domain, "turn_off", {"entity_id": entity_id}, f"{entity_domain}.turn_off {entity_id}")
return None
if fixture_type == "scene":
if not force and self._normalized_value(raw_values[0], mapping) < EDGE_TRIGGER_THRESHOLD:
return None
if entity_domain != "scene":
runtime.last_error = f"Entity {entity_id} er ikke en scene."
runtime.last_error_at = datetime.now(UTC)
return None
return ("scene", "turn_on", {"entity_id": entity_id}, f"scene.turn_on {entity_id}")
if fixture_type == "automation":
if not force and self._normalized_value(raw_values[0], mapping) < EDGE_TRIGGER_THRESHOLD:
return None
if entity_domain != "automation":
runtime.last_error = f"Entity {entity_id} er ikke en automation."
runtime.last_error_at = datetime.now(UTC)
return None
return ("automation", "trigger", {"entity_id": entity_id}, f"automation.trigger {entity_id}")
capabilities = self._entities_by_id.get(entity_id)
if capabilities is None:
runtime.last_error = "Entity-capabilities mangler. Hent HA-enheder eller test forbindelsen først."
runtime.last_error_at = datetime.now(UTC)
return None
if entity_domain != "light":
runtime.last_error = f"Entity {entity_id} understoetter ikke fixturetype {fixture_type}."
runtime.last_error_at = datetime.now(UTC)
return None
if fixture_type == "dimmer":
brightness = self._normalized_value(raw_values[0], mapping)
if brightness <= 0 and not force:
return ("light", "turn_off", {"entity_id": entity_id, "transition": transition}, f"light.turn_off {entity_id}")
payload: dict[str, object] = {"entity_id": entity_id, "transition": transition}
if bool(capabilities.get("supports_brightness", False)):
payload["brightness"] = max(1, brightness)
return ("light", "turn_on", payload, f"light.turn_on {entity_id} brightness={payload.get('brightness', 'on')}")
if fixture_type == "rgb":
return self._build_rgb_call(mapping, raw_values, capabilities, transition, include_white=False, force=force)
if fixture_type == "rgbw":
return self._build_rgb_call(mapping, raw_values, capabilities, transition, include_white=True, force=force)
if fixture_type == "cct":
return self._build_cct_call(mapping, raw_values, capabilities, transition, force=force)
return None
def _build_rgb_call(
self,
mapping: dict[str, object],
raw_values: list[int],
capabilities: dict[str, object],
transition: float,
*,
include_white: bool,
force: bool,
) -> tuple[str, str, dict[str, object], str] | None:
entity_id = str(mapping["entity_id"])
has_master = bool(mapping["master_dimmer"])
offset = 1 if has_master else 0
master = self._normalized_value(raw_values[0], mapping) if has_master else 255
colors = [self._normalized_value(value, mapping) for value in raw_values[offset : offset + 3]]
white = self._normalized_value(raw_values[offset + 3], mapping) if include_white else 0
scaled_colors = [int(round(color * (master / 255))) for color in colors]
scaled_white = int(round(white * (master / 255))) if include_white else 0
brightness = master if has_master else max(scaled_colors + ([scaled_white] if include_white else [0]))
if brightness <= 0 and not force:
return ("light", "turn_off", {"entity_id": entity_id, "transition": transition}, f"light.turn_off {entity_id}")
payload: dict[str, object] = {"entity_id": entity_id, "transition": transition}
if bool(capabilities.get("supports_brightness", False)):
payload["brightness"] = max(1, brightness)
if include_white:
if bool(capabilities.get("supports_rgbw", False)):
payload["rgbw_color"] = scaled_colors + [scaled_white]
return (
"light",
"turn_on",
payload,
f"light.turn_on {entity_id} rgbw={payload['rgbw_color']} brightness={payload.get('brightness', 'on')}",
)
if bool(capabilities.get("supports_rgbww", False)):
payload["rgbww_color"] = scaled_colors + [scaled_white, 0]
return (
"light",
"turn_on",
payload,
f"light.turn_on {entity_id} rgbww={payload['rgbww_color']} brightness={payload.get('brightness', 'on')}",
)
return None
if bool(capabilities.get("supports_rgb", False)):
payload["rgb_color"] = scaled_colors
return (
"light",
"turn_on",
payload,
f"light.turn_on {entity_id} rgb={payload['rgb_color']} brightness={payload.get('brightness', 'on')}",
)
if bool(capabilities.get("supports_rgbw", False)):
payload["rgbw_color"] = scaled_colors + [0]
return (
"light",
"turn_on",
payload,
f"light.turn_on {entity_id} rgbw={payload['rgbw_color']} brightness={payload.get('brightness', 'on')}",
)
if bool(capabilities.get("supports_rgbww", False)):
payload["rgbww_color"] = scaled_colors + [0, 0]
return (
"light",
"turn_on",
payload,
f"light.turn_on {entity_id} rgbww={payload['rgbww_color']} brightness={payload.get('brightness', 'on')}",
)
return None
def _build_cct_call(
self,
mapping: dict[str, object],
raw_values: list[int],
capabilities: dict[str, object],
transition: float,
*,
force: bool,
) -> tuple[str, str, dict[str, object], str] | None:
if not bool(capabilities.get("supports_color_temp_kelvin", False)):
return None
entity_id = str(mapping["entity_id"])
has_master = bool(mapping["master_dimmer"])
offset = 1 if has_master else 0
master = self._normalized_value(raw_values[0], mapping) if has_master else 255
warm = self._normalized_value(raw_values[offset], mapping)
cold = self._normalized_value(raw_values[offset + 1], mapping)
brightness = master if has_master else max(warm, cold)
if brightness <= 0 and not force:
return ("light", "turn_off", {"entity_id": entity_id, "transition": transition}, f"light.turn_off {entity_id}")
total = max(1, warm + cold)
cool_ratio = cold / total
kelvin = int(round(2200 + (cool_ratio * 4300)))
payload: dict[str, object] = {
"entity_id": entity_id,
"color_temp_kelvin": kelvin,
"transition": transition,
}
if bool(capabilities.get("supports_brightness", False)):
payload["brightness"] = max(1, brightness)
return (
"light",
"turn_on",
payload,
f"light.turn_on {entity_id} kelvin={kelvin} brightness={payload.get('brightness', 'on')}",
)
def _build_test_values(self, mapping: dict[str, object]) -> list[int]:
fixture_type = str(mapping["fixture_type"])
if fixture_type in {"scene", "automation", "switch"}:
return [255]
if fixture_type == "dimmer":
return [255]
if fixture_type == "rgb":
return [255, 255, 80, 80] if bool(mapping["master_dimmer"]) else [255, 80, 80]
if fixture_type == "rgbw":
return [255, 255, 80, 80, 40] if bool(mapping["master_dimmer"]) else [255, 80, 80, 40]
if fixture_type == "cct":
return [255, 255, 120] if bool(mapping["master_dimmer"]) else [255, 120]
return [255]
def _read_raw_values(self, mapping: dict[str, object], values: list[int]) -> list[int]:
start_address = int(mapping["start_address"])
span = self._channel_span(mapping)
if start_address < 1 or start_address + span - 1 > 512:
return [0] * span
return values[start_address - 1 : start_address - 1 + span]
def _channel_span(self, mapping: dict[str, object]) -> int:
fixture_type = str(mapping["fixture_type"])
has_master = bool(mapping["master_dimmer"])
if fixture_type in {"dimmer", "switch", "scene", "automation"}:
return 1
if fixture_type == "rgb":
return 4 if has_master else 3
if fixture_type == "rgbw":
return 5 if has_master else 4
if fixture_type == "cct":
return 3 if has_master else 2
return 1
def _normalized_value(self, raw_value: int, mapping: dict[str, object]) -> int:
clamped = max(0, min(255, int(raw_value)))
if bool(mapping["invert_channel"]):
clamped = 255 - clamped
minimum = max(0, min(255, int(mapping["min_value"])))
maximum = max(minimum, min(255, int(mapping["max_value"])))
return int(round(minimum + ((clamped / 255) * (maximum - minimum))))
async def _load(self) -> None:
async with self._session_factory() as session:
config_setting = await session.get(Setting, HA_CONFIG_KEY)
mappings_setting = await session.get(Setting, HA_MAPPINGS_KEY)
if config_setting is not None and isinstance(config_setting.value, dict):
raw_config = dict(config_setting.value)
self._token = str(raw_config.get("token", "") or "").strip()
self._config = self._normalize_config(raw_config)
if mappings_setting is not None and isinstance(mappings_setting.value, dict):
items = mappings_setting.value.get("items", [])
if isinstance(items, list):
self._mappings = [self._normalize_mapping(item) for item in items if isinstance(item, dict)]
for mapping in self._mappings:
self._ensure_runtime(int(mapping["id"]), self._channel_span(mapping))
async def _persist_setting(self, key: str, value: dict[str, object]) -> None:
async with self._session_factory() as session:
setting = await session.get(Setting, key)
if setting is None:
setting = Setting(key=key, value=value, updated_at=datetime.now(UTC))
session.add(setting)
else:
setting.value = value
setting.updated_at = datetime.now(UTC)
await session.commit()
async def _persist_mappings(self) -> None:
await self._persist_setting(HA_MAPPINGS_KEY, {"items": self._mappings})
def _default_config(self) -> dict[str, object]:
return {
"enabled": False,
"base_url": "",
"default_universe": 10,
}
def _normalize_config(self, payload: dict[str, object]) -> dict[str, object]:
return {
"enabled": bool(payload.get("enabled", False)),
"base_url": str(payload.get("base_url", "") or "").strip(),
"default_universe": max(1, min(63999, int(payload.get("default_universe", 10) or 10))),
}
def _normalize_mapping(self, payload: dict[str, object]) -> dict[str, object]:
fixture_type = str(payload.get("fixture_type", "dimmer")).lower()
if fixture_type not in {"dimmer", "rgb", "rgbw", "cct", "switch", "scene", "automation"}:
fixture_type = "dimmer"
mapping = {
"id": int(payload.get("id", 0) or 0),
"name": str(payload.get("name", "") or "").strip() or str(payload.get("entity_id", "") or "").strip(),
"universe": max(1, min(63999, int(payload.get("universe", self._config["default_universe"]) or self._config["default_universe"]))),
"start_address": max(1, min(512, int(payload.get("start_address", 1) or 1))),
"fixture_type": fixture_type,
"entity_id": str(payload.get("entity_id", "") or "").strip(),
"rate_limit_hz": max(0.1, min(30.0, float(payload.get("rate_limit_hz", 5) or 5))),
"deadband": max(0, min(255, int(payload.get("deadband", 2) or 2))),
"fade_ms": max(0, min(10000, int(payload.get("fade_ms", 0) or 0))),
"invert_channel": bool(payload.get("invert_channel", False)),
"min_value": max(0, min(255, int(payload.get("min_value", 0) or 0))),
"max_value": max(0, min(255, int(payload.get("max_value", 255) or 255))),
"enabled": bool(payload.get("enabled", True)),
"master_dimmer": bool(payload.get("master_dimmer", fixture_type in {"rgb", "rgbw", "cct"})),
}
if mapping["max_value"] < mapping["min_value"]:
mapping["max_value"] = mapping["min_value"]
if not mapping["name"]:
mapping["name"] = mapping["entity_id"] or f"{fixture_type}-{mapping['universe']}-{mapping['start_address']}"
return mapping
def _normalize_entity(self, payload: dict[str, object]) -> dict[str, object] | None:
entity_id = str(payload.get("entity_id", "") or "").strip()
if "." not in entity_id:
return None
domain = entity_id.split(".", 1)[0]
if domain not in {"light", "switch", "input_boolean", "scene", "automation"}:
return None
attributes = payload.get("attributes", {})
attr_map = attributes if isinstance(attributes, dict) else {}
supported_modes = attr_map.get("supported_color_modes", [])
modes = {str(mode).lower() for mode in supported_modes if isinstance(mode, str)}
color_mode = str(attr_map.get("color_mode", "") or "").lower()
if color_mode:
modes.add(color_mode)
friendly_name = str(attr_map.get("friendly_name", "") or "")
supports_brightness = domain == "light" and any(mode not in {"onoff"} for mode in modes)
supports_rgb = domain == "light" and any(mode in {"rgb", "hs", "xy"} for mode in modes)
supports_rgbw = domain == "light" and "rgbw" in modes
supports_rgbww = domain == "light" and "rgbww" in modes
supports_color_temp_kelvin = domain == "light" and "color_temp" in modes
return {
"entity_id": entity_id,
"domain": domain,
"friendly_name": friendly_name,
"state": str(payload.get("state", "")),
"supports_brightness": supports_brightness,
"supports_rgb": supports_rgb,
"supports_rgbw": supports_rgbw,
"supports_rgbww": supports_rgbww,
"supports_color_temp_kelvin": supports_color_temp_kelvin,
}
def _entity_domain(self, entity_id: str) -> str:
if "." not in entity_id:
return ""
return entity_id.split(".", 1)[0].strip().lower()
def _serialize_mapping(self, mapping: dict[str, object]) -> dict[str, object]:
runtime = self._ensure_runtime(int(mapping["id"]), self._channel_span(mapping))
status = "disabled"
if bool(mapping["enabled"]):
status = "sending" if runtime.in_flight else "active"
if runtime.last_error:
status = "error"
return {
**mapping,
"channel_span": self._channel_span(mapping),
"status": status,
"last_dmx_values": runtime.last_dmx_values,
"last_sent_summary": runtime.last_sent_summary,
"last_service": runtime.last_service,
"last_success_at": self._iso(runtime.last_success_at),
"last_error": runtime.last_error,
"last_error_at": self._iso(runtime.last_error_at),
"in_flight": runtime.in_flight,
}
def _next_mapping_id(self) -> int:
return max((int(mapping["id"]) for mapping in self._mappings), default=0) + 1
def _find_mapping(self, mapping_id: int) -> dict[str, object]:
for mapping in self._mappings:
if int(mapping["id"]) == mapping_id:
return mapping
raise LookupError("Home Assistant mapping not found")
def _ensure_runtime(self, mapping_id: int, span: int) -> MappingRuntime:
runtime = self._mapping_runtime.get(mapping_id)
if runtime is None:
runtime = MappingRuntime(last_dmx_values=[0] * span)
self._mapping_runtime[mapping_id] = runtime
return runtime
if len(runtime.last_dmx_values) != span:
runtime.last_dmx_values = [0] * span
runtime.last_sent_raw_values = None
runtime.pending_raw_values = None
return runtime
def _prune_orphaned_runtimes(self) -> None:
active_ids = {int(mapping["id"]) for mapping in self._mappings}
for mapping_id in list(self._mapping_runtime):
if mapping_id not in active_ids:
self._mapping_runtime.pop(mapping_id, None)
async def _drain_mapping_tasks(self) -> None:
while self._mapping_tasks:
tasks = list(self._mapping_tasks.values())
await asyncio.gather(*tasks, return_exceptions=True)
def _iso(self, value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
def _summarize_exception(self, exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
return f"HTTP {exc.response.status_code} fra Home Assistant"
if isinstance(exc, (httpx.ConnectTimeout, httpx.ReadTimeout)):
return "Timeout ved kald til Home Assistant"
if isinstance(exc, httpx.ConnectError):
return "Home Assistant kunne ikke kontaktes"
return str(exc) or exc.__class__.__name__
+3
View File
@@ -0,0 +1,3 @@
from app.live.service import LiveDeskService
__all__ = ["LiveDeskService"]
+219
View File
@@ -0,0 +1,219 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.dmx.engine import DmxEngine
from app.dmx.frame import FrameLayer
from app.homeassistant.service import HomeAssistantService
from app.patch.service import PatchService
class LiveDeskService:
def __init__(
self,
engine: DmxEngine,
home_assistant: HomeAssistantService | None = None,
session_factory: async_sessionmaker[AsyncSession] = SessionLocal,
) -> None:
self.engine = engine
self.patch = PatchService(session_factory)
self.home_assistant = home_assistant
self._values_by_patch: dict[int, dict[int, int]] = {}
self._values_by_home_assistant_mapping: dict[int, dict[int, int]] = {}
async def snapshot(self) -> dict[str, object]:
patches = await self.patch.list_instances()
items: list[dict[str, object]] = []
for patch in patches:
relative_values = self._values_by_patch.get(int(patch["id"]), {})
channels: list[dict[str, object]] = []
for channel in patch.get("channels", []):
if not isinstance(channel, dict):
continue
relative_index = int(channel.get("index", 0))
absolute_channel = int(patch["start_address"]) + relative_index - 1
channels.append(
{
"index": relative_index,
"absolute_channel": absolute_channel,
"key": str(channel.get("key", f"channel-{relative_index}")),
"display_name": str(channel.get("display_name", f"Channel {relative_index}")),
"precedence": str(channel.get("precedence", "ltp")),
"resolution": int(channel.get("resolution", 8)),
"value": int(relative_values.get(relative_index, 0)),
}
)
items.append(
{
"id": f"patch:{patch['id']}",
"source_type": "patch",
"source_id": patch["id"],
"patch_id": patch["id"],
"name": patch["name"],
"manufacturer": patch["manufacturer"],
"model": patch["model"],
"mode_key": patch["mode_key"],
"universe": patch["universe"],
"start_address": patch["start_address"],
"end_address": patch["end_address"],
"channel_count": patch["channel_count"],
"entity_id": None,
"status": "active" if patch.get("enabled", True) else "disabled",
"last_sent_summary": None,
"channels": channels,
}
)
if self.home_assistant is not None:
mappings = (await self.home_assistant.list_mappings())["items"]
for mapping in mappings:
if not isinstance(mapping, dict):
continue
mapping_id = int(mapping["id"])
relative_values = self._values_by_home_assistant_mapping.get(mapping_id, {})
channels = []
for channel in self._build_home_assistant_channels(mapping):
relative_index = int(channel["index"])
absolute_channel = int(mapping["start_address"]) + relative_index - 1
channels.append(
{
"index": relative_index,
"absolute_channel": absolute_channel,
"key": str(channel["key"]),
"display_name": str(channel["display_name"]),
"precedence": str(channel["precedence"]),
"resolution": 8,
"value": int(relative_values.get(relative_index, 0)),
}
)
items.append(
{
"id": f"home_assistant:{mapping_id}",
"source_type": "home_assistant",
"source_id": mapping_id,
"patch_id": None,
"name": mapping["name"],
"manufacturer": "Home Assistant",
"model": mapping["fixture_type"],
"mode_key": f"HA {mapping['fixture_type']}",
"universe": mapping["universe"],
"start_address": mapping["start_address"],
"end_address": int(mapping["start_address"]) + int(mapping["channel_span"]) - 1,
"channel_count": mapping["channel_span"],
"entity_id": mapping["entity_id"],
"status": mapping["status"],
"last_sent_summary": mapping["last_sent_summary"],
"channels": channels,
}
)
return {"items": items}
async def set_patch_values(self, patch_id: int, values: dict[int, int]) -> dict[str, object]:
patches = await self.patch.list_instances()
patch = next((item for item in patches if int(item["id"]) == patch_id), None)
if patch is None:
raise LookupError("Patch not found")
channel_map = {
int(channel["index"]): channel
for channel in patch.get("channels", [])
if isinstance(channel, dict) and "index" in channel
}
sanitized_values: dict[int, int] = {}
absolute_values: dict[int, int] = {}
precedence_map: dict[int, str] = {}
for relative_index, raw_value in values.items():
if relative_index not in channel_map:
continue
value = max(0, min(255, int(raw_value)))
sanitized_values[relative_index] = value
absolute_channel = int(patch["start_address"]) + relative_index - 1
absolute_values[absolute_channel] = value
precedence_map[absolute_channel] = str(channel_map[relative_index].get("precedence", "ltp")).lower()
self._values_by_patch[patch_id] = sanitized_values
self.engine.set_layer(
FrameLayer.from_channel_values(
name=f"mixer:patch:{patch_id}",
priority=90,
values=absolute_values,
precedence_map=precedence_map,
universe=int(patch["universe"]),
)
)
return await self.snapshot()
async def clear_patch(self, patch_id: int) -> dict[str, object]:
self._values_by_patch.pop(patch_id, None)
self.engine.remove_layer(f"mixer:patch:{patch_id}")
return await self.snapshot()
async def set_home_assistant_mapping_values(self, mapping_id: int, values: dict[int, int]) -> dict[str, object]:
if self.home_assistant is None:
raise LookupError("Home Assistant live mixer is not available")
mappings = (await self.home_assistant.list_mappings())["items"]
mapping = next((item for item in mappings if int(item["id"]) == mapping_id), None)
if mapping is None:
raise LookupError("Home Assistant mapping not found")
channel_map = {
int(channel["index"]): channel for channel in self._build_home_assistant_channels(mapping)
}
sanitized_values: dict[int, int] = {}
absolute_values: dict[int, int] = {}
precedence_map: dict[int, str] = {}
for relative_index, raw_value in values.items():
if relative_index not in channel_map:
continue
value = max(0, min(255, int(raw_value)))
sanitized_values[relative_index] = value
absolute_channel = int(mapping["start_address"]) + relative_index - 1
absolute_values[absolute_channel] = value
precedence_map[absolute_channel] = str(channel_map[relative_index].get("precedence", "ltp")).lower()
self._values_by_home_assistant_mapping[mapping_id] = sanitized_values
self.engine.set_layer(
FrameLayer.from_channel_values(
name=f"mixer:home-assistant:{mapping_id}",
priority=90,
values=absolute_values,
precedence_map=precedence_map,
universe=int(mapping["universe"]),
)
)
return await self.snapshot()
async def clear_home_assistant_mapping(self, mapping_id: int) -> dict[str, object]:
self._values_by_home_assistant_mapping.pop(mapping_id, None)
self.engine.remove_layer(f"mixer:home-assistant:{mapping_id}")
return await self.snapshot()
def _build_home_assistant_channels(self, mapping: dict[str, object]) -> list[dict[str, object]]:
fixture_type = str(mapping["fixture_type"])
has_master = bool(mapping.get("master_dimmer", False))
if fixture_type == "switch":
return [{"index": 1, "key": "Switch", "display_name": "State", "precedence": "ltp"}]
if fixture_type == "scene":
return [{"index": 1, "key": "Scene", "display_name": "Trigger", "precedence": "ltp"}]
if fixture_type == "automation":
return [{"index": 1, "key": "Automation", "display_name": "Trigger", "precedence": "ltp"}]
if fixture_type == "dimmer":
return [{"index": 1, "key": "Dimmer", "display_name": "Dimmer", "precedence": "ltp"}]
channels: list[dict[str, object]] = []
next_index = 1
if has_master:
channels.append({"index": next_index, "key": "Master", "display_name": "Master", "precedence": "ltp"})
next_index += 1
if fixture_type == "rgb":
names = ["Red", "Green", "Blue"]
elif fixture_type == "rgbw":
names = ["Red", "Green", "Blue", "White"]
elif fixture_type == "cct":
names = ["Warm", "Cold"]
else:
names = ["Channel"]
for name in names:
channels.append({"index": next_index, "key": name, "display_name": name, "precedence": "ltp"})
next_index += 1
return channels
+40
View File
@@ -0,0 +1,40 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.router import api_router
from app.core.config import get_settings
from app.core.database import init_database
from app.core.dependencies import app_state
from app.core.logging import configure_logging
from app.websocket.router import websocket_router
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
configure_logging()
await init_database()
await app_state.startup()
try:
yield
finally:
await app_state.shutdown()
settings = get_settings()
app = FastAPI(title=settings.app_name, lifespan=lifespan, docs_url=None, redoc_url=None)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
app.include_router(websocket_router)
if settings.frontend_dist.exists():
app.mount("/", StaticFiles(directory=settings.frontend_dist, html=True), name="frontend")
+1
View File
@@ -0,0 +1 @@
"""MIDI bridge integration services."""
+834
View File
@@ -0,0 +1,834 @@
from __future__ import annotations
import hashlib
import hmac
import logging
import secrets
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from fnmatch import fnmatch
from time import monotonic
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.dmx.engine import DmxEngine
from app.dmx.frame import FrameLayer
from app.effects.service import EffectService
from app.models.entities import MidiBridge, MidiBridgeToken, MidiMapping, Scene
from app.scenes.service import SceneService
logger = logging.getLogger(__name__)
MIDI_PROTOCOL_VERSION = 1
MIDI_SCOPES = ["midi:connect", "midi:events", "midi:heartbeat"]
MIDI_ACTIONS = {
"activate_scene",
"deactivate_scene",
"toggle_scene",
"flash_scene",
"blackout_on",
"blackout_off",
"blackout_toggle",
"set_master_dimmer",
"set_scene_intensity",
"start_effect",
"stop_effect",
}
MIDI_MODES = {"trigger", "toggle", "hold", "flash", "continuous"}
SCENE_ACTIONS = {
"activate_scene",
"deactivate_scene",
"toggle_scene",
"flash_scene",
"set_scene_intensity",
}
EFFECT_ACTIONS = {"start_effect", "stop_effect"}
def utc_now() -> datetime:
return datetime.now(UTC)
@dataclass(slots=True)
class TokenCacheEntry:
id: int
label: str
token_hash: str
bridge_id: str | None
scopes: list[str]
created_at: datetime
revoked_at: datetime | None
last_used_at: datetime | None
@dataclass(slots=True)
class LearnState:
active: bool = False
allow_passthrough: bool = False
expires_at: datetime | None = None
captured_event: dict[str, object] | None = None
class MidiService:
def __init__(
self,
engine: DmxEngine,
scenes: SceneService,
effects: EffectService,
session_factory: async_sessionmaker[AsyncSession] = SessionLocal,
) -> None:
self.engine = engine
self.scenes = scenes
self.effects = effects
self._session_factory = session_factory
self._mappings: list[dict[str, object]] = []
self._tokens: list[TokenCacheEntry] = []
self._bridges: dict[str, dict[str, object]] = {}
self._bridge_event_windows: dict[str, deque[float]] = {}
self._bridge_persist_deadline: dict[str, float] = {}
self._toggle_state: dict[int, bool] = {}
self._hold_state: dict[int, bool] = {}
self._learn_state = LearnState()
async def startup(self) -> None:
await self.reload()
async def reload(self) -> None:
await self._reload_mappings()
await self._reload_tokens()
await self._reload_bridges()
async def list_mappings(self) -> dict[str, object]:
return {"items": [dict(item) for item in self._mappings]}
async def get_mapping(self, mapping_id: int) -> dict[str, object]:
mapping = self._mapping_by_id(mapping_id)
if mapping is None:
raise LookupError("MIDI-mapping ikke fundet")
return dict(mapping)
async def create_mapping(self, payload: dict[str, object]) -> dict[str, object]:
record = MidiMapping(
name=str(payload["name"]).strip(),
enabled=bool(payload.get("enabled", True)),
bridge_id=self._normalize_optional_text(payload.get("bridge_id")),
device_name=self._normalize_optional_text(payload.get("device_name")),
message_type=str(payload["message_type"]).strip(),
channel=self._optional_int(payload.get("channel")),
number=int(payload["number"]),
action=str(payload["action"]).strip(),
target_type=str(payload.get("target_type") or "scene").strip(),
target_id=self._normalize_optional_text(payload.get("target_id")),
mode=str(payload.get("mode") or "trigger").strip(),
minimum_value=int(payload.get("minimum_value", 0)),
maximum_value=int(payload.get("maximum_value", 127)),
updated_at=utc_now(),
)
self._validate_mapping_record(record)
async with self._session_factory() as session:
session.add(record)
await session.commit()
await session.refresh(record)
await self._reload_mappings()
return self._serialize_mapping(record, None)
async def update_mapping(self, mapping_id: int, payload: dict[str, object]) -> dict[str, object]:
async with self._session_factory() as session:
record = await session.get(MidiMapping, mapping_id)
if record is None:
raise LookupError("MIDI-mapping ikke fundet")
record.name = str(payload["name"]).strip()
record.enabled = bool(payload.get("enabled", True))
record.bridge_id = self._normalize_optional_text(payload.get("bridge_id"))
record.device_name = self._normalize_optional_text(payload.get("device_name"))
record.message_type = str(payload["message_type"]).strip()
record.channel = self._optional_int(payload.get("channel"))
record.number = int(payload["number"])
record.action = str(payload["action"]).strip()
record.target_type = str(payload.get("target_type") or "scene").strip()
record.target_id = self._normalize_optional_text(payload.get("target_id"))
record.mode = str(payload.get("mode") or "trigger").strip()
record.minimum_value = int(payload.get("minimum_value", 0))
record.maximum_value = int(payload.get("maximum_value", 127))
record.updated_at = utc_now()
self._validate_mapping_record(record)
await session.commit()
await session.refresh(record)
await self._reload_mappings()
return self._mapping_by_id(mapping_id) or self._serialize_mapping(record, None)
async def delete_mapping(self, mapping_id: int) -> dict[str, object]:
async with self._session_factory() as session:
record = await session.get(MidiMapping, mapping_id)
if record is None:
raise LookupError("MIDI-mapping ikke fundet")
await session.delete(record)
await session.commit()
self._toggle_state.pop(mapping_id, None)
self._hold_state.pop(mapping_id, None)
await self._reload_mappings()
return {"deleted": mapping_id}
async def create_token(self, payload: dict[str, object]) -> dict[str, object]:
raw_token = f"tuxmidi_{secrets.token_urlsafe(32)}"
record = MidiBridgeToken(
label=str(payload["label"]).strip(),
token_hash=self._hash_token(raw_token),
bridge_id=self._normalize_optional_text(payload.get("bridge_id")),
scopes=self._normalize_scopes(payload.get("scopes")),
created_at=utc_now(),
)
async with self._session_factory() as session:
session.add(record)
await session.commit()
await session.refresh(record)
await self._reload_tokens()
serialized = self._serialize_token(record)
serialized["token"] = raw_token
return serialized
async def list_tokens(self) -> dict[str, object]:
return {"items": [self._serialize_token_entry(entry) for entry in self._tokens]}
async def revoke_token(self, token_id: int) -> dict[str, object]:
async with self._session_factory() as session:
record = await session.get(MidiBridgeToken, token_id)
if record is None:
raise LookupError("MIDI-token ikke fundet")
record.revoked_at = utc_now()
await session.commit()
await self._reload_tokens()
return {"revoked": token_id}
async def list_bridges(self) -> dict[str, object]:
self._expire_bridge_presence()
items = sorted(self._bridges.values(), key=lambda bridge: str(bridge["bridge_id"]).casefold())
return {"items": [dict(item) for item in items]}
async def get_learn_state(self) -> dict[str, object]:
self._expire_learn_state()
return self._serialize_learn_state()
async def start_learn(self, timeout_seconds: int, allow_passthrough: bool) -> dict[str, object]:
self._learn_state = LearnState(
active=True,
allow_passthrough=allow_passthrough,
expires_at=utc_now() + timedelta(seconds=timeout_seconds),
captured_event=None,
)
return self._serialize_learn_state()
async def cancel_learn(self) -> dict[str, object]:
self._learn_state.active = False
self._learn_state.expires_at = None
return self._serialize_learn_state()
async def receive_heartbeat(self, payload: dict[str, object], remote_ip: str | None) -> dict[str, object]:
if int(payload.get("protocol_version", 0)) != MIDI_PROTOCOL_VERSION:
raise ValueError("Ugyldig MIDI protocol_version")
bridge_id = str(payload["bridge_id"]).strip()
bridge = self._touch_bridge(
bridge_id=bridge_id,
device_name=self._normalize_optional_text(payload.get("device")),
remote_ip=remote_ip,
protocol_version=int(payload.get("protocol_version", MIDI_PROTOCOL_VERSION)),
is_event=False,
event=None,
error=None,
)
await self._persist_bridge(bridge_id, force=True)
return {
"accepted": True,
"bridge_id": bridge_id,
"online": bool(bridge["online"]),
"protocol_version": MIDI_PROTOCOL_VERSION,
}
async def receive_event(self, payload: dict[str, object], remote_ip: str | None) -> dict[str, object]:
if int(payload.get("protocol_version", 0)) != MIDI_PROTOCOL_VERSION:
raise ValueError("Ugyldig MIDI protocol_version")
bridge_id = str(payload["bridge_id"]).strip()
event = self._normalize_event(payload)
bridge = self._touch_bridge(
bridge_id=bridge_id,
device_name=str(payload.get("device") or "").strip(),
remote_ip=remote_ip,
protocol_version=int(payload.get("protocol_version", MIDI_PROTOCOL_VERSION)),
is_event=True,
event=event,
error=None,
)
if not self._check_bridge_rate_limit(bridge_id):
bridge["last_error"] = "For mange MIDI-events fra bridgen."
await self._persist_bridge(bridge_id, force=True)
return {"accepted": False, "reason": "rate-limited"}
if self._learn_state.active and not self._learn_state.allow_passthrough:
self._capture_learn_event(bridge, event)
await self._persist_bridge(bridge_id)
return {"accepted": True, "captured_for_learning": True, "matched": 0}
matches = [mapping for mapping in self._mappings if self._mapping_matches(mapping, bridge, event)]
executed = 0
errors: list[str] = []
for mapping in matches:
try:
if self._learn_state.active and self._learn_state.captured_event is None:
self._capture_learn_event(bridge, event)
performed = await self._dispatch_mapping(mapping, event)
executed += 1 if performed else 0
except Exception as exc:
error_message = f"Mapping {mapping['id']} fejlede: {exc}"
errors.append(error_message)
bridge["last_error"] = error_message
logger.warning("MIDI mapping failed: %s", error_message)
await self._persist_bridge(bridge_id, force=bool(errors))
return {
"accepted": True,
"bridge_id": bridge_id,
"matched": len(matches),
"executed": executed,
"errors": errors,
}
async def test_mapping(self, mapping_id: int, value: int, message_type: str | None = None) -> dict[str, object]:
mapping = self._mapping_by_id(mapping_id)
if mapping is None:
raise LookupError("MIDI-mapping ikke fundet")
test_event = {
"type": message_type or str(mapping["message_type"]),
"channel": int(mapping["channel"] or 0),
"number": int(mapping["number"]),
"value": value,
}
await self._dispatch_mapping(mapping, test_event)
return {
"status": "sent",
"mapping_id": mapping_id,
"service": mapping["action"],
"value": value,
}
async def verify_token(self, token: str | None, required_scope: str, bridge_id: str | None = None) -> TokenCacheEntry:
if not token:
raise PermissionError("Mangler Bearer-token")
token_hash = self._hash_token(token)
for entry in self._tokens:
if not hmac.compare_digest(entry.token_hash, token_hash):
continue
if entry.revoked_at is not None:
break
if required_scope not in entry.scopes:
break
if entry.bridge_id and bridge_id and entry.bridge_id != bridge_id:
break
await self._mark_token_used(entry.id)
return entry
raise PermissionError("Ugyldig eller tilbagekaldt MIDI-token")
async def _dispatch_mapping(self, mapping: dict[str, object], event: dict[str, object]) -> bool:
action = str(mapping["action"])
mode = str(mapping["mode"])
target_id = self._normalize_optional_text(mapping.get("target_id"))
value = int(event["value"])
normalized = self._normalize_value(value, int(mapping["minimum_value"]), int(mapping["maximum_value"]))
message_type = str(event["type"])
if action == "activate_scene" and target_id:
if mode == "hold":
if message_type == "note_on":
await self.scenes.activate(target_id)
self._hold_state[int(mapping["id"])] = True
return True
if message_type == "note_off":
await self.scenes.release(target_id)
self._hold_state[int(mapping["id"])] = False
return True
return False
if message_type == "note_off":
return False
await self.scenes.activate(target_id)
return True
if action == "deactivate_scene" and target_id:
if message_type == "note_off" and mode == "hold":
return False
await self.scenes.release(target_id)
return True
if action == "toggle_scene" and target_id:
if message_type == "note_off":
return False
if self.engine.layers.get(f"scene:{target_id}") is not None:
await self.scenes.release(target_id)
self._toggle_state[int(mapping["id"])] = False
else:
await self.scenes.activate(target_id)
self._toggle_state[int(mapping["id"])] = True
return True
if action == "flash_scene" and target_id:
layer_name = f"midi:flash:{mapping['id']}:{target_id}"
if message_type == "note_on":
layer = await self._build_scene_layer(
target_id,
layer_name=layer_name,
scale=1.0,
priority_override=90,
)
self.engine.set_layer(layer)
self._hold_state[int(mapping["id"])] = True
return True
if message_type == "note_off":
self.engine.remove_layer(layer_name)
self._hold_state[int(mapping["id"])] = False
return True
return False
if action == "blackout_on":
if message_type == "note_off" and mode != "continuous":
return False
self.engine.trigger_blackout()
return True
if action == "blackout_off":
if message_type == "note_off" and mode != "continuous":
return False
self.engine.release_blackout()
return True
if action == "blackout_toggle":
if message_type == "note_off":
return False
if self.engine.blackout:
self.engine.release_blackout()
self._toggle_state[int(mapping["id"])] = False
else:
self.engine.trigger_blackout()
self._toggle_state[int(mapping["id"])] = True
return True
if action == "set_master_dimmer":
self.engine.master = max(0, min(255, int(round(normalized * 255))))
return True
if action == "set_scene_intensity" and target_id:
layer_name = f"midi:intensity:{mapping['id']}:{target_id}"
if normalized <= 0:
self.engine.remove_layer(layer_name)
return True
layer = await self._build_scene_layer(
target_id,
layer_name=layer_name,
scale=normalized,
priority_override=80,
)
self.engine.set_layer(layer)
return True
if action == "start_effect" and target_id:
if mode == "hold":
if message_type == "note_on":
self.effects.trigger(target_id)
self._hold_state[int(mapping["id"])] = True
return True
if message_type == "note_off":
self.effects.stop(target_id)
self._hold_state[int(mapping["id"])] = False
return True
return False
if mode == "toggle":
if message_type == "note_off":
return False
if target_id in self.effects.active_effects:
self.effects.stop(target_id)
self._toggle_state[int(mapping["id"])] = False
else:
self.effects.trigger(target_id)
self._toggle_state[int(mapping["id"])] = True
return True
if message_type == "note_off":
return False
self.effects.trigger(target_id)
return True
if action == "stop_effect" and target_id:
if message_type == "note_off" and mode != "continuous":
return False
self.effects.stop(target_id)
return True
raise ValueError(f"Ukendt eller ikke-understøttet MIDI-action: {action}")
async def _build_scene_layer(
self,
slug: str,
layer_name: str,
scale: float,
priority_override: int | None = None,
) -> FrameLayer:
scene, values_by_universe, precedence_by_universe = await self._resolve_scene_layer_data(slug)
scaled_values: dict[int, dict[int, int]] = {}
for universe, values in values_by_universe.items():
scaled_values[universe] = {
channel: max(0, min(255, int(round(value * scale))))
for channel, value in values.items()
}
return FrameLayer(
name=layer_name,
priority=priority_override if priority_override is not None else int(scene.priority),
values_by_universe=scaled_values,
precedence_map_by_universe=precedence_by_universe,
)
async def _resolve_scene_layer_data(
self,
slug: str,
) -> tuple[Scene, dict[int, dict[int, int]], dict[int, dict[int, str]]]:
async with self._session_factory() as session:
result = await session.execute(select(Scene).where(Scene.slug == slug).limit(1))
scene = result.scalar_one_or_none()
if scene is None:
raise LookupError("Scene ikke fundet")
values_by_universe, precedence_by_universe = await self.scenes._resolve_layer_data(scene)
return scene, values_by_universe, precedence_by_universe
async def _reload_mappings(self) -> None:
async with self._session_factory() as session:
result = await session.execute(select(MidiMapping).order_by(MidiMapping.created_at, MidiMapping.id))
self._mappings = [self._serialize_mapping(record, None) for record in result.scalars().all()]
async def _reload_tokens(self) -> None:
async with self._session_factory() as session:
result = await session.execute(select(MidiBridgeToken).order_by(MidiBridgeToken.created_at, MidiBridgeToken.id))
self._tokens = [
TokenCacheEntry(
id=record.id,
label=record.label,
token_hash=record.token_hash,
bridge_id=record.bridge_id,
scopes=list(record.scopes or MIDI_SCOPES),
created_at=record.created_at,
revoked_at=record.revoked_at,
last_used_at=record.last_used_at,
)
for record in result.scalars().all()
]
async def _reload_bridges(self) -> None:
async with self._session_factory() as session:
result = await session.execute(select(MidiBridge).order_by(MidiBridge.bridge_id))
self._bridges = {record.bridge_id: self._serialize_bridge(record) for record in result.scalars().all()}
async def _mark_token_used(self, token_id: int) -> None:
now = utc_now()
for entry in self._tokens:
if entry.id == token_id:
entry.last_used_at = now
break
async with self._session_factory() as session:
record = await session.get(MidiBridgeToken, token_id)
if record is None:
return
record.last_used_at = now
await session.commit()
def _mapping_matches(self, mapping: dict[str, object], bridge: dict[str, object], event: dict[str, object]) -> bool:
if not bool(mapping["enabled"]):
return False
mapping_message_type = str(mapping["message_type"])
event_type = str(event["type"])
if mapping_message_type != event_type:
if not (
mapping_message_type == "note_on"
and event_type == "note_off"
and str(mapping["mode"]) in {"hold", "flash"}
):
return False
if int(mapping["number"]) != int(event["number"]):
return False
channel = mapping.get("channel")
if channel is not None and int(channel) != int(event["channel"]):
return False
if not self._wildcard_match(mapping.get("bridge_id"), bridge.get("bridge_id")):
return False
if not self._wildcard_match(mapping.get("device_name"), bridge.get("device_name")):
return False
return True
def _touch_bridge(
self,
bridge_id: str,
device_name: str | None,
remote_ip: str | None,
protocol_version: int,
is_event: bool,
event: dict[str, object] | None,
error: str | None,
) -> dict[str, object]:
now = utc_now()
bridge = self._bridges.get(bridge_id) or {
"bridge_id": bridge_id,
"device_name": device_name or "",
"ip_address": remote_ip,
"protocol_version": protocol_version,
"online": True,
"last_heartbeat_at": None,
"last_event_at": None,
"last_error": None,
"last_event": None,
"updated_at": now.isoformat(),
"created_at": now.isoformat(),
}
bridge["device_name"] = device_name or bridge.get("device_name") or ""
bridge["ip_address"] = remote_ip or bridge.get("ip_address")
bridge["protocol_version"] = protocol_version
bridge["online"] = True
bridge["last_heartbeat_at"] = now.isoformat()
bridge["updated_at"] = now.isoformat()
if is_event:
bridge["last_event_at"] = now.isoformat()
bridge["last_event"] = event
if error is not None:
bridge["last_error"] = error
self._bridges[bridge_id] = bridge
return bridge
async def _persist_bridge(self, bridge_id: str, force: bool = False) -> None:
now = monotonic()
previous = self._bridge_persist_deadline.get(bridge_id, 0.0)
if not force and now - previous < 2.0:
return
self._bridge_persist_deadline[bridge_id] = now
bridge = self._bridges.get(bridge_id)
if bridge is None:
return
async with self._session_factory() as session:
result = await session.execute(select(MidiBridge).where(MidiBridge.bridge_id == bridge_id).limit(1))
record = result.scalar_one_or_none()
if record is None:
record = MidiBridge(
bridge_id=bridge_id,
device_name=self._normalize_optional_text(bridge.get("device_name")),
ip_address=self._normalize_optional_text(bridge.get("ip_address")),
protocol_version=int(bridge.get("protocol_version", MIDI_PROTOCOL_VERSION)),
online=bool(bridge.get("online", True)),
created_at=utc_now(),
updated_at=utc_now(),
)
session.add(record)
record.device_name = self._normalize_optional_text(bridge.get("device_name"))
record.ip_address = self._normalize_optional_text(bridge.get("ip_address"))
record.protocol_version = int(bridge.get("protocol_version", MIDI_PROTOCOL_VERSION))
record.online = bool(bridge.get("online", True))
record.last_heartbeat_at = self._parse_dt(bridge.get("last_heartbeat_at"))
record.last_event_at = self._parse_dt(bridge.get("last_event_at"))
record.last_error = self._normalize_optional_text(bridge.get("last_error"))
record.last_event = dict(bridge.get("last_event") or {})
record.updated_at = utc_now()
await session.commit()
def _check_bridge_rate_limit(self, bridge_id: str) -> bool:
window = self._bridge_event_windows.setdefault(bridge_id, deque())
now = monotonic()
while window and now - window[0] > 1.0:
window.popleft()
window.append(now)
return len(window) <= 200
def _expire_bridge_presence(self) -> None:
threshold = utc_now() - timedelta(seconds=15)
for bridge in self._bridges.values():
last_heartbeat = self._parse_dt(bridge.get("last_heartbeat_at"))
bridge["online"] = bool(last_heartbeat and last_heartbeat >= threshold)
def _expire_learn_state(self) -> None:
if not self._learn_state.active or self._learn_state.expires_at is None:
return
if self._learn_state.expires_at < utc_now():
self._learn_state.active = False
def _capture_learn_event(self, bridge: dict[str, object], event: dict[str, object]) -> None:
self._learn_state.captured_event = {
"bridge_id": bridge.get("bridge_id"),
"device": bridge.get("device_name"),
"timestamp": utc_now().isoformat(),
"message": dict(event),
}
self._learn_state.active = False
def _serialize_learn_state(self) -> dict[str, object]:
return {
"active": self._learn_state.active,
"allow_passthrough": self._learn_state.allow_passthrough,
"expires_at": self._learn_state.expires_at.isoformat() if self._learn_state.expires_at else None,
"captured_event": self._learn_state.captured_event,
}
def _serialize_mapping(self, record: MidiMapping, runtime: dict[str, object] | None) -> dict[str, object]:
return {
"id": record.id,
"name": record.name,
"enabled": record.enabled,
"bridge_id": record.bridge_id,
"device_name": record.device_name,
"message_type": record.message_type,
"channel": record.channel,
"number": record.number,
"action": record.action,
"target_type": record.target_type,
"target_id": record.target_id,
"mode": record.mode,
"minimum_value": record.minimum_value,
"maximum_value": record.maximum_value,
"created_at": record.created_at.isoformat(),
"updated_at": record.updated_at.isoformat(),
"active": bool(runtime.get("active")) if runtime else self._mapping_is_active(record),
}
def _serialize_bridge(self, record: MidiBridge) -> dict[str, object]:
return {
"bridge_id": record.bridge_id,
"device_name": record.device_name or "",
"ip_address": record.ip_address,
"protocol_version": record.protocol_version,
"online": record.online,
"last_heartbeat_at": record.last_heartbeat_at.isoformat() if record.last_heartbeat_at else None,
"last_event_at": record.last_event_at.isoformat() if record.last_event_at else None,
"last_error": record.last_error,
"last_event": record.last_event or None,
"updated_at": record.updated_at.isoformat(),
"created_at": record.created_at.isoformat(),
}
def _serialize_token(self, record: MidiBridgeToken) -> dict[str, object]:
return {
"id": record.id,
"label": record.label,
"bridge_id": record.bridge_id,
"scopes": list(record.scopes or MIDI_SCOPES),
"created_at": record.created_at.isoformat(),
"revoked_at": record.revoked_at.isoformat() if record.revoked_at else None,
"last_used_at": record.last_used_at.isoformat() if record.last_used_at else None,
"token_preview": self._token_preview(record.token_hash),
}
def _serialize_token_entry(self, entry: TokenCacheEntry) -> dict[str, object]:
return {
"id": entry.id,
"label": entry.label,
"bridge_id": entry.bridge_id,
"scopes": list(entry.scopes),
"created_at": entry.created_at.isoformat(),
"revoked_at": entry.revoked_at.isoformat() if entry.revoked_at else None,
"last_used_at": entry.last_used_at.isoformat() if entry.last_used_at else None,
"token_preview": self._token_preview(entry.token_hash),
}
def _normalize_event(self, payload: dict[str, object]) -> dict[str, object]:
message = payload.get("message")
if not isinstance(message, dict):
raise ValueError("MIDI payload mangler message")
return {
"type": str(message["type"]),
"channel": int(message["channel"]),
"number": int(message["number"]),
"value": int(message["value"]),
}
def _mapping_is_active(self, record: MidiMapping) -> bool:
target_id = record.target_id
if record.action in SCENE_ACTIONS and target_id:
if record.action == "set_scene_intensity":
return self.engine.layers.get(f"midi:intensity:{record.id}:{target_id}") is not None
if record.action == "flash_scene":
return self.engine.layers.get(f"midi:flash:{record.id}:{target_id}") is not None
return self.engine.layers.get(f"scene:{target_id}") is not None
if record.action in EFFECT_ACTIONS and target_id:
return target_id in self.effects.active_effects
if record.action.startswith("blackout"):
return self.engine.blackout
if record.action == "set_master_dimmer":
return self.engine.master > 0
return False
def _mapping_by_id(self, mapping_id: int) -> dict[str, object] | None:
for mapping in self._mappings:
if int(mapping["id"]) == mapping_id:
return mapping
return None
def _validate_mapping_record(self, record: MidiMapping) -> None:
if record.action not in MIDI_ACTIONS:
raise ValueError("Ukendt MIDI-action")
if record.mode not in MIDI_MODES:
raise ValueError("Ukendt MIDI-mode")
if record.minimum_value > record.maximum_value:
raise ValueError("minimum_value må ikke være større end maximum_value")
if record.action in SCENE_ACTIONS and not record.target_id:
raise ValueError("Scene-actions kræver target_id")
if record.action in EFFECT_ACTIONS and not record.target_id:
raise ValueError("Effect-actions kræver target_id")
if record.action == "set_scene_intensity" and record.mode != "continuous":
raise ValueError("set_scene_intensity kræver continuous-mode")
if record.action == "set_master_dimmer" and record.mode != "continuous":
raise ValueError("set_master_dimmer kræver continuous-mode")
if record.action == "flash_scene" and record.mode not in {"hold", "flash"}:
raise ValueError("flash_scene kræver hold eller flash-mode")
if record.message_type == "control_change" and record.mode in {"hold", "flash"}:
return
def _normalize_value(self, raw: int, minimum: int, maximum: int) -> float:
clamped = max(minimum, min(maximum, raw))
span = max(1, maximum - minimum)
return max(0.0, min(1.0, (clamped - minimum) / span))
def _wildcard_match(self, pattern: object, value: object) -> bool:
normalized_pattern = self._normalize_optional_text(pattern)
if not normalized_pattern or normalized_pattern == "*":
return True
normalized_value = self._normalize_optional_text(value)
if not normalized_value:
return False
return fnmatch(normalized_value.casefold(), normalized_pattern.casefold())
def _normalize_optional_text(self, value: object) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
def _optional_int(self, value: object) -> int | None:
if value is None or value == "":
return None
return int(value)
def _normalize_scopes(self, payload: object) -> list[str]:
if not isinstance(payload, list):
return list(MIDI_SCOPES)
scopes = [str(scope).strip() for scope in payload if str(scope).strip()]
return scopes or list(MIDI_SCOPES)
def _hash_token(self, token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _token_preview(self, token_hash: str) -> str:
return f"********{token_hash[-4:]}"
def _parse_dt(self, value: object) -> datetime | None:
if isinstance(value, datetime):
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
if isinstance(value, str) and value:
parsed = datetime.fromisoformat(value)
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
return None
+2
View File
@@ -0,0 +1,2 @@
"""MixItUp integration services."""
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from collections import deque
from time import monotonic
from app.dmx.engine import DmxEngine
from app.effects.service import EffectService
from app.models.schemas import TriggerRequest
from app.scenes.service import SceneService
class TriggerService:
def __init__(
self,
engine: DmxEngine,
effects: EffectService,
scenes: SceneService,
) -> None:
self.engine = engine
self.effects = effects
self.scenes = scenes
self.queue: deque[dict[str, object]] = deque(maxlen=100)
self._recent_ids: dict[str, float] = {}
def enqueue(self, slug: str, payload: TriggerRequest) -> dict[str, object]:
if payload.event_id:
previous = self._recent_ids.get(payload.event_id)
if previous and monotonic() - previous < 30:
return {"accepted": False, "reason": "duplicate-event"}
self._recent_ids[payload.event_id] = monotonic()
item: dict[str, object] = {
"slug": slug,
"payload": payload.model_dump(by_alias=True),
}
self.queue.append(item)
return {"accepted": True, "queue_depth": len(self.queue)}
def snapshot(self) -> list[dict[str, object]]:
return list(self.queue)
+2
View File
@@ -0,0 +1,2 @@
"""ORM and schema modules."""
+6
View File
@@ -0,0 +1,6 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
def utc_now() -> datetime:
return datetime.now(UTC)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(30), default="administrator")
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class SessionRecord(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, index=True)
csrf_token: Mapped[str] = mapped_column(String(64))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class ApiToken(Base):
__tablename__ = "api_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(120))
token_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(30), default="operator")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(120), primary_key=True)
value: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class FixtureSource(Base):
__tablename__ = "fixture_sources"
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_key: Mapped[str] = mapped_column(String(100))
fixture_key: Mapped[str] = mapped_column(String(150))
schema_ref: Mapped[str] = mapped_column(String(255))
source_url: Mapped[str] = mapped_column(String(255))
payload: Mapped[dict[str, object]] = mapped_column(JSON)
payload_hash: Mapped[str] = mapped_column(String(64))
imported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class FixtureDefinition(Base):
__tablename__ = "fixture_definitions"
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
manufacturer: Mapped[str] = mapped_column(String(120))
model: Mapped[str] = mapped_column(String(160))
short_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
categories: Mapped[list[str]] = mapped_column(JSON, default=list)
normalized_data: Mapped[dict[str, object]] = mapped_column(JSON)
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class FixtureInstance(Base):
__tablename__ = "fixture_instances"
id: Mapped[int] = mapped_column(primary_key=True)
universe: Mapped[int] = mapped_column(Integer, default=1)
name: Mapped[str] = mapped_column(String(160))
definition_id: Mapped[int] = mapped_column(Integer, index=True)
mode_key: Mapped[str] = mapped_column(String(120))
start_address: Mapped[int] = mapped_column(Integer)
channel_count: Mapped[int] = mapped_column(Integer)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
group_names: Mapped[list[str]] = mapped_column(JSON, default=list)
position: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Scene(Base):
__tablename__ = "scenes"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160))
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
color: Mapped[str] = mapped_column(String(20), default="#00e5ff")
icon: Mapped[str | None] = mapped_column(String(40), nullable=True)
priority: Mapped[int] = mapped_column(Integer, default=10)
fade_in_ms: Mapped[int] = mapped_column(Integer, default=500)
fade_out_ms: Mapped[int] = mapped_column(Integer, default=500)
hold_ms: Mapped[int] = mapped_column(Integer, default=0)
master_limit: Mapped[int] = mapped_column(Integer, default=255)
values: Mapped[list[dict[str, object]]] = mapped_column(JSON, default=list)
targets: Mapped[list[dict[str, object]]] = mapped_column(JSON, default=list)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class Effect(Base):
__tablename__ = "effects"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160))
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
effect_type: Mapped[str] = mapped_column(String(80))
parameters: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
priority: Mapped[int] = mapped_column(Integer, default=40)
cooldown_ms: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class TriggerMapping(Base):
__tablename__ = "trigger_mappings"
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
name: Mapped[str] = mapped_column(String(160))
event_type: Mapped[str] = mapped_column(String(60))
action_type: Mapped[str] = mapped_column(String(30))
action_slug: Mapped[str] = mapped_column(String(160))
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class TriggerEvent(Base):
__tablename__ = "trigger_events"
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(160), index=True)
event_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
status: Mapped[str] = mapped_column(String(30), default="queued")
payload: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class SystemEvent(Base):
__tablename__ = "system_events"
id: Mapped[int] = mapped_column(primary_key=True)
category: Mapped[str] = mapped_column(String(40))
level: Mapped[str] = mapped_column(String(20))
message: Mapped[str] = mapped_column(Text)
payload: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class BackupRecord(Base):
__tablename__ = "backups"
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(160))
path: Mapped[str] = mapped_column(String(255))
manifest: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class MidiBridge(Base):
__tablename__ = "midi_bridges"
id: Mapped[int] = mapped_column(primary_key=True)
bridge_id: Mapped[str] = mapped_column(String(160), unique=True, index=True)
device_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
protocol_version: Mapped[int] = mapped_column(Integer, default=1)
online: Mapped[bool] = mapped_column(Boolean, default=False)
last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
last_event: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class MidiBridgeToken(Base):
__tablename__ = "midi_bridge_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(120))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
bridge_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
scopes: Mapped[list[str]] = mapped_column(JSON, default=list)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class MidiMapping(Base):
__tablename__ = "midi_mappings"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160))
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
bridge_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
device_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
message_type: Mapped[str] = mapped_column(String(40))
channel: Mapped[int | None] = mapped_column(Integer, nullable=True)
number: Mapped[int] = mapped_column(Integer)
action: Mapped[str] = mapped_column(String(60))
target_type: Mapped[str] = mapped_column(String(40))
target_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
mode: Mapped[str] = mapped_column(String(40), default="trigger")
minimum_value: Mapped[int] = mapped_column(Integer, default=0)
maximum_value: Mapped[int] = mapped_column(Integer, default=127)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class HealthResponse(BaseModel):
status: str
simulator_enabled: bool
version: str
class ChannelValue(BaseModel):
channel: int = Field(ge=1, le=512)
value: int = Field(ge=0, le=255)
precedence: str = "htp"
source: str = "scene"
class FixturePositionPayload(BaseModel):
x: float = Field(default=50.0, ge=0.0, le=100.0)
y: float = Field(default=50.0, ge=0.0, le=100.0)
z: float = Field(default=0.0, ge=0.0, le=100.0)
rotation: float = Field(default=0.0, ge=0.0, le=360.0)
class SceneAttributeValue(BaseModel):
attribute: str
value: int = Field(ge=0, le=255)
precedence: str | None = None
class SceneTargetPayload(BaseModel):
target_type: Literal["fixture", "group"]
patch_id: int | None = Field(default=None, ge=1)
group_name: str | None = None
values: list[SceneAttributeValue] = Field(default_factory=list)
class ScenePayload(BaseModel):
name: str
slug: str
color: str = "#00e5ff"
icon: str | None = None
priority: int = 10
fade_in_ms: int = 500
fade_out_ms: int = 500
hold_ms: int = 0
master_limit: int = 255
values: list[ChannelValue] = Field(default_factory=list)
targets: list[SceneTargetPayload] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
class EffectPayload(BaseModel):
name: str
slug: str
effect_type: str
parameters: dict[str, object] = Field(default_factory=dict)
priority: int = 40
cooldown_ms: int = 0
class TriggerRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
event_type: str = Field(default="custom", alias="eventType")
username: str | None = None
display_name: str | None = Field(default=None, alias="displayName")
amount: int | None = None
event_id: str | None = Field(default=None, alias="eventId")
platform: str = "twitch"
metadata: dict[str, object] = Field(default_factory=dict)
class TelemetrySnapshot(BaseModel):
uptime_seconds: float
cpu_percent: float
ram_percent: float
temperature_c: float | None = None
queue_depth: int
reconnect_count: int
last_error: str | None = None
last_sent_at: datetime | None = None
class PatchPayload(BaseModel):
universe: int = Field(default=1, ge=1, le=63999)
name: str
definition_id: int = Field(ge=1)
mode_key: str
start_address: int = Field(ge=1, le=512)
enabled: bool = True
group_names: list[str] = Field(default_factory=list)
position: FixturePositionPayload = Field(default_factory=FixturePositionPayload)
class PatchValidationPayload(BaseModel):
universe: int = Field(default=1, ge=1, le=63999)
definition_id: int = Field(ge=1)
mode_key: str
start_address: int = Field(ge=1, le=512)
enabled: bool = True
exclude_patch_id: int | None = Field(default=None, ge=1)
class ManualPatchValuesPayload(BaseModel):
values: dict[int, int] = Field(default_factory=dict)
class BpmAudioStartPayload(BaseModel):
device: str = "alsa:auto"
class BpmAudioConfigPayload(BaseModel):
preferred_device: str = "alsa:auto"
class DmxOutputConfigPayload(BaseModel):
backend: Literal["simulator", "ola", "artnet"] = "simulator"
universe: int = Field(default=1, ge=1, le=63999)
output_port: str | None = None
target_host: str | None = None
class HomeAssistantConfigPayload(BaseModel):
enabled: bool = False
base_url: str = ""
token: str | None = None
default_universe: int = Field(default=10, ge=1, le=63999)
class HomeAssistantMappingPayload(BaseModel):
name: str
universe: int = Field(default=10, ge=1, le=63999)
start_address: int = Field(ge=1, le=512)
fixture_type: Literal["dimmer", "rgb", "rgbw", "cct", "switch", "scene", "automation"] = "dimmer"
entity_id: str
rate_limit_hz: float = Field(default=5.0, ge=0.1, le=30.0)
deadband: int = Field(default=2, ge=0, le=255)
fade_ms: int = Field(default=0, ge=0, le=10000)
invert_channel: bool = False
min_value: int = Field(default=0, ge=0, le=255)
max_value: int = Field(default=255, ge=0, le=255)
enabled: bool = True
master_dimmer: bool = True
class MidiBridgeTokenCreatePayload(BaseModel):
label: str
bridge_id: str | None = None
scopes: list[str] = Field(default_factory=lambda: ["midi:connect", "midi:events", "midi:heartbeat"])
class MidiBridgeTokenResponse(BaseModel):
id: int
label: str
bridge_id: str | None
scopes: list[str]
created_at: datetime
revoked_at: datetime | None = None
last_used_at: datetime | None = None
token_preview: str
class MidiMessagePayload(BaseModel):
type: Literal["note_on", "note_off", "control_change", "program_change"]
channel: int = Field(ge=0, le=15)
number: int = Field(ge=0, le=127)
value: int = Field(ge=0, le=127)
class MidiBridgeEventPayload(BaseModel):
type: Literal["midi_event"]
protocol_version: int = Field(default=1, ge=1)
bridge_id: str
device: str
timestamp: datetime
message: MidiMessagePayload
class MidiBridgeHeartbeatPayload(BaseModel):
type: Literal["heartbeat"]
protocol_version: int = Field(default=1, ge=1)
bridge_id: str
device: str | None = None
timestamp: datetime | None = None
class MidiMappingPayload(BaseModel):
name: str
enabled: bool = True
bridge_id: str | None = None
device_name: str | None = None
message_type: Literal["note_on", "note_off", "control_change", "program_change"]
channel: int | None = Field(default=None, ge=0, le=15)
number: int = Field(ge=0, le=127)
action: Literal[
"activate_scene",
"deactivate_scene",
"toggle_scene",
"flash_scene",
"blackout_on",
"blackout_off",
"blackout_toggle",
"set_master_dimmer",
"set_scene_intensity",
"start_effect",
"stop_effect",
]
target_type: Literal["scene", "effect", "system", "global"] = "scene"
target_id: str | None = None
mode: Literal["trigger", "toggle", "hold", "flash", "continuous"] = "trigger"
minimum_value: int = Field(default=0, ge=0, le=127)
maximum_value: int = Field(default=127, ge=0, le=127)
class MidiLearnStartPayload(BaseModel):
timeout_seconds: int = Field(default=15, ge=3, le=120)
allow_passthrough: bool = False
class MidiTestMappingPayload(BaseModel):
value: int = Field(default=127, ge=0, le=127)
message_type: Literal["note_on", "note_off", "control_change", "program_change"] | None = None
+1
View File
@@ -0,0 +1 @@
"""Patch persistence and validation services."""
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.models.entities import FixtureDefinition, FixtureInstance
from app.models.schemas import PatchPayload, PatchValidationPayload
class PatchService:
def __init__(self, session_factory: async_sessionmaker[AsyncSession] = SessionLocal) -> None:
self._session_factory = session_factory
async def list_instances(self) -> list[dict[str, object]]:
async with self._session_factory() as session:
result = await session.execute(select(FixtureInstance).order_by(FixtureInstance.universe, FixtureInstance.start_address))
instances = result.scalars().all()
fixtures = await self._definitions_by_ids(session, {instance.definition_id for instance in instances})
return [self._serialize_instance(instance, fixtures.get(instance.definition_id)) for instance in instances]
async def validate(self, payload: PatchValidationPayload) -> dict[str, object]:
async with self._session_factory() as session:
definition = await session.get(FixtureDefinition, payload.definition_id)
if definition is None:
raise LookupError("Fixture not found")
mode = self._resolve_mode(definition, payload.mode_key)
if mode is None:
raise LookupError("Fixture mode not found")
channel_count = int(mode["channel_count"])
end_address = payload.start_address + channel_count - 1
conflicts: list[dict[str, object]] = []
if end_address > 512:
conflicts.append(
{
"type": "range",
"message": f"Patch {payload.start_address}-{end_address} overskrider DMX kanal 512.",
}
)
result = await session.execute(
select(FixtureInstance).where(FixtureInstance.universe == payload.universe).order_by(FixtureInstance.start_address)
)
instances = result.scalars().all()
fixtures = await self._definitions_by_ids(session, {instance.definition_id for instance in instances})
for instance in instances:
if payload.exclude_patch_id is not None and instance.id == payload.exclude_patch_id:
continue
instance_end = instance.start_address + instance.channel_count - 1
overlaps = not (instance_end < payload.start_address or instance.start_address > end_address)
if overlaps:
definition_for_instance = fixtures.get(instance.definition_id)
conflicts.append(
{
"type": "overlap",
"patch_id": instance.id,
"name": instance.name,
"range": f"{instance.start_address}-{instance_end}",
"fixture": self._definition_label(definition_for_instance),
"message": f"{instance.name} optager allerede {instance.start_address}-{instance_end}.",
}
)
occupied = sorted(
{
address
for instance in instances
if payload.exclude_patch_id is None or instance.id != payload.exclude_patch_id
for address in range(instance.start_address, instance.start_address + instance.channel_count)
}
)
return {
"valid": not conflicts,
"range": f"{payload.start_address}-{end_address}",
"end_address": end_address,
"channel_count": channel_count,
"occupied_channels": occupied,
"conflicts": conflicts,
}
async def create_instance(self, payload: PatchPayload) -> dict[str, object]:
validation = await self.validate(
PatchValidationPayload(
universe=payload.universe,
definition_id=payload.definition_id,
mode_key=payload.mode_key,
start_address=payload.start_address,
enabled=payload.enabled,
)
)
if not validation["valid"]:
raise ValueError("Patch overlapper eller overskrider kanalrangen")
async with self._session_factory() as session:
definition = await session.get(FixtureDefinition, payload.definition_id)
if definition is None:
raise LookupError("Fixture not found")
mode = self._resolve_mode(definition, payload.mode_key)
if mode is None:
raise LookupError("Fixture mode not found")
instance = FixtureInstance(
universe=payload.universe,
name=payload.name,
definition_id=payload.definition_id,
mode_key=payload.mode_key,
start_address=payload.start_address,
channel_count=int(mode["channel_count"]),
enabled=payload.enabled,
group_names=self._sanitize_groups(payload.group_names),
position=payload.position.model_dump(),
tags=[],
)
session.add(instance)
await session.commit()
await session.refresh(instance)
return self._serialize_instance(instance, definition)
async def update_instance(self, patch_id: int, payload: PatchPayload) -> dict[str, object]:
validation = await self.validate(
PatchValidationPayload(
universe=payload.universe,
definition_id=payload.definition_id,
mode_key=payload.mode_key,
start_address=payload.start_address,
enabled=payload.enabled,
exclude_patch_id=patch_id,
)
)
if not validation["valid"]:
raise ValueError("Patch overlapper eller overskrider kanalrangen")
async with self._session_factory() as session:
instance = await session.get(FixtureInstance, patch_id)
if instance is None:
raise LookupError("Patch not found")
definition = await session.get(FixtureDefinition, payload.definition_id)
if definition is None:
raise LookupError("Fixture not found")
mode = self._resolve_mode(definition, payload.mode_key)
if mode is None:
raise LookupError("Fixture mode not found")
instance.universe = payload.universe
instance.name = payload.name
instance.definition_id = payload.definition_id
instance.mode_key = payload.mode_key
instance.start_address = payload.start_address
instance.channel_count = int(mode["channel_count"])
instance.enabled = payload.enabled
instance.group_names = self._sanitize_groups(payload.group_names)
instance.position = payload.position.model_dump()
await session.commit()
await session.refresh(instance)
return self._serialize_instance(instance, definition)
async def delete_instance(self, patch_id: int) -> dict[str, object]:
async with self._session_factory() as session:
instance = await session.get(FixtureInstance, patch_id)
if instance is None:
raise LookupError("Patch not found")
deleted_name = instance.name
await session.delete(instance)
await session.commit()
return {"deleted": deleted_name, "id": patch_id}
async def _definitions_by_ids(self, session: AsyncSession, ids: set[int]) -> dict[int, FixtureDefinition]:
if not ids:
return {}
result = await session.execute(select(FixtureDefinition).where(FixtureDefinition.id.in_(ids)))
definitions = result.scalars().all()
return {definition.id: definition for definition in definitions}
def _resolve_mode(self, definition: FixtureDefinition, mode_key: str) -> dict[str, Any] | None:
modes = definition.normalized_data.get("modes", [])
if not isinstance(modes, list):
return None
for mode in modes:
if isinstance(mode, dict) and str(mode.get("key")) == mode_key:
return mode
return None
def _serialize_instance(
self,
instance: FixtureInstance,
definition: FixtureDefinition | None,
) -> dict[str, object]:
end_address = instance.start_address + instance.channel_count - 1
mode = self._resolve_mode(definition, instance.mode_key) if definition is not None else None
return {
"id": instance.id,
"universe": instance.universe,
"name": instance.name,
"definition_id": instance.definition_id,
"fixture_slug": definition.slug if definition is not None else None,
"manufacturer": definition.manufacturer if definition is not None else None,
"model": definition.model if definition is not None else None,
"mode_key": instance.mode_key,
"start_address": instance.start_address,
"end_address": end_address,
"channel_count": instance.channel_count,
"enabled": instance.enabled,
"group_names": self._sanitize_groups(instance.group_names),
"position": self._serialize_position(instance.position),
"channels": mode.get("channels", []) if isinstance(mode, dict) else [],
}
def _definition_label(self, definition: FixtureDefinition | None) -> str:
if definition is None:
return "Ukendt fixture"
return f"{definition.manufacturer} {definition.model}"
def _sanitize_groups(self, groups: list[str]) -> list[str]:
unique: list[str] = []
seen: set[str] = set()
for group in groups:
normalized = str(group).strip()
key = normalized.casefold()
if not normalized or key in seen:
continue
unique.append(normalized)
seen.add(key)
return unique
def _serialize_position(self, payload: object) -> dict[str, float]:
if not isinstance(payload, dict):
payload = {}
return {
"x": float(payload.get("x", 50.0)),
"y": float(payload.get("y", 50.0)),
"z": float(payload.get("z", 0.0)),
"rotation": float(payload.get("rotation", 0.0)),
}
+2
View File
@@ -0,0 +1,2 @@
"""Scene services."""
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import SessionLocal
from app.dmx.engine import DmxEngine
from app.dmx.frame import FrameLayer
from app.models.entities import Scene
from app.models.schemas import ScenePayload
from app.patch.service import PatchService
class SceneService:
def __init__(
self,
engine: DmxEngine,
session_factory: async_sessionmaker[AsyncSession] = SessionLocal,
) -> None:
self.engine = engine
self._session_factory = session_factory
self.patch = PatchService(session_factory)
self.active_slug: str | None = None
async def save(self, payload: ScenePayload, scene_id: int | None = None) -> dict[str, object]:
async with self._session_factory() as session:
scene = await self._load_for_write(session, payload.slug, scene_id)
if scene is None:
scene = Scene(
name=payload.name,
slug=payload.slug,
color=payload.color,
icon=payload.icon,
priority=payload.priority,
fade_in_ms=payload.fade_in_ms,
fade_out_ms=payload.fade_out_ms,
hold_ms=payload.hold_ms,
master_limit=payload.master_limit,
values=[],
targets=[],
tags=payload.tags,
)
session.add(scene)
scene.name = payload.name
scene.slug = payload.slug
scene.color = payload.color
scene.icon = payload.icon
scene.priority = payload.priority
scene.fade_in_ms = payload.fade_in_ms
scene.fade_out_ms = payload.fade_out_ms
scene.hold_ms = payload.hold_ms
scene.master_limit = payload.master_limit
scene.values = [item.model_dump() for item in payload.values]
scene.targets = [item.model_dump() for item in payload.targets]
scene.tags = payload.tags
await session.commit()
await session.refresh(scene)
return self._serialize_scene(scene)
async def list(self) -> list[dict[str, object]]:
async with self._session_factory() as session:
result = await session.execute(select(Scene).order_by(Scene.priority, Scene.created_at, Scene.id))
return [self._serialize_scene(scene) for scene in result.scalars().all()]
async def get(self, scene_id: int) -> dict[str, object]:
async with self._session_factory() as session:
scene = await session.get(Scene, scene_id)
if scene is None:
raise LookupError("Scene not found")
return self._serialize_scene(scene)
async def activate(self, slug: str) -> dict[str, object]:
async with self._session_factory() as session:
result = await session.execute(select(Scene).where(Scene.slug == slug).limit(1))
scene = result.scalar_one_or_none()
if scene is None:
raise LookupError("Scene not found")
values_by_universe, precedence_map_by_universe = await self._resolve_layer_data(scene)
self.engine.set_layer(
FrameLayer(
name=f"scene:{scene.slug}",
priority=scene.priority,
values_by_universe=values_by_universe,
precedence_map_by_universe=precedence_map_by_universe,
)
)
self.active_slug = slug
return self._serialize_scene(scene)
async def release(self, slug: str) -> None:
self.engine.remove_layer(f"scene:{slug}")
if self.active_slug == slug:
self.active_slug = None
async def delete(self, scene_id: int) -> dict[str, object]:
async with self._session_factory() as session:
scene = await session.get(Scene, scene_id)
if scene is None:
raise LookupError("Scene not found")
slug = scene.slug
await self.release(slug)
await session.delete(scene)
await session.commit()
return {"deleted": slug}
async def _load_for_write(
self,
session: AsyncSession,
slug: str,
scene_id: int | None,
) -> Scene | None:
if scene_id is not None:
scene = await session.get(Scene, scene_id)
if scene is None:
raise LookupError("Scene not found")
result = await session.execute(select(Scene).where(Scene.slug == slug, Scene.id != scene_id).limit(1))
if result.scalar_one_or_none() is not None:
raise ValueError("Scene slug findes allerede")
return scene
result = await session.execute(select(Scene).where(Scene.slug == slug).limit(1))
return result.scalar_one_or_none()
async def _resolve_layer_data(self, scene: Scene) -> tuple[dict[int, dict[int, int]], dict[int, dict[int, str]]]:
values_by_universe: dict[int, dict[int, int]] = {1: {}}
precedence_map_by_universe: dict[int, dict[int, str]] = {1: {}}
for entry in scene.values:
if not isinstance(entry, dict):
continue
if "channel" not in entry:
continue
channel = int(entry["channel"])
if not 1 <= channel <= 512:
continue
values_by_universe.setdefault(1, {})[channel] = max(0, min(255, int(entry.get("value", 0))))
precedence_map_by_universe.setdefault(1, {})[channel] = str(entry.get("precedence", "ltp")).lower()
if not scene.targets:
return values_by_universe, precedence_map_by_universe
patches = await self.patch.list_instances()
for target in scene.targets:
if not isinstance(target, dict):
continue
matches = self._resolve_target_matches(target, patches)
attribute_values = target.get("values", [])
if not isinstance(attribute_values, list):
continue
for patch in matches:
for attribute in attribute_values:
if not isinstance(attribute, dict):
continue
channel = self._match_patch_channel(patch, str(attribute.get("attribute", "")))
if channel is None:
continue
absolute_channel = int(patch["start_address"]) + int(channel["index"]) - 1
universe = int(patch.get("universe", 1))
values_by_universe.setdefault(universe, {})[absolute_channel] = max(
0, min(255, int(attribute.get("value", 0)))
)
precedence_map_by_universe.setdefault(universe, {})[absolute_channel] = str(
attribute.get("precedence") or channel.get("precedence", "ltp")
).lower()
return values_by_universe, precedence_map_by_universe
def _resolve_target_matches(
self,
target: dict[str, object],
patches: list[dict[str, object]],
) -> list[dict[str, object]]:
target_type = str(target.get("target_type", "fixture"))
if target_type == "group":
group_name = str(target.get("group_name", "")).strip()
if not group_name:
return []
group_key = group_name.casefold()
return [
patch
for patch in patches
if any(str(name).casefold() == group_key for name in patch.get("group_names", []))
]
patch_id = target.get("patch_id")
if patch_id is None:
return []
return [patch for patch in patches if int(patch["id"]) == int(patch_id)]
def _match_patch_channel(
self,
patch: dict[str, object],
attribute_name: str,
) -> dict[str, object] | None:
normalized = self._normalize_attribute(attribute_name)
for channel in patch.get("channels", []):
if not isinstance(channel, dict):
continue
candidates = {
self._normalize_attribute(str(channel.get("key", ""))),
self._normalize_attribute(str(channel.get("display_name", ""))),
}
if normalized in candidates:
return channel
return None
def _normalize_attribute(self, value: str) -> str:
return "".join(character for character in value.casefold() if character.isalnum())
def _serialize_scene(self, scene: Scene) -> dict[str, object]:
return {
"id": scene.id,
"name": scene.name,
"slug": scene.slug,
"color": scene.color,
"icon": scene.icon,
"priority": scene.priority,
"fade_in_ms": scene.fade_in_ms,
"fade_out_ms": scene.fade_out_ms,
"hold_ms": scene.hold_ms,
"master_limit": scene.master_limit,
"values": scene.values,
"targets": scene.targets,
"tags": scene.tags,
"is_active": scene.slug == self.active_slug,
}
+3
View File
@@ -0,0 +1,3 @@
from app.system.service import SystemControlService
__all__ = ["SystemControlService"]
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
from app.core.config import get_settings
@dataclass(slots=True)
class SystemCommandResult:
accepted: bool
action: str
status: str
detail: str
def as_dict(self) -> dict[str, object]:
return {
"accepted": self.accepted,
"action": self.action,
"status": self.status,
"detail": self.detail,
}
class SystemControlService:
def __init__(self) -> None:
settings = get_settings()
self._enabled = settings.system_control_enabled
self._platform = os.name
self._helper = settings.system_control_helper
async def restart_service(self) -> dict[str, object]:
return (await self._run_helper(
action="restart-service",
helper_action="restart-service",
)).as_dict()
async def reboot_host(self) -> dict[str, object]:
return (await self._run_helper(
action="reboot-host",
helper_action="reboot-host",
)).as_dict()
async def _run_helper(
self,
*,
action: str,
helper_action: str,
) -> SystemCommandResult:
if not self._enabled:
return SystemCommandResult(
accepted=False,
action=action,
status="disabled",
detail="Systemkontrol er deaktiveret i serverkonfigurationen.",
)
if self._platform != "posix":
return SystemCommandResult(
accepted=False,
action=action,
status="unsupported-platform",
detail="Systemkontrol understoettes kun paa Linux/systemd-installationer.",
)
if not os.path.exists(self._helper):
return SystemCommandResult(
accepted=False,
action=action,
status="helper-missing",
detail="Systemkontrol-helperen findes ikke paa maskinen.",
)
process = await asyncio.create_subprocess_exec(
"sudo",
"-n",
self._helper,
helper_action,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_output = (stderr or stdout).decode("utf-8", errors="ignore").strip() or "Ukendt fejl"
return SystemCommandResult(
accepted=False,
action=action,
status="command-failed",
detail=f"Kunne ikke planlaegge handlingen: {error_output}",
)
return SystemCommandResult(
accepted=True,
action=action,
status="scheduled",
detail="Handling planlagt. Serveren kan blive utilgaengelig et kort oeje.",
)
+2
View File
@@ -0,0 +1,2 @@
"""Telemetry services."""
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from time import monotonic
import psutil
from app.dmx.backends import BackendStatus
@dataclass(slots=True)
class TelemetryEvent:
created_at: datetime
category: str
level: str
message: str
class TelemetryService:
def __init__(self) -> None:
self.started_at = monotonic()
self.events: deque[TelemetryEvent] = deque(maxlen=1000)
self.last_sent_at: datetime | None = None
self.last_error: str | None = None
self.reconnect_count = 0
self.frames_sent = 0
self.send_errors = 0
def record_send_success(self, status: BackendStatus) -> None:
self.last_sent_at = datetime.now(UTC)
self.last_error = None
self.reconnect_count = status.reconnect_count
self.frames_sent = status.frames_sent
self.send_errors = status.send_errors
def record_send_failure(self, error: str, status: BackendStatus) -> None:
self.last_error = error
self.reconnect_count = status.reconnect_count
self.frames_sent = status.frames_sent
self.send_errors = status.send_errors
self.events.append(
TelemetryEvent(datetime.now(UTC), "DMX", "ERROR", error)
)
def snapshot(self, queue_depth: int = 0) -> dict[str, object]:
temperature = None
try:
readings = psutil.sensors_temperatures()
if readings:
first = next(iter(readings.values()))
if first:
temperature = float(first[0].current)
except Exception:
temperature = None
return {
"uptime_seconds": round(monotonic() - self.started_at, 2),
"cpu_percent": psutil.cpu_percent(interval=None),
"ram_percent": psutil.virtual_memory().percent,
"temperature_c": temperature,
"queue_depth": queue_depth,
"reconnect_count": self.reconnect_count,
"last_error": self.last_error,
"last_sent_at": self.last_sent_at,
"frames_sent": self.frames_sent,
"send_errors": self.send_errors,
"events": [
{
"created_at": event.created_at.isoformat(),
"category": event.category,
"level": event.level,
"message": event.message,
}
for event in list(self.events)[-50:]
],
}
+2
View File
@@ -0,0 +1,2 @@
"""WebSocket helpers."""
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import asyncio
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.core.dependencies import app_state
websocket_router = APIRouter()
@websocket_router.websocket("/ws/live")
async def live_socket(websocket: WebSocket) -> None:
await websocket.accept()
try:
while True:
await websocket.send_json(
{
"engine": app_state.engine.snapshot(),
"telemetry": app_state.telemetry.snapshot(len(app_state.triggers.queue)),
"bpm": app_state.bpm.snapshot(),
"queue": app_state.triggers.snapshot(),
}
)
await asyncio.sleep(0.1)
except WebSocketDisconnect:
return
+4
View File
@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+593
View File
@@ -0,0 +1,593 @@
from uuid import uuid4
from app.core.dependencies import app_state
from app.main import app
from fastapi.testclient import TestClient
def test_health_endpoint() -> None:
with TestClient(app) as client:
response = client.get("/api/v1/health")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["simulator_enabled"] is True
def test_trigger_endpoint_returns_202() -> None:
with TestClient(app) as client:
response = client.post(
"/api/v1/triggers/raid",
json={"eventType": "raid", "displayName": "Tester", "eventId": "evt-1"},
)
assert response.status_code == 202
assert response.json()["accepted"] is True
def test_dmx_status_exposes_backend_counters() -> None:
with TestClient(app) as client:
response = client.get("/api/v1/dmx/status")
assert response.status_code == 200
payload = response.json()
assert payload["backend"] in {"simulator", "ola", "artnet"}
assert payload["selected_universe"] == 1
assert "selected_output_port" in payload
assert payload["frames_sent"] >= 0
assert payload["send_errors"] >= 0
assert "last_successful_frame" in payload
def test_dmx_output_config_can_be_saved_and_read_back() -> None:
with TestClient(app) as client:
original = client.get("/api/v1/dmx/config").json()
response = client.put(
"/api/v1/dmx/config",
json={
"backend": "artnet",
"universe": 7,
"output_port": "WLED stue",
"target_host": "192.168.2.50",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["backend"] == "artnet"
assert payload["universe"] == 7
assert payload["output_port"] == "WLED stue"
assert payload["target_host"] == "192.168.2.50"
read_back = client.get("/api/v1/dmx/config")
assert read_back.status_code == 200
assert read_back.json()["backend"] == "artnet"
assert read_back.json()["universe"] == 7
restore_response = client.put("/api/v1/dmx/config", json=original)
assert restore_response.status_code == 200
def test_dmx_artnet_discover_endpoint_returns_nodes(monkeypatch) -> None:
async def fake_discover(timeout_s: float) -> dict[str, object]:
assert timeout_s == 0.25
return {
"count": 1,
"items": [
{
"ip": "192.168.2.60",
"short_name": "WLED",
"long_name": "WLED Node",
"label": "WLED Node (192.168.2.60)",
"net": 0,
"sub_switch": 0,
"port_count": 1,
"raw_port_address": 0,
}
],
}
monkeypatch.setattr(app_state.dmx, "discover_artnet", fake_discover)
with TestClient(app) as client:
response = client.post("/api/v1/dmx/artnet/discover?timeout_s=0.25")
assert response.status_code == 200
payload = response.json()
assert payload["count"] == 1
assert payload["items"][0]["ip"] == "192.168.2.60"
def test_home_assistant_config_can_be_saved_and_mapping_created(monkeypatch) -> None:
with TestClient(app) as client:
config_response = client.put(
"/api/v1/integrations/home-assistant/config",
json={
"enabled": True,
"base_url": "http://ha.local:8123",
"token": "secret-token",
"default_universe": 10,
},
)
assert config_response.status_code == 200
assert config_response.json()["enabled"] is True
assert config_response.json()["base_url"] == "http://ha.local:8123"
assert config_response.json()["has_token"] is True
assert "token" not in config_response.json()
assert config_response.json()["token_mask"] == "********"
create_response = client.post(
"/api/v1/integrations/home-assistant/mappings",
json={
"name": "HA RGB",
"universe": 10,
"start_address": 1,
"fixture_type": "rgb",
"entity_id": "light.bar_rgb",
"rate_limit_hz": 5,
"deadband": 2,
"fade_ms": 150,
"invert_channel": False,
"min_value": 0,
"max_value": 255,
"enabled": True,
"master_dimmer": True,
},
)
assert create_response.status_code == 200
mapping = create_response.json()
assert mapping["universe"] == 10
assert mapping["channel_span"] == 4
async def fake_test_mapping(mapping_id: int) -> dict[str, object]:
return {"status": "sent", "mapping_id": mapping_id}
monkeypatch.setattr(app_state.home_assistant, "test_mapping", fake_test_mapping)
test_response = client.post(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}/test")
assert test_response.status_code == 200
assert test_response.json()["status"] == "sent"
delete_response = client.delete(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}")
assert delete_response.status_code == 200
def test_home_assistant_connection_endpoint_returns_status(monkeypatch) -> None:
async def fake_test_connection() -> dict[str, object]:
return {
"reachable": True,
"auth_ok": True,
"ha_version": "2026.7.0",
"last_error": None,
"last_successful_call_at": "2026-07-23T12:45:00+02:00",
"last_connection_success_at": "2026-07-23T12:45:00+02:00",
}
monkeypatch.setattr(app_state.home_assistant, "test_connection", fake_test_connection)
with TestClient(app) as client:
response = client.post("/api/v1/integrations/home-assistant/test-connection")
assert response.status_code == 200
payload = response.json()
assert payload["reachable"] is True
assert payload["auth_ok"] is True
assert payload["ha_version"] == "2026.7.0"
def test_bpm_config_can_be_saved_and_read_back() -> None:
with TestClient(app) as client:
original = client.get("/api/v1/bpm/config").json()["preferred_device"]
response = client.put(
"/api/v1/bpm/config",
json={"preferred_device": "alsa:plughw:CARD=SB,DEV=0"},
)
assert response.status_code == 200
assert response.json()["preferred_device"] == "alsa:plughw:CARD=SB,DEV=0"
status_response = client.get("/api/v1/bpm/status")
assert status_response.status_code == 200
assert status_response.json()["selected_device"] == "alsa:plughw:CARD=SB,DEV=0"
restore_response = client.put(
"/api/v1/bpm/config",
json={"preferred_device": original},
)
assert restore_response.status_code == 200
def test_restart_service_endpoint_returns_scheduler_result(monkeypatch) -> None:
async def fake_restart_service() -> dict[str, object]:
return {
"accepted": True,
"action": "restart-service",
"status": "scheduled",
"detail": "Servicegenstart planlagt.",
}
monkeypatch.setattr(app_state.system, "restart_service", fake_restart_service)
with TestClient(app) as client:
response = client.post("/api/v1/system/restart-service")
assert response.status_code == 200
assert response.json()["accepted"] is True
assert response.json()["action"] == "restart-service"
def test_reboot_host_endpoint_returns_scheduler_result(monkeypatch) -> None:
async def fake_reboot_host() -> dict[str, object]:
return {
"accepted": True,
"action": "reboot-host",
"status": "scheduled",
"detail": "Hostgenstart planlagt.",
}
monkeypatch.setattr(app_state.system, "reboot_host", fake_reboot_host)
with TestClient(app) as client:
response = client.post("/api/v1/system/reboot-host")
assert response.status_code == 200
assert response.json()["accepted"] is True
assert response.json()["action"] == "reboot-host"
def test_delete_scene_releases_active_layer() -> None:
with TestClient(app) as client:
app_state.scenes.active_slug = None
app_state.engine.layers.clear()
existing_scenes = client.get("/api/v1/scenes").json()["items"]
for scene in existing_scenes:
if scene["slug"] == "brr3-test":
client.delete(f"/api/v1/scenes/{scene['id']}")
create_response = client.post(
"/api/v1/scenes",
json={
"name": "BRR-3 test",
"slug": "brr3-test",
"priority": 10,
"values": [{"channel": 10, "value": 255, "precedence": "htp", "source": "scene"}],
},
)
assert create_response.status_code == 200
activate_response = client.post("/api/v1/scenes/brr3-test/activate")
assert activate_response.status_code == 200
assert "scene:brr3-test" in app_state.engine.layers
scene_id = create_response.json()["id"]
delete_response = client.delete(f"/api/v1/scenes/{scene_id}")
assert delete_response.status_code == 200
assert delete_response.json() == {"deleted": "brr3-test"}
assert "scene:brr3-test" not in app_state.engine.layers
assert app_state.scenes.active_slug is None
def test_imported_fixture_persists_and_can_be_patched() -> None:
universe = 101
unique_key = f"brr3-{uuid4().hex[:8]}"
payload = {
"manufacturer": "Eurolite",
"model": "BRR-3 Test",
"categories": ["Color Changer"],
"source": {"manufacturer_key": "eurolite", "fixture_key": unique_key},
"modes": [
{
"key": "6ch",
"channels": [
{"key": "Red", "display_name": "Red", "precedence": "ltp", "resolution": 8},
{"key": "Green", "display_name": "Green", "precedence": "ltp", "resolution": 8},
{"key": "Blue", "display_name": "Blue", "precedence": "ltp", "resolution": 8},
{"key": "White", "display_name": "White", "precedence": "ltp", "resolution": 8},
{"key": "Amber", "display_name": "Amber", "precedence": "ltp", "resolution": 8},
{"key": "UV", "display_name": "UV", "precedence": "ltp", "resolution": 8},
],
}
],
}
with TestClient(app) as client:
import_response = client.post("/api/v1/fixtures/import-file", json=payload)
assert import_response.status_code == 200
fixture = import_response.json()
fixture_id = fixture["id"]
assert fixture["manufacturer"] == "Eurolite"
assert fixture["modes"][0]["channel_count"] == 6
list_response = client.get("/api/v1/fixtures")
assert list_response.status_code == 200
assert any(item["id"] == fixture_id for item in list_response.json()["items"])
validate_response = client.post(
"/api/v1/patch/validate",
json={
"universe": universe,
"definition_id": fixture_id,
"mode_key": "6ch",
"start_address": 10,
},
)
assert validate_response.status_code == 200
assert validate_response.json()["valid"] is True
assert validate_response.json()["range"] == "10-15"
create_patch_response = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Eurolite venstre",
"definition_id": fixture_id,
"mode_key": "6ch",
"start_address": 10,
"enabled": True,
"group_names": ["front", "wash"],
"position": {"x": 20, "y": 35, "z": 8, "rotation": 15},
},
)
assert create_patch_response.status_code == 201
patch = create_patch_response.json()
assert patch["start_address"] == 10
assert patch["end_address"] == 15
assert patch["group_names"] == ["front", "wash"]
assert patch["position"]["x"] == 20
conflict_response = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Eurolite højre",
"definition_id": fixture_id,
"mode_key": "6ch",
"start_address": 12,
"enabled": True,
"group_names": [],
"position": {"x": 70, "y": 35, "z": 8, "rotation": 345},
},
)
assert conflict_response.status_code == 409
delete_patch_response = client.delete(f"/api/v1/patch/{patch['id']}")
assert delete_patch_response.status_code == 200
delete_fixture_response = client.delete(f"/api/v1/fixtures/{fixture_id}")
assert delete_fixture_response.status_code == 204
def test_import_ofl_style_fixture_file_supports_mode_channels() -> None:
payload = {
"$schema": "https://raw.githubusercontent.com/OpenLightingProject/open-fixture-library/master/schemas/fixture.json",
"manufacturer": "Eurolite",
"name": "LED Bar-3 HCL Bar",
"categories": ["Color Changer"],
"source": {
"manufacturer_key": "eurolite",
"fixture_key": f"led-bar-3-hcl-bar-{uuid4().hex[:8]}",
},
"availableChannels": {
"Dimmer": {"capability": {"type": "Intensity"}},
"Strobe": {
"capabilities": [
{"dmxRange": [0, 9], "type": "NoFunction"},
{"dmxRange": [10, 255], "type": "ShutterStrobe"},
]
},
"Red": {"capability": {"type": "ColorIntensity", "color": "Red"}},
"Green": {"capability": {"type": "ColorIntensity", "color": "Green"}},
"Blue": {"capability": {"type": "ColorIntensity", "color": "Blue"}},
"White": {"capability": {"type": "ColorIntensity", "color": "White"}},
"Amber": {"capability": {"type": "ColorIntensity", "color": "Amber"}},
"UV": {"capability": {"type": "ColorIntensity", "color": "UV"}},
},
"modes": [
{"name": "6ch", "channels": ["Red", "Green", "Blue", "White", "Amber", "UV"]},
{"name": "10ch", "channels": ["Dimmer", "Strobe", "Red", "Green", "Blue", "White", "Amber", "UV"]},
],
}
with TestClient(app) as client:
response = client.post("/api/v1/fixtures/import-file", json=payload)
assert response.status_code == 200
fixture = response.json()
assert fixture["manufacturer"] == "Eurolite"
assert fixture["model"] == "LED Bar-3 HCL Bar"
assert fixture["source"]["manufacturer_key"] == "eurolite"
assert fixture["modes"][0]["channels"][0]["key"] == "Red"
assert fixture["modes"][1]["channels"][0]["precedence"] == "htp"
assert fixture["modes"][1]["channels"][1]["capabilities"][1]["type"] == "ShutterStrobe"
delete_fixture_response = client.delete(f"/api/v1/fixtures/{fixture['id']}")
assert delete_fixture_response.status_code == 204
def test_live_mixer_returns_and_updates_patched_fixture() -> None:
universe = 102
unique_key = f"live-mixer-{uuid4().hex[:8]}"
payload = {
"manufacturer": "Eurolite",
"model": "LED Bar-3 HCL Bar",
"categories": ["Color Changer"],
"source": {"manufacturer_key": "eurolite", "fixture_key": unique_key},
"modes": [
{
"key": "10-channel",
"channels": [
{"key": "Dimmer", "display_name": "Dimmer", "precedence": "htp", "resolution": 8},
{"key": "Strobe", "display_name": "Strobe", "precedence": "ltp", "resolution": 8},
{"key": "Red", "display_name": "Red", "precedence": "ltp", "resolution": 8},
],
}
],
}
with TestClient(app) as client:
fixture = client.post("/api/v1/fixtures/import-file", json=payload).json()
patch = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Eurolite live",
"definition_id": fixture["id"],
"mode_key": "10-channel",
"start_address": 10,
"enabled": True,
"group_names": [],
"position": {"x": 40, "y": 50, "z": 5, "rotation": 0},
},
).json()
mixer_response = client.get("/api/v1/live/mixer")
assert mixer_response.status_code == 200
assert any(item["patch_id"] == patch["id"] for item in mixer_response.json()["items"])
update_response = client.put(
f"/api/v1/live/mixer/{patch['id']}",
json={"values": {"1": 200, "3": 128}},
)
assert update_response.status_code == 200
mixer_item = next(item for item in update_response.json()["items"] if item["patch_id"] == patch["id"])
dimmer = next(channel for channel in mixer_item["channels"] if channel["index"] == 1)
red = next(channel for channel in mixer_item["channels"] if channel["index"] == 3)
assert dimmer["value"] == 200
assert red["absolute_channel"] == 12
clear_response = client.delete(f"/api/v1/live/mixer/{patch['id']}")
assert clear_response.status_code == 200
assert client.delete(f"/api/v1/patch/{patch['id']}").status_code == 200
assert client.delete(f"/api/v1/fixtures/{fixture['id']}").status_code == 204
def test_live_mixer_returns_and_updates_home_assistant_mapping() -> None:
with TestClient(app) as client:
create_response = client.post(
"/api/v1/integrations/home-assistant/mappings",
json={
"name": "Loft bunker",
"universe": 10,
"start_address": 1,
"fixture_type": "switch",
"entity_id": "light.loft_bunker",
"rate_limit_hz": 5,
"deadband": 2,
"fade_ms": 0,
"invert_channel": False,
"min_value": 0,
"max_value": 255,
"enabled": True,
"master_dimmer": False,
},
)
assert create_response.status_code == 200
mapping = create_response.json()
mixer_response = client.get("/api/v1/live/mixer")
assert mixer_response.status_code == 200
mixer_item = next(
item
for item in mixer_response.json()["items"]
if item["source_type"] == "home_assistant" and item["source_id"] == mapping["id"]
)
assert mixer_item["entity_id"] == "light.loft_bunker"
assert mixer_item["universe"] == 10
assert mixer_item["channel_count"] == 1
update_response = client.put(
f"/api/v1/live/mixer/home-assistant/{mapping['id']}",
json={"values": {"1": 255}},
)
assert update_response.status_code == 200
updated_item = next(
item
for item in update_response.json()["items"]
if item["source_type"] == "home_assistant" and item["source_id"] == mapping["id"]
)
assert updated_item["channels"][0]["value"] == 255
assert updated_item["channels"][0]["absolute_channel"] == 1
clear_response = client.delete(f"/api/v1/live/mixer/home-assistant/{mapping['id']}")
assert clear_response.status_code == 200
delete_response = client.delete(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}")
assert delete_response.status_code == 200
def test_scene_group_target_resolves_fixture_channels() -> None:
universe = 103
unique_key = f"scene-group-{uuid4().hex[:8]}"
payload = {
"manufacturer": "Eurolite",
"model": "LED Bar-3 HCL Bar",
"categories": ["Color Changer"],
"source": {"manufacturer_key": "eurolite", "fixture_key": unique_key},
"modes": [
{
"key": "10-channel",
"channels": [
{"key": "Dimmer", "display_name": "Dimmer", "precedence": "htp", "resolution": 8},
{"key": "Strobe", "display_name": "Strobe", "precedence": "ltp", "resolution": 8},
{"key": "Blue", "display_name": "Blue", "precedence": "ltp", "resolution": 8},
],
}
],
}
with TestClient(app) as client:
fixture = client.post("/api/v1/fixtures/import-file", json=payload).json()
patch_left = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Bar venstre",
"definition_id": fixture["id"],
"mode_key": "10-channel",
"start_address": 1,
"enabled": True,
"group_names": ["synk-a", "front"],
"position": {"x": 20, "y": 30, "z": 5, "rotation": 10},
},
).json()
patch_right = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Bar højre",
"definition_id": fixture["id"],
"mode_key": "10-channel",
"start_address": 11,
"enabled": True,
"group_names": ["synk-a", "front"],
"position": {"x": 80, "y": 30, "z": 5, "rotation": 350},
},
).json()
scene_response = client.post(
"/api/v1/scenes",
json={
"name": "Front blue",
"slug": "front-blue",
"priority": 20,
"values": [],
"targets": [
{
"target_type": "group",
"group_name": "synk-a",
"values": [
{"attribute": "Dimmer", "value": 200},
{"attribute": "Blue", "value": 128},
],
}
],
},
)
assert scene_response.status_code == 200
activate_response = client.post("/api/v1/scenes/front-blue/activate")
assert activate_response.status_code == 200
layer = app_state.engine.layers["scene:front-blue"]
assert layer.values_by_universe[universe][1] == 200
assert layer.values_by_universe[universe][3] == 128
assert layer.values_by_universe[universe][11] == 200
assert layer.values_by_universe[universe][13] == 128
scene_id = scene_response.json()["id"]
assert client.delete(f"/api/v1/scenes/{scene_id}").status_code == 200
assert client.delete(f"/api/v1/patch/{patch_left['id']}").status_code == 200
assert client.delete(f"/api/v1/patch/{patch_right['id']}").status_code == 200
assert client.delete(f"/api/v1/fixtures/{fixture['id']}").status_code == 204
+118
View File
@@ -0,0 +1,118 @@
import struct
import pytest
from app.dmx.backends import (
ARTNET_HEADER,
ARTNET_OPCODE_DMX,
ARTNET_OPCODE_POLL_REPLY,
ARTNET_PORT,
ArtNetDmxBackend,
build_artnet_dmx_packet,
parse_artnet_poll_reply,
)
from app.dmx.frame import DmxFrame
def test_build_artnet_packet_sends_full_512_frame_to_expected_universe() -> None:
values = [0] * 512
values[0] = 255
values[9] = 64
packet = build_artnet_dmx_packet(2, values, sequence=9)
assert packet.startswith(ARTNET_HEADER)
assert struct.unpack_from("<H", packet, 8)[0] == ARTNET_OPCODE_DMX
assert packet[12] == 9
assert packet[14] == 1
assert packet[15] == 0
assert struct.unpack_from(">H", packet, 16)[0] == 512
assert len(packet) == 530
assert packet[18] == 255
assert packet[27] == 64
def test_parse_artnet_poll_reply_extracts_node_identity() -> None:
packet = bytearray(239)
packet[0:8] = ARTNET_HEADER
struct.pack_into("<H", packet, 8, ARTNET_OPCODE_POLL_REPLY)
packet[10:14] = bytes([192, 168, 2, 55])
packet[18] = 0
packet[19] = 1
packet[26 : 26 + len(b"WLED Node\x00")] = b"WLED Node\x00"
packet[44 : 44 + len(b"Paravega Test Node\x00")] = b"Paravega Test Node\x00"
struct.pack_into(">H", packet, 172, 4)
packet[190] = 3
node = parse_artnet_poll_reply(bytes(packet))
assert node is not None
assert node.ip == "192.168.2.55"
assert node.short_name == "WLED Node"
assert node.long_name == "Paravega Test Node"
assert node.port_count == 4
assert node.raw_port_address == 3
assert node.label == "Paravega Test Node (192.168.2.55)"
@pytest.mark.asyncio
async def test_artnet_backend_sends_udp_packet_and_updates_status(monkeypatch) -> None:
sent_packets: list[tuple[bytes, tuple[str, int]]] = []
class FakeSocket:
def setsockopt(self, *_args: object) -> None:
return
def sendto(self, packet: bytes, address: tuple[str, int]) -> None:
sent_packets.append((packet, address))
def close(self) -> None:
return
backend = ArtNetDmxBackend(
universe=4,
target_host="192.168.2.77",
output_port="Paravega stue",
)
monkeypatch.setattr(backend, "_socket", FakeSocket())
frame = DmxFrame(universe=4)
frame.set_channel(1, 200, "scene")
frame.set_channel(4, 99, "scene")
await backend.send_frame(frame)
status = backend.get_status()
assert status.connected is True
assert status.degraded is False
assert status.frames_sent == 1
assert status.send_errors == 0
assert status.selected_universe == 4
assert status.selected_output_port == "Paravega stue"
assert sent_packets[0][1] == ("192.168.2.77", ARTNET_PORT)
assert sent_packets[0][0][18] == 200
assert sent_packets[0][0][21] == 99
@pytest.mark.asyncio
async def test_artnet_backend_marks_degraded_on_socket_error(monkeypatch) -> None:
class FakeSocket:
def setsockopt(self, *_args: object) -> None:
return
def sendto(self, _packet: bytes, _address: tuple[str, int]) -> None:
raise OSError("Network unreachable")
def close(self) -> None:
return
backend = ArtNetDmxBackend(universe=1, target_host="192.168.2.90")
monkeypatch.setattr(backend, "_socket", FakeSocket())
frame = DmxFrame()
frame.set_channel(1, 255, "scene")
with pytest.raises(RuntimeError, match="Network unreachable"):
await backend.send_frame(frame)
status = backend.get_status()
assert status.connected is False
assert status.degraded is True
assert status.send_errors == 1
assert status.last_error == "Network unreachable"
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
from app.backup.service import BackupService
def test_backup_and_restore_roundtrip(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
backup_dir = data_dir / "backups"
data_dir.mkdir(parents=True)
sample_file = data_dir / "settings.json"
sample_file.write_text('{"scene":"base"}', encoding="utf-8")
service = BackupService(data_dir=data_dir, backup_dir=backup_dir)
created = service.create_backup("audit-backup")
sample_file.write_text('{"scene":"modified"}', encoding="utf-8")
service.restore_backup(created.id)
assert sample_file.read_text(encoding="utf-8") == '{"scene":"base"}'
assert any(item.label == "audit-backup" for item in service.list_backups())
def test_restore_empty_backup_clears_runtime_files(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
backup_dir = data_dir / "backups"
data_dir.mkdir(parents=True)
service = BackupService(data_dir=data_dir, backup_dir=backup_dir)
empty_backup = service.create_backup("empty-runtime")
sample_file = data_dir / "settings.json"
sample_file.write_text('{"scene":"modified"}', encoding="utf-8")
service.restore_backup(empty_backup.id)
assert not sample_file.exists()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import asyncio
from app.bpm.service import BeatAnalyzer, BpmService
def test_beat_analyzer_estimates_click_track_bpm() -> None:
analyzer = BeatAnalyzer(sample_rate=44_100, window_size=1024)
click = (b"\x00\x40" * 2048) + (b"\x00\x00" * (44_100 // 2 - 2048))
result = None
for _ in range(6):
result = analyzer.feed_pcm16(click)
assert result is not None
bpm, confidence = result
assert 117 <= bpm <= 124
assert confidence > 0.5
def test_bpm_service_synthetic_audio_updates_snapshot() -> None:
async def run_test() -> None:
service = BpmService()
await service.start_audio("synthetic-click-track")
await asyncio.sleep(1.2)
snapshot = service.snapshot()
await service.stop_audio()
assert snapshot["mode"] == "audio:synthetic-click-track"
assert snapshot["audio_connected"] is True
assert 117 <= float(snapshot["bpm"]) <= 123
assert float(snapshot["confidence"]) > 0.3
assert float(snapshot["input_level"]) > 0.0
assert float(snapshot["peak_level"]) >= float(snapshot["input_level"])
assert snapshot["clipping"] is False
asyncio.run(run_test())
def test_parse_alsa_card_line_supports_named_aliases() -> None:
service = BpmService()
card = service._parse_card_line("card 1: SB [HDA ATI SB], device 0: ALC883 Analog [ALC883 Analog]")
device = service._parse_device_from_card_line(
"card 1: SB [HDA ATI SB], device 0: ALC883 Analog [ALC883 Analog]"
)
assert card == ("1", "SB", "HDA ATI SB")
assert device == ("0", "ALC883 Analog", "ALC883 Analog")
def test_busy_alsa_error_is_humanized() -> None:
service = BpmService()
message = service._humanize_alsa_error(
"alsa:plughw:CARD=SB,DEV=0",
"arecord: main:831: audio open error: Device or resource busy",
)
assert "Mikrofonen er optaget" in message
+39
View File
@@ -0,0 +1,39 @@
from app.dmx.frame import DmxFrame, FrameLayer, merge_layers
def test_frame_has_512_channels() -> None:
frame = DmxFrame()
assert len(frame.values) == 512
assert all(value == 0 for value in frame.values)
def test_htp_chooses_highest_value() -> None:
base = FrameLayer.from_channel_values("base", priority=10, values={1: 80}, precedence_map={1: "htp"})
overlay = FrameLayer.from_channel_values("overlay", priority=20, values={1: 200}, precedence_map={1: "htp"})
frames = merge_layers([base, overlay])
assert frames[1].values[0] == 200
assert frames[1].source_map[0] == "overlay"
def test_ltp_chooses_latest_priority_layer() -> None:
base = FrameLayer.from_channel_values("base", priority=10, values={2: 200}, precedence_map={2: "ltp"})
overlay = FrameLayer.from_channel_values("overlay", priority=20, values={2: 15}, precedence_map={2: "ltp"})
frames = merge_layers([base, overlay])
assert frames[1].values[1] == 15
assert frames[1].source_map[1] == "overlay"
def test_blackout_overrides_layers() -> None:
layer = FrameLayer.from_channel_values("scene", priority=10, values={1: 255, 2: 128}, precedence_map={1: "htp"})
frames = merge_layers([layer], blackout=True)
assert frames[1].values[0] == 0
assert frames[1].values[1] == 0
def test_layers_are_merged_per_universe() -> None:
front = FrameLayer.from_channel_values("front", priority=10, values={1: 255}, universe=1)
home_assistant = FrameLayer.from_channel_values("ha", priority=10, values={1: 180, 2: 90}, universe=10)
frames = merge_layers([front, home_assistant])
assert frames[1].values[0] == 255
assert frames[10].values[0] == 180
assert frames[10].values[1] == 90
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import asyncio
from app.effects.service import EffectService
from app.models.schemas import EffectPayload
class FakeEngine:
def __init__(self) -> None:
self.layers: dict[str, object] = {}
def set_layer(self, layer) -> None: # type: ignore[no-untyped-def]
self.layers[layer.name] = layer
def remove_layer(self, name: str) -> None:
self.layers.pop(name, None)
class FakeBpm:
def __init__(self) -> None:
self.current_bpm = 180.0
self.audio_connected = False
self.beat_counter = 0
def test_beat_flash_effect_pulses_layer_from_bpm_clock() -> None:
async def run_test() -> None:
engine = FakeEngine()
bpm = FakeBpm()
service = EffectService(engine, bpm) # type: ignore[arg-type]
payload = EffectPayload(
name="Beat dimmer",
slug="beat-dimmer",
effect_type="beat-flash",
priority=60,
parameters={
"duration_ms": 90,
"channels": {"1": 255},
"precedence": {"1": "htp"},
},
)
service.save(payload)
service.trigger(payload.slug)
await asyncio.sleep(0.03)
assert "effect:beat-dimmer" in engine.layers
await asyncio.sleep(0.16)
assert "effect:beat-dimmer" not in engine.layers
service.stop(payload.slug)
await service.shutdown()
asyncio.run(run_test())
@@ -0,0 +1,682 @@
from __future__ import annotations
import asyncio
import httpx
import pytest
from app.dmx.engine import DmxEngine
from app.dmx.frame import DmxFrame
from app.homeassistant.service import HomeAssistantService
from app.telemetry.service import TelemetryService
class FakeHomeAssistantAdapter:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, dict[str, object]]] = []
self.list_payload: list[dict[str, object]] = [
{
"entity_id": "light.bar_rgb",
"state": "on",
"attributes": {
"friendly_name": "Bar RGB",
"supported_color_modes": ["rgb", "brightness"],
},
},
{
"entity_id": "light.bar_rgbw",
"state": "on",
"attributes": {
"friendly_name": "Bar RGBW",
"supported_color_modes": ["rgbw", "brightness"],
},
},
{
"entity_id": "light.bar_cct",
"state": "on",
"attributes": {
"friendly_name": "Bar CCT",
"supported_color_modes": ["color_temp", "brightness"],
},
},
{
"entity_id": "light.test",
"state": "off",
"attributes": {
"friendly_name": "Test light",
"supported_color_modes": ["brightness"],
},
},
{
"entity_id": "switch.test",
"state": "off",
"attributes": {
"friendly_name": "Test switch",
},
},
{
"entity_id": "input_boolean.test",
"state": "off",
"attributes": {
"friendly_name": "Test bool",
},
},
{
"entity_id": "scene.test",
"state": "scening",
"attributes": {
"friendly_name": "Test scene",
},
},
{
"entity_id": "automation.test",
"state": "on",
"attributes": {
"friendly_name": "Test automation",
},
},
]
self.connection_error: Exception | None = None
self.dispatch_error: Exception | None = None
async def get_api_root(self, base_url: str, token: str) -> dict[str, object]:
if self.connection_error is not None:
raise self.connection_error
return {"message": "API running."}
async def get_config(self, base_url: str, token: str) -> dict[str, object]:
if self.connection_error is not None:
raise self.connection_error
return {"version": "2026.7.0"}
async def list_entities(self, base_url: str, token: str) -> list[dict[str, object]]:
if self.connection_error is not None:
raise self.connection_error
return self.list_payload
async def call_service(
self,
base_url: str,
token: str,
domain: str,
service: str,
data: dict[str, object],
) -> dict[str, object]:
if self.dispatch_error is not None:
raise self.dispatch_error
self.calls.append((base_url, token, f"{domain}.{service}", data))
return {"ok": True}
def build_service(adapter: FakeHomeAssistantAdapter) -> HomeAssistantService:
engine = DmxEngine(TelemetryService())
service = HomeAssistantService(engine, adapter=adapter)
service._config = {
"enabled": True,
"base_url": "http://ha.local:8123",
"default_universe": 10,
}
service._token = "test-token"
return service
@pytest.mark.asyncio
async def test_home_assistant_connection_reports_version_and_auth() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
result = await service.test_connection()
assert result["reachable"] is True
assert result["auth_ok"] is True
assert result["ha_version"] == "2026.7.0"
config = await service.get_config()
assert config["has_token"] is True
assert config["token_mask"] == "********"
@pytest.mark.asyncio
async def test_home_assistant_rgb_mapping_reads_virtual_universe_10() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 255, "scene")
frame.set_channel(2, 200, "scene")
frame.set_channel(3, 100, "scene")
frame.set_channel(4, 50, "scene")
service.engine.current_frames = {1: DmxFrame(universe=1), 10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 1,
"name": "Bar RGB",
"universe": 10,
"start_address": 1,
"fixture_type": "rgb",
"entity_id": "light.bar_rgb",
"rate_limit_hz": 5,
"deadband": 1,
"fade_ms": 150,
"enabled": True,
"master_dimmer": True,
}
)
]
await service.dispatch_once()
assert len(adapter.calls) == 1
_base_url, _token, service_name, data = adapter.calls[0]
assert service_name == "light.turn_on"
assert data["entity_id"] == "light.bar_rgb"
assert data["brightness"] == 255
assert data["rgb_color"] == [200, 100, 50]
@pytest.mark.asyncio
async def test_home_assistant_rgbw_mapping_at_channel_512_uses_last_channel() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(508, 255, "scene")
frame.set_channel(509, 10, "scene")
frame.set_channel(510, 20, "scene")
frame.set_channel(511, 30, "scene")
frame.set_channel(512, 40, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 2,
"name": "Bar RGBW",
"universe": 10,
"start_address": 508,
"fixture_type": "rgbw",
"entity_id": "light.bar_rgbw",
"rate_limit_hz": 5,
"deadband": 1,
"fade_ms": 100,
"enabled": True,
"master_dimmer": True,
}
)
]
await service.dispatch_once()
assert adapter.calls[0][2] == "light.turn_on"
assert adapter.calls[0][3]["rgbw_color"] == [10, 20, 30, 40]
@pytest.mark.asyncio
async def test_home_assistant_rate_limit_prevents_duplicate_spam() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 180, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 3,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"rate_limit_hz": 5,
"deadband": 2,
"fade_ms": 0,
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
await service.dispatch_once()
assert len(adapter.calls) == 1
@pytest.mark.asyncio
async def test_scene_mapping_uses_rising_edge_only() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 4,
"name": "Party scene",
"universe": 10,
"start_address": 6,
"fixture_type": "scene",
"entity_id": "scene.party_mode",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
frame.set_channel(6, 140, "scene")
await service.dispatch_once()
await service.dispatch_once()
assert len(adapter.calls) == 1
assert adapter.calls[0][2] == "scene.turn_on"
@pytest.mark.asyncio
async def test_switch_mapping_uses_hysteresis() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 5,
"name": "Switch",
"universe": 10,
"start_address": 50,
"fixture_type": "switch",
"entity_id": "switch.party_relay",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(50, 150, "scene")
await service.dispatch_once()
frame.set_channel(50, 130, "scene")
await service.dispatch_once()
frame.set_channel(50, 110, "scene")
await service.dispatch_once()
assert [call[2] for call in adapter.calls] == ["switch.turn_on", "switch.turn_off"]
@pytest.mark.asyncio
async def test_switch_fixture_routes_light_entity_by_entity_domain() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 10,
"name": "Light as switch",
"universe": 10,
"start_address": 20,
"fixture_type": "switch",
"entity_id": "light.test",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(20, 150, "scene")
await service.dispatch_once()
frame.set_channel(20, 110, "scene")
await service.dispatch_once()
mapping = (await service.list_mappings())["items"][0]
assert [call[2] for call in adapter.calls] == ["light.turn_on", "light.turn_off"]
assert mapping["last_sent_summary"] == "light.turn_off light.test"
@pytest.mark.asyncio
async def test_switch_fixture_routes_switch_entity_by_entity_domain() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 11,
"name": "Switch domain",
"universe": 10,
"start_address": 21,
"fixture_type": "switch",
"entity_id": "switch.test",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(21, 150, "scene")
await service.dispatch_once()
frame.set_channel(21, 110, "scene")
await service.dispatch_once()
assert [call[2] for call in adapter.calls] == ["switch.turn_on", "switch.turn_off"]
@pytest.mark.asyncio
async def test_dimmer_fixture_routes_light_entity_with_brightness() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(22, 180, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 12,
"name": "Dimmer light",
"universe": 10,
"start_address": 22,
"fixture_type": "dimmer",
"entity_id": "light.test",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
assert adapter.calls[0][2] == "light.turn_on"
assert adapter.calls[0][3]["brightness"] == 180
@pytest.mark.asyncio
async def test_scene_fixture_routes_scene_entity() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 13,
"name": "Scene test",
"universe": 10,
"start_address": 23,
"fixture_type": "scene",
"entity_id": "scene.test",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(23, 200, "scene")
await service.dispatch_once()
assert adapter.calls[0][2] == "scene.turn_on"
@pytest.mark.asyncio
async def test_automation_fixture_routes_automation_entity() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 14,
"name": "Automation test",
"universe": 10,
"start_address": 24,
"fixture_type": "automation",
"entity_id": "automation.test",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(24, 200, "scene")
await service.dispatch_once()
assert adapter.calls[0][2] == "automation.trigger"
@pytest.mark.asyncio
async def test_test_mapping_uses_same_entity_domain_routing() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 15,
"name": "Test mapping light switch",
"universe": 10,
"start_address": 25,
"fixture_type": "switch",
"entity_id": "light.test",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.test_mapping(15)
assert adapter.calls[0][2] == "light.turn_on"
@pytest.mark.asyncio
async def test_test_mapping_resyncs_back_to_live_dmx() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 120, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 6,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.test_mapping(6)
await service.dispatch_once()
assert adapter.calls[0][3]["brightness"] == 255
assert adapter.calls[-1][3]["brightness"] == 120
@pytest.mark.asyncio
async def test_timeout_error_is_tracked_without_blocking() -> None:
adapter = FakeHomeAssistantAdapter()
adapter.dispatch_error = httpx.ReadTimeout("timeout")
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 180, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 7,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
mapping = (await service.list_mappings())["items"][0]
assert mapping["last_error"] == "Timeout ved kald til Home Assistant"
@pytest.mark.asyncio
async def test_401_is_reported_from_connection_test() -> None:
adapter = FakeHomeAssistantAdapter()
request = httpx.Request("GET", "http://ha.local:8123/api/")
response = httpx.Response(401, request=request)
adapter.connection_error = httpx.HTTPStatusError("401", request=request, response=response)
service = build_service(adapter)
result = await service.test_connection()
assert result["reachable"] is True
assert result["auth_ok"] is False
assert result["last_error"] == "HTTP 401 fra Home Assistant"
@pytest.mark.asyncio
async def test_404_entity_not_found_is_tracked() -> None:
adapter = FakeHomeAssistantAdapter()
request = httpx.Request("POST", "http://ha.local:8123/api/services/light/turn_on")
response = httpx.Response(404, request=request)
adapter.dispatch_error = httpx.HTTPStatusError("404", request=request, response=response)
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 180, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 8,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
mapping = (await service.list_mappings())["items"][0]
assert mapping["last_error"] == "HTTP 404 fra Home Assistant"
@pytest.mark.asyncio
async def test_home_assistant_unavailable_is_reported() -> None:
adapter = FakeHomeAssistantAdapter()
adapter.connection_error = httpx.ConnectError("offline")
service = build_service(adapter)
result = await service.test_connection()
assert result["reachable"] is False
assert result["auth_ok"] is False
assert result["last_error"] == "Home Assistant kunne ikke kontaktes"
@pytest.mark.asyncio
async def test_persistence_survives_service_reload() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
await service.save_config(
{
"enabled": True,
"base_url": "http://ha.local:8123",
"token": "persistent-token",
"default_universe": 10,
}
)
await service.create_mapping(
{
"name": "Persisted RGB",
"universe": 10,
"start_address": 1,
"fixture_type": "rgb",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": True,
}
)
reloaded = HomeAssistantService(service.engine, adapter=adapter)
await reloaded._load()
config = await reloaded.get_config()
mappings = await reloaded.list_mappings()
assert config["base_url"] == "http://ha.local:8123"
assert config["has_token"] is True
assert mappings["items"][0]["entity_id"] == "light.bar_rgb"
@pytest.mark.asyncio
async def test_slow_dispatch_drops_intermediate_frames_and_keeps_latest() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 50, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 9,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": False,
"deadband": 1,
"rate_limit_hz": 30,
}
)
]
first_call = asyncio.Event()
release_call = asyncio.Event()
async def slow_call(
base_url: str,
token: str,
domain: str,
service_name: str,
data: dict[str, object],
) -> dict[str, object]:
adapter.calls.append((base_url, token, f"{domain}.{service_name}", data))
first_call.set()
await release_call.wait()
return {"ok": True}
adapter.call_service = slow_call # type: ignore[method-assign]
dispatch_task = asyncio.create_task(service._dispatch_enabled_mappings())
await first_call.wait()
frame.set_channel(1, 120, "scene")
await service._dispatch_enabled_mappings()
frame.set_channel(1, 220, "scene")
await service._dispatch_enabled_mappings()
release_call.set()
await dispatch_task
await service._drain_mapping_tasks()
assert adapter.calls[0][3]["brightness"] == 50
assert adapter.calls[-1][3]["brightness"] == 220
assert len(adapter.calls) == 2
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS_DIR = ROOT / "scripts"
def read_script(name: str) -> str:
return (SCRIPTS_DIR / name).read_text(encoding="utf-8")
def test_install_linux_script_exists_and_supports_required_flags() -> None:
script = read_script("install-linux.sh")
assert 'parse_common_args "$@"' in script
assert "verify_debian_13_amd64" in script
assert 'run_install_flow "Debian 13 amd64"' in script
def test_update_linux_script_exists_and_uses_common_arg_parser() -> None:
script = read_script("update-linux.sh")
assert 'parse_common_args "$@"' in script
assert "verify_debian_13_amd64" in script
assert 'run_update_flow "Debian 13 amd64"' in script
def test_common_install_library_contains_required_linux_behaviour() -> None:
script = read_script("lib/install-common.sh")
assert "python3-venv" in script
assert "sqlite3" in script
assert "sudo" in script
assert "nodejs" in script
assert "npm" in script
assert "python3-ola" in script
assert "ola-python" in script
assert "olad" in script
assert 'PNPM_REQUIRED_VERSION="10.28.2"' in script
assert "--system-site-packages" in script
assert "/opt/tuxdmx.new" in script
assert "/opt/tuxdmx" in script
assert "/var/lib/tuxdmx" in script
assert "/var/log/tuxdmx" in script
assert "/etc/tuxdmx" in script
assert "dialout,plugdev,audio" in script
assert "frontend/dist/index.html" in script
assert "pip\" check" in script
assert "apt-listchanges" in script
assert "debconf" in script
assert "Ignorerer kendt Debian pip check-advarsel" in script
assert "import greenlet" in script
assert "import sqlalchemy" in script
assert "import ola" in script
assert "backend/app/main.py" in script
assert "systemctl status \"$SYSTEMD_UNIT_NAME\" --no-pager --full" in script
assert "journalctl -u \"$SYSTEMD_UNIT_NAME\" -n 200 --no-pager" in script
assert "update-rc.d" in script
assert "systemctl is-enabled \"$OLA_SYSTEMD_UNIT\"" in script
assert "service \"$OLA_SERVICE_NAME\" restart" in script
assert "--warning=no-file-changed" in script
assert "--exclude=\"./tuxdmx.json.log\"" in script
assert "Kunne ikke oprette backup af datamappe." in script
assert "http://127.0.0.1:8000/api/v1/health" in script
assert "/etc/sudoers.d/tuxdmx-control" in script
assert "control-system.sh restart-service" in script
assert "control-system.sh reboot-host" in script
assert "restore_rollbacks" in script
def test_pi_and_linux_installers_share_common_library() -> None:
for name in (
"install-pi.sh",
"install-linux.sh",
"update-pi.sh",
"update-linux.sh",
):
script = read_script(name)
assert 'source "$SCRIPT_DIR/lib/install-common.sh"' in script
def test_systemd_unit_contains_required_production_fields() -> None:
unit = (ROOT / "systemd" / "tuxdmx.service").read_text(encoding="utf-8")
assert "WorkingDirectory=/opt/tuxdmx" in unit
assert "After=network-online.target olad.service" in unit
assert "Wants=network-online.target olad.service" in unit
assert "Restart=on-failure" in unit
assert "UMask=0027" in unit
def test_alembic_ini_uses_path_relative_to_config_file() -> None:
alembic_ini = (ROOT / "backend" / "alembic.ini").read_text(encoding="utf-8")
assert "script_location = %(here)s/alembic" in alembic_ini
def test_package_manager_and_requirements_are_locked() -> None:
package_json = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
assert package_json["packageManager"] == "pnpm@10.28.2"
assert package_json["engines"]["node"] == "20.x"
assert package_json["engines"]["pnpm"] == "10.28.2"
requirements = (ROOT / "requirements.lock").read_text(encoding="utf-8")
assert "greenlet==" in requirements
assert "sqlalchemy==" in requirements
def test_shell_scripts_parse_with_bash_when_available() -> None:
bash = shutil.which("bash")
if bash is None:
return
if "system32\\bash.exe" in bash.lower():
return
for script_name in (
"scripts/install-pi.sh",
"scripts/update-pi.sh",
"scripts/install-linux.sh",
"scripts/update-linux.sh",
"scripts/uninstall-linux.sh",
"scripts/lib/install-common.sh",
):
subprocess.run(
[bash, "-n", str(ROOT / script_name)],
check=True,
cwd=ROOT,
)
def test_shellcheck_when_available() -> None:
shellcheck = shutil.which("shellcheck")
if shellcheck is None:
return
subprocess.run(
[
shellcheck,
str(ROOT / "scripts/install-pi.sh"),
str(ROOT / "scripts/update-pi.sh"),
str(ROOT / "scripts/install-linux.sh"),
str(ROOT / "scripts/update-linux.sh"),
str(ROOT / "scripts/lib/install-common.sh"),
],
check=True,
cwd=ROOT,
)
def test_pip_check_and_greenlet_import() -> None:
subprocess.run([sys.executable, "-m", "pip", "check"], check=True, cwd=ROOT)
subprocess.run(
[sys.executable, "-c", "import greenlet; print(greenlet.__version__)"],
check=True,
cwd=ROOT,
)
+322
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
def test_alembic_upgrade_from_empty_database(tmp_path: Path) -> None:
database_path = tmp_path / "audit-migrations.db"
env = os.environ.copy()
env["TUXDMX_DATABASE_URL"] = f"sqlite:///{database_path.as_posix()}"
result = subprocess.run(
[
sys.executable,
"-m",
"alembic",
"-c",
"backend/alembic.ini",
"upgrade",
"head",
],
cwd=Path(__file__).resolve().parents[2],
env=env,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
with sqlite3.connect(database_path) as connection:
tables = {
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
assert "alembic_version" in tables
assert "users" in tables
assert "fixture_sources" in tables
assert "fixture_definitions" in tables
assert "fixture_instances" in tables
assert "midi_bridges" in tables
assert "midi_bridge_tokens" in tables
assert "midi_mappings" in tables
+37
View File
@@ -0,0 +1,37 @@
import asyncio
import httpx
from app.fixtures.ofl import OflClient
def test_fetch_fixture_uses_json_endpoint(monkeypatch) -> None:
requested_urls: list[str] = []
class FakeResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, object]:
return {"name": "LED Bar-3 HCL Bar", "modes": []}
class FakeAsyncClient:
def __init__(self, *args, **kwargs) -> None:
pass
async def __aenter__(self) -> "FakeAsyncClient":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def get(self, url: str) -> FakeResponse:
requested_urls.append(url)
return FakeResponse()
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
payload = asyncio.run(OflClient().fetch_fixture("eurolite", "led-bar-3-hcl-bar"))
assert payload["name"] == "LED Bar-3 HCL Bar"
assert requested_urls == ["https://open-fixture-library.org/eurolite/led-bar-3-hcl-bar.json"]
+141
View File
@@ -0,0 +1,141 @@
import pytest
from app.dmx.backends import FakeOlaClientAdapter, OlaDmxBackend, PythonOlaClientAdapter
from app.dmx.frame import DmxFrame
@pytest.mark.asyncio
async def test_ola_backend_sends_full_frame_and_updates_status() -> None:
adapter = FakeOlaClientAdapter()
backend = OlaDmxBackend(
universe=7,
output_port="usb-dmx-1",
adapter_factory=lambda: adapter,
)
frame = DmxFrame(universe=7)
frame.set_channel(1, 255, "scene")
frame.set_channel(512, 64, "scene")
await backend.send_frame(frame)
status = backend.get_status()
assert status.connected is True
assert status.degraded is False
assert status.frames_sent == 1
assert status.send_errors == 0
assert status.last_successful_frame is not None
assert status.selected_universe == 7
assert status.selected_output_port == "usb-dmx-1"
assert adapter.open_calls == 1
assert len(adapter.sent_frames) == 1
sent_universe, values = adapter.sent_frames[0]
assert sent_universe == 7
assert len(values) == 512
assert values[0] == 255
assert values[511] == 64
@pytest.mark.asyncio
async def test_ola_backend_reconnects_after_send_failure() -> None:
created_adapters: list[FakeOlaClientAdapter] = []
def factory() -> FakeOlaClientAdapter:
adapter = FakeOlaClientAdapter(
send_results=[RuntimeError("olad unavailable")] if not created_adapters else [],
)
created_adapters.append(adapter)
return adapter
backend = OlaDmxBackend(
universe=1,
output_port="usb-dmx-1",
adapter_factory=factory,
)
frame = DmxFrame()
frame.set_channel(1, 128, "scene")
with pytest.raises(RuntimeError, match="olad unavailable"):
await backend.send_frame(frame)
failed_status = backend.get_status()
assert failed_status.connected is False
assert failed_status.degraded is True
assert failed_status.send_errors == 1
assert failed_status.frames_sent == 0
await backend.send_frame(frame)
recovered_status = backend.get_status()
assert recovered_status.connected is True
assert recovered_status.degraded is False
assert recovered_status.reconnect_count == 1
assert recovered_status.frames_sent == 1
assert recovered_status.send_errors == 1
assert len(created_adapters) == 2
assert created_adapters[0].close_calls == 1
assert created_adapters[1].sent_frames[0][1][0] == 128
@pytest.mark.asyncio
async def test_ola_backend_does_not_send_random_black_frame_on_reconnect() -> None:
created_adapters: list[FakeOlaClientAdapter] = []
def factory() -> FakeOlaClientAdapter:
adapter = FakeOlaClientAdapter(
send_results=[RuntimeError("send failed")] if not created_adapters else [],
)
created_adapters.append(adapter)
return adapter
backend = OlaDmxBackend(
universe=3,
output_port="usb-dmx-2",
adapter_factory=factory,
)
failed_frame = DmxFrame(universe=3)
failed_frame.set_channel(1, 10, "effect")
recovered_frame = DmxFrame(universe=3)
recovered_frame.set_channel(1, 200, "effect")
recovered_frame.set_channel(2, 150, "effect")
with pytest.raises(RuntimeError, match="send failed"):
await backend.send_frame(failed_frame)
await backend.send_frame(recovered_frame)
assert created_adapters[0].sent_frames[0][1][0] == 10
assert created_adapters[1].sent_frames[0][0] == 3
assert created_adapters[1].sent_frames[0][1] == recovered_frame.values
assert any(value != 0 for value in created_adapters[1].sent_frames[0][1])
def test_python_ola_adapter_uses_buffer_with_tobytes() -> None:
captured: dict[str, object] = {}
class FakeClient:
def SendDmx(self, universe: int, payload: object, callback: object) -> None:
captured["universe"] = universe
captured["payload_type"] = type(payload).__name__
captured["has_tobytes"] = hasattr(payload, "tobytes")
callback(True)
class FakeWrapper:
def __init__(self) -> None:
self._client = FakeClient()
def Client(self) -> FakeClient:
return self._client
def Run(self) -> None:
return
def Stop(self) -> None:
return
adapter = PythonOlaClientAdapter(timeout_s=0.1)
adapter._wrapper_class = FakeWrapper
adapter.send_frame(1, [0, 255] + [0] * 510)
assert captured["universe"] == 1
assert captured["payload_type"] == "array"
assert captured["has_tobytes"] is True
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_app_imports_with_production_like_absolute_paths(tmp_path: Path) -> None:
frontend_dist = tmp_path / "frontend-dist"
data_dir = tmp_path / "data"
backup_dir = data_dir / "backups"
fixture_dir = data_dir / "fixtures"
diagnostics_dir = data_dir / "diagnostics"
uploads_dir = data_dir / "uploads"
log_dir = tmp_path / "logs"
frontend_dist.mkdir(parents=True)
data_dir.mkdir(parents=True)
backup_dir.mkdir()
fixture_dir.mkdir()
diagnostics_dir.mkdir()
uploads_dir.mkdir()
log_dir.mkdir()
(frontend_dist / "index.html").write_text(
"<!doctype html><title>TuxDMX</title>",
encoding="utf-8",
)
env = os.environ.copy()
env.update(
{
"PYTHONPATH": str(ROOT / "backend"),
"TUXDMX_FRONTEND_DIST": str(frontend_dist),
"TUXDMX_DATA_DIR": str(data_dir),
"TUXDMX_BACKUP_DIR": str(backup_dir),
"TUXDMX_FIXTURE_CACHE_DIR": str(fixture_dir),
"TUXDMX_DIAGNOSTICS_DIR": str(diagnostics_dir),
"TUXDMX_UPLOADS_DIR": str(uploads_dir),
"TUXDMX_LOG_DIR": str(log_dir),
"TUXDMX_DATABASE_URL": f"sqlite+aiosqlite:///{(data_dir / 'tuxdmx.db').as_posix()}",
}
)
subprocess.run(
[
sys.executable,
"-c",
(
"from app.core.config import get_settings; "
"get_settings.cache_clear(); "
"import app.main; "
"settings = get_settings(); "
"assert settings.frontend_dist.is_absolute(); "
"assert settings.data_dir.is_absolute(); "
"assert settings.backup_dir.is_absolute(); "
"assert settings.diagnostics_dir.is_absolute(); "
"assert settings.uploads_dir.is_absolute(); "
"print('ok')"
),
],
check=True,
cwd=ROOT,
env=env,
)
+124
View File
@@ -0,0 +1,124 @@
# MIDI Bridge
## Formål
TuxDMX kan modtage normaliserede MIDI-events fra en ekstern Windows- eller Linux-maskine via en separat Python-bro i `tools/tuxdmx-midi-bridge/`.
Bridgen læser kun MIDI og sender events videre til TuxDMX. Den genererer aldrig DMX direkte.
## Eksisterende arkitektur før ændringen
- Sceneaktivering fandtes allerede via `POST /api/v1/scenes/{scene_slug}/activate`
- Scenerelease fandtes allerede via `POST /api/v1/scenes/{scene_slug}/release`
- Blackout fandtes allerede via `POST /api/v1/dmx/blackout` og `POST /api/v1/dmx/release-blackout`
- Effektstart/-stop fandtes allerede via `POST /api/v1/effects/{effect_slug}/trigger` og `POST /api/v1/effects/{effect_slug}/stop`
- Live status blev sendt ud via WebSocket på `/ws/live`
- Session/CSRF og API-tokenmodeller fandtes i backend, men MIDI-broen havde ingen eksisterende transport eller mappingmodel
## Nye backend-endpoints
### Admin / WebUI
- `GET /api/v1/integrations/midi/bridges`
- `GET /api/v1/integrations/midi/mappings`
- `POST /api/v1/integrations/midi/mappings`
- `PUT /api/v1/integrations/midi/mappings/{mapping_id}`
- `DELETE /api/v1/integrations/midi/mappings/{mapping_id}`
- `POST /api/v1/integrations/midi/mappings/{mapping_id}/test`
- `GET /api/v1/integrations/midi/tokens`
- `POST /api/v1/integrations/midi/tokens`
- `DELETE /api/v1/integrations/midi/tokens/{token_id}`
- `GET /api/v1/integrations/midi/learn`
- `POST /api/v1/integrations/midi/learn/start`
- `POST /api/v1/integrations/midi/learn/cancel`
### Bridge transport
- `POST /api/v1/integrations/midi/bridge/heartbeat`
- `POST /api/v1/integrations/midi/bridge/events`
Bridge-endpoints kræver `Authorization: Bearer <token>`.
## Datamodel
### `midi_bridges`
- `bridge_id`
- `device_name`
- `ip_address`
- `protocol_version`
- `online`
- `last_heartbeat_at`
- `last_event_at`
- `last_error`
- `last_event`
### `midi_bridge_tokens`
- `label`
- `token_hash`
- `bridge_id` valgfri låsning til ét bridge-id
- `scopes`
- `last_used_at`
- `revoked_at`
### `midi_mappings`
- `name`
- `enabled`
- `bridge_id`
- `device_name`
- `message_type`
- `channel`
- `number`
- `action`
- `target_type`
- `target_id`
- `mode`
- `minimum_value`
- `maximum_value`
## Understøttede actions
- `activate_scene`
- `deactivate_scene`
- `toggle_scene`
- `flash_scene`
- `blackout_on`
- `blackout_off`
- `blackout_toggle`
- `set_master_dimmer`
- `set_scene_intensity`
- `start_effect`
- `stop_effect`
## Modes
- `trigger`
- `toggle`
- `hold`
- `flash`
- `continuous`
## Sikkerhed
- Tokens returneres kun i klartekst ved oprettelse
- Efter lagring vises kun `token_preview`
- Bridge-token logges ikke
- Protokolversion skal være `1`
- Bridge kan låses til specifikt `bridge_id`
- Ugyldige eller tilbagekaldte tokens afvises med `401`
## Bridge-installation
Se den fulde bridgepakke i:
- `tools/tuxdmx-midi-bridge/README.md`
- `tools/tuxdmx-midi-bridge/config.example.yaml`
- `tools/tuxdmx-midi-bridge/tuxdmx-midi-bridge.service.example`
## Kendte begrænsninger
- Fysisk MIDI-hardware er ikke testet i denne workspace-session
- Bridge bruger HTTP, ikke indgående WebSocket, selv om UI stadig bruger WebSocket til live status
- `set_effect_speed` er ikke implementeret, fordi effektmotoren endnu ikke har et stabilt genanvendeligt speed-interface
+39 -1
View File
@@ -13,10 +13,29 @@ Denne manual beskriver den nuvaerende WebUI og de vigtigste arbejdsgange.
## Vigtigt
- Screenshots i denne mappe er genereret lokalt i `mock`-tilstand den 23. juli 2026.
- Screenshots i denne mappe er genereret lokalt i `mock`-tilstand den 25. juli 2026.
- De afspejler UI og flows, men er ikke bevis for fysisk hardwareafvikling.
- Fysisk hardwaretest skal stadig vurderes separat paa target-maskinen.
## Navigation
WebUI bruger nu en kompakt sidebar med grupperede undermenuer:
- `Overblik`
Dashboard og Live Desk
- `Live-kontrol`
Scener, Effekter og BPM
- `Lysopsaetning`
Fixtures, DMX Patch og Placering
- `Integrationer`
MixItUp og MIDI
- `Overvaagning`
DMX monitor og Telemetry
- `System`
Backup og Indstillinger
Kun den aktive sektion staar aaben ad gangen, saa UI ikke fylder unodigt meget i venstre side.
## Arkitektur kort fortalt
- `Universe 1`
@@ -186,6 +205,19 @@ ALSA-input skal vaelges i `Settings`, og derefter bruges BPM-siden til selve dri
Herfra kan du teste triggerflow og senere knytte stream-events til scener eller effekter.
## MIDI
`MIDI` samler den separate MIDI-bridge og dens mappings.
Herfra kan du:
- se aktive bridges
- oprette og tilbagekalde bridge-tokens
- bruge `MIDI Learn`
- mappe knapper og faders til scener, effekter, blackout og master
MIDI sender ikke DMX direkte. Den kalder backendens eksisterende scene-, effekt- og systemhandlinger.
## DMX monitor
`DMX monitor` viser DMX-aktivitet.
@@ -269,6 +301,12 @@ Fra denne workspace:
powershell -ExecutionPolicy Bypass -File .\scripts\sync-windows-deploy.ps1
```
Hvis du vil pushe dokumentation, screenshots og kode videre til TuxiNet Git:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\push-git-tuxinet.ps1 -InitIfNeeded -SetOrigin -StageAll -Commit -CommitMessage "Update docs and screenshots" -Push
```
### Linux target-host
```bash
+2 -1
View File
@@ -13,8 +13,9 @@ Indhold:
Vigtigt:
- Screenshots i denne mappe er genereret den 23. juli 2026 fra den lokale WebUI i `mock=1` mode.
- Screenshots i denne mappe er genereret den 25. juli 2026 fra den lokale WebUI i `mock=1` mode.
- De viser den aktuelle UI-struktur og sideopbygning.
- De afspejler den kompakte, grupperede navigation med undermenuer.
- De er ikke dokumentation for fysisk Raspberry Pi-, OLA-, USB-DMX- eller lys-hardwaretest.
- Hardwareafgraensninger og manuel hardwarevalidering hoerer stadig hjemme i [HARDWARE-VALIDATION.md](../HARDWARE-VALIDATION.md).
+6 -1
View File
@@ -1,6 +1,6 @@
# Screenshots
Dette er et samlet screenshotsaet af TuxDMX WebUI genereret den 23. juli 2026.
Dette er et samlet screenshotsaet af TuxDMX WebUI genereret den 25. juli 2026.
Kilde:
@@ -10,6 +10,7 @@ Kilde:
Formaal:
- vise sideopbygning
- vise den kompakte navigation med grupperede undermenuer
- understoette manualen
- give et hurtigt visuelt overblik over systemet
@@ -51,6 +52,10 @@ Formaal:
![MixItUp](./screenshots/mixitup.png)
### MIDI
![MIDI](./screenshots/midi.png)
### DMX monitor
![DMX monitor](./screenshots/dmx-monitor.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+37
View File
@@ -0,0 +1,37 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["frontend/dist/**", "node_modules/**"],
},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["frontend/src/**/*.{ts,tsx}", "frontend/tests/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TuxDMX</title>
<style>
html, body, #root {
margin: 0;
min-height: 100%;
background: #080b12;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1280
View File
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
import { ReactNode, useEffect, useMemo, useState } from "react";
type AppShellProps = {
currentPage: string;
sections: Array<{
id: string;
label: string;
icon: SectionIconName;
tone?: "accent" | "muted";
items: Array<{ id: string; label: string; icon: IconName }>;
}>;
onNavigate: (page: string) => void;
children: ReactNode;
};
type IconName =
| "dashboard"
| "live"
| "scenes"
| "effects"
| "bpm"
| "fixtures"
| "patch"
| "placement"
| "mixitup"
| "midi"
| "monitor"
| "telemetry"
| "backup"
| "settings";
type SectionIconName = "overview" | "live-control" | "lighting-setup" | "integrations" | "monitoring" | "system";
function NavIcon({ name }: { name: IconName }) {
const commonProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
switch (name) {
case "dashboard":
return (
<svg {...commonProps}>
<path d="M4 5h7v6H4zM13 5h7v10h-7zM4 13h7v6H4zM13 17h7v2h-7z" />
</svg>
);
case "live":
return (
<svg {...commonProps}>
<path d="M4 16a8 8 0 0 1 16 0" />
<path d="M7 16a5 5 0 0 1 10 0" />
<path d="M10.5 16a1.5 1.5 0 0 1 3 0" />
<circle cx="12" cy="16" r="1.2" fill="currentColor" stroke="none" />
</svg>
);
case "scenes":
return (
<svg {...commonProps}>
<path d="M4 7h16M4 12h16M4 17h10" />
<circle cx="18" cy="17" r="2" />
</svg>
);
case "effects":
return (
<svg {...commonProps}>
<path d="M13 2 5 14h6l-1 8 8-12h-6z" />
</svg>
);
case "bpm":
return (
<svg {...commonProps}>
<path d="M5 12h2l2-4 4 8 2-4h4" />
</svg>
);
case "fixtures":
return (
<svg {...commonProps}>
<path d="M8 4h8v4H8zM6 8h12v10H6zM10 12h4M10 16h4" />
</svg>
);
case "patch":
return (
<svg {...commonProps}>
<path d="M7 7h4v4H7zM13 13h4v4h-4z" />
<path d="M11 9h2m-6 6h2m4-4 2-2m-6 6 2-2" />
</svg>
);
case "placement":
return (
<svg {...commonProps}>
<path d="M12 21s6-5.2 6-11a6 6 0 1 0-12 0c0 5.8 6 11 6 11Z" />
<circle cx="12" cy="10" r="2" />
</svg>
);
case "mixitup":
return (
<svg {...commonProps}>
<path d="M4 8h8M4 16h12M16 8h4M10 16h2" />
<circle cx="14" cy="8" r="2" />
<circle cx="8" cy="16" r="2" />
</svg>
);
case "midi":
return (
<svg {...commonProps}>
<path d="M6 5h8v12a3 3 0 1 1-3-3h1" />
<path d="M14 8h4v8a3 3 0 1 1-3-3h1" />
</svg>
);
case "monitor":
return (
<svg {...commonProps}>
<rect x="3" y="5" width="18" height="12" rx="2" />
<path d="M8 20h8M10 17v3M14 17v3" />
</svg>
);
case "telemetry":
return (
<svg {...commonProps}>
<path d="M5 18V9M10 18V5M15 18v-7M20 18v-3" />
</svg>
);
case "backup":
return (
<svg {...commonProps}>
<path d="M5 8h14v10H5z" />
<path d="M9 8V5h6v3M12 12v3M10.5 13.5 12 15l1.5-1.5" />
</svg>
);
case "settings":
return (
<svg {...commonProps}>
<circle cx="12" cy="12" r="3" />
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.5-2.3.8a7 7 0 0 0-1.7-1L14.5 3h-5l-.4 2.8a7 7 0 0 0-1.7 1L5 6 3 9.5 5 11a7 7 0 0 0 0 2l-2 1.5L5 18l2.4-.8a7 7 0 0 0 1.7 1l.4 2.8h5l.4-2.8a7 7 0 0 0 1.7-1l2.3.8 2-3.5L18.9 13c.1-.3.1-.7.1-1Z" />
</svg>
);
}
}
function SectionIcon({ name }: { name: SectionIconName }) {
const commonProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
switch (name) {
case "overview":
return (
<svg {...commonProps}>
<path d="M4 5h7v6H4zM13 5h7v6h-7zM4 13h7v6H4zM13 13h7v6h-7z" />
</svg>
);
case "live-control":
return (
<svg {...commonProps}>
<path d="M4 16a8 8 0 0 1 16 0" />
<path d="M7 16a5 5 0 0 1 10 0" />
<path d="M10.5 16a1.5 1.5 0 0 1 3 0" />
<circle cx="12" cy="16" r="1.2" fill="currentColor" stroke="none" />
</svg>
);
case "lighting-setup":
return (
<svg {...commonProps}>
<path d="M8 4h8v4H8zM6 8h12v10H6zM10 12h4M10 16h4" />
</svg>
);
case "integrations":
return (
<svg {...commonProps}>
<path d="M8 8h3v3H8zM13 13h3v3h-3z" />
<path d="m11 9 2-2m-4 8 2-2m2 0 2 2m-6-6 2 2" />
</svg>
);
case "monitoring":
return (
<svg {...commonProps}>
<path d="M5 18V9M10 18V5M15 18v-7M20 18v-3" />
</svg>
);
case "system":
return (
<svg {...commonProps}>
<circle cx="12" cy="12" r="3" />
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.5-2.3.8a7 7 0 0 0-1.7-1L14.5 3h-5l-.4 2.8a7 7 0 0 0-1.7 1L5 6 3 9.5 5 11a7 7 0 0 0 0 2l-2 1.5L5 18l2.4-.8a7 7 0 0 0 1.7 1l.4 2.8h5l.4-2.8a7 7 0 0 0 1.7-1l2.3.8 2-3.5L18.9 13c.1-.3.1-.7.1-1Z" />
</svg>
);
}
}
export function AppShell({ currentPage, sections, onNavigate, children }: AppShellProps) {
const activeSectionId = useMemo(
() => sections.find((section) => section.items.some((item) => item.id === currentPage))?.id ?? sections[0]?.id ?? "",
[currentPage, sections]
);
const [openSectionId, setOpenSectionId] = useState(activeSectionId);
useEffect(() => {
setOpenSectionId(activeSectionId);
}, [activeSectionId]);
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<h1>TuxDMX</h1>
<p>Headless lysstyring med simulator, ærlig telemetri og Twitch-klar API.</p>
</div>
<nav className="nav-list" aria-label="Primær navigation">
{sections.map((section) => (
<section
key={section.id}
className={`nav-section nav-section-${section.tone ?? "muted"} ${openSectionId === section.id ? "open" : ""}`}
>
<button
type="button"
className={`nav-section-trigger ${openSectionId === section.id ? "active" : ""}`}
aria-expanded={openSectionId === section.id}
onClick={() => setOpenSectionId((current) => (current === section.id ? activeSectionId : section.id))}
>
<span className="nav-section-trigger-main">
<span className="nav-section-icon">
<SectionIcon name={section.icon} />
</span>
<span className="nav-section-copy">
<span className="nav-section-label">{section.label}</span>
<span className="nav-section-meta">{section.items.length} sider</span>
</span>
</span>
<span className="nav-section-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="m8 10 4 4 4-4" />
</svg>
</span>
</button>
{openSectionId === section.id ? (
<div className="nav-section-items">
{section.items.map((page) => (
<button
key={page.id}
className={`nav-button ${currentPage === page.id ? "active" : ""}`}
onClick={() => onNavigate(page.id)}
>
<span className="nav-button-icon">
<NavIcon name={page.icon} />
</span>
<span className="nav-button-label">{page.label}</span>
</button>
))}
</div>
) : null}
</section>
))}
</nav>
</aside>
<main className="content">{children}</main>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
type ChannelGridProps = {
values: number[];
sources: string[];
};
export function ChannelGrid({ values, sources }: ChannelGridProps) {
const activeChannels = values
.map((value, index) => ({
channel: index + 1,
value,
source: sources[index] ?? "idle"
}))
.filter((entry) => entry.value > 0 || entry.source !== "idle");
if (activeChannels.length === 0) {
return (
<div className="event-item">
<strong>Ingen aktive DMX-kanaler</strong>
<div className="muted">Monitoren viser kun kanaler med værdi over 0 eller aktiv kilde.</div>
</div>
);
}
return (
<div className="channel-grid" aria-label="DMX kanalgrid">
{activeChannels.map((entry) => (
<div
key={entry.channel}
className="channel-cell"
style={{
background: `linear-gradient(180deg, rgba(0,229,255,${Math.max(
0.08,
entry.value / 255
)}), rgba(255,61,242,0.06))`
}}
title={`Kanal ${entry.channel} • Kilde ${entry.source}`}
>
<span>{entry.channel}</span>
<strong>{entry.value}</strong>
<small>{entry.source.replace(/^.*:/, "")}</small>
</div>
))}
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
type StatusPillProps = {
label: string;
tone: "success" | "warning" | "danger" | "info";
};
export function StatusPill({ label, tone }: StatusPillProps) {
return <span className={`pill ${tone}`}>{label}</span>;
}
+604
View File
@@ -0,0 +1,604 @@
export type SystemStatus = {
app: string;
setup_required: boolean;
engine: {
backend: string;
connected: boolean;
degraded: boolean;
last_error: string | null;
blackout: boolean;
fps: number;
master: number;
frame: number[];
source_map: string[];
updated_at?: string | null;
};
telemetry: {
uptime_seconds: number;
cpu_percent: number;
ram_percent: number;
temperature_c: number | null;
queue_depth: number;
reconnect_count: number;
last_error: string | null;
events: Array<{ created_at: string; category: string; level: string; message: string }>;
};
bpm: BpmStatus;
};
export type DmxOutputConfig = {
backend: "simulator" | "ola" | "artnet";
universe: number;
output_port: string;
target_host: string;
artnet_nodes: ArtNetNode[];
};
export type DmxDevice = {
name: string;
backend: string;
connected: boolean;
output_port: string | null;
universe: number;
};
export type HomeAssistantConfig = {
enabled: boolean;
base_url: string;
default_universe: number;
has_token: boolean;
token_mask: string;
mapping_count: number;
dispatch_count: number;
error_count: number;
last_error: string | null;
last_successful_call_at: string | null;
last_connection_success_at: string | null;
last_connection_error: string | null;
ha_version: string | null;
auth_ok: boolean;
reachable: boolean;
};
export type HomeAssistantConnectionResult = {
reachable: boolean;
auth_ok: boolean;
ha_version: string | null;
last_error: string | null;
last_successful_call_at: string | null;
last_connection_success_at: string | null;
};
export type HomeAssistantEntity = {
entity_id: string;
domain: string;
friendly_name: string;
state: string;
};
export type HomeAssistantMapping = {
id: number;
name: string;
universe: number;
start_address: number;
fixture_type: "dimmer" | "rgb" | "rgbw" | "cct" | "switch" | "scene" | "automation";
entity_id: string;
rate_limit_hz: number;
deadband: number;
fade_ms: number;
invert_channel: boolean;
min_value: number;
max_value: number;
enabled: boolean;
master_dimmer: boolean;
channel_span: number;
status: string;
last_dmx_values: number[];
last_sent_summary: string | null;
last_service: string | null;
last_success_at: string | null;
last_error: string | null;
last_error_at: string | null;
in_flight: boolean;
};
export type MidiBridgeStatus = {
bridge_id: string;
device_name: string;
ip_address: string | null;
protocol_version: number;
online: boolean;
last_heartbeat_at: string | null;
last_event_at: string | null;
last_error: string | null;
last_event: {
type: string;
channel: number;
number: number;
value: number;
} | null;
updated_at: string;
created_at: string;
};
export type MidiMapping = {
id: number;
name: string;
enabled: boolean;
bridge_id: string | null;
device_name: string | null;
message_type: "note_on" | "note_off" | "control_change" | "program_change";
channel: number | null;
number: number;
action:
| "activate_scene"
| "deactivate_scene"
| "toggle_scene"
| "flash_scene"
| "blackout_on"
| "blackout_off"
| "blackout_toggle"
| "set_master_dimmer"
| "set_scene_intensity"
| "start_effect"
| "stop_effect";
target_type: "scene" | "effect" | "system" | "global";
target_id: string | null;
mode: "trigger" | "toggle" | "hold" | "flash" | "continuous";
minimum_value: number;
maximum_value: number;
created_at: string;
updated_at: string;
active: boolean;
};
export type MidiBridgeToken = {
id: number;
label: string;
bridge_id: string | null;
scopes: string[];
created_at: string;
revoked_at: string | null;
last_used_at: string | null;
token_preview: string;
token?: string;
};
export type MidiLearnState = {
active: boolean;
allow_passthrough: boolean;
expires_at: string | null;
captured_event: {
bridge_id: string;
device: string;
timestamp: string;
message: {
type: string;
channel: number;
number: number;
value: number;
};
} | null;
};
export type ArtNetNode = {
ip: string;
short_name: string;
long_name: string;
label: string;
net: number;
sub_switch: number;
port_count: number;
raw_port_address: number;
};
export type BpmDevice = {
id: string;
name: string;
backend: string;
is_default: boolean;
recommended?: boolean;
};
export type BpmStatus = {
mode: string;
bpm: number;
confidence: number;
devices: string[];
device_details?: BpmDevice[];
current_device: string;
selected_device: string;
recommended_device: string;
audio_connected: boolean;
last_error: string | null;
last_beat_at?: number | null;
input_level: number;
peak_level: number;
clipping: boolean;
sample_rate: number;
channels: number;
format: string;
};
export type BpmConfig = {
preferred_device: string;
recommended_device: string;
sample_rate: number;
channels: number;
format: string;
};
export type FixturePreview = {
manufacturer: string;
model: string;
categories: string[];
schema_version: string;
modes: Array<{
key: string;
channel_count: number;
channels: Array<{
index: number;
key: string;
display_name: string;
precedence: string;
resolution: number;
}>;
}>;
};
export type FixtureSummary = {
id: number;
slug: string;
manufacturer: string;
model: string;
short_name: string | null;
categories: string[];
schema_version: string;
source: {
manufacturer_key?: string;
fixture_key?: string;
};
warnings: string[];
modes: Array<{
key: string;
channel_count: number;
channels: Array<{
index: number;
key: string;
display_name: string;
precedence: string;
resolution: number;
capabilities?: unknown[];
}>;
}>;
};
export type PatchItem = {
id: number;
universe: number;
name: string;
definition_id: number;
fixture_slug: string | null;
manufacturer: string | null;
model: string | null;
mode_key: string;
start_address: number;
end_address: number;
channel_count: number;
enabled: boolean;
group_names: string[];
position: {
x: number;
y: number;
z: number;
rotation: number;
};
channels: Array<{
index: number;
key: string;
display_name: string;
precedence: string;
resolution: number;
}>;
};
export type SceneItem = {
id: number;
name: string;
slug: string;
color: string;
icon: string | null;
priority: number;
fade_in_ms: number;
fade_out_ms: number;
hold_ms: number;
master_limit: number;
values: Array<{
channel: number;
value: number;
precedence: string;
source?: string;
}>;
targets: Array<{
target_type: "fixture" | "group";
patch_id?: number | null;
group_name?: string | null;
values: Array<{
attribute: string;
value: number;
precedence?: string | null;
}>;
}>;
tags: string[];
is_active: boolean;
};
export type PatchValidation = {
valid: boolean;
range: string;
end_address: number;
channel_count: number;
occupied_channels: number[];
conflicts: Array<{
type: string;
patch_id?: number;
name?: string;
range?: string;
fixture?: string;
message: string;
}>;
};
export type SystemCommandResult = {
accepted: boolean;
action: string;
status: string;
detail: string;
};
export type LiveMixerItem = {
id: string;
source_type: "patch" | "home_assistant";
source_id: number;
patch_id: number | null;
name: string;
manufacturer: string | null;
model: string | null;
mode_key: string;
universe: number;
start_address: number;
end_address: number;
channel_count: number;
entity_id?: string | null;
status?: string | null;
last_sent_summary?: string | null;
channels: Array<{
index: number;
absolute_channel: number;
key: string;
display_name: string;
precedence: string;
resolution: number;
value: number;
}>;
};
const jsonHeaders = { "Content-Type": "application/json" };
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api/v1${path}`, init);
if (!response.ok) {
throw new Error(`API-fejl ${response.status}`);
}
return (await response.json()) as T;
}
export const api = {
getStatus: () => request<SystemStatus>("/system/status"),
restartService: () => request<SystemCommandResult>("/system/restart-service", { method: "POST" }),
rebootHost: () => request<SystemCommandResult>("/system/reboot-host", { method: "POST" }),
getDmxConfig: () => request<DmxOutputConfig>("/dmx/config"),
saveDmxConfig: (payload: Record<string, unknown>) =>
request<DmxOutputConfig>("/dmx/config", {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
discoverArtNet: (timeoutS = 1) =>
request<{ items: ArtNetNode[]; count: number }>(`/dmx/artnet/discover?timeout_s=${timeoutS}`, {
method: "POST"
}),
listDmxDevices: () => request<{ devices: DmxDevice[] }>("/dmx/devices"),
getHomeAssistantConfig: () => request<HomeAssistantConfig>("/integrations/home-assistant/config"),
saveHomeAssistantConfig: (payload: Record<string, unknown>) =>
request<HomeAssistantConfig>("/integrations/home-assistant/config", {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
testHomeAssistantConnection: () =>
request<HomeAssistantConnectionResult>("/integrations/home-assistant/test-connection", {
method: "POST"
}),
listHomeAssistantEntities: () =>
request<{ items: HomeAssistantEntity[] }>("/integrations/home-assistant/entities"),
listHomeAssistantMappings: () =>
request<{ items: HomeAssistantMapping[] }>("/integrations/home-assistant/mappings"),
createHomeAssistantMapping: (payload: Record<string, unknown>) =>
request<HomeAssistantMapping>("/integrations/home-assistant/mappings", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
updateHomeAssistantMapping: (mappingId: number, payload: Record<string, unknown>) =>
request<HomeAssistantMapping>(`/integrations/home-assistant/mappings/${mappingId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
deleteHomeAssistantMapping: (mappingId: number) =>
request<Record<string, unknown>>(`/integrations/home-assistant/mappings/${mappingId}`, { method: "DELETE" }),
testHomeAssistantMapping: (mappingId: number) =>
request<Record<string, unknown>>(`/integrations/home-assistant/mappings/${mappingId}/test`, {
method: "POST"
}),
listMidiBridges: () => request<{ items: MidiBridgeStatus[] }>("/integrations/midi/bridges"),
listMidiMappings: () => request<{ items: MidiMapping[] }>("/integrations/midi/mappings"),
createMidiMapping: (payload: Record<string, unknown>) =>
request<MidiMapping>("/integrations/midi/mappings", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
updateMidiMapping: (mappingId: number, payload: Record<string, unknown>) =>
request<MidiMapping>(`/integrations/midi/mappings/${mappingId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
deleteMidiMapping: (mappingId: number) =>
request<Record<string, unknown>>(`/integrations/midi/mappings/${mappingId}`, { method: "DELETE" }),
testMidiMapping: (mappingId: number, value = 127) =>
request<Record<string, unknown>>(`/integrations/midi/mappings/${mappingId}/test`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ value })
}),
listMidiTokens: () => request<{ items: MidiBridgeToken[] }>("/integrations/midi/tokens"),
createMidiToken: (payload: Record<string, unknown>) =>
request<MidiBridgeToken>("/integrations/midi/tokens", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
revokeMidiToken: (tokenId: number) =>
request<Record<string, unknown>>(`/integrations/midi/tokens/${tokenId}`, { method: "DELETE" }),
getMidiLearnState: () => request<MidiLearnState>("/integrations/midi/learn"),
startMidiLearn: (timeoutSeconds = 15, allowPassthrough = false) =>
request<MidiLearnState>("/integrations/midi/learn/start", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ timeout_seconds: timeoutSeconds, allow_passthrough: allowPassthrough })
}),
cancelMidiLearn: () =>
request<MidiLearnState>("/integrations/midi/learn/cancel", {
method: "POST"
}),
getFrame: () => request<{ universe: number; values: number[]; sources: string[] }>("/dmx/frame"),
getFixtures: () => request<{ items: FixtureSummary[] }>("/fixtures"),
searchFixtures: (query: string) =>
request<{ results: Array<{ fixture_key: string; cached: boolean }> }>(
`/fixtures/search-ofl?query=${encodeURIComponent(query)}`,
{ method: "POST" }
),
previewFixture: (manufacturerKey: string, fixtureKey: string) =>
request<FixturePreview>(
`/fixtures/preview-ofl?manufacturer_key=${encodeURIComponent(manufacturerKey)}&fixture_key=${encodeURIComponent(fixtureKey)}`,
{ method: "POST" }
),
importFixture: (manufacturerKey: string, fixtureKey: string) =>
request<FixtureSummary>(
`/fixtures/import-ofl?manufacturer_key=${encodeURIComponent(manufacturerKey)}&fixture_key=${encodeURIComponent(fixtureKey)}`,
{ method: "POST" }
),
importFixtureFile: (payload: Record<string, unknown>) =>
request<FixtureSummary>("/fixtures/import-file", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
getLiveMixer: () => request<{ items: LiveMixerItem[] }>("/live/mixer"),
setLiveMixerPatchValues: (patchId: number, values: Record<number, number>) =>
request<{ items: LiveMixerItem[] }>(`/live/mixer/${patchId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({ values })
}),
setLiveMixerHomeAssistantValues: (mappingId: number, values: Record<number, number>) =>
request<{ items: LiveMixerItem[] }>(`/live/mixer/home-assistant/${mappingId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({ values })
}),
clearLiveMixerPatchValues: (patchId: number) =>
request<{ items: LiveMixerItem[] }>(`/live/mixer/${patchId}`, { method: "DELETE" }),
clearLiveMixerHomeAssistantValues: (mappingId: number) =>
request<{ items: LiveMixerItem[] }>(`/live/mixer/home-assistant/${mappingId}`, { method: "DELETE" }),
listPatches: () => request<{ items: PatchItem[] }>("/patch"),
validatePatch: (payload: Record<string, unknown>) =>
request<PatchValidation>("/patch/validate", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
createPatch: (payload: Record<string, unknown>) =>
request<PatchItem>("/patch", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
updatePatch: (patchId: number, payload: Record<string, unknown>) =>
request<PatchItem>(`/patch/${patchId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
deletePatch: (patchId: number) => request<Record<string, unknown>>(`/patch/${patchId}`, { method: "DELETE" }),
listScenes: () => request<{ items: SceneItem[] }>("/scenes"),
createScene: (payload: Record<string, unknown>) =>
request<SceneItem>("/scenes", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
activateScene: (slug: string) =>
request<SceneItem>(`/scenes/${encodeURIComponent(slug)}/activate`, { method: "POST" }),
releaseScene: (slug: string) =>
request<Record<string, unknown>>(`/scenes/${encodeURIComponent(slug)}/release`, { method: "POST" }),
deleteScene: (sceneId: number) =>
request<Record<string, unknown>>(`/scenes/${sceneId}`, { method: "DELETE" }),
listEffects: () => request<{ items: Array<Record<string, unknown>> }>("/effects"),
createEffect: (payload: Record<string, unknown>) =>
request<Record<string, unknown>>("/effects", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
triggerEffect: (slug: string) =>
request<Record<string, unknown>>(`/effects/${encodeURIComponent(slug)}/trigger`, { method: "POST" }),
stopEffect: (slug: string) =>
request<Record<string, unknown>>(`/effects/${encodeURIComponent(slug)}/stop`, { method: "POST" }),
tapBpm: () => request<Record<string, unknown>>("/bpm/tap", { method: "POST" }),
setManualBpm: (bpm: number) =>
request<Record<string, unknown>>(`/bpm/manual?bpm=${bpm}`, { method: "POST" }),
startAudioBpm: (device: string) =>
request<BpmStatus>("/bpm/audio/start", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ device })
}),
stopAudioBpm: () =>
request<Record<string, string>>("/bpm/audio/stop", { method: "POST" }),
listBpmDevices: () => request<{ devices: BpmDevice[] }>("/bpm/devices"),
getBpmConfig: () => request<BpmConfig>("/bpm/config"),
saveBpmConfig: (preferredDevice: string) =>
request<BpmConfig>("/bpm/config", {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({ preferred_device: preferredDevice })
}),
blackout: () => request<Record<string, unknown>>("/dmx/blackout", { method: "POST" }),
releaseBlackout: () => request<Record<string, unknown>>("/dmx/release-blackout", { method: "POST" }),
listBackups: () => request<{ items: Array<Record<string, unknown>> }>("/backups"),
createBackup: () => request<Record<string, unknown>>("/backups", { method: "POST" }),
restoreBackup: (backupId: string) =>
request<Record<string, unknown>>(`/backups/${encodeURIComponent(backupId)}/restore`, {
method: "POST"
}),
triggerMixitup: (slug: string, payload: Record<string, unknown>) =>
request<Record<string, unknown>>(`/triggers/${encodeURIComponent(slug)}`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
})
};

Some files were not shown because too many files have changed in this diff Show More