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

This commit is contained in:
2026-07-25 10:26:29 +02:00
parent d6cda77aaf
commit 1f110866f5
170 changed files with 24657 additions and 3 deletions
@@ -0,0 +1,5 @@
"""Standalone MIDI bridge for TuxDMX."""
__all__ = ["__version__"]
__version__ = "1.0.0"
@@ -0,0 +1,5 @@
from .main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,52 @@
from __future__ import annotations
import httpx
from .config import ServerConfig
class TuxDmxMidiClient:
def __init__(self, server: ServerConfig, timeout: float = 5.0) -> None:
self._server = server
self._timeout = timeout
@property
def headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._server.api_token}",
"Content-Type": "application/json",
}
@property
def heartbeat_url(self) -> str:
return f"{self._server.url}/heartbeat"
@property
def events_url(self) -> str:
return f"{self._server.url}/events"
async def send_heartbeat(self, payload: dict[str, object]) -> dict[str, object]:
async with httpx.AsyncClient(timeout=self._timeout, verify=self._server.verify_tls) as client:
response = await client.post(self.heartbeat_url, headers=self.headers, json=payload)
response.raise_for_status()
return response.json()
async def send_event(self, payload: dict[str, object]) -> dict[str, object]:
async with httpx.AsyncClient(timeout=self._timeout, verify=self._server.verify_tls) as client:
response = await client.post(self.events_url, headers=self.headers, json=payload)
response.raise_for_status()
return response.json()
async def test_connection(self, bridge_id: str, device_name: str) -> dict[str, object]:
return await self.send_heartbeat(
{
"type": "heartbeat",
"protocol_version": 1,
"bridge_id": bridge_id,
"device": device_name,
}
)
def reconnect_delay(attempt: int, base_seconds: float) -> float:
return min(base_seconds * max(1, attempt), 30.0)
@@ -0,0 +1,90 @@
from __future__ import annotations
import os
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
@dataclass(slots=True)
class ServerConfig:
url: str
api_token: str
reconnect_seconds: float = 3.0
verify_tls: bool = True
@dataclass(slots=True)
class MidiConfig:
device_name: str
channel_filter: int | None = None
ignore_active_sensing: bool = True
@dataclass(slots=True)
class PerformanceConfig:
control_change_interval_ms: int = 25
suppress_duplicate_values: bool = True
@dataclass(slots=True)
class LoggingConfig:
level: str = "INFO"
show_midi_events: bool = True
@dataclass(slots=True)
class BridgeConfig:
bridge_id: str
server: ServerConfig
midi: MidiConfig
performance: PerformanceConfig
logging: LoggingConfig
def load_config(path: str | Path) -> BridgeConfig:
config_path = Path(path)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raise ValueError("Konfigurationsfilen skal være YAML med topniveau som objekt.")
server_data = raw.get("server") or {}
midi_data = raw.get("midi") or {}
performance_data = raw.get("performance") or {}
logging_data = raw.get("logging") or {}
env_token = os.getenv("TUXDMX_API_TOKEN", "").strip()
api_token = env_token or str(server_data.get("api_token", "")).strip()
if not api_token:
raise ValueError("API-token mangler. Angiv det i YAML eller via TUXDMX_API_TOKEN.")
return BridgeConfig(
bridge_id=str(raw.get("bridge_id") or socket.gethostname()).strip(),
server=ServerConfig(
url=str(server_data.get("url", "")).rstrip("/"),
api_token=api_token,
reconnect_seconds=float(server_data.get("reconnect_seconds", 3)),
verify_tls=bool(server_data.get("verify_tls", True)),
),
midi=MidiConfig(
device_name=str(midi_data.get("device_name", "")).strip(),
channel_filter=_optional_int(midi_data.get("channel_filter")),
ignore_active_sensing=bool(midi_data.get("ignore_active_sensing", True)),
),
performance=PerformanceConfig(
control_change_interval_ms=int(performance_data.get("control_change_interval_ms", 25)),
suppress_duplicate_values=bool(performance_data.get("suppress_duplicate_values", True)),
),
logging=LoggingConfig(
level=str(logging_data.get("level", "INFO")).upper(),
show_midi_events=bool(logging_data.get("show_midi_events", True)),
),
)
def _optional_int(value: Any) -> int | None:
if value in {None, "", "null"}:
return None
return int(value)
@@ -0,0 +1,10 @@
from __future__ import annotations
import logging
def configure_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
)
@@ -0,0 +1,131 @@
from __future__ import annotations
import argparse
import asyncio
import logging
from contextlib import suppress
from .client import TuxDmxMidiClient, reconnect_delay
from .config import BridgeConfig, load_config
from .logging_setup import configure_logging
from .midi import MidiEventFilter, list_input_devices, match_input_device, normalize_message
logger = logging.getLogger("tuxdmx_midi_bridge")
async def run_bridge(config: BridgeConfig, debug: bool = False) -> None:
if debug:
config.logging.level = "DEBUG"
config.logging.show_midi_events = True
configure_logging(config.logging.level)
client = TuxDmxMidiClient(config.server)
event_filter = MidiEventFilter(
channel_filter=config.midi.channel_filter,
control_change_interval_ms=config.performance.control_change_interval_ms,
suppress_duplicate_values=config.performance.suppress_duplicate_values,
)
attempt = 0
while True:
try:
device_name = match_input_device(config.midi.device_name)
if device_name is None:
raise RuntimeError(f"MIDI-input matcher ikke '{config.midi.device_name}'.")
logger.info("Valgt MIDI-input: %s", device_name)
import mido
with mido.open_input(device_name) as port:
await client.send_heartbeat(
{
"type": "heartbeat",
"protocol_version": 1,
"bridge_id": config.bridge_id,
"device": device_name,
}
)
logger.info("Forbundet til TuxDMX som bridge '%s'", config.bridge_id)
attempt = 0
heartbeat_task = asyncio.create_task(_heartbeat_loop(client, config.bridge_id, device_name))
try:
while True:
for message in port.iter_pending():
if config.midi.ignore_active_sensing and getattr(message, "type", "") == "active_sensing":
continue
payload = normalize_message(message, config.bridge_id, device_name)
if payload is None or not event_filter.should_forward(payload):
continue
if config.logging.show_midi_events:
logger.info(
"MIDI %s ch=%s no=%s val=%s",
payload["message"]["type"],
payload["message"]["channel"],
payload["message"]["number"],
payload["message"]["value"],
)
await client.send_event(payload)
await asyncio.sleep(0.01)
finally:
heartbeat_task.cancel()
with suppress(asyncio.CancelledError):
await heartbeat_task
except KeyboardInterrupt:
logger.info("Afslutter MIDI-bridge kontrolleret.")
return
except Exception as exc:
attempt += 1
delay = reconnect_delay(attempt, config.server.reconnect_seconds)
logger.warning("Bridge-fejl: %s. Nyt forsøg om %.1f sek.", exc, delay)
await asyncio.sleep(delay)
async def _heartbeat_loop(client: TuxDmxMidiClient, bridge_id: str, device_name: str) -> None:
while True:
await asyncio.sleep(5)
await client.send_heartbeat(
{
"type": "heartbeat",
"protocol_version": 1,
"bridge_id": bridge_id,
"device": device_name,
}
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="TuxDMX MIDI bridge")
parser.add_argument("--config", default="config.yaml", help="Sti til YAML-konfiguration")
parser.add_argument("--list-devices", action="store_true", help="List tilgængelige MIDI-inputs")
parser.add_argument("--test-connection", action="store_true", help="Test forbindelse til TuxDMX")
parser.add_argument("--debug", action="store_true", help="Aktivér debuglog")
return parser
async def _async_main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.list_devices:
for name in list_input_devices():
print(name)
return 0
config = load_config(args.config)
configure_logging("DEBUG" if args.debug else config.logging.level)
if args.test_connection:
client = TuxDmxMidiClient(config.server)
device_name = match_input_device(config.midi.device_name) or config.midi.device_name
result = await client.test_connection(config.bridge_id, device_name)
print(result)
return 0
await run_bridge(config, debug=args.debug)
return 0
def main() -> int:
return asyncio.run(_async_main())
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,87 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from time import monotonic
from typing import Any
def list_input_devices() -> list[str]:
import mido
return list(mido.get_input_names())
def match_input_device(device_name: str, devices: list[str] | None = None) -> str | None:
available = devices or list_input_devices()
requested = device_name.casefold()
for name in available:
if requested in name.casefold():
return name
return None
def normalize_message(message: Any, bridge_id: str, device_name: str) -> dict[str, object] | None:
message_type = getattr(message, "type", None)
if message_type not in {"note_on", "note_off", "control_change", "program_change"}:
return None
number = getattr(message, "note", None)
if message_type in {"control_change", "program_change"}:
number = getattr(message, "control", None) if message_type == "control_change" else getattr(message, "program", None)
if number is None:
return None
value = getattr(message, "velocity", None)
if message_type == "control_change":
value = getattr(message, "value", 0)
if message_type == "program_change":
value = getattr(message, "value", 127)
if value is None:
value = 0
return {
"type": "midi_event",
"protocol_version": 1,
"bridge_id": bridge_id,
"device": device_name,
"timestamp": datetime.now(UTC).isoformat(),
"message": {
"type": message_type,
"channel": int(getattr(message, "channel", 0)),
"number": int(number),
"value": int(value),
},
}
@dataclass(slots=True)
class MidiEventFilter:
channel_filter: int | None
control_change_interval_ms: int
suppress_duplicate_values: bool
_last_sent_at: dict[tuple[str, int, int], float] = field(default_factory=dict, init=False)
_last_values: dict[tuple[str, int, int], int] = field(default_factory=dict, init=False)
def should_forward(self, payload: dict[str, object]) -> bool:
message = payload["message"]
if not isinstance(message, dict):
return False
message_type = str(message["type"])
channel = int(message["channel"])
number = int(message["number"])
value = int(message["value"])
if self.channel_filter is not None and channel != self.channel_filter:
return False
key = (message_type, channel, number)
if self.suppress_duplicate_values and self._last_values.get(key) == value:
return False
if message_type == "control_change":
now_ms = monotonic() * 1000
last_sent = self._last_sent_at.get(key, 0.0)
if now_ms - last_sent < self.control_change_interval_ms:
return False
self._last_sent_at[key] = now_ms
self._last_values[key] = value
return True