Update docs and screenshots
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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")
|
||||
@@ -0,0 +1,2 @@
|
||||
"""TuxDMX backend package."""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""API modules."""
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(router)
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Authentication services."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Backup and restore services."""
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""BPM services."""
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Core application services."""
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""DMX runtime services."""
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Effect runtime services."""
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Fixture import and normalization."""
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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__
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.live.service import LiveDeskService
|
||||
|
||||
__all__ = ["LiveDeskService"]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""MIDI bridge integration services."""
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
"""MixItUp integration services."""
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""ORM and schema modules."""
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Patch persistence and validation services."""
|
||||
@@ -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)),
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Scene services."""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.system.service import SystemControlService
|
||||
|
||||
__all__ = ["SystemControlService"]
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Telemetry services."""
|
||||
|
||||
@@ -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:]
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""WebSocket helpers."""
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user