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
+6
View File
@@ -0,0 +1,6 @@
All rights reserved.
Copyright (c) Thomas / TuxiNet.
Dette softwareprojekt og alle tilhorende filer er proprietaere.
Ingen del ma kopieres, distribueres eller sublicenseres uden skriftlig tilladelse.
+59
View File
@@ -0,0 +1,59 @@
# TuxDMX MIDI Bridge
Standalone Python-bro, som lytter på en lokal USB-MIDI-controller og sender normaliserede MIDI-events videre til TuxDMX over HTTP.
## Krav
- Python 3.11+
- En MIDI-inputenhed, som kan læses af `mido` / `python-rtmidi`
- En TuxDMX-instans med en gyldig MIDI-token
## Hurtig start
### Windows
```bat
install-windows.bat
copy config.example.yaml config.yaml
run-windows.bat config.yaml
```
Autostart kan laves via Windows Task Scheduler med:
```bat
.venv\Scripts\python.exe -m tuxdmx_midi_bridge --config C:\path\to\config.yaml
```
### Linux
```bash
./install-linux.sh
cp config.example.yaml config.yaml
./run-linux.sh config.yaml
```
Eksempel på systemd-service ligger i `tuxdmx-midi-bridge.service.example`.
## CLI
```bash
python -m tuxdmx_midi_bridge --config config.yaml
python -m tuxdmx_midi_bridge --list-devices
python -m tuxdmx_midi_bridge --test-connection --config config.yaml
python -m tuxdmx_midi_bridge --debug --config config.yaml
```
## Konfiguration
- `server.url`: HTTP-base til TuxDMX bridge-endpoints, fx `http://host:8000/api/v1/integrations/midi/bridge`
- `server.api_token`: kan overstyres af `TUXDMX_API_TOKEN`
- `midi.device_name`: delnavn der matches mod tilgængelige MIDI-inputs
- `midi.channel_filter`: sæt til `0-15` eller `null`
- `performance.control_change_interval_ms`: rate limit på fadere og knapper
- `performance.suppress_duplicate_values`: ignorer gentagne identiske værdier
## Sikkerhed
- Token logges aldrig i klartekst.
- TLS-verifikation er standard og slås kun fra eksplicit i konfigurationen.
- Bridge sender kun MIDI-events og heartbeats. Den genererer ikke DMX direkte.
@@ -0,0 +1,20 @@
bridge_id: "bar-laptop"
server:
url: "http://192.168.2.50:8000/api/v1/integrations/midi/bridge"
api_token: "CHANGE_ME"
reconnect_seconds: 3
verify_tls: true
midi:
device_name: "USB MIDI"
channel_filter: null
ignore_active_sensing: true
performance:
control_change_interval_ms: 25
suppress_duplicate_values: true
logging:
level: "INFO"
show_midi_events: true
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements.txt
if [[ ! -f config.yaml ]]; then
cp config.example.yaml config.yaml
fi
echo "Installeret. Rediger config.yaml og start med ./run-linux.sh"
@@ -0,0 +1,11 @@
@echo off
setlocal
cd /d "%~dp0"
py -3 -m venv .venv
call ".venv\Scripts\python.exe" -m pip install --upgrade pip
call ".venv\Scripts\python.exe" -m pip install -r requirements.txt
if not exist "config.yaml" copy /Y "config.example.yaml" "config.yaml" >nul
echo Installeret. Rediger config.yaml og start med run-windows.bat
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "tuxdmx-midi-bridge"
version = "1.0.0"
description = "Standalone MIDI bridge for TuxDMX."
requires-python = ">=3.11"
dependencies = [
"mido>=1.3.2,<2",
"python-rtmidi>=1.5.8,<2",
"httpx>=0.28,<0.29",
"PyYAML>=6.0,<7",
]
[tool.setuptools.packages.find]
where = ["."]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -0,0 +1,4 @@
mido>=1.3.2,<2
python-rtmidi>=1.5.8,<2
httpx>=0.28,<0.29
PyYAML>=6.0,<7
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
if [[ ! -d .venv ]]; then
echo "Virtuelt miljø mangler. Kør ./install-linux.sh først." >&2
exit 1
fi
exec .venv/bin/python -m tuxdmx_midi_bridge --config "${1:-config.yaml}"
+10
View File
@@ -0,0 +1,10 @@
@echo off
setlocal
cd /d "%~dp0"
if not exist ".venv\Scripts\python.exe" (
echo Virtuelt miljoe mangler. Koer install-windows.bat foerst.
exit /b 1
)
".venv\Scripts\python.exe" -m tuxdmx_midi_bridge --config "%~1"
@@ -0,0 +1,25 @@
from __future__ import annotations
from pathlib import Path
from tuxdmx_midi_bridge.config import load_config
def test_load_config_prefers_env_token(monkeypatch, tmp_path: Path) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""
bridge_id: bridge-a
server:
url: "http://127.0.0.1:8000/api/v1/integrations/midi/bridge"
api_token: "yaml-token"
midi:
device_name: "USB MIDI"
""".strip(),
encoding="utf-8",
)
monkeypatch.setenv("TUXDMX_API_TOKEN", "env-token")
config = load_config(config_path)
assert config.server.api_token == "env-token"
assert config.bridge_id == "bridge-a"
@@ -0,0 +1,38 @@
from __future__ import annotations
from dataclasses import dataclass
from tuxdmx_midi_bridge.midi import MidiEventFilter, normalize_message
@dataclass
class FakeMessage:
type: str
channel: int
note: int | None = None
control: int | None = None
program: int | None = None
velocity: int | None = None
value: int | None = None
def test_normalize_note_on() -> None:
payload = normalize_message(
FakeMessage(type="note_on", channel=0, note=36, velocity=127),
"bridge-a",
"USB MIDI",
)
assert payload is not None
assert payload["message"]["type"] == "note_on"
assert payload["message"]["number"] == 36
assert payload["message"]["value"] == 127
def test_filter_suppresses_duplicates_and_rate_limits_control_change() -> None:
event_filter = MidiEventFilter(channel_filter=None, control_change_interval_ms=25, suppress_duplicate_values=True)
payload = {
"message": {"type": "control_change", "channel": 0, "number": 14, "value": 64}
}
assert event_filter.should_forward(payload) is True
assert event_filter.should_forward(payload) is False
@@ -0,0 +1,7 @@
from tuxdmx_midi_bridge.client import reconnect_delay
def test_reconnect_delay_is_bounded() -> None:
assert reconnect_delay(1, 3) == 3
assert reconnect_delay(20, 3) == 30
@@ -0,0 +1,15 @@
[Unit]
Description=TuxDMX MIDI Bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=tuxdmx
WorkingDirectory=/opt/tuxdmx-midi-bridge
ExecStart=/opt/tuxdmx-midi-bridge/.venv/bin/python -m tuxdmx_midi_bridge --config /opt/tuxdmx-midi-bridge/config.yaml
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
@@ -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