Files
HMS MediaEngine Agent 362e089be0 AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell.
Jetzt ist sauber getrennt:

ROOT (was der Nutzer sieht und braucht):
- run.py                     = das Programm
- hms_app/                   = der Anwendungscode
- HMS MediaEngine.app        = macOS Doppelklick-Starter
- HMS-Start.vbs              = Windows Doppelklick-Starter
- HMS-Install.vbs             = Windows Erst-Installation
- HMS-Mac-Install.command     = macOS Homebrew-Installation
- HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix)
- installer_gui.py           = grafischer Installer
- launcher.pyw + launcher_core.py = interne Start-Logik
- LIESMICH.txt               = 10-Zeilen-Kurzanleitung
- .gitignore

_entwicklung/ (alles andere, NICHT benoetigt):
- packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/
  build/ fixture_profiles/
- PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md
- pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py

Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann
_entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne.

Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
2026-09-11 23:44:06 +02:00

229 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""DMX-zu-Parameter-Verkabelung (PLAN.md §11, §16, §29.2).
Verbindet Art-Net-Receiver → FixturePatch → Master32/Layer64-Engines →
ParameterEngine:
- jedes DMX-Update wird über den Patch dekodiert
- dekodierte Werte fließen über stabile Parameterpfade in die Engine
(§11: alle Quellen über die Parameter-Engine, nie direkt in den Renderer)
- Master-Blackout läuft mit SAFETY-Priorität (§11.2: überstimmt alles)
- Load/Commit-Events steuern PreloadSlot für atomaren Clipwechsel (§16.5,
§12.2): Preload bei pending selection, Commit wechselt atomar
- Signalverlust: konfigurierbare Policy je Patch (§16.1, §11.3)
"""
from __future__ import annotations
from dataclasses import dataclass
from hms_parameter.engine import ControlSource, ParameterEngine
from hms_artnet.fixtures import (
Layer64Engine,
Master32Engine,
TransportCommand,
)
from hms_artnet.patch import MASTER_CHANNELS, FixturePatch
from hms_artnet.receiver import DmxUpdate, LossBehavior
@dataclass
class RouterStats:
"""Telemetrie je Router (§28.2: Paketrate, Events)."""
updates_processed: int = 0
master_updates: int = 0
layer_updates: int = 0
load_commits: int = 0
blackouts: int = 0
class DmxToParameterRouter:
"""Routet DMX-Daten über den Patch in die Parameter-Engine.
Aufbau (§6.1B, §11):
- ein Master32Engine für das Master-Fixture
- je gepatchtem Layer ein Layer64Engine (Flankenzustand je Instanz)
- PreloadSlot je Layer für atomaren Clipwechsel (§12.2, §16.5)
Blackout (§16.3): Master-Kanal 3 mit SAFETY-Priorität; beim Aufheben
wird der Override releast (nicht auf 0 gesetzt) der darunterliegende
Zustand bleibt erhalten (§11.3 Release-Semantik).
"""
def __init__(
self,
patch: FixturePatch,
engine: ParameterEngine,
loss_behavior: LossBehavior = LossBehavior.HOLD,
) -> None:
self._patch = patch
self._engine = engine
self._loss_behavior = loss_behavior
self._master_engine = Master32Engine()
self._layer_engines: dict[str, Layer64Engine] = {}
self._master_blackout_active = False
self.stats = RouterStats()
# Preload-Slots je Layer (später vom Renderer bedient, §12.2)
self._pending_loads: dict[str, dict] = {} # layer_id → Auswahl
# ---------- DMX-Verarbeitung ----------
def handle_update(self, update: DmxUpdate) -> None:
"""Verarbeitet ein DMX-Update über den Patch.
- Master-Universe: 32 Kanäle ab Master-Basisadresse dekodieren
- Layer-Fixtures: 64 Kanäle je gepatchtem Layer im Universe
- Signalverlust (leere Daten): Policy je Verhalten (§16.1)
"""
self.stats.updates_processed += 1
if update.sequence == -1 and not update.data:
self._handle_signal_loss(update.universe)
return
self._process_master(update)
self._process_layers(update)
# ---------- Master (§16.3) ----------
def _process_master(self, update: DmxUpdate) -> None:
if update.universe != self._patch.master_universe:
return
start = self._patch.master_base_address - 1
segment = bytes(update.data[start : start + MASTER_CHANNELS])
if len(segment) < MASTER_CHANNELS:
return # Universe noch nicht vollständig; kein Fehler
control = self._master_engine.decode(segment)
self.stats.master_updates += 1
# Blackout: SAFETY-Priorität, Release beim Aufheben (§11.2, §11.3)
if control.blackout and not self._master_blackout_active:
self._engine.set_value("master/blackout", 1.0, ControlSource.SAFETY)
self._master_blackout_active = True
self.stats.blackouts += 1
elif not control.blackout and self._master_blackout_active:
self._engine.release("master/blackout", ControlSource.SAFETY)
self._master_blackout_active = False
src = ControlSource.CONSOLE # Lichtpult (§11.2 Priorität 3)
self._engine.set_value("master/intensity", control.master_intensity, src)
self._engine.set_value("master/global_speed", control.global_speed, src)
self._engine.set_value("master/bpm", control.bpm, src)
self._engine.set_value("master/global_hue", control.global_hue, src)
self._engine.set_value(
"master/global_saturation", control.global_saturation, src
)
self._engine.set_value(
"master/test_pattern", float(control.test_pattern), src
)
if control.events:
self.stats.load_commits += len(control.events)
# ---------- Layer (§16.4, §16.5) ----------
def _process_layers(self, update: DmxUpdate) -> None:
for entry in self._patch.layers():
if entry.universe != update.universe:
continue
layer_engine = self._layer_engines.setdefault(
entry.layer_id, Layer64Engine()
)
start = entry.base_address - 1
segment = bytes(update.data[start : start + 64])
if len(segment) < 64:
continue # Universe unvollständig: Layer still auslassen
control = layer_engine.decode(segment)
self.stats.layer_updates += 1
base = f"composition/{entry.composition_id}/layer/{entry.layer_id}"
src = ControlSource.CONSOLE
# Grundsteuerung (§16.4)
if control.enabled:
self._engine.set_value(f"{base}/enabled", 1.0, src)
else:
self._engine.set_value(f"{base}/enabled", 0.0, src)
self._engine.set_value(f"{base}/opacity", control.opacity, src)
self._engine.set_value(f"{base}/blend_mode", float(control.blend_mode), src)
self._engine.set_value(
f"{base}/transform/position_x", control.position_x, src
)
self._engine.set_value(
f"{base}/transform/position_y", control.position_y, src
)
self._engine.set_value(f"{base}/transform/scale_x", control.scale_x, src)
self._engine.set_value(f"{base}/transform/scale_y", control.scale_y, src)
self._engine.set_value(
f"{base}/transform/rotation_deg", control.rotation_deg, src
)
self._engine.set_value(f"{base}/color/hue", control.hue, src)
self._engine.set_value(
f"{base}/color/saturation", control.saturation, src
)
self._engine.set_value(
f"{base}/color/brightness", control.brightness, src
)
self._engine.set_value(f"{base}/color/contrast", control.contrast, src)
# Medien-Transport (§16.4 Kanäle 10-20, Media-Modus)
self._engine.set_value(
f"{base}/source/speed", control.speed, src
)
self._engine.set_value(
f"{base}/source/position", control.position, src
)
self._engine.set_value(
f"{base}/source/in_point", control.in_point, src
)
self._engine.set_value(
f"{base}/source/out_point", control.out_point, src
)
if control.transport is TransportCommand.RETRIGGER:
self._engine.set_value(f"{base}/source/retrigger", 1.0, src)
# FX-Steuerung (§16.4 Kanäle 41-62)
self._engine.set_value(
f"{base}/fx1/enabled", 1.0 if control.fx1_enabled else 0.0, src
)
self._engine.set_value(f"{base}/fx1/mix", control.fx1_mix, src)
self._engine.set_value(
f"{base}/fx2/enabled", 1.0 if control.fx2_enabled else 0.0, src
)
self._engine.set_value(f"{base}/fx2/mix", control.fx2_mix, src)
# Load/Commit-Events: atomare Auswahl (§16.5, §12.2)
for event in control.events:
if event.kind == "load_commit":
self.stats.load_commits += 1
self._pending_loads[entry.layer_id] = {
"bank": event.pending_bank,
"folder": event.pending_folder,
"index": event.pending_index,
"universe": update.universe,
}
elif event.kind == "retrigger":
self._engine.set_value(f"{base}/source/retrigger", 1.0, src)
# ---------- Ausstehende Loads (§12.2: Renderer bedient Preload) ----------
def pending_load_for(self, layer_id: str) -> dict | None:
"""Liefert die ausstehende Load-Auswahl eines Layers (für den
Renderer-Preload) und entfernt sie (Verbrauch durch Aufrufer)."""
return self._pending_loads.pop(layer_id, None)
# ---------- Signalverlust (§16.1, §11.3) ----------
def _handle_signal_loss(self, universe: int) -> None:
"""DMX-Ausfall auf einem Universe: Policy je Konfiguration.
- HOLD: nichts tun (letzter Zustand bleibt, §11.3)
- FADE_TO_BLACK: Master-Override auf 0 setzen (SAFETY)
"""
if self._loss_behavior is LossBehavior.HOLD:
return
if (
self._loss_behavior is LossBehavior.FADE_TO_BLACK
and universe == self._patch.master_universe
):
# Intensität über SAFETY auf 0; beim Wiederkommen releast die
# Master-Verarbeitung den Override nicht hier explizit setzen
self._engine.set_value("master/intensity", 0.0, ControlSource.SAFETY)