Files
hms-mediaengine/_entwicklung/packages/artnet/hms_artnet/patch.py
T
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

197 lines
6.8 KiB
Python

"""Patch-Verwaltung: Layer→DMX-Adressen und Export (PLAN.md §16.2).
Ein Patch ordnet Show-Layern DMX-Adressen zu:
- Master-Fixture: eine 32-Kanal-Instanz (§16.3)
- Layer-Fixtures: bis zu 8 Instanzen à 64 Kanäle je Universe (§16.2)
Validierung:
- Adressen lassen ihr Fixture vollständig ins Universe passen
- keine Überlappung im selben Universe (Fehler, kein stilles Verschieben)
- Layer-IDs eindeutig; Komposition/Layer müssen UUIDs sein (§10.2)
Export (§16.2): enthält Node-ID, Art-Net Short Name, IP, Net/SubNet/Universe,
Startadresse, Layernummer und Fixture-Version.
"""
from __future__ import annotations
import csv
import io
import uuid
from dataclasses import dataclass
MASTER_CHANNELS = 32 # §16.3
LAYER_CHANNELS = 64 # §16.4
DMX_CHANNELS = 512 # DMX512
class PatchError(Exception):
"""Ungültiger Patch (Überlappung, außerhalb des Universes, …)."""
@dataclass(frozen=True)
class PatchEntry:
"""Ein Layer-Fixture im Patch (§16.2).
- layer_id/composition_id: stabile UUIDs (§10.2)
- universe: 15-Bit Port-Address (Net<<8 | SubUni)
- base_address: 1-basierte DMX-Startadresse (1..449 für 64 Kanäle)
- layer_number: menschenlesbare Layernummer für den Export (1-basiert)
"""
layer_id: str
composition_id: str
universe: int
base_address: int
layer_number: int
def __post_init__(self) -> None:
for name, value in (("layer_id", self.layer_id), ("composition_id", self.composition_id)):
try:
uuid.UUID(value)
except (ValueError, AttributeError) as exc:
raise ValueError(f"{name} muss eine UUID sein (§10.2)") from exc
if not 0 <= self.universe < 0x8000:
raise ValueError("universe muss 0..0x7FFF sein")
if not 1 <= self.base_address <= DMX_CHANNELS - LAYER_CHANNELS + 1:
raise ValueError(
f"base_address {self.base_address} passt nicht für 64 Kanäle "
f"(erlaubt 1..{DMX_CHANNELS - LAYER_CHANNELS + 1})"
)
if self.layer_number < 1:
raise ValueError("layer_number muss 1-basiert sein")
@property
def end_address(self) -> int:
"""Letzte belegte DMX-Adresse (inklusiv)."""
return self.base_address + LAYER_CHANNELS - 1
class FixturePatch:
"""Vollständiger DMX-Patch eines Nodes (§16.2).
- master: Adresse des Master32-Fixtures (ein Universe, 1-basiert)
- layers: Liste von PatchEntry (Layer-Fixtures)
validate(): prüft Überlappungen; add/remove verwalten Einträge.
"""
def __init__(
self,
node_id: str,
short_name: str,
master_universe: int,
master_base_address: int = 1,
) -> None:
self.node_id = node_id
self.short_name = short_name
self.master_universe = master_universe
self.master_base_address = master_base_address
self._layers: dict[str, PatchEntry] = {} # layer_id → entry
# ---------- Verwaltung ----------
def add_layer(self, entry: PatchEntry) -> None:
"""Fügt ein Layer-Fixture hinzu; Überlappung im selben Universe = Fehler."""
if entry.layer_id in self._layers:
raise PatchError(f"Layer {entry.layer_id} bereits im Patch")
for existing in self._layers.values():
if existing.universe == entry.universe and self._overlaps(existing, entry):
raise PatchError(
f"Adressüberlappung Universe {entry.universe}: "
f"L{existing.layer_number} [{existing.base_address}.."
f"{existing.end_address}] vs "
f"L{entry.layer_number} [{entry.base_address}..{entry.end_address}]"
)
self._layers[entry.layer_id] = entry
def remove_layer(self, layer_id: str) -> None:
self._layers.pop(layer_id, None)
def layers(self) -> list[PatchEntry]:
return list(self._layers.values())
def layer_by_id(self, layer_id: str) -> PatchEntry | None:
return self._layers.get(layer_id)
def validate(self) -> list[str]:
"""Liefert Fehlerliste; leer = gültig. Master darf kein Layer überlappen."""
errors: list[str] = []
if not 1 <= self.master_base_address <= DMX_CHANNELS - MASTER_CHANNELS + 1:
errors.append(
f"Master-Adresse {self.master_base_address} passt nicht für "
f"32 Kanäle (erlaubt 1..{DMX_CHANNELS - MASTER_CHANNELS + 1})"
)
master_range = (
self.master_base_address,
self.master_base_address + MASTER_CHANNELS - 1,
)
for entry in self._layers.values():
if entry.universe == self.master_universe and self._range_overlap(
(entry.base_address, entry.end_address), master_range
):
errors.append(
f"Layer {entry.layer_number} überlappt Master-Fixture "
f"im Universe {self.master_universe}"
)
return errors
# ---------- Export (§16.2) ----------
def export_csv(self, ip_or_host: str = "", fixture_version: str = "1.0.0") -> str:
"""Menschenlesbare Kanalliste nach §16.2: enthält Node-ID, Short
Name, IP, Net/SubNet/Universe, Startadresse, Layernummer, Version."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(
[
"node_id",
"artnet_short_name",
"ip_or_host",
"fixture",
"universe",
"start_address",
"channels",
"layer_number",
"fixture_version",
]
)
writer.writerow(
[
self.node_id,
self.short_name,
ip_or_host,
"HMS MediaEngine Master 32ch",
self.master_universe,
self.master_base_address,
MASTER_CHANNELS,
"",
fixture_version,
]
)
for entry in sorted(self._layers.values(), key=lambda e: (e.universe, e.base_address)):
writer.writerow(
[
self.node_id,
self.short_name,
ip_or_host,
"HMS MediaEngine Layer 64ch",
entry.universe,
entry.base_address,
LAYER_CHANNELS,
entry.layer_number,
fixture_version,
]
)
return buf.getvalue()
# ---------- Interna ----------
@staticmethod
def _overlaps(a: PatchEntry, b: PatchEntry) -> bool:
return not (a.end_address < b.base_address or b.end_address < a.base_address)
@staticmethod
def _range_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool:
return not (a[1] < b[0] or b[1] < a[0])