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,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
|
||||
Reference in New Issue
Block a user