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).
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 23:44:06 +02:00
parent 696e8eb1b3
commit 362e089be0
338 changed files with 24 additions and 387 deletions
@@ -0,0 +1,5 @@
"""hms_adaptive Adaptive Quality Controller (PLAN.md §5.2)."""
from hms_adaptive.controller import AdaptiveQualityController, QualityLevel
__all__ = ["AdaptiveQualityController", "QualityLevel"]
@@ -0,0 +1,88 @@
"""Adaptive Quality Controller (PLAN.md §5.2).
Verbindliche Regeln:
- Hysterese: höchstens eine Stufenänderung je Regelintervall
- Abwertung schnell auf anhaltende Last, Aufwertung deutlich langsamer
- Mindesthaltezeit je Stufe gegen Oszillation (kein Pumpen)
- geschützte Größen bleiben unverändert: physische Auflösung, Refresh,
Layer-Reihenfolge, aktive Layer, DMX-Zuordnung, Parametersemantik
- Wechsel nur an Framegrenze atomar anwenden; alle Varianten vorab kompiliert
"""
from __future__ import annotations
import enum
import time
from dataclasses import dataclass, field
class QualityLevel(enum.IntEnum):
LOW = 0
MEDIUM = 1
HIGH = 2
# Abwertungsschwellen p99 (ms) je aktueller Stufe
_DOWNGRADE_MS = {QualityLevel.HIGH: 14.0, QualityLevel.MEDIUM: 15.0}
# Aufwertungsschwellen p99 (ms): deutliche Reserve nötig
_UPGRADE_MS = {QualityLevel.MEDIUM: 10.0, QualityLevel.LOW: 8.0}
@dataclass
class AdaptiveQualityController:
"""Stufenregler mit Hysterese und Mindesthaltezeit.
step(p99_frame_ms) führt höchstens eine Stufenänderung je Intervall aus
und gibt die aktuelle Stufe zurück; der Aufrufer wendet sie atomar an
der Framegrenze an. Upgrade braucht deutlich mehr gute Intervalle als
Downgrade schlechte, damit kein sichtbares Pumpen entsteht.
"""
interval_ms: int = 500
min_hold_ms: int = 2000
downgrade_intervals: int = 2
upgrade_intervals: int = 6
level: QualityLevel = QualityLevel.HIGH
_last_change_ns: int = field(default_factory=time.monotonic_ns, repr=False)
_bad_intervals: int = field(default=0, repr=False)
_good_intervals: int = field(default=0, repr=False)
_reason: str = ""
def step(self, p99_frame_ms: float) -> QualityLevel:
"""Ein Regelschritt; gibt die (ggf. geänderte) Stufe zurück."""
now = time.monotonic_ns()
held_ms = (now - self._last_change_ns) / 1_000_000
budget = _DOWNGRADE_MS.get(self.level)
if budget is not None and p99_frame_ms > budget:
self._bad_intervals += 1
self._good_intervals = 0
if (
self._bad_intervals >= self.downgrade_intervals
and held_ms >= self.min_hold_ms
and self.level is not QualityLevel.LOW
):
self.level = QualityLevel(self.level - 1)
self._last_change_ns = now
self._bad_intervals = 0
self._reason = f"p99 {p99_frame_ms:.2f}ms > budget {budget}ms"
else:
self._bad_intervals = 0
target = _UPGRADE_MS.get(self.level)
if target is not None and p99_frame_ms < target:
self._good_intervals += 1
if (
self._good_intervals >= self.upgrade_intervals
and held_ms >= self.min_hold_ms
and self.level is not QualityLevel.HIGH
):
self.level = QualityLevel(self.level + 1)
self._last_change_ns = now
self._good_intervals = 0
self._reason = f"p99 {p99_frame_ms:.2f}ms < reserve {target}ms"
else:
self._good_intervals = 0
return self.level
@property
def last_reason(self) -> str:
return self._reason
@@ -0,0 +1,79 @@
"""hms_artnet Art-Net 4 Steuerung (PLAN.md §16).
ArtDMX-Empfang, ArtPoll/ArtPollReply (Discovery als Media Server, Style 0x02),
Fixture-Engine (Master32/Layer64, §16.3–§16.6), Patch-Verwaltung (§16.2),
DMX-zu-Parameter-Verkabelung (§11, §16), Universe-Plan (§16.2),
konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten.
Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert.
"""
from hms_artnet.fixtures import (
Layer64Engine,
LayerControl,
LayerEvent,
Master32Engine,
MasterControl,
MasterEvent,
SourceType,
TransportCommand,
UniverseCollisionError,
UniversePlan,
UniverseRange,
map_speed,
)
from hms_artnet.mapping import DmxLayerMapper, LayerDmxMapping, RisingEdge
from hms_artnet.packets import (
OPDMX,
OPPOLL,
OPPOLLREPLY,
UDP_PORT,
build_artpoll_reply,
build_dmx,
build_poll,
parse_dmx,
parse_poll,
parse_poll_reply,
)
from hms_artnet.patch import (
FixturePatch,
PatchEntry,
PatchError,
)
from hms_artnet.receiver import ArtNetReceiver, DmxUpdate, LossBehavior
from hms_artnet.wiring import DmxToParameterRouter, RouterStats
__all__ = [
"OPDMX",
"OPPOLL",
"OPPOLLREPLY",
"UDP_PORT",
"build_dmx",
"parse_dmx",
"build_poll",
"parse_poll",
"build_artpoll_reply",
"parse_poll_reply",
"ArtNetReceiver",
"DmxUpdate",
"LossBehavior",
"DmxLayerMapper",
"LayerDmxMapping",
"RisingEdge",
"Master32Engine",
"MasterControl",
"MasterEvent",
"Layer64Engine",
"LayerControl",
"LayerEvent",
"SourceType",
"TransportCommand",
"map_speed",
"UniversePlan",
"UniverseRange",
"UniverseCollisionError",
"FixturePatch",
"PatchEntry",
"PatchError",
"DmxToParameterRouter",
"RouterStats",
]
@@ -0,0 +1,423 @@
"""Art-Net-Fixture-Engine: Master32- und Layer64-Dekodierung
(PLAN.md §16.3, §16.4, §16.5, §16.6).
Dekodiert DMX-Kanäle in strukturale Steuerdaten und Emitting von
Trigger-Ereignissen (Flanken). Rein datenverarbeitend; alle Ergebnisse
fließen über die Parameter-Engine (§11), nie direkt in den Renderer.
Fixtures:
- HMS MediaEngine Master 32ch (§16.3)
- HMS MediaEngine Layer 64ch (§16.4): ein Layer-Fixture = exakt 64 Kanäle,
8 Layer = exakt 1 Universe (§16.2)
Verhalten:
- 16-Bit: MSB zuerst (DMX-Konvention)
- Trigger: steigende Flanke über Schwelle, kein Dauerzustand (§16.3/§16.5)
- Load/Commit (§16.5): Bank/Folder/Index-Änderungen erzeugen pending
selection; erst Flanke auf Load/Commit löst Preload+Umschaltung aus;
optional Auto-Load mit Debounce (V1: manuell)
- Pickup/Takeover (§16.5): Wertänderungen nach Plugin-/Quellwechsel werden
erst nach neuem Load/Commit interpretiert keine Parametersprünge
- Speed-Mapping (§16.6): signed 16-Bit; Mittelpunkt (32768) = Pause (0),
untere Hälfte 4x bis 0, obere Hälfte 0 bis +4x, definierter Wert 40960
entspricht exakt 1×
- reservierte Kanäle: neutral ignorieren (§16.3/§16.4), keine Fehler
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
# ---------- Master32 (§16.3) ----------
@dataclass(frozen=True)
class MasterEvent:
"""Ereignis des Master-Fixtures (nur bei Flanken)."""
kind: str # preset_recall | tap_tempo | release_overrides
@dataclass
class MasterControl:
"""Dekodierte Master32-Steuerdaten (§16.3)."""
master_intensity: float = 1.0 # 16 Bit Kanal 1-2 → 0..1
blackout: bool = False # Kanal 3: >=128, höchste Priorität
freeze_output: bool = False # Kanal 4
preset_bank: int = 0 # Kanal 5
preset_index: int = 0 # 16 Bit Kanal 6-7
transition_type: int = 0 # Kanal 9: Enum
transition_duration: float = 0.0 # 16 Bit Kanal 10-11 → 0..1 (Max konfiguriert)
global_speed: float = 1.0 # 16 Bit Kanal 12-13 (Speed-Mapping §16.6)
bpm: float = 120.0 # 16 Bit Kanal 14-15 → 20..300
audio_reactive: bool = False # Kanal 22
audio_gain: float = 1.0 # Kanal 23 → 0..2
automation_ai_enable: bool = False # Kanal 24: nur Freigabe
test_pattern: int = 0 # Kanal 25: Enum, 0 = aus
preview_enable: bool = False # Kanal 26
global_hue: float = 0.0 # Kanal 27 → -0.5..0.5
global_saturation: float = 1.0 # Kanal 28 → 0..2
fallback_preset: int = 0 # Kanal 29
events: list[MasterEvent] = field(default_factory=list)
class Master32Engine:
"""Dekodiert ein 32-Kanal-DMX-Segment in MasterControl.
Kanäle 1721 und 3132 sind reserviert und werden neutral ignoriert
(§16.3). Trigger-Kanäle (8 Preset Recall, 16 Tap Tempo, 30 Release)
emittieren Flanken-Ereignisse, gehaltene Werte nicht.
"""
TRIGGER_THRESHOLD = 128
def __init__(self) -> None:
self._prev: bytearray | None = None # letzter Kanalstand für Flanken
def decode(self, channels: bytes) -> MasterControl:
"""Dekodiert exakt 32 Kanäle; reservierte neutral ignorierend."""
if len(channels) < 32:
raise ValueError(f"Master32 benötigt 32 Kanäle, erhalten {len(channels)}")
prev = self._prev
curr = bytearray(channels[:32])
events: list[MasterEvent] = []
threshold = self.TRIGGER_THRESHOLD
def _rising(channel_index: int) -> bool:
"""Steigende Flanke: jetzt über Schwelle, vorher darunter."""
now_active = channels[channel_index] >= threshold
before_active = bool(prev and prev[channel_index] >= threshold)
return now_active and not before_active
if _rising(7): # Kanal 8: Preset Recall (§16.3: direkter Abruf)
events.append(MasterEvent(kind="preset_recall"))
if _rising(15): # Kanal 16: Tap Tempo
events.append(MasterEvent(kind="tap_tempo"))
if _rising(29): # Kanal 30: Release Manual Overrides (Schutzlogik §16.3)
events.append(MasterEvent(kind="release_overrides"))
self._prev = curr
speed_16 = decode_u16(channels, 11) # Kanal 12-13 (0-basiert 11-12)
bpm_16 = decode_u16(channels, 13) # Kanal 14-15
return MasterControl(
master_intensity=decode_u16(channels, 0) / 65535.0,
blackout=channels[2] >= self.TRIGGER_THRESHOLD,
freeze_output=channels[3] >= self.TRIGGER_THRESHOLD,
preset_bank=channels[4],
preset_index=decode_u16(channels, 5),
transition_type=channels[8],
transition_duration=decode_u16(channels, 9) / 65535.0,
global_speed=map_speed(speed_16),
bpm=20.0 + (bpm_16 / 65535.0) * 280.0, # 20..300 BPM
audio_reactive=channels[21] >= self.TRIGGER_THRESHOLD,
audio_gain=channels[22] / 255.0 * 2.0,
automation_ai_enable=channels[23] >= self.TRIGGER_THRESHOLD,
test_pattern=channels[24],
preview_enable=channels[25] >= self.TRIGGER_THRESHOLD,
global_hue=(channels[26] / 255.0) - 0.5,
global_saturation=channels[27] / 255.0 * 2.0,
fallback_preset=channels[28],
events=events,
)
# ---------- Layer64 (§16.4) ----------
class SourceType(StrEnum):
"""Kanal 4: Source Type (§16.4)."""
MEDIA = "media"
GENERATOR = "generator"
SOLID = "solid"
LIVE = "live" # später: Capture (§16.4)
class TransportCommand(StrEnum):
"""Kanal 10: Transport (§16.4)."""
STOP = "stop"
PLAY = "play"
PAUSE = "pause"
RETRIGGER = "retrigger"
@dataclass(frozen=True)
class LayerEvent:
"""Ereignisse des Layer-Fixtures (nur Flanken, §16.5)."""
kind: str # load_commit | retrigger
pending_bank: int = 0
pending_folder: int = 0
pending_index: int = 0
@dataclass
class LayerControl:
"""Dekodierte Layer64-Steuerdaten (§16.4).
source_block (Kanal 13-20) ist modusabhängig: bei Media =
Speed/Position/In/Out; bei Generator/Solid = G1..G8 (§16.4).
"""
enabled: bool = True # Kanal 1
opacity: float = 1.0 # 16 Bit Kanal 2-3
source_type: SourceType = SourceType.MEDIA # Kanal 4
media_bank: int = 0 # Kanal 5
media_folder: int = 0 # Kanal 6
media_index: int = 0 # 16 Bit Kanal 7-8 (pending bis Load)
transport: TransportCommand = TransportCommand.STOP # Kanal 10
loop_mode: int = 0 # Kanal 11: Enum
playback_direction: int = 0 # Kanal 12: Enum
speed: float = 1.0 # Kanal 13-14: signed Speed-Mapping (§16.6), Media-Modus
position: float = 0.0 # Kanal 15-16 → 0..1, Media-Modus
in_point: float = 0.0 # Kanal 17-18 → 0..1, Media-Modus
out_point: float = 1.0 # Kanal 19-20 → 0..1, Media-Modus
generator_params: tuple[float, ...] = () # G1..G8 (0..1), Generator/Solid
blend_mode: int = 0 # Kanal 21: Enum
position_x: float = 0.0 # 16 Bit signed Kanal 23-24 → -1..1
position_y: float = 0.0 # Kanal 25-26
scale_x: float = 1.0 # 16 Bit Kanal 27-28 → 0..4
scale_y: float = 1.0 # Kanal 29-30
rotation_deg: float = 0.0 # 16 Bit Kanal 31-32 → 0..360
crop_left: float = 0.0 # Kanal 33-36 → 0..1
crop_right: float = 0.0
crop_top: float = 0.0
crop_bottom: float = 0.0
hue: float = 0.0 # Kanal 37-40 → Reglerbereiche
saturation: float = 1.0
brightness: float = 1.0
contrast: float = 1.0
fx1_enabled: bool = False # Kanal 41
fx1_plugin: int = 0 # Kanal 42: Show-Registry
fx1_mix: float = 1.0 # Kanal 43 → 0..1
fx1_params: tuple[float, ...] = () # Kanal 44-51: P1-P8
fx2_enabled: bool = False # Kanal 52
fx2_plugin: int = 0 # Kanal 53
fx2_mix: float = 1.0 # Kanal 54
fx2_params: tuple[float, ...] = () # Kanal 55-62: P1-P8
events: list[LayerEvent] = field(default_factory=list)
def decode_u16(channels: bytes, msb_index: int) -> int:
"""16-Bit: MSB zuerst (DMX-Konvention)."""
return (channels[msb_index] << 8) | channels[msb_index + 1]
def decode_s16_normalized(channels: bytes, msb_index: int) -> float:
"""16-Bit signed → -1..1 (Position, §16.4)."""
raw = decode_u16(channels, msb_index)
return (raw / 32768.0) - 1.0
def map_speed(raw_u16: int) -> float:
"""Speed-Mapping nach §16.6 (signed 16-Bit):
- untere Hälfte (0..32767): 4× (bei 0) bis 0 (Richtung Mittelpunkt)
- Mittelpunkt (32768): Pause (0.0)
- obere Hälfte (32769..65535): 0+ bis +4× (Max konfiguriert)
- definierter Referenzwert 40960 entspricht exakt +1×
(denn (4096032768)/32768 × 4 = 1.0; dokumentiert in fixtures und
Fixture-Handbuch)
- Totzone um Pause optional (hier nicht implementiert, §16.6)
"""
half = 32768
if raw_u16 == half:
return 0.0 # Mittelpunkt = Pause (§16.6)
if raw_u16 < half:
# untere Hälfte: 0 → 4x, Richtung 32768 → 0 (monoton steigend)
return -(1.0 - raw_u16 / half) * 4.0
# obere Hälfte: 32769 → 0+, Richtung 65535 → +4x (monoton steigend)
return ((raw_u16 - half) / half) * 4.0
class Layer64Engine:
"""Dekodiert ein 64-Kanal-DMX-Segment in LayerControl (§16.4/§16.5).
Load/Commit (§16.5):
- Änderungen an Bank/Folder/Index/SourceType erzeugen nur eine pending
selection (kein direktes Laden)
- erst eine steigende Flanke auf Kanal 9 (Load/Commit) emittiert ein
load_commit-Ereignis mit der vollständigen Auswahl
- Pickup/Takeover: nach Load/Commit gilt die Auswahl als bestätigt;
Parameteränderungen wirken sofort (keine Sprünge, da Werte erst
nach Commit neu interpretiert werden)
"""
TRIGGER_THRESHOLD = 128
def __init__(self) -> None:
self._prev: bytearray | None = None
self._pending_loaded = True # beim Start: keine pending selection
def decode(self, channels: bytes) -> LayerControl:
if len(channels) < 64:
raise ValueError(f"Layer64 benötigt 64 Kanäle, erhalten {len(channels)}")
prev = self._prev
curr = bytearray(channels[:64])
events: list[LayerEvent] = []
def _rising(idx: int) -> bool:
now = channels[idx] >= self.TRIGGER_THRESHOLD
before = bool(prev and prev[idx] >= self.TRIGGER_THRESHOLD)
return now and not before
source_type = _source_type_of(channels[3])
# pending selection (§16.5): Auswahl ändert sich, laden erst bei Flanke
pending_changed = (
prev is None
or curr[4] != prev[4]
or curr[5] != prev[5]
or decode_u16(curr, 6) != decode_u16(prev, 6)
or curr[3] != prev[3]
)
if pending_changed:
self._pending_loaded = False
if _rising(8): # Kanal 9: Load/Commit Selection
events.append(
LayerEvent(
kind="load_commit",
pending_bank=channels[4],
pending_folder=channels[5],
pending_index=decode_u16(channels, 6),
)
)
self._pending_loaded = True
if _rising(62): # Kanal 63: Layer Retrigger/Reset
events.append(LayerEvent(kind="retrigger"))
self._prev = curr
# modusabhängiger Source-Block (Kanäle 13-20, §16.4)
speed = 1.0
position = 0.0
in_point = 0.0
out_point = 1.0
generator_params: tuple[float, ...] = ()
if source_type is SourceType.MEDIA:
speed = map_speed(decode_u16(channels, 12))
position = decode_u16(channels, 14) / 65535.0
in_point = decode_u16(channels, 16) / 65535.0
out_point = decode_u16(channels, 18) / 65535.0
else:
# Generator/Solid/Live: G1..G8 über Kanäle 13-20 (§16.4)
generator_params = tuple(c / 255.0 for c in channels[12:20])
return LayerControl(
enabled=channels[0] >= self.TRIGGER_THRESHOLD,
opacity=decode_u16(channels, 1) / 65535.0,
source_type=source_type,
media_bank=channels[4],
media_folder=channels[5],
media_index=decode_u16(channels, 6),
transport=_transport_of(channels[9]),
loop_mode=channels[10],
playback_direction=channels[11],
speed=speed,
position=position,
in_point=in_point,
out_point=out_point,
generator_params=generator_params,
blend_mode=channels[20],
position_x=decode_s16_normalized(channels, 22),
position_y=decode_s16_normalized(channels, 24),
scale_x=decode_u16(channels, 26) / 65535.0 * 4.0,
scale_y=decode_u16(channels, 28) / 65535.0 * 4.0,
rotation_deg=decode_u16(channels, 30) / 65535.0 * 360.0,
crop_left=channels[32] / 255.0,
crop_right=channels[33] / 255.0,
crop_top=channels[34] / 255.0,
crop_bottom=channels[35] / 255.0,
hue=(channels[36] / 255.0) - 0.5,
saturation=channels[37] / 255.0 * 2.0,
brightness=channels[38] / 255.0 * 2.0,
contrast=channels[39] / 255.0 * 2.0,
fx1_enabled=channels[40] >= self.TRIGGER_THRESHOLD,
fx1_plugin=channels[41],
fx1_mix=channels[42] / 255.0,
fx1_params=tuple(c / 255.0 for c in channels[43:51]),
fx2_enabled=channels[51] >= self.TRIGGER_THRESHOLD,
fx2_plugin=channels[52],
fx2_mix=channels[53] / 255.0,
fx2_params=tuple(c / 255.0 for c in channels[54:62]),
events=events,
)
@property
def selection_pending(self) -> bool:
"""True, wenn eine Auswahl geändert, aber noch nicht committed wurde."""
return not self._pending_loaded
def _source_type_of(value: int) -> SourceType:
"""Kanal 4: 0=Media, 1=Generator, 2=Solid, 3=Live (§16.4)."""
mapping = {
0: SourceType.MEDIA,
1: SourceType.GENERATOR,
2: SourceType.SOLID,
3: SourceType.LIVE,
}
return mapping.get(value, SourceType.MEDIA)
def _transport_of(value: int) -> TransportCommand:
"""Kanal 10: 0=Stop, 1=Play, 2=Pause, 3=Retrigger (§16.4)."""
mapping = {
0: TransportCommand.STOP,
1: TransportCommand.PLAY,
2: TransportCommand.PAUSE,
3: TransportCommand.RETRIGGER,
}
return mapping.get(value, TransportCommand.STOP)
# ---------- Universe-Plan (§16.2 Mehrserver-Patch) ----------
class UniverseCollisionError(Exception):
"""Überlappende Universe-Bereiche zweier Nodes (§16.2: Blocker)."""
@dataclass(frozen=True)
class UniverseRange:
"""Zusammenhängender Universe-Bereich eines Nodes (§16.2)."""
node_id: str
first: int # inklusiv
last: int # inklusiv
class UniversePlan:
"""Zentraler Universe-Plan: kollisionsfreie Bereiche je Node (§16.2).
- add: registriert einen Bereich; Überschneidung = Fehler (Blocker vor
Show-Lock), kein stillsches Zusammenführen
- overlaps: Preflight-Prüfung vor dem Show-Lock
"""
def __init__(self) -> None:
self._ranges: list[UniverseRange] = []
def add(self, rng: UniverseRange) -> None:
if rng.first > rng.last:
raise ValueError("first muss <= last sein")
for other in self._ranges:
if self._overlaps(rng, other):
raise UniverseCollisionError(
f"Universe-Kollision: {rng.node_id} [{rng.first}..{rng.last}] "
f"vs {other.node_id} [{other.first}..{other.last}] (§16.2)"
)
self._ranges.append(rng)
@staticmethod
def _overlaps(a: UniverseRange, b: UniverseRange) -> bool:
return not (a.last < b.first or b.last < a.first)
def overlaps(self, rng: UniverseRange) -> bool:
"""True, wenn der Bereich einen bestehenden schneidet (Preflight)."""
return any(self._overlaps(rng, other) for other in self._ranges)
def ranges(self) -> tuple[UniverseRange, ...]:
return tuple(self._ranges)
@@ -0,0 +1,111 @@
"""DMX→Parameter-Mapping (PLAN.md §16.416.6, §36 Nr. 8).
Bildet DMX-Kanäle eines Universes auf stabile Parameterpfade ab:
- 8-Bit: Byte / 255
- 16-Bit: (MSB << 8 | LSB) / 65535, MSB zuerst (DMX-Konvention)
- Flankenerkennung für Trigger (steigende Flanke, §16.3/§16.5)
- Signalverlust je konfigurierter Policy; HOLD ist V1-Standard (§11.3)
Alle Werte fließen ausschließlich über die Parameter-Engine in den Control
Core (§11); der Renderer wird nie direkt berührt.
"""
from __future__ import annotations
from dataclasses import dataclass
from hms_parameter.engine import ControlSource, ParameterEngine
from hms_parameter.paths import layer_opacity_path
from hms_artnet.receiver import DmxUpdate, LossBehavior
class RisingEdge:
"""Erkennt steigende Flanken über einer Schwelle (§16.5).
Ein Trigger ist ein Ereignis, kein Dauerzustand: derselbe gehaltene
Faderwert löst genau einmal aus; erst nach Rückkehr unter die Schwelle
kann erneut getriggert werden.
"""
def __init__(self, threshold: int = 64) -> None:
if not 0 <= threshold <= 255:
raise ValueError("threshold must be 0..255")
self.threshold = threshold
self._was_active = False
def feed(self, value: int) -> bool:
active = value >= self.threshold
triggered = active and not self._was_active
self._was_active = active
return triggered
@dataclass(frozen=True)
class LayerDmxMapping:
"""Layer-Fixture-Belegung (Auszug Layer64, §16.4).
base_address: 1-basierte DMX-Startadresse des Layer-Fixtures
Kanäle relativ: 1 = Enable, 23 = Opacity (16 Bit, MSB zuerst)
"""
universe: int
base_address: int
composition_id: str
layer_id: str
loss_behavior: LossBehavior = LossBehavior.HOLD
def __post_init__(self) -> None:
if not 0 <= self.universe < 0x8000:
raise ValueError("universe must be 0..0x7FFF")
if not 1 <= self.base_address <= 512 - 2:
raise ValueError("base_address must leave room for channels 1..3")
@property
def enable_path(self) -> str:
return f"composition/{self.composition_id}/layer/{self.layer_id}/enabled"
@property
def opacity_path(self) -> str:
return layer_opacity_path(self.composition_id, self.layer_id)
class DmxLayerMapper:
"""Wandelt DmxUpdates eines Universes in Parameter-Engine-Werte.
Phase-0-Umfang (§36 Nr. 8): DMX-Kanal auf Layer-Opacity mappen.
Media-Auswahl mit Load/Commit-Semantik (§16.5) folgt in Phase 2;
RisingEdge ist bereits getestet verfügbar.
"""
def __init__(
self,
mapping: LayerDmxMapping,
engine: ParameterEngine,
source: ControlSource = ControlSource.CONSOLE,
) -> None:
self._mapping = mapping
self._engine = engine
self._source = source
def _channel(self, data: bytes, relative: int) -> int:
"""Liest Kanal relativ zur Base-Adresse (1-basiert); 0 wenn zu kurz."""
idx = self._mapping.base_address - 1 + (relative - 1)
if 0 <= idx < len(data):
return data[idx]
return 0
def handle(self, update: DmxUpdate) -> None:
if update.universe != self._mapping.universe:
return
if update.sequence == -1 and not update.data:
# Signalverlust (§11.3, §16.1): Policy anwenden, niemals still
if self._mapping.loss_behavior is LossBehavior.FADE_TO_BLACK:
self._engine.release(self._mapping.opacity_path, self._source)
self._engine.release(self._mapping.enable_path, self._source)
# HOLD: letzten Zustand behalten keine Aktion
return
enable = 1.0 if self._channel(update.data, 1) >= 128 else 0.0
opacity = ((self._channel(update.data, 2) << 8) | self._channel(update.data, 3)) / 65535.0
self._engine.set_value(self._mapping.enable_path, enable, self._source)
self._engine.set_value(self._mapping.opacity_path, opacity, self._source)
@@ -0,0 +1,225 @@
"""Art-Net-Pakete: Bau und Parse (offizielle Art-Net-4-Spezifikation).
Verifizierte Regeln:
- ID: 'Art-Net\\0' (8 Bytes)
- OpCode: Int16 little-endian (low byte first)
- ProtVer: 14, high byte first (0x00 0x0E)
- ArtDMX: OpCode 0x5000, 18-Byte-Header + 2..512 Datenbytes, gerade Länge
- ArtPoll: OpCode 0x2000, 14 Bytes Kern, >= 14 akzeptieren
- ArtPollReply: OpCode 0x2100, 210 Bytes, Style 0x02 = StMedia,
NodeReport-Format '#hhhh [hhhh] text', Port 0x1936
"""
from __future__ import annotations
import struct
from dataclasses import dataclass
ARTNET_ID = b"Art-Net\x00"
PROTVER = 14
OPDMX = 0x5000
OPPOLL = 0x2000
OPPOLLREPLY = 0x2100
UDP_PORT = 0x1936 # 6454
STYLE_STMEDIA = 0x02
def _header(opcode: int) -> bytes:
"""ID + OpCode (little-endian) + ProtVer 14 (high byte first).
Endianness gemäß Spezifikation: OpCode low byte first, ProtVer
dagegen high byte first (0x00 0x0E).
"""
return ARTNET_ID + struct.pack("<H", opcode) + struct.pack(">H", PROTVER)
def build_dmx(universe: int, data: bytes, sequence: int = 0, physical: int = 0) -> bytes:
"""Baut ein ArtDMX-Paket (OpCode 0x5000).
universe: 15-bit Port-Address (Net<<8 | SubUni)
data: 2..512 Kanalbytes; Länge muss gerade sein, wird aufgerundet.
"""
if not 0 <= universe < 0x8000:
raise ValueError("universe must be 0..0x7FFF")
if not 2 <= len(data) <= 512:
raise ValueError("data must be 2..512 bytes")
if len(data) % 2:
data = data + b"\x00"
length = len(data)
sub_uni = universe & 0xFF
net = (universe >> 8) & 0x7F
return (
_header(OPDMX)
+ struct.pack(">BB", sequence & 0xFF, physical & 0xFF)
+ struct.pack(">BB", sub_uni, net)
+ struct.pack(">H", length)
+ data
)
@dataclass(frozen=True)
class DmxPacket:
sequence: int
physical: int
universe: int
data: bytes
def parse_dmx(packet: bytes) -> DmxPacket | None:
"""Parst ein ArtDMX-Paket; None wenn kein gültiges ArtDMX."""
if len(packet) < 18 or packet[:8] != ARTNET_ID:
return None
(opcode,) = struct.unpack_from("<H", packet, 8)
if opcode != OPDMX:
return None
(protver,) = struct.unpack_from(">H", packet, 10)
if protver < 14:
return None
sequence = packet[12]
physical = packet[13]
sub_uni = packet[14]
net = packet[15] & 0x7F
(length,) = struct.unpack_from(">H", packet, 16)
if length < 2 or length > 512:
return None
if len(packet) < 18 + length:
return None
return DmxPacket(sequence, physical, (net << 8) | sub_uni, bytes(packet[18 : 18 + length]))
def build_poll(talk_to_me: int = 0x00, priority: int = 0x0A) -> bytes:
"""Baut ein ArtPoll-Paket (OpCode 0x2000, 14 Bytes Kern)."""
return _header(OPPOLL) + struct.pack(">BB", talk_to_me, priority)
@dataclass(frozen=True)
class PollPacket:
talk_to_me: int
priority: int
def parse_poll(packet: bytes) -> PollPacket | None:
"""Parst ein ArtPoll; akzeptiert >= 14 Bytes (fehlende Felder = 0)."""
if len(packet) < 14 or packet[:8] != ARTNET_ID:
return None
(opcode,) = struct.unpack_from("<H", packet, 8)
if opcode != OPPOLL:
return None
(protver,) = struct.unpack_from(">H", packet, 10)
if protver < 14:
return None
return PollPacket(packet[12], packet[13])
@dataclass(frozen=True)
class PollReplyInfo:
ip: str
short_name: str
long_name: str
node_report: str
style: int
bind_index: int
net_switch: int
sub_switch: int
num_ports: int
port_types: bytes
sw_in: bytes
sw_out: bytes
mac: bytes
def build_artpoll_reply(
ip: bytes,
short_name: str,
long_name: str,
node_report: str = "Media Server Ready",
report_code: int = 0x0000,
error_count: int = 0,
style: int = STYLE_STMEDIA,
mac: bytes = b"\x00" * 6,
net_switch: int = 0,
sub_switch: int = 0,
num_ports: int = 1,
port_types: bytes = b"\x80\x00\x00\x00", # Port 0: DMX512, Output-fähig
good_output: bytes = b"\x00\x00\x00\x00",
sw_out: bytes = b"\x00\x00\x00\x00",
bind_index: int = 1,
esta_man: int = 0x0000, # unregistriert; ESTA-Code später beantragen
vers_info: int = 0x00010000,
) -> bytes:
"""Baut ein ArtPollReply (exakt 210 Bytes) als Media Server (Style 0x02)."""
if len(ip) != 4:
raise ValueError("ip must be 4 bytes")
if len(mac) != 6:
raise ValueError("mac must be 6 bytes")
short = short_name.encode("ascii", errors="replace")[:17]
long = long_name.encode("ascii", errors="replace")[:63]
report = f"#{report_code:04X} [{error_count:04X}] {node_report}".encode(
"ascii", errors="replace"
)[:63]
pkt = bytearray()
pkt += ARTNET_ID
pkt += struct.pack("<H", OPPOLLREPLY)
pkt += ip
pkt += struct.pack(">H", UDP_PORT)
pkt += struct.pack(">I", vers_info)
pkt += struct.pack(">B", net_switch & 0x7F)
pkt += struct.pack(">B", sub_switch & 0x0F)
pkt += struct.pack(">H", 0x0000) # OEM: Platzhalter bis Registrierung
pkt += b"\x00" # UbeaVersion
pkt += b"\x00" # Status1
pkt += struct.pack(">H", esta_man) # ESTA Manufacturer, high byte first
pkt += short.ljust(18, b"\x00")
pkt += long.ljust(64, b"\x00")
pkt += report.ljust(64, b"\x00")
pkt += struct.pack(">BB", 0, num_ports & 0x03) # NumPortsLo 0..4
pkt += port_types[:4].ljust(4, b"\x00")
pkt += b"\x00" * 4 # GoodInput (kein DMX-In in V1)
pkt += good_output[:4].ljust(4, b"\x00")
pkt += b"\x00" * 4 # SwIn
pkt += sw_out[:4].ljust(4, b"\x00")
pkt += b"\x00" * 3 # SwVideo, SwMacro, SwRemote (deprecated = 0)
pkt += b"\x00" * 3 # Spare1..3
pkt += struct.pack(">B", style)
pkt += mac
pkt += struct.pack(">B", bind_index)
if len(pkt) != 210:
raise AssertionError(f"ArtPollReply must be 210 bytes, got {len(pkt)}")
return bytes(pkt)
def parse_poll_reply(packet: bytes) -> PollReplyInfo | None:
"""Parst ein ArtPollReply (akzeptiert >= 210 Bytes)."""
if len(packet) < 210 or packet[:8] != ARTNET_ID:
return None
(opcode,) = struct.unpack_from("<H", packet, 8)
if opcode != OPPOLLREPLY:
return None
ip = ".".join(str(b) for b in packet[10:14])
net_switch = packet[20] & 0x7F
sub_switch = packet[21] & 0x0F
short_name = packet[28:46].split(b"\x00")[0].decode("ascii", errors="replace")
long_name = packet[46:110].split(b"\x00")[0].decode("ascii", errors="replace")
node_report = packet[110:174].split(b"\x00")[0].decode("ascii", errors="replace")
num_ports = packet[175]
port_types = bytes(packet[176:180])
sw_in = bytes(packet[188:192])
sw_out = bytes(packet[192:196])
style = packet[202]
mac = bytes(packet[203:209])
bind_index = packet[209]
return PollReplyInfo(
ip=ip,
short_name=short_name,
long_name=long_name,
node_report=node_report,
style=style,
bind_index=bind_index,
net_switch=net_switch,
sub_switch=sub_switch,
num_ports=num_ports,
port_types=port_types,
sw_in=sw_in,
sw_out=sw_out,
mac=mac,
)
@@ -0,0 +1,196 @@
"""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])
@@ -0,0 +1,169 @@
"""Art-Net-Empfänger (PLAN.md §16.1).
- UDP 6454, wählbare Schnittstelle, optional Sender-Allowlist
- ArtPoll → ArtPollReply als Media Server (Style 0x02)
- ArtDMX-Sequenznummern auswerten, soweit vorhanden
- mehrere Sender werden nicht still zusammengeführt: je Universe wird der
aktive Sender vermerkt; ein Senderwechsel wird protokolliert (§16.1, §16.2)
- Signalverlust je Universe konfigurierbar (hold/fade_to_scene/fade_to_black)
"""
from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
from hms_artnet.packets import build_artpoll_reply, parse_dmx, parse_poll
class LossBehavior(Enum):
"""Verhalten bei DMX-Signalverlust (§11.3)."""
HOLD = "hold"
FADE_TO_BLACK = "fade_to_black"
FADE_TO_SCENE = "fade_to_scene"
DISABLE_SOURCE = "disable_source"
@dataclass(frozen=True)
class DmxUpdate:
"""Ein DMX-Update je Universe; sequence=-1 und data=b'' = Signalverlust."""
universe: int
data: bytes
sender_ip: str
received_ns: int
sequence: int
@dataclass
class UniverseTelemetry:
universe: int
packets: int = 0
last_received_ns: int = 0
last_sender_ip: str = ""
sender_changed: int = 0
sequence_gaps: int = 0
loss_reported: bool = False
@dataclass
class ArtNetReceiver:
"""Asynchroner Art-Net-Empfänger (asyncio-Datagramm, ein Socket)."""
universes: set[int] = field(default_factory=set)
bind_host: str = "0.0.0.0"
port: int = 6454
short_name: str = "HMS MediaEngine"
long_name: str = "HMS MediaEngine Render Node"
node_ip: bytes = b"\x7f\x00\x00\x01"
mac: bytes = b"\x00\x00\x00\x00\x00\x00"
sender_allowlist: set[str] = field(default_factory=set)
timeout_ms: int = 2500
loss_behavior: LossBehavior = LossBehavior.HOLD
_transport: object | None = field(default=None, repr=False)
_telemetry: dict[int, UniverseTelemetry] = field(default_factory=dict, repr=False)
_last_sequence: dict[int, int] = field(default_factory=dict, repr=False)
_handlers: list[Callable[[DmxUpdate], None]] = field(default_factory=list, repr=False)
_watchdog_task: object | None = field(default=None, repr=False)
def on_dmx(self, handler: Callable[[DmxUpdate], None]) -> None:
self._handlers.append(handler)
async def start(self) -> None:
loop = asyncio.get_running_loop()
self._transport, _ = await loop.create_datagram_endpoint(
lambda: _Protocol(self), local_addr=(self.bind_host, self.port)
)
self._watchdog_task = loop.create_task(self._signal_watchdog())
async def stop(self) -> None:
if self._watchdog_task:
self._watchdog_task.cancel()
self._watchdog_task = None
if self._transport:
self._transport.close()
self._transport = None
def telemetry(self) -> dict[int, UniverseTelemetry]:
return dict(self._telemetry)
def _handle_datagram(self, data: bytes, addr: tuple) -> None:
sender_ip = addr[0] if addr else ""
if self.sender_allowlist and sender_ip not in self.sender_allowlist:
return
if parse_poll(data) is not None:
reply = build_artpoll_reply(
ip=self.node_ip,
short_name=self.short_name,
long_name=self.long_name,
node_report="Media Server Ready",
mac=self.mac,
)
if self._transport is not None:
self._transport.sendto(reply, addr)
return
dmx = parse_dmx(data)
if dmx is None or dmx.universe not in self.universes:
return
tel = self._telemetry.setdefault(dmx.universe, UniverseTelemetry(dmx.universe))
if dmx.sequence != 0:
last = self._last_sequence.get(dmx.universe)
if last is not None and dmx.sequence != ((last + 1) & 0xFF):
tel.sequence_gaps += 1
self._last_sequence[dmx.universe] = dmx.sequence
if tel.last_sender_ip and tel.last_sender_ip != sender_ip:
tel.sender_changed += 1
tel.packets += 1
tel.last_received_ns = time.monotonic_ns()
tel.last_sender_ip = sender_ip
tel.loss_reported = False
update = DmxUpdate(
universe=dmx.universe,
data=dmx.data,
sender_ip=sender_ip,
received_ns=tel.last_received_ns,
sequence=dmx.sequence,
)
for handler in list(self._handlers):
handler(update)
async def _signal_watchdog(self) -> None:
while True:
await asyncio.sleep(0.5)
now = time.monotonic_ns()
threshold = self.timeout_ms * 1_000_000
for uni, tel in list(self._telemetry.items()):
if (
tel.last_received_ns
and not tel.loss_reported
and now - tel.last_received_ns > threshold
):
tel.loss_reported = True
for handler in list(self._handlers):
handler(
DmxUpdate(
universe=uni,
data=b"",
sender_ip=tel.last_sender_ip,
received_ns=now,
sequence=-1,
)
)
class _Protocol(asyncio.DatagramProtocol):
def __init__(self, receiver: ArtNetReceiver) -> None:
self._receiver = receiver
def datagram_received(self, data: bytes, addr: tuple) -> None:
self._receiver._handle_datagram(data, addr)
def error_received(self, exc: Exception) -> None:
# Socket-Fehler nicht schlucken (§33); an Watchdog-Protokoll escalate via log
import logging
logging.getLogger("hms.artnet").error("Art-Net socket error: %s", exc)
@@ -0,0 +1,228 @@
"""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)
@@ -0,0 +1,317 @@
"""Audio-Analyse-Engine (PLAN.md §20).
- Peak und RMS
- FFT-Spektrum mit konfigurierbaren Frequenzbändern
- Bass, Low-Mid, Mid, High-Mid, Treble
- Spectral Flux / Onset
- Beat-Trigger und BPM-Schätzung
- Beat-Phase und Confidence
Kein LLM, keine Cloudanfrage im Audiothread (§20.3). Ringbuffer statt
unkontrollierter Queues. Feature-Snapshots timestamped mit der gemeinsamen
monotonen Zeitbasis (§12.2).
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
@dataclass(frozen=True)
class AudioFeatures:
"""Feature-Snapshot einer Analyse-Periode (§20.2, timestamped §20.3)."""
rms: float = 0.0
peak: float = 0.0
bass: float = 0.0
low_mid: float = 0.0
mid: float = 0.0
high_mid: float = 0.0
treble: float = 0.0
spectral_flux: float = 0.0
beat: bool = False
beat_confidence: float = 0.0
bpm: float = 0.0
beat_phase: float = 0.0
silence: bool = True
monotonic_ns: int = 0
@dataclass
class BandConfig:
"""Frequenzband-Konfiguration in Hz (§20.2: konfigurierbare Bänder)."""
bass_max: float = 250.0
low_mid_max: float = 800.0
mid_max: float = 2500.0
high_mid_max: float = 8000.0
treble_max: float = 20000.0
class RingBuffer:
"""Kreisring für Audio-Samples (§20.3: Ringbuffer statt Queues)."""
def __init__(self, capacity: int) -> None:
if capacity <= 0:
raise ValueError("capacity must be positive")
self._data = [0.0] * capacity
self._size = 0
self._head = 0
self._capacity = capacity
def push(self, value: float) -> None:
self._data[self._head] = value
self._head = (self._head + 1) % self._capacity
self._size = min(self._size + 1, self._capacity)
def extend(self, values: list[float]) -> None:
for v in values:
self.push(v)
def latest(self, count: int) -> list[float]:
"""Die letzten `count` Werte in chronologischer Reihenfolge."""
count = min(count, self._size)
result = []
start = (self._head - count) % self._capacity
for i in range(count):
result.append(self._data[(start + i) % self._capacity])
return result
def __len__(self) -> int:
return self._size
@property
def capacity(self) -> int:
return self._capacity
def compute_rms(samples: list[float]) -> float:
"""Root Mean Square (§20.2)."""
if not samples:
return 0.0
return math.sqrt(sum(s * s for s in samples) / len(samples))
def compute_peak(samples: list[float]) -> float:
"""Absoluter Maximalwert (§20.2)."""
return max((abs(s) for s in samples), default=0.0)
def compute_fft_magnitude(samples: list[float], sample_rate: float) -> list[float]:
"""Vereinfachte FFT über DFT (ohne NumPy im Livepfad; für kleine Fenster).
Nutzt das Discrete Fourier Transform O(n²). Für Produktionsbetrieb wird
diese durch GStreamer-FFT oder rustfft ersetzt hier als plattformneutrale
Referenzimplementierung mit deterministischen Ergebnissen.
"""
n = len(samples)
if n == 0 or sample_rate <= 0:
return []
result: list[float] = []
for k in range(n // 2):
real = 0.0
imag = 0.0
for t, sample in enumerate(samples):
angle = 2.0 * math.pi * k * t / n
real += sample * math.cos(angle)
imag -= sample * math.sin(angle)
result.append(math.sqrt(real * real + imag * imag) / n)
return result
def frequency_of_bin(bin_index: int, fft_size: int, sample_rate: float) -> float:
"""Frequenz eines FFT-Bins in Hz."""
if fft_size == 0:
return 0.0
return bin_index * sample_rate / fft_size
def compute_band_energy(
magnitudes: list[float],
sample_rate: float,
low_hz: float,
high_hz: float,
) -> float:
"""Energie in einem Frequenzband (normalisiert auf 0..1)."""
if not magnitudes:
return 0.0
fft_size = len(magnitudes) * 2
total = 0.0
count = 0
for i, mag in enumerate(magnitudes):
freq = frequency_of_bin(i, fft_size, sample_rate)
if low_hz <= freq < high_hz:
total += mag
count += 1
if count == 0:
return 0.0
return min(total / count, 1.0)
def compute_spectral_flux(
current: list[float],
previous: list[float],
) -> float:
"""Spectral Flux: Summe der positiven Änderungen (§20.2 Onset)."""
if len(current) != len(previous) or not current:
return 0.0
flux = 0.0
for cur, prev in zip(current, previous, strict=False):
diff = cur - prev
if diff > 0:
flux += diff
return flux
@dataclass
class BeatDetector:
"""Beat-Erkennung über Spectral Flux mit adaptivem Schwellwert (§20.2).
- feed(flux): neuer Flux-Wert je Analyse-Periode
- beat: True bei erkanntem Beat (Schwellwert + Mindestabstand)
- bpm: Schätzung über Inter-Beat-Intervalle
- confidence: Verhältnis erkannter Beats zu erwarteten
"""
threshold_factor: float = 1.5 # über Mittelwert des Flux-Fensters
min_interval_s: float = 0.25 # 240 BPM Maximum
window_size: int = 43 # ~0.5 s bei 86 Hz Analyse-Rate
_flux_history: list[float] = field(default_factory=list)
_last_beat_ns: int = -1 # -1 = noch kein Beat (Sentinel)
_beat_intervals: list[float] = field(default_factory=list)
bpm: float = 0.0
beat_phase: float = 0.0
confidence: float = 0.0
beat_active: bool = False
def feed(self, flux: float, now_ns: int) -> bool:
"""Verarbeitet einen Flux-Wert; True bei erkanntem Beat."""
self._flux_history.append(flux)
if len(self._flux_history) > self.window_size:
self._flux_history.pop(0)
self.beat_active = False
if len(self._flux_history) < 4:
return False
mean_flux = sum(self._flux_history) / len(self._flux_history)
threshold = mean_flux * self.threshold_factor
# Mindestabstand prüfen (nicht mehr als 240 BPM)
# _last_beat_ns == -1 bedeutet: noch kein Beat erkannt → immer zulassen
if self._last_beat_ns >= 0:
since_last = (now_ns - self._last_beat_ns) / 1e9
else:
since_last = float("inf") # erster Beat ist immer erlaubt
if flux > threshold and since_last >= self.min_interval_s:
self.beat_active = True
interval = since_last if self._last_beat_ns >= 0 else 0.0
if 0.0 < interval < 3.0: # max 3 s zwischen Beats
self._beat_intervals.append(interval)
if len(self._beat_intervals) > 12:
self._beat_intervals.pop(0)
# BPM als Median der letzten Intervalle
sorted_intervals = sorted(self._beat_intervals)
median = sorted_intervals[len(sorted_intervals) // 2]
if median > 0:
self.bpm = 60.0 / median
self.beat_phase = (now_ns % int(median * 1e9)) / (median * 1e9)
self._last_beat_ns = now_ns
self.confidence = min(
len(self._beat_intervals) / 8.0, 1.0
)
return self.beat_active
def update_phase(self, now_ns: int) -> None:
"""Aktualisiert die Beat-Phase kontinuierlich zwischen Beats."""
if self.bpm > 0:
period_ns = int((60.0 / self.bpm) * 1e9)
if period_ns > 0:
self.beat_phase = (now_ns % period_ns) / period_ns
class AudioAnalyzer:
"""Vollständige Audio-Analyse pro Periode (§20.2, §20.3).
- feed(samples): neue Audiosamples (mono, -1..1)
- analyze(): berechnet Features und gibt einen AudioFeatures-Snapshot
- Ringbuffer begrenzt Speicher (§33: kein unbeschränkter Zustand)
"""
SAMPLE_RATE = 44100.0
WINDOW_SIZE = 512 # FFT-Fenster
SILENCE_THRESHOLD = 0.001
def __init__(self, bands: BandConfig | None = None) -> None:
self._bands = bands or BandConfig()
self._samples = RingBuffer(self.WINDOW_SIZE * 2)
self._prev_magnitudes: list[float] = []
self._beat_detector = BeatDetector()
self._last_features = AudioFeatures()
@property
def features(self) -> AudioFeatures:
return self._last_features
def feed(self, samples: list[float]) -> None:
"""Fügt neue Samples in den Ringbuffer ein."""
self._samples.extend(samples)
def analyze(self, now_ns: int | None = None) -> AudioFeatures:
"""Berechnet den nächsten Feature-Snapshot.
Läuft typischerweise 50-100 mal pro Sekunde (§20.3).
"""
now = now_ns if now_ns is not None else time.monotonic_ns()
window = self._samples.latest(self.WINDOW_SIZE)
if len(window) < self.WINDOW_SIZE // 2:
return self._last_features # nicht genug Daten
rms = compute_rms(window)
peak = compute_peak(window)
silence = rms < self.SILENCE_THRESHOLD
magnitudes = compute_fft_magnitude(window, self.SAMPLE_RATE)
bass = compute_band_energy(
magnitudes, self.SAMPLE_RATE, 0, self._bands.bass_max
)
low_mid = compute_band_energy(
magnitudes, self.SAMPLE_RATE, self._bands.bass_max, self._bands.low_mid_max
)
mid = compute_band_energy(
magnitudes, self.SAMPLE_RATE, self._bands.low_mid_max, self._bands.mid_max
)
high_mid = compute_band_energy(
magnitudes, self.SAMPLE_RATE, self._bands.mid_max, self._bands.high_mid_max
)
treble = compute_band_energy(
magnitudes, self.SAMPLE_RATE, self._bands.high_mid_max, self._bands.treble_max
)
flux = compute_spectral_flux(magnitudes, self._prev_magnitudes)
self._prev_magnitudes = magnitudes
beat = self._beat_detector.feed(flux, now)
self._beat_detector.update_phase(now)
features = AudioFeatures(
rms=rms,
peak=peak,
bass=bass,
low_mid=low_mid,
mid=mid,
high_mid=high_mid,
treble=treble,
spectral_flux=flux,
beat=beat,
beat_confidence=self._beat_detector.confidence,
bpm=self._beat_detector.bpm,
beat_phase=self._beat_detector.beat_phase,
silence=silence,
monotonic_ns=now,
)
self._last_features = features
return features
@@ -0,0 +1,224 @@
"""Audio-Mapping-Engine (PLAN.md §20.4).
Jedes Audiofeature kann über ein Binding auf einen Parameter wirken:
Audiofeature → Gate/Threshold → Normalisierung → Gain → Kurve →
Attack/Release → Min/Max → optional Quantisierung → Zielparameter
Bindings sind speicherbar, aktivierbar und priorisierbar (§20.4).
Ohne-Audio-Modulatoren: LFO, Random, Envelope, Step Sequencer (§20.5).
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass, field
from enum import StrEnum
from hms_audio import AudioFeatures
class CurveType(StrEnum):
"""Anwendungskurven (§20.4)."""
LINEAR = "linear"
QUADRATIC = "quadratic"
CUBIC = "cubic"
EXPONENTIAL = "exponential"
def apply_curve(value: float, curve: CurveType) -> float:
"""Wendet eine Kurve auf einen 0..1-Wert an."""
value = max(0.0, min(1.0, value))
if curve is CurveType.LINEAR:
return value
if curve is CurveType.QUADRATIC:
return value * value
if curve is CurveType.CUBIC:
return value * value * value
if curve is CurveType.EXPONENTIAL:
return math.pow(value, 4.0) if value > 0 else 0.0
return value
@dataclass
class AudioBinding:
"""Ein Audio→Parameter-Binding (§20.4).
Pipeline: Gate → Normalize → Gain → Curve → Attack/Release → Clamp.
"""
id: str
feature: str # rms, peak, bass, mid, treble, beat, beat_phase
parameter_path: str
threshold: float = 0.05 # Gate: Feature muss darüber liegen
gain: float = 1.0
curve: CurveType = CurveType.LINEAR
attack_s: float = 0.01 # Anstiegszeit
release_s: float = 0.1 # Abfallzeit
min_value: float = 0.0
max_value: float = 1.0
enabled: bool = True
# Interner Zustand
_current: float = field(default=0.0, repr=False)
_last_update_ns: int = field(default=0, repr=False)
def process(self, features: AudioFeatures, now_ns: int) -> float:
"""Verarbeitet ein Feature-Snapshot; gibt den Parameterwert zurück.
Attack/Release: exponentielle Glättung mit Zeitschritten.
"""
if not self.enabled:
return self._current
raw = getattr(features, self.feature, 0.0)
if isinstance(raw, bool):
raw = 1.0 if raw else 0.0
# Gate: unter Schwelle → 0
if raw < self.threshold:
raw = 0.0
else:
raw = (raw - self.threshold) / (1.0 - self.threshold)
# Gain + Kurve
shaped = apply_curve(min(raw * self.gain, 1.0), self.curve)
# Attack/Release mit dt
if self._last_update_ns > 0:
dt_s = (now_ns - self._last_update_ns) / 1e9
if dt_s > 0:
if shaped > self._current:
rate = dt_s / max(self.attack_s, 0.001)
else:
rate = dt_s / max(self.release_s, 0.001)
self._current += (shaped - self._current) * min(rate, 1.0)
else:
self._current = shaped
self._last_update_ns = now_ns
# Clamp auf Min/Max
return self.min_value + self._current * (self.max_value - self.min_value)
@dataclass
class LFO:
"""LFO-Modulator ohne Audio (§20.5): Sine/Triangle/Saw/Square."""
id: str
waveform: str = "sine" # sine | triangle | saw | square
rate_hz: float = 1.0
min_value: float = 0.0
max_value: float = 1.0
phase: float = 0.0
def process(self, now_ns: int) -> float:
t = now_ns / 1e9
phase = (self.phase + t * self.rate_hz) % 1.0
if self.waveform == "sine":
raw = 0.5 + 0.5 * math.sin(2.0 * math.pi * phase)
elif self.waveform == "triangle":
raw = abs(2.0 * phase - 1.0)
elif self.waveform == "saw":
raw = phase
else: # square
raw = 1.0 if phase < 0.5 else 0.0
return self.min_value + raw * (self.max_value - self.min_value)
@dataclass
class RandomModulator:
"""Random-Modulator mit Seed (§20.5)."""
id: str
rate_hz: float = 2.0
min_value: float = 0.0
max_value: float = 1.0
seed: int = 0
_rng: random.Random = field(default_factory=lambda: random.Random(), repr=False)
_last_step: int = 0
_current: float = 0.0
def __post_init__(self) -> None:
self._rng = random.Random(self.seed)
def process(self, now_ns: int) -> float:
step = int((now_ns / 1e9) * self.rate_hz)
if step != self._last_step:
self._last_step = step
self._current = self._rng.random()
return self.min_value + self._current * (self.max_value - self.min_value)
@dataclass
class StepSequencer:
"""Step-Sequencer (§20.5): BPM-synchron, 8-16 Steps."""
id: str
steps: list[float] = field(default_factory=lambda: [0.0] * 16)
bpm: float = 120.0
min_value: float = 0.0
max_value: float = 1.0
def process(self, now_ns: int) -> float:
if not self.steps:
return self.min_value
period_s = 60.0 / max(self.bpm, 1.0)
t = now_ns / 1e9
step_index = int(t / period_s) % len(self.steps)
raw = self.steps[step_index]
return self.min_value + raw * (self.max_value - self.min_value)
class ModulatorEngine:
"""Verwaltet alle Modulatoren und Audio-Bindings (§20.4, §20.5).
- process_audio(features, now): verarbeitet alle aktiven Audio-Bindings
- process_modulators(now): verarbeitet LFO/Random/Sequencer
- Ergebnisse werden über die Parameter-Engine angewendet (§11:
AUDIO-Priorität 6)
"""
def __init__(self) -> None:
self.audio_bindings: dict[str, AudioBinding] = {}
self.lfos: dict[str, LFO] = {}
self.randoms: dict[str, RandomModulator] = {}
self.sequencers: dict[str, StepSequencer] = {}
def add_audio_binding(self, binding: AudioBinding) -> None:
self.audio_bindings[binding.id] = binding
def add_lfo(self, lfo: LFO) -> None:
self.lfos[lfo.id] = lfo
def add_random(self, mod: RandomModulator) -> None:
self.randoms[mod.id] = mod
def add_sequencer(self, seq: StepSequencer) -> None:
self.sequencers[seq.id] = seq
def process_audio(
self, features: AudioFeatures, now_ns: int
) -> dict[str, float]:
"""Verarbeitet alle aktiven Audio-Bindings; Pfad→Wert."""
results: dict[str, float] = {}
for binding in self.audio_bindings.values():
if binding.enabled:
results[binding.parameter_path] = binding.process(features, now_ns)
return results
def process_modulators(self, now_ns: int) -> dict[str, dict[str, float]]:
"""Verarbeitet alle Nicht-Audio-Modulatoren; Typ→(id→Wert)."""
results: dict[str, dict[str, float]] = {
"lfo": {},
"random": {},
"sequencer": {},
}
for lfo_id, lfo in self.lfos.items():
results["lfo"][lfo_id] = lfo.process(now_ns)
for mod_id, mod in self.randoms.items():
results["random"][mod_id] = mod.process(now_ns)
for seq_id, seq in self.sequencers.items():
results["sequencer"][seq_id] = seq.process(now_ns)
return results
@@ -0,0 +1,5 @@
"""hms_capabilities Hardware-Erkennung und Capability-Tiers (PLAN.md §5)."""
from hms_capabilities.probe import CapabilityReport, CapabilityTier, detect_cpu_ram
__all__ = ["CapabilityTier", "CapabilityReport", "detect_cpu_ram"]
@@ -0,0 +1,99 @@
"""Capability-Probe (PLAN.md §5, §5.2).
Phase 0: plattformneutrale Basis-Erkennung (CPU/RAM/OS) und Tier-Vergabe
nach gemessenen Fakten. GPU-/Decoder-/Display-Erkennung läuft auf dem
Zielsystem (D3D11/GL/GLES); hier kein Fake-Ergebnis (§33: keine nicht
getestete Dekodierung als Hardwarebeschleunigung ausgeben).
"""
from __future__ import annotations
import enum
class CapabilityTier(enum.StrEnum):
DESKTOP_FULL = "DESKTOP_FULL"
DESKTOP_LITE = "DESKTOP_LITE"
PI_LITE = "PI_LITE"
HEADLESS_CONTROL = "HEADLESS_CONTROL"
# Mindest-VRAM für DESKTOP_FULL (§5)
_DESKTOP_FULL_MIN_VRAM_GB = 8.0
def detect_cpu_ram() -> dict[str, object]:
"""Basis-Hardwareinformationen (plattformneutral, ohne Fake)."""
import os
import platform
info: dict[str, object] = {
"os": platform.system(),
"os_release": platform.release(),
"machine": platform.machine(),
"cpu_count": os.cpu_count() or 1,
"ram_total_gb": _ram_gb(),
}
return info
def _ram_gb() -> float:
"""RAM in GB; Linux via /proc/meminfo, sonst -1 (unbekannt, nicht geraten)."""
try:
with open("/proc/meminfo", encoding="ascii") as fh:
for line in fh:
if line.startswith("MemTotal:"):
kib = int(line.split()[1])
return round(kib / (1024 * 1024), 2)
except (OSError, ValueError):
pass
return -1.0
class CapabilityReport:
"""Ergebnis des Capability-Selbsttests (Phase 0: Skelett).
GPU/Decoder/Displays werden auf dem Zielsystem gemessen und hier
ergänzt; ein Report ohne GPU-Messung kann kein DESKTOP-Tier vergeben.
"""
def __init__(self) -> None:
self.cpu_ram = detect_cpu_ram()
self.gpu: dict[str, object] | None = None
self.decoders: dict[str, object] | None = None
self.displays: dict[str, object] | None = None
self.tier: CapabilityTier | None = None
self.fingerprint: str = "" # an Messwerte gebunden (§5.2)
def conclude_tier(
self,
has_gpu: bool,
vram_gb: float | None,
decode_ok: bool,
has_display: bool,
) -> CapabilityTier | None:
"""Vergibt das Tier nach gemessenen Fakten; None wenn unklar.
Unklar bedeutet: Gate 0 darf nicht grün melden, solange keine
Messwerte vorliegen (§33).
"""
if not has_display:
self.tier = CapabilityTier.HEADLESS_CONTROL
return self.tier
if not has_gpu or not decode_ok:
return None
if vram_gb is not None and vram_gb >= _DESKTOP_FULL_MIN_VRAM_GB:
self.tier = CapabilityTier.DESKTOP_FULL
else:
self.tier = CapabilityTier.DESKTOP_LITE
return self.tier
def as_dict(self) -> dict[str, object]:
return {
"cpu_ram": self.cpu_ram,
"gpu": self.gpu,
"decoders": self.decoders,
"displays": self.displays,
"tier": self.tier.value if self.tier else None,
"fingerprint": self.fingerprint,
}
@@ -0,0 +1,71 @@
"""hms_cluster Clusterprotokoll, Registry, Paarung, Discovery, Gruppen,
Clock-Sync, zeitgestempelte Aktivierung (§6.3–§6.5; ADR-0009/0010)."""
from hms_cluster.activation import (
ActivationCoordinator,
ArmState,
PlannedActivation,
)
from hms_cluster.clock import ClockEstimator, ClockSample, now_monotonic_ns
from hms_cluster.discovery import (
DISCOVERY_PROTOCOL_VERSION,
SERVICE_TYPE,
ManualNodeList,
ServiceInfo,
capability_digest,
)
from hms_cluster.groups import GroupRouter, GroupRule, ServerGroup, TargetKind
from hms_cluster.message import ClusterMessage, CommandStatus, CommandTracker
from hms_cluster.pairing import (
PairingPin,
PairingStore,
Scope,
generate_pin,
hash_token,
identity_fingerprint,
new_token,
pin_valid,
)
from hms_cluster.registry import (
DuplicateNodeError,
HealthThresholds,
NodeCategory,
NodeEntry,
NodeHealth,
NodeRegistry,
)
__all__ = [
"SERVICE_TYPE",
"DISCOVERY_PROTOCOL_VERSION",
"ServiceInfo",
"ManualNodeList",
"capability_digest",
"ClusterMessage",
"CommandStatus",
"CommandTracker",
"PairingPin",
"PairingStore",
"Scope",
"generate_pin",
"pin_valid",
"identity_fingerprint",
"new_token",
"hash_token",
"DuplicateNodeError",
"HealthThresholds",
"NodeCategory",
"NodeEntry",
"NodeHealth",
"NodeRegistry",
"GroupRouter",
"GroupRule",
"ServerGroup",
"TargetKind",
"ClockEstimator",
"ClockSample",
"now_monotonic_ns",
"ActivationCoordinator",
"ArmState",
"PlannedActivation",
]
@@ -0,0 +1,148 @@
"""Zeitgestempelte Preset-Aktivierung über Gruppen (§6.5, §6.4).
Koordinierter Show-Modus (§6.3): Der Coordinator verteilt zeitgestempelte
Preset-/Parameterkommandos mit Preload/Arm/Ack und execute_at typischerweise
100300 ms im Voraus. Zwei-Phasen-Aktivierung (§6.5): vollständig ins
Staging übertragen und validieren, danach atomar auf dieselbe Revision
schalten.
execute_at ist eine Showzeit (Coordinator-Zeitbasis); jeder Node bildet sie
über seinen Clock-Offset auf die lokale monotone Zeit ab (§6.4).
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from enum import StrEnum
from hms_cluster.clock import now_monotonic_ns
from hms_cluster.groups import GroupRouter, TargetKind
DEFAULT_LEAD_NS = 200_000_000 # 200 ms Vorlauf (§6.4: typisch 100300 ms)
class ArmState(StrEnum):
"""Phasen eines geplanten Preset-Starts (§6.5)."""
CREATED = "created"
ARMED = "armed"
EXECUTED = "executed"
FAILED = "failed"
@dataclass
class PlannedActivation:
"""Ein zeitgestempelter Gruppen-Command (§6.5)."""
command_id: str
scene_id: str
target_kind: TargetKind
target_id: str | None
execute_at_show_ns: int # Coordinator-Showzeit
created_ns: int
state: ArmState = ArmState.CREATED
armed_nodes: frozenset[str] = field(default_factory=frozenset)
executed_nodes: frozenset[str] = field(default_factory=frozenset)
@classmethod
def plan(
cls,
scene_id: str,
target_kind: TargetKind,
target_id: str | None,
now_show_ns: int,
lead_ns: int = DEFAULT_LEAD_NS,
) -> PlannedActivation:
"""Plant die Ausführung `lead_ns` im Voraus (§6.4)."""
return cls(
command_id=str(uuid.uuid4()),
scene_id=scene_id,
target_kind=target_kind,
target_id=target_id,
execute_at_show_ns=now_show_ns + lead_ns,
created_ns=now_monotonic_ns(),
)
class ActivationCoordinator:
"""Koordiniert Preload/Arm/Execute über eine Node-Gruppe (§6.5).
- schedule(): plant die Aktivierung mit Vorlauf
- acknowledge_arm(): Node bestätigt arm (Preflight grün)
- acknowledge_execute(): Node hat ausgeführt (mit Ist-Zeit)
- due(): Aktivierungen, deren Showzeit erreicht ist (pro Tick)
Fehlerfälle (§6.5): fehlt eine Arm-Bestätigung, blockiert die
Ausführung standardmäßig; das wird als FAILED sichtbar dokumentiert
und nicht still überbrückt.
"""
def __init__(self, router: GroupRouter) -> None:
self._router = router
self._planned: dict[str, PlannedActivation] = {}
def schedule(
self,
scene_id: str,
target_kind: TargetKind = TargetKind.ALL,
target_id: str | None = None,
now_show_ns: int | None = None,
lead_ns: int = DEFAULT_LEAD_NS,
) -> PlannedActivation:
"""Plant eine Aktivierung; Ziel-Nodes werden sofort aufgelöst."""
now = now_show_ns if now_show_ns is not None else now_monotonic_ns()
planned = PlannedActivation.plan(
scene_id=scene_id,
target_kind=target_kind,
target_id=target_id,
now_show_ns=now,
lead_ns=lead_ns,
)
self._planned[planned.command_id] = planned
return planned
def expected_nodes(self, planned: PlannedActivation) -> frozenset[str]:
"""Ziel-Nodes dieser Aktivierung (§17.5: vor Commit sichtbar)."""
return self._router.preview_targets(planned.target_kind, planned.target_id)
def acknowledge_arm(self, command_id: str, node_id: str) -> ArmState:
"""Node hat geprüft und armed (§6.5)."""
planned = self._planned.get(command_id)
if planned is None:
raise KeyError(f"unbekannter Command {command_id}")
planned.armed_nodes = frozenset(set(planned.armed_nodes) | {node_id})
return planned.state
def acknowledge_execute(self, command_id: str, node_id: str) -> ArmState:
"""Node hat ausgeführt; Ist-Zeit wird vom Node gemeldet (§6.5)."""
planned = self._planned.get(command_id)
if planned is None:
raise KeyError(f"unbekannter Command {command_id}")
planned.executed_nodes = frozenset(set(planned.executed_nodes) | {node_id})
if planned.executed_nodes >= self.expected_nodes(planned):
planned.state = ArmState.EXECUTED
return planned.state
def due(self, now_show_ns: int | None = None) -> list[PlannedActivation]:
"""Aktivierungen, deren Showzeit gekommen ist nur für armed.
Nicht voll armbare Aktivierungen werden FAILED, nicht still
ausgeführt (§6.5: fehlende Bestätigung blockiert standardmäßig).
"""
now = now_show_ns if now_show_ns is not None else now_monotonic_ns()
due_list: list[PlannedActivation] = []
for planned in list(self._planned.values()):
if planned.state is not ArmState.CREATED:
continue
if now >= planned.execute_at_show_ns:
expected = self.expected_nodes(planned)
if expected and planned.armed_nodes >= expected:
planned.state = ArmState.ARMED
due_list.append(planned)
else:
planned.state = ArmState.FAILED # §6.5: Blockade sichtbar
return due_list
def get(self, command_id: str) -> PlannedActivation | None:
return self._planned.get(command_id)
@@ -0,0 +1,117 @@
"""Clock-Offset- und Drift-Messung (PLAN.md §6.4 Clock Sync).
V1-Verfahren: PTP, wenn verfügbar; sonst gemessene Offset-/Drift-
Schätzung gegen den Coordinator (§6.4). Das hier ist die softwareseitige
Messung: Round-Trip-basierte Offset-Schätzung mit Min-Filterung (NTP-artig)
und linearer Drift-Schätzung über Probenpaare.
Grenzen (§6.4): messbare Softwarezeit, kein Genlock; p95 ≤ 10 ms für
vorgepufferte Preset-/Command-Starts im verkabelten Referenz-LAN ist ein
Abnahmeziel von Gate 2, keine Zusage für beliebige Netze.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ClockSample:
"""Eine RTT-Messprobe zwischen Coordinator und Node.
t0/t1 in lokaler monotoner Zeit des Coordinators; node_time ist die
vom Node zurückgemeldete eigene monotone Zeit (normiert).
"""
t0_ns: int
t1_ns: int
node_time_ns: int
@property
def rtt_ns(self) -> int:
return self.t1_ns - self.t0_ns
@property
def offset_ns(self) -> int:
"""Min-RTT-Näherung: Offset = node_time - (t0 + rtt/2)."""
return self.node_time_ns - (self.t0_ns + self.rtt_ns // 2)
@dataclass
class ClockEstimator:
"""Schätzt Offset und Drift aus RTT-Proben (§6.4).
- feed(): neue Probe; behält die Proben mit kleinster RTT (Min-Filter,
weil geringe RTT ≈ geringe Warteschlangen-Verzögerung)
- offset: geglätteter Offset gegen den Coordinator
- drift_ppm: Änderung des Offsets über die Zeit (μs/s)
- Window begrenzt (kein unbeschränkter Zustand, §33)
"""
max_samples: int = 64
_samples: list[ClockSample] = field(default_factory=list)
_best: list[ClockSample] = field(default_factory=list) # kleinste RTTs
def feed(self, sample: ClockSample) -> None:
if sample.rtt_ns < 0:
raise ValueError("negative RTT unmöglich")
self._samples.append(sample)
if len(self._samples) > self.max_samples:
self._samples.pop(0)
# Min-RTT-Filter: nur Proben mit RTT ≤ 2× Minimum sind belastbar;
# hohe RTT bedeutet Warteschlangen-Jitter, der den Offset verfälscht
min_rtt = min(s.rtt_ns for s in self._samples)
ranked = sorted(
(s for s in self._samples if s.rtt_ns <= 2 * min_rtt),
key=lambda s: s.rtt_ns,
)[:8]
self._best = ranked
@property
def offset_ns(self) -> int | None:
"""Aktueller Offset-Schätzer (Mittel über Best-Proben)."""
if not self._best:
return None
return sum(s.offset_ns for s in self._best) // len(self._best)
@property
def rtt_ns(self) -> int | None:
"""Beste (kleinste) gemessene RTT."""
if not self._best:
return None
return min(s.rtt_ns for s in self._best)
@property
def drift_ppm(self) -> float | None:
"""Lineare Drift-Schätzung über die Best-Proben (μs/s).
Offset-Änderung geteilt durch verstrichene RTT-Mittezeit; None bei
weniger als zwei Best-Proben oder zu kurzem Fenster (< 1 s).
"""
if len(self._best) < 2:
return None
ordered = sorted(self._best, key=lambda s: s.t0_ns)
first, last = ordered[0], ordered[-1]
dt_ns = last.t0_ns - first.t0_ns
if dt_ns < 1_000_000_000: # < 1 s: Drift nicht belastbar
return None
d_offset = last.offset_ns - first.offset_ns
return (d_offset / dt_ns) * 1_000_000.0
def map_show_time(self, show_time_ns: int) -> int | None:
"""Bildet Coordinator-Showzeit auf lokale Node-Zeit ab (§6.4:
Showzeit → lokale Monotonic).
Voraussetzung: dieser Estimator läuft Node-seitig mit Proben,
deren node_time die eigene Uhr ist.
"""
offset = self.offset_ns
if offset is None:
return None
return show_time_ns + offset
def now_monotonic_ns() -> int:
"""Gemeinsame monotone Zeitbasis (§12.2: Audio/Video gemeinsame Basis)."""
return time.monotonic_ns()
@@ -0,0 +1,131 @@
"""Discovery: mDNS-Service-Modell + manuelle Fallback-Liste (PLAN.md §6.3;
ADR-0009).
- Service-Typ: _hmsmedia._tcp.local.
- TXT nur kleine, nicht vertrauliche Daten: proto, node, roles, port, caps
- manuelle Node-Liste für VLANs/geroutete Netze (§6.3)
- IP-Wechsel ändert die node_id nicht (§6.3)
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
SERVICE_TYPE = "_hmsmedia._tcp.local."
DISCOVERY_PROTOCOL_VERSION = 1
@dataclass(frozen=True)
class ServiceInfo:
"""mDNS-Ankündigung eines Nodes (TXT-Inhalt, ADR-0009)."""
node_id: str
display_name: str
port: int
roles: tuple[str, ...]
protocol_version: int = DISCOVERY_PROTOCOL_VERSION
capability_digest: str = ""
@property
def instance_name(self) -> str:
"""Eindeutiger Instanzname: bereinigter Anzeigename."""
safe = "".join(c for c in self.display_name if c.isalnum() or c in " -_")
return safe[:63] or self.node_id[:8]
def txt(self) -> dict[str, str]:
"""TXT-Record: klein, nicht vertraulich (ADR-0009, §27.1)."""
return {
"proto": str(self.protocol_version),
"node": self.node_id,
"roles": ",".join(self.roles),
"port": str(self.port),
"caps": self.capability_digest[:16],
}
@classmethod
def from_txt(cls, instance_name: str, port: int, txt: dict[str, str]) -> ServiceInfo:
"""Parst eine fremde Ankündigung; wirft bei unvollständigen Daten."""
required = ("proto", "node", "port")
missing = [k for k in required if k not in txt]
if missing:
raise ValueError(f"TXT unvollstaendig, fehlt: {missing}")
node_id = txt["node"]
if len(node_id) < 8:
raise ValueError("node-Eintrag ungueltig")
roles = tuple(r for r in txt.get("roles", "").split(",") if r)
return cls(
node_id=node_id,
display_name=instance_name,
port=int(txt["port"]) or port,
roles=roles,
protocol_version=int(txt["proto"]),
capability_digest=txt.get("caps", ""),
)
def capability_digest(capabilities: dict) -> str:
"""Kurzer, stabiler Digest über Capabilities (ADR-0009 TXT 'caps')."""
canonical = json.dumps(capabilities, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
@dataclass
class ManualNodeList:
"""Persistente manuelle Node-Liste (Fallback ohne mDNS, §6.3).
JSON-Format: Liste von {"host", "port", "node_id"}; ungeprüfte Hosts
bleiben Kategorie `unknown`, bis sich die Node legitim identifiziert.
"""
path: Path
def save(self, entries: list[dict]) -> None:
clean = [
{
"host": str(e.get("host", "")),
"port": int(e.get("port", 0)),
"node_id": str(e.get("node_id", "")),
}
for e in entries
]
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(
json.dumps(clean, indent=2, ensure_ascii=False), encoding="utf-8"
)
def load(self) -> list[dict]:
if not self.path.is_file():
return []
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return []
entries = []
for item in data if isinstance(data, list) else []:
if not isinstance(item, dict):
continue
host = str(item.get("host", ""))
if not host:
continue
try:
port = int(item.get("port", 0))
except (TypeError, ValueError):
continue
entries.append(
{"host": host, "port": port, "node_id": str(item.get("node_id", ""))}
)
return entries
def add(self, host: str, port: int, node_id: str = "") -> None:
entries = self.load()
if any(e["host"] == host and e["port"] == port for e in entries):
return
entries.append({"host": host, "port": port, "node_id": node_id})
self.save(entries)
def remove(self, host: str, port: int) -> None:
entries = [e for e in self.load() if not (e["host"] == host and e["port"] == port)]
self.save(entries)
@@ -0,0 +1,116 @@
"""Servergruppen und Zielrouting (PLAN.md §6.3 Bedienmodelle, §10.1 ServerGroup).
- Coordinator routet Commands an `All`, eine `ServerGroup`, einen einzelnen
`Node` oder einen `Output` (§6.3 Control-Center-Modell)
- Zielregel je Gruppe: all | selected | tag_query | feste Node-Liste
- Zielauflösung ist deterministisch und testbar; Gruppenänderungen zeigen
vor dem Commit, welche Nodes sie erhalten (§17.5)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
class TargetKind(StrEnum):
"""Command-Ziele (§6.3): All, ServerGroup, Node oder Output."""
ALL = "all"
SERVER_GROUP = "server_group"
NODE = "node"
OUTPUT = "output"
class GroupRule(StrEnum):
"""Zielregel je ServerGroup (§10.1)."""
ALL = "all"
SELECTED = "selected"
TAG_QUERY = "tag_query"
NODE_LIST = "node_list"
@dataclass
class ServerGroup:
"""Servergruppe (§10.1): Node-Auswahl mit Zielregel.
- node_ids: feste Mitglieder bei SELECTED/NODE_LIST
- tags: Node-Tags für TAG_QUERY (Node-Einträge tragen passende Tags)
- output_map: optionaler Layer-/Output-Zuordnungshinweis (§10.1)
"""
id: str
name: str
rule: GroupRule = GroupRule.SELECTED
node_ids: frozenset[str] = field(default_factory=frozenset)
tags: frozenset[str] = field(default_factory=frozenset)
output_map: dict[str, str] = field(default_factory=dict) # layer_id → output_id
class GroupRouter:
"""Löst Command-Ziele auf Node-Mengen auf (§6.3).
Der Coordinator nutzt resolve() vor jedem Senden; die UI kann
resolve() für die Commit-Vorschau verwenden (§17.5: „welche Nodes
erhalten dies?").
"""
def __init__(self) -> None:
self._groups: dict[str, ServerGroup] = {}
self._node_tags: dict[str, frozenset[str]] = {} # node_id → tags
self._node_outputs: dict[str, list[str]] = {} # node_id → output_ids
# ---------- Verwaltung ----------
def upsert_group(self, group: ServerGroup) -> None:
self._groups[group.id] = group
def remove_group(self, group_id: str) -> None:
self._groups.pop(group_id, None)
def get_group(self, group_id: str) -> ServerGroup | None:
return self._groups.get(group_id)
def set_node_tags(self, node_id: str, tags: frozenset[str]) -> None:
self._node_tags[node_id] = frozenset(tags)
def set_node_outputs(self, node_id: str, output_ids: list[str]) -> None:
self._node_outputs[node_id] = list(output_ids)
def known_nodes(self) -> frozenset[str]:
return frozenset(self._node_tags)
# ---------- Zielauflösung (§6.3) ----------
def resolve(self, kind: TargetKind, target_id: str | None = None) -> frozenset[str]:
"""Löst ein Ziel auf eine Node-Menge auf; leer bei unbekanntem Ziel."""
if kind is TargetKind.ALL:
return self.known_nodes()
if kind is TargetKind.NODE:
return frozenset({target_id}) if target_id in self._node_tags else frozenset()
if kind is TargetKind.OUTPUT:
return frozenset(
node_id
for node_id, outputs in self._node_outputs.items()
if target_id in outputs
)
if kind is TargetKind.SERVER_GROUP:
group = self._groups.get(target_id or "")
if group is None:
return frozenset()
if group.rule is GroupRule.ALL:
return self.known_nodes()
if group.rule is GroupRule.SELECTED or group.rule is GroupRule.NODE_LIST:
return group.node_ids & self.known_nodes()
if group.rule is GroupRule.TAG_QUERY:
return frozenset(
node_id
for node_id, tags in self._node_tags.items()
if group.tags & tags
)
return frozenset()
def preview_targets(self, kind: TargetKind, target_id: str | None = None) -> frozenset[str]:
"""Commit-Vorschau: identisch zu resolve (§17.5)."""
return self.resolve(kind, target_id)
@@ -0,0 +1,164 @@
"""Cluster-Nachrichtenhülle (PLAN.md §6.5).
Jede Cluster-Nachricht enthält mindestens: cluster_id, node_id, command_id,
Sequenz, Projekt-Revision, Absenderzeit, optionale execute_at-Showzeit und
Trace-ID. Zustandsändernde Commands sind idempotent und werden mit
accepted/armed/executed/failed bestätigt.
"""
from __future__ import annotations
import time
import uuid
from enum import StrEnum
class CommandStatus(StrEnum):
"""Bestätigungsstufen zustandsändernder Commands (§6.5)."""
ACCEPTED = "accepted"
ARMED = "armed"
EXECUTED = "executed"
FAILED = "failed"
class ClusterMessage:
"""Versionierte Cluster-Nachricht mit Pflichtfeldern (§6.5).
- sequence: je Absender monoton; Lücken signalisieren Paketverlust
- project_revision: Zustandsrevision, auf die sich der Command bezieht
- sender_time_ns: monotone Absenderzeit (nicht Wanduhr)
- execute_at_show_time_ns: optional; 100300 ms Vorlauf für Sync-Starts
- trace_id: Korrelations-ID über Log-Grenzen (§28.1)
"""
__slots__ = (
"cluster_id",
"node_id",
"command_id",
"sequence",
"project_revision",
"sender_time_ns",
"execute_at_show_time_ns",
"trace_id",
"status",
"payload",
)
def __init__(
self,
cluster_id: str,
node_id: str,
command_id: str,
sequence: int,
project_revision: int,
sender_time_ns: int | None = None,
execute_at_show_time_ns: int | None = None,
trace_id: str | None = None,
status: CommandStatus | None = None,
payload: dict | None = None,
) -> None:
for field_name, value in (
("cluster_id", cluster_id),
("node_id", node_id),
("command_id", command_id),
):
try:
uuid.UUID(value)
except (ValueError, AttributeError) as exc:
raise ValueError(f"{field_name} must be a UUID") from exc
if sequence < 0:
raise ValueError("sequence must be >= 0")
if project_revision < 0:
raise ValueError("project_revision must be >= 0")
self.cluster_id = cluster_id
self.node_id = node_id
self.command_id = command_id
self.sequence = sequence
self.project_revision = project_revision
self.sender_time_ns = (
sender_time_ns if sender_time_ns is not None else time.monotonic_ns()
)
self.execute_at_show_time_ns = execute_at_show_time_ns
self.trace_id = trace_id or str(uuid.uuid4())
self.status = status
self.payload = payload or {}
def to_dict(self) -> dict:
return {
"cluster_id": self.cluster_id,
"node_id": self.node_id,
"command_id": self.command_id,
"sequence": self.sequence,
"project_revision": self.project_revision,
"sender_time_ns": self.sender_time_ns,
"execute_at_show_time_ns": self.execute_at_show_time_ns,
"trace_id": self.trace_id,
"status": self.status.value if self.status else None,
"payload": self.payload,
}
@classmethod
def from_dict(cls, data: dict) -> ClusterMessage:
status_raw = data.get("status")
status = CommandStatus(status_raw) if status_raw else None
return cls(
cluster_id=data["cluster_id"],
node_id=data["node_id"],
command_id=data["command_id"],
sequence=int(data["sequence"]),
project_revision=int(data["project_revision"]),
sender_time_ns=data.get("sender_time_ns"),
execute_at_show_time_ns=data.get("execute_at_show_time_ns"),
trace_id=data.get("trace_id"),
status=status,
payload=data.get("payload", {}),
)
class CommandTracker:
"""Idempotenz je (node_id, command_id) mit Statusübergängen (§6.5).
- register: neuer Command → True; Duplikat → False (gleiches Ack)
- advance: nur vorwärts accepted → armed → executed/failed
- veraltete Einträge werden nach Kapazität begrenzt (kein unbeschränkter
Cache, §33)
"""
_ORDER = {
CommandStatus.ACCEPTED: 1,
CommandStatus.ARMED: 2,
CommandStatus.EXECUTED: 3,
CommandStatus.FAILED: 3,
}
def __init__(self, capacity: int = 4096) -> None:
if capacity <= 0:
raise ValueError("capacity must be positive")
self._capacity = capacity
self._states: dict[str, CommandStatus] = {}
def register(self, node_id: str, command_id: str) -> bool:
"""True, wenn der Command neu ist; False bei Duplikat."""
key = f"{node_id}:{command_id}"
if key in self._states:
return False
self._states[key] = CommandStatus.ACCEPTED
if len(self._states) > self._capacity:
oldest = next(iter(self._states))
del self._states[oldest]
return True
def advance(self, node_id: str, command_id: str, new_status: CommandStatus) -> bool:
"""Nur vorwärts; False bei unbekanntem Command oder Rückschritt."""
key = f"{node_id}:{command_id}"
current = self._states.get(key)
if current is None:
return False
if self._ORDER[new_status] <= self._ORDER[current]:
return False
self._states[key] = new_status
return True
def status(self, node_id: str, command_id: str) -> CommandStatus | None:
return self._states.get(f"{node_id}:{command_id}")
@@ -0,0 +1,175 @@
"""Node-Paarung: PIN, Fingerprint, Token-Scopes, Widerruf (§6.3, §27.1)."""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass, field
from enum import StrEnum
PIN_TTL_S = 120.0
class Scope(StrEnum):
"""Getrennte Berechtigungsscopes (§27.1)."""
READ = "read"
CONTROL = "control"
CONTENT_SYNC = "content_sync"
ADMIN = "admin"
_ALL_SCOPES = frozenset(s.value for s in Scope)
@dataclass(frozen=True)
class PairingPin:
"""Kurzlebige Paarungs-PIN (§6.3)."""
value: str
created_ns: int
@property
def expires_ns(self) -> int:
return self.created_ns + int(PIN_TTL_S * 1_000_000_000)
def generate_pin() -> PairingPin:
"""6-stellige PIN, kryptographisch erzeugt (§6.3)."""
return PairingPin(
value=f"{secrets.randbelow(1_000_000):06d}",
created_ns=time.monotonic_ns(),
)
def pin_valid(pin: PairingPin, now_ns: int | None = None) -> bool:
now = now_ns if now_ns is not None else time.monotonic_ns()
return now <= pin.expires_ns
def identity_fingerprint(
node_id: str,
display_name: str,
public_key_pem: str | None = None,
) -> str:
"""Sichtbarer Fingerprint über öffentliche Identitätsdaten (§6.3).
Format: 8 Gruppen à 4 Hex-Zeichen (128 Bits des SHA-256), für Menschen
vergleichbar.
"""
material = f"{node_id}|{display_name}".encode()
if public_key_pem:
material += b"|" + public_key_pem.encode("ascii", errors="replace")
digest = hashlib.sha256(material).hexdigest()
groups = [digest[i : i + 4] for i in range(0, 32, 4)]
return ":".join(groups)
@dataclass(frozen=True)
class PairedToken:
"""Ausgestelltes Token; nur der Hash wird gespeichert (§27.1)."""
token_hash: str
scopes: frozenset[str]
expires_ns: int | None
created_ns: int
def hash_token(token: str) -> str:
"""Token-Hash; Klartext existiert nur beim Besitzer."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def new_token(scopes: frozenset[str], ttl_s: float | None = None) -> tuple[str, PairedToken]:
"""Erzeugt Token + gespeicherte Repräsentation; Klartext genau einmal."""
unknown = scopes - _ALL_SCOPES
if unknown:
raise ValueError(f"unknown scopes: {sorted(unknown)}")
if not scopes:
raise ValueError("scopes must not be empty")
now = time.monotonic_ns()
token = secrets.token_urlsafe(32)
expires = now + int(ttl_s * 1_000_000_000) if ttl_s is not None else None
return token, PairedToken(
token_hash=hash_token(token),
scopes=scopes,
expires_ns=expires,
created_ns=now,
)
@dataclass
class PairingStore:
"""PINs, Paarungszustand und Tokens je Node (§6.3, §27.1, ADR-0010)."""
_pins: dict[str, PairingPin] = field(default_factory=dict)
_tokens: dict[str, PairedToken] = field(default_factory=dict)
_failed_attempts: dict[str, int] = field(default_factory=dict)
max_pin_attempts: int = 3
def issue_pin(self, node_id: str) -> PairingPin:
pin = generate_pin()
self._pins[node_id] = pin
self._failed_attempts.pop(node_id, None)
return pin
def complete_pairing(
self,
node_id: str,
entered_pin: str,
fingerprint_seen: str,
expected_fingerprint: str,
scopes: frozenset[str],
token_ttl_s: float | None = None,
now_ns: int | None = None,
) -> str:
"""Prüft PIN + Fingerprint, stellt Token aus; gibt Klartext zurück.
Fehlversuche erhöhen den Zähler; nach max_pin_attempts wird die PIN
gesperrt (Neuausstellung nötig). Vergleiche konstantzeit über
hmac.compare_digest.
"""
pin = self._pins.get(node_id)
now = now_ns if now_ns is not None else time.monotonic_ns()
if pin is None:
raise PermissionError("keine PIN ausgestellt")
if self._failed_attempts.get(node_id, 0) >= self.max_pin_attempts:
raise PermissionError("PIN gesperrt; neu ausstellen")
if not pin_valid(pin, now_ns=now) or not hmac.compare_digest(pin.value, entered_pin):
self._failed_attempts[node_id] = self._failed_attempts.get(node_id, 0) + 1
raise PermissionError("PIN falsch oder abgelaufen")
if not hmac.compare_digest(
fingerprint_seen.strip().lower(), expected_fingerprint.strip().lower()
):
self._failed_attempts[node_id] = self._failed_attempts.get(node_id, 0) + 1
raise PermissionError("Fingerprint stimmt nicht ueberein")
token, stored = new_token(scopes, ttl_s=token_ttl_s)
self._tokens[node_id] = stored
self._pins.pop(node_id, None) # PIN nur einmal verwendbar
self._failed_attempts.pop(node_id, None)
return token
def revoke(self, node_id: str) -> None:
"""Sofortiger Widerruf (§27.1)."""
self._tokens.pop(node_id, None)
self._pins.pop(node_id, None)
def verify(
self,
node_id: str,
token: str,
required_scope: Scope,
now_ns: int | None = None,
) -> bool:
"""Token- und Scope-Prüfung; Hash-Vergleich konstantzeit."""
stored = self._tokens.get(node_id)
if stored is None:
return False
now = now_ns if now_ns is not None else time.monotonic_ns()
if stored.expires_ns is not None and now > stored.expires_ns:
return False
if not hmac.compare_digest(stored.token_hash, hash_token(token)):
return False
return required_scope.value in stored.scopes
@@ -0,0 +1,168 @@
"""Node-Registry mit Heartbeat-Zuständen (PLAN.md §6.3, §6.5).
- Zustände je Node: online / degraded / stale / offline mit konfigurierbaren
Schwellen (§6.5)
- doppelte node_id wird als Fehler blockiert, nie still übernommen (§6.3)
- Kategorien für die UI: discovered / paired / unknown / incompatible /
offline werden getrennt geführt (§6.3)
- persistente node_id bleibt identisch bei IP-Wechsel; Endpunkte werden
als „zuletzt bekannt" aktualisiert (§10.1 Node)
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import StrEnum
class NodeHealth(StrEnum):
ONLINE = "online"
DEGRADED = "degraded"
STALE = "stale"
OFFLINE = "offline"
class NodeCategory(StrEnum):
"""UI-Kategorien gemäß §6.3: gefunden, gepaart, unbekannt, inkompatibel,
offline werden getrennt aufgeführt."""
DISCOVERED = "discovered"
PAIRED = "paired"
UNKNOWN = "unknown"
INCOMPATIBLE = "incompatible"
OFFLINE = "offline"
class DuplicateNodeError(Exception):
"""Doppelte node_id wird als Fehler blockiert (§6.3)."""
@dataclass
class NodeEntry:
"""Registry-Eintrag: Identität stabil, Endpunkte „zuletzt bekannt" (§10.1)."""
node_id: str
display_name: str
roles: tuple[str, ...] = ()
api_port: int = 0
protocol_version: int = 1
capability_digest: str = ""
last_known_endpoints: list[str] = field(default_factory=list)
last_heartbeat_ns: int = 0
health: NodeHealth = NodeHealth.OFFLINE
category: NodeCategory = NodeCategory.DISCOVERED
clock_offset_ns: int = 0
def record_endpoint(self, endpoint: str) -> None:
"""IP-Wechsel: node_id bleibt, Endpunkt wird aktualisiert (§6.3)."""
if endpoint in self.last_known_endpoints:
self.last_known_endpoints.remove(endpoint)
self.last_known_endpoints.insert(0, endpoint)
del self.last_known_endpoints[4:] # die letzten 5 genügen
@dataclass
class HealthThresholds:
"""Konfigurierbare Schwellen je Zustand (§6.5).
Heartbeat pünktlich < degraded_after_ns; verspätet, aber vorhanden
< stale_after_ns; danach offline. Standard-Heartbeat 500 ms (§6.5).
"""
heartbeat_interval_ns: int = 500_000_000
degraded_after_ns: int = 2_000_000_000 # 2 s ohne Heartbeat
stale_after_ns: int = 5_000_000_000 # 5 s ohne Heartbeat
class NodeRegistry:
"""Autoritative Liste bekannter Nodes (Coordinator-seitig)."""
def __init__(
self,
thresholds: HealthThresholds | None = None,
protocol_version: int = 1,
) -> None:
self._nodes: dict[str, NodeEntry] = {}
self._thresholds = thresholds or HealthThresholds()
self._protocol_version = protocol_version
def register(
self,
node_id: str,
display_name: str,
roles: tuple[str, ...] = (),
api_port: int = 0,
protocol_version: int = 1,
capability_digest: str = "",
endpoint: str = "",
) -> NodeEntry:
"""Neue Node oder Update bekannter Node; Doppel-ID mit
widersprüchlicher Identität ist ein Fehler (§6.3)."""
existing = self._nodes.get(node_id)
if existing is not None and existing.display_name != display_name:
raise DuplicateNodeError(
f"node_id {node_id} bereits als {existing.display_name!r} registriert"
)
if existing is None:
entry = NodeEntry(
node_id=node_id,
display_name=display_name,
roles=tuple(roles),
api_port=api_port,
protocol_version=protocol_version,
capability_digest=capability_digest,
)
self._nodes[node_id] = entry
else:
entry = existing
entry.roles = tuple(roles)
entry.api_port = api_port
entry.capability_digest = capability_digest
if endpoint:
entry.record_endpoint(endpoint)
# Inkompatible Protokollversion sichtbar kategorisieren (§6.3)
if entry.protocol_version != self._protocol_version:
entry.category = NodeCategory.INCOMPATIBLE
return entry
def record_heartbeat(self, node_id: str, clock_offset_ns: int = 0) -> None:
entry = self._nodes.get(node_id)
if entry is None:
raise KeyError(f"unknown node {node_id}")
entry.last_heartbeat_ns = time.monotonic_ns()
entry.clock_offset_ns = clock_offset_ns
def evaluate_health(self, node_id: str) -> NodeHealth:
"""Berechnet den Zustand aus letztem Heartbeat + Schwellen (§6.5)."""
entry = self._nodes[node_id]
if entry.last_heartbeat_ns == 0:
entry.health = NodeHealth.OFFLINE
if entry.category not in (NodeCategory.INCOMPATIBLE, NodeCategory.PAIRED):
entry.category = NodeCategory.DISCOVERED
return entry.health
elapsed = time.monotonic_ns() - entry.last_heartbeat_ns
if elapsed < self._thresholds.degraded_after_ns:
entry.health = NodeHealth.ONLINE
elif elapsed < self._thresholds.stale_after_ns:
entry.health = NodeHealth.DEGRADED
else:
entry.health = NodeHealth.OFFLINE
if entry.category is NodeCategory.PAIRED:
entry.category = NodeCategory.OFFLINE # Vertrauen bleibt, nur weg
return entry.health
def mark_paired(self, node_id: str) -> None:
self._nodes[node_id].category = NodeCategory.PAIRED
def mark_unknown(self, node_id: str) -> None:
self._nodes[node_id].category = NodeCategory.UNKNOWN
def get(self, node_id: str) -> NodeEntry | None:
return self._nodes.get(node_id)
def by_category(self, category: NodeCategory) -> list[NodeEntry]:
return [n for n in self._nodes.values() if n.category is category]
def all(self) -> list[NodeEntry]:
return list(self._nodes.values())
@@ -0,0 +1,17 @@
"""hms_content_sync Content-Manifest und Sync-Basis (§6.4, §10.1)."""
from hms_content_sync.manifest import (
CHUNK_SIZE,
ContentManifest,
ManifestDiff,
ManifestEntry,
hash_file,
)
__all__ = [
"CHUNK_SIZE",
"ContentManifest",
"ManifestDiff",
"ManifestEntry",
"hash_file",
]
@@ -0,0 +1,209 @@
"""ContentManifest (PLAN.md §10.1, §6.4).
Content Sync (§6.4): SHA-256-Manifest, resumierbare Chunks, Hashprüfung,
Staging Ziel sind byte-identische freigegebene Inhalte auf allen Nodes.
Manifest-Einträge decken Medien, Plugins und Projektdateien ab; die Art
wird je Eintrag vermerkt, damit Preflight Plugin-API- und
Backend-Anforderungen prüfen kann (§10.1).
"""
from __future__ import annotations
import hashlib
import os
import uuid
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
CHUNK_SIZE = 4 * 1024 * 1024 # 4 MiB Standard-Chunk für Resume (§6.4)
KIND_MEDIA = "media"
KIND_PLUGIN = "plugin"
KIND_PROJECT = "project"
def hash_file(path: Path | str, chunk_size: int = CHUNK_SIZE) -> tuple[str, list[str]]:
"""SHA-256 über die ganze Datei + je Chunk (resumierbar, §6.4).
Rückgabe: (file_hash_hex, [chunk_hash_hex, ...] in Dateireihenfolge).
"""
if chunk_size <= 0:
raise ValueError("chunk_size muss positiv sein")
file_hash = hashlib.sha256()
chunk_hashes: list[str] = []
with open(path, "rb") as fh:
while True:
data = fh.read(chunk_size)
if not data:
break
chunk_hashes.append(hashlib.sha256(data).hexdigest())
file_hash.update(data)
if not chunk_hashes: # leere Datei: ein leerer Chunk-Hash
chunk_hashes.append(hashlib.sha256(b"").hexdigest())
return file_hash.hexdigest(), chunk_hashes
@dataclass(frozen=True)
class ManifestEntry:
"""Eine Datei im Manifest (§10.1): Pfad, Größe, Hash, Art."""
rel_path: str # portabel, POSIX-relativ
kind: str # media | plugin | project
size_bytes: int
sha256: str
chunk_hashes: tuple[str, ...] = ()
def to_dict(self) -> dict:
return {
"rel_path": self.rel_path,
"kind": self.kind,
"size_bytes": self.size_bytes,
"sha256": self.sha256,
"chunk_hashes": list(self.chunk_hashes),
}
@classmethod
def from_dict(cls, data: dict) -> ManifestEntry:
return cls(
rel_path=data["rel_path"],
kind=data["kind"],
size_bytes=int(data["size_bytes"]),
sha256=data["sha256"],
chunk_hashes=tuple(data.get("chunk_hashes", ())),
)
@dataclass(frozen=True)
class ManifestDiff:
"""Unterschied zweier Manifeste (Basis für Sync-Transfer, §6.4)."""
added: frozenset[str] = field(default_factory=frozenset)
removed: frozenset[str] = field(default_factory=frozenset)
changed: frozenset[str] = field(default_factory=frozenset)
@property
def empty(self) -> bool:
return not (self.added or self.removed or self.changed)
class ContentManifest:
"""Versioniertes Inhalts-Manifest (§10.1 ContentManifest).
- manifest_id: Instanz-ID (UUID)
- revision: monotone Manifest-Revision (neu bei jeder Änderung)
- entries: rel_path → ManifestEntry
Der Manifest-Inhalt selbst ist deterministisch (sortierte Einträge);
die manifest_id identifiziert den Ausgabezeitpunkt.
"""
def __init__(
self,
manifest_id: str | None = None,
revision: int = 0,
entries: dict[str, ManifestEntry] | None = None,
) -> None:
self.manifest_id = manifest_id or str(uuid.uuid4())
self.revision = revision
self._entries: dict[str, ManifestEntry] = dict(entries or {})
@property
def entries(self) -> dict[str, ManifestEntry]:
return dict(self._entries)
# ---------- Aufbau ----------
@classmethod
def build(
cls,
root: Path | str,
kind_of=None,
chunk_size: int = CHUNK_SIZE,
revision: int = 0,
) -> ContentManifest:
"""Baut das Manifest aus einem Verzeichnis (§6.4 SHA-256).
kind_of: callable(rel_path) → "media"|"plugin"|"project";
Default: alles KIND_MEDIA.
"""
root_path = Path(root)
if not root_path.is_dir():
raise FileNotFoundError(f"Manifest-Root fehlt: {root_path}")
entries: dict[str, ManifestEntry] = {}
for dirpath, _dirnames, filenames in os.walk(root_path):
for name in sorted(filenames):
abs_path = Path(dirpath) / name
if not abs_path.is_file():
continue
rel = abs_path.relative_to(root_path).as_posix()
if ".." in PurePosixPath(rel).parts:
continue # defensive: nie Traversal ins Manifest
file_hash, chunks = hash_file(abs_path, chunk_size=chunk_size)
kind = kind_of(rel) if kind_of else KIND_MEDIA
entries[rel] = ManifestEntry(
rel_path=rel,
kind=kind,
size_bytes=abs_path.stat().st_size,
sha256=file_hash,
chunk_hashes=tuple(chunks),
)
return cls(revision=revision, entries=entries)
# ---------- Verifikation (§6.4 Hashprüfung) ----------
def verify_file(self, root: Path | str, rel_path: str) -> bool:
"""Prüft Größe + Hash einer Datei gegen den Manifest-Eintrag."""
entry = self._entries.get(rel_path)
if entry is None:
return False
abs_path = Path(root) / rel_path
if not abs_path.is_file():
return False
stat = abs_path.stat()
if stat.st_size != entry.size_bytes:
return False
file_hash, _chunks = hash_file(abs_path)
return file_hash == entry.sha256
# ---------- Diff (§6.4 Sync-Basis) ----------
def diff(self, other: ContentManifest) -> ManifestDiff:
"""Was muss von self nach other übertragen/gelöscht werden?"""
mine = set(self._entries)
theirs = set(other._entries)
added = frozenset(theirs - mine)
removed = frozenset(mine - theirs)
changed = frozenset(
rel
for rel in mine & theirs
if self._entries[rel].sha256 != other._entries[rel].sha256
)
return ManifestDiff(added=added, removed=removed, changed=changed)
# ---------- Serialisierung ----------
def to_dict(self) -> dict:
return {
"manifest_id": self.manifest_id,
"revision": self.revision,
"entries": {
rel: entry.to_dict()
for rel, entry in sorted(self._entries.items())
},
}
@classmethod
def from_dict(cls, data: dict) -> ContentManifest:
entries = {
rel: ManifestEntry.from_dict(entry)
for rel, entry in data.get("entries", {}).items()
}
return cls(
manifest_id=data.get("manifest_id"),
revision=int(data.get("revision", 0)),
entries=entries,
)
def __len__(self) -> int:
return len(self._entries)
@@ -0,0 +1,56 @@
"""hms_domain plattformneutrale Domänenobjekte (PLAN.md §10)."""
from hms_domain.identity import NodeIdentity, NodeRole
from hms_domain.ids import new_node_id, new_uuid, persistent_node_id
from hms_domain.model import (
DOMAIN_SCHEMA_VERSION,
BlendMode,
ColorControls,
ColorSpace,
Composition,
Crop,
EffectInstance,
EffectScope,
Layer,
LayerType,
LoopMode,
MediaAsset,
OutputSurface,
PresetScene,
Project,
QualityMode,
Source,
SourceType,
Transform2D,
TransitionType,
TransportState,
)
__all__ = [
"new_uuid",
"new_node_id",
"persistent_node_id",
"NodeIdentity",
"NodeRole",
"DOMAIN_SCHEMA_VERSION",
"Project",
"Composition",
"Layer",
"LayerType",
"Source",
"SourceType",
"EffectInstance",
"EffectScope",
"BlendMode",
"Transform2D",
"Crop",
"ColorControls",
"ColorSpace",
"LoopMode",
"TransportState",
"QualityMode",
"MediaAsset",
"OutputSurface",
"PresetScene",
"TransitionType",
]
@@ -0,0 +1,97 @@
"""Node-Identität und Rollen (PLAN.md §6.3, §10.1, §3.6).
Der Control Core lädt beim Start:
- persistente node_id aus userdata/identity/node_id (IP-/hostname-unabhängig)
- konfigurierbare Rollen (RENDER_NODE, COORDINATOR, CONTROL_DESK)
- Anzeigename (editierbar, nicht identitätsstiftend)
Rollen sind Pflicht für Discovery (TXT 'roles') und Cluster-Protokoll.
Es gibt in V1 keine automatische Leader-Wahl (§6.3): der Betreiber legt die
Coordinator-Rolle fest.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from hms_domain.ids import new_node_id, persistent_node_id
class NodeRole(StrEnum):
"""Rollen gemäß §6.3. Keine Auto-Leader-Wahl: Rollen sind Konfiguration."""
RENDER_NODE = "RENDER_NODE"
COORDINATOR = "COORDINATOR"
CONTROL_DESK = "CONTROL_DESK"
_VALID_COMBOS = {
# reine Render-Nodes: decodieren und rendern lokal (§6.3)
frozenset({NodeRole.RENDER_NODE}),
# Coordinator kann auf einem Render-Node mitlaufen (§6.3)
frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR}),
# reiner Coordinator / Control-PC ohne Renderer (HEADLESS_CONTROL, §6.3)
frozenset({NodeRole.COORDINATOR}),
frozenset({NodeRole.CONTROL_DESK}),
frozenset({NodeRole.CONTROL_DESK, NodeRole.COORDINATOR}),
}
@dataclass(frozen=True)
class NodeIdentity:
"""Stabiles Identitätsbündel eines Servers (§3.6, §10.1).
- node_id: persistent, UUID, unabhängig von IP/Hostname
- display_name: editierbar; Umbenennung zerstört keine Automationen,
weil Parameterpfade UUID-basiert sind (§10.2)
- roles: konfigurierte Rollen; RENDER_NODE impliziert lokalen Renderer
"""
node_id: str
display_name: str
roles: frozenset[NodeRole]
def __post_init__(self) -> None:
if not self.node_id:
raise ValueError("node_id darf nicht leer sein")
if not self.roles:
raise ValueError("mindestens eine Rolle erforderlich (§6.3)")
if frozenset(self.roles) not in _VALID_COMBOS:
allowed = ", ".join(
sorted("|".join(sorted(r.value for r in c)) for c in _VALID_COMBOS)
)
raise ValueError(
f"ungültige Rollenkombination {sorted(r.value for r in self.roles)}; "
f"erlaubt: {allowed}"
)
@property
def is_coordinator(self) -> bool:
return NodeRole.COORDINATOR in self.roles
@property
def renders_locally(self) -> bool:
return NodeRole.RENDER_NODE in self.roles
@classmethod
def load_or_create(
cls,
identity_dir: Path,
display_name: str,
roles: frozenset[NodeRole],
) -> NodeIdentity:
"""Lädt die persistente node_id oder erzeugt sie genau einmal.
IP-Wechsel ändern die node_id nicht; Doppelvergabe wird über die
Datei (O_EXCL) verhindert (§6.3, hms_domain.ids).
"""
identity_file = identity_dir / "node_id"
node_id = persistent_node_id(identity_file)
return cls(node_id=node_id, display_name=display_name, roles=roles)
@classmethod
def ephemeral(cls, display_name: str, roles: frozenset[NodeRole]) -> NodeIdentity:
"""Nur für Tests: Identität ohne Persistenz (kein Showbetrieb)."""
return cls(node_id=new_node_id(), display_name=display_name, roles=roles)
@@ -0,0 +1,53 @@
"""Stabile IDs (PLAN.md §3.6, §10.1).
- UUIDs für alle Show-Objekte.
- Persistente node_id: einmal erzeugt, dauerhaft gespeichert; unabhängig
von IP-Adresse und Hostname (§6.3).
"""
from __future__ import annotations
import os
import uuid
from pathlib import Path
def new_uuid() -> str:
"""Stabile UUID für Show-Objekte (Layer, Effekte, Outputs, ...)."""
return str(uuid.uuid4())
def _machine_independent_seed() -> bytes:
"""Einstreu ohne IP/Hostname: OS-Urandom hat Priorität (§6.3)."""
return os.urandom(16)
def new_node_id() -> str:
"""Erzeugt eine neue, netzwerkunabhängige node_id (UUIDv4)."""
return str(uuid.UUID(bytes=_machine_independent_seed(), version=4))
def persistent_node_id(identity_file: Path) -> str:
"""Lädt die node_id aus identity_file oder erzeugt sie genau einmal.
IP-Wechsel ändern die node_id nicht; doppelte Vergabe über die Datei
wird durch exklusives Erzeugen (O_EXCL) verhindert.
"""
identity_file = Path(identity_file)
if identity_file.exists():
existing = identity_file.read_text(encoding="utf-8").strip()
if existing:
uuid.UUID(existing) # Validierung: muss UUID sein
return existing
identity_file.parent.mkdir(parents=True, exist_ok=True)
candidate = new_node_id()
try:
fd = os.open(identity_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(candidate)
return candidate
except FileExistsError:
existing = identity_file.read_text(encoding="utf-8").strip()
if not existing:
raise
return existing
@@ -0,0 +1,452 @@
"""Domänenmodell (PLAN.md §10.1, §12.3, §13.2).
Alle Modelle sind plattformneutral (Pydantic) und werden sowohl für
Persistenz als auch API/IPC verwendet (§10). Sie enthalten keinerlei
D3D11-/HLSL-/OpenGL-/GLSL-Typen (§12.6).
Regeln:
- stabile UUIDs für alle Objekte (§3.6)
- Parameter werden über stabile Pfade adressiert, nie über Namen (§10.2)
- Layer-Reihenfolge = Z-Reihenfolge, eindeutig und lückenlos
- zwei Effekt-Slots je Layer im MVP (§4.1)
- Blend-Modi V1 fest definiert (§12.4)
- Schema-Version für Migrationen (§24.4)
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import PurePosixPath
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
DOMAIN_SCHEMA_VERSION = 1
def _now() -> str:
return datetime.now(UTC).isoformat()
def _new_uuid() -> str:
return str(uuid.uuid4())
class LayerType(StrEnum):
"""Layer-Typen V1 (§12.3). Media/Generator/Adjustment/Group."""
MEDIA = "media"
IMAGE = "image"
SOLID = "solid"
GENERATOR = "generator"
ADJUSTMENT = "adjustment"
GROUP = "group"
class BlendMode(StrEnum):
"""Blend-Modi V1 (§12.4) mit Golden-Image-Tests zu prüfen."""
NORMAL = "normal"
ADD = "add"
MULTIPLY = "multiply"
SCREEN = "screen"
LIGHTEN = "lighten"
DARKEN = "darken"
DIFFERENCE = "difference"
OVERLAY = "overlay"
ALPHA_PREMULTIPLIED = "alpha_premultiplied"
class SourceType(StrEnum):
"""Quelltypen V1 (§12.3 Media Layer + Generator Layer)."""
VIDEO = "video"
IMAGE = "image"
SOLID = "solid"
GENERATOR = "generator"
class LoopMode(StrEnum):
"""Playback-Loop (§12.5). Ping-Pong nur wenn technisch unterstützt."""
ONCE = "once"
LOOP = "loop"
PING_PONG = "ping_pong"
class TransportState(StrEnum):
"""Transportzustand einer Quelle (§12.5)."""
STOPPED = "stopped"
PLAYING = "playing"
PAUSED = "paused"
class ColorSpace(StrEnum):
COLOR_SRGB = "sRGB"
LINEAR = "linear"
class EffectScope(StrEnum):
"""Instanzierbare Scopes für Filter (§14.8). V1: Layer/Group/Master."""
SOURCE = "source"
LAYER = "layer"
GROUP = "group"
MASTER = "master"
OUTPUT = "output"
class QualityMode(StrEnum):
"""Auto Quality oder feste Stufe (§14.8)."""
AUTO = "auto"
FIXED = "fixed"
# ---------- Transformation (2D, §10.1 Layer) ----------
class Transform2D(BaseModel):
"""Feste 2D-Transformation je Layer (§10.1).
position_x/y und anchor in normalisierten Canvas-Koordinaten (-1..1
relativ zur Canvas-Mitte), rotation in Grad, scale um Anchor.
Zusätzliche Transformeffekte stehen zusätzlich in der Effektkette
(§15.2 Transform2D-Effekt).
"""
position_x: float = 0.0
position_y: float = 0.0
anchor_x: float = 0.5
anchor_y: float = 0.5
scale_x: float = 1.0
scale_y: float = 1.0
rotation_deg: float = 0.0
# ---------- Crop (§10.1) ----------
class Crop(BaseModel):
"""Beschnitt je Seite, normalisiert 0..1."""
left: float = Field(default=0.0, ge=0.0, le=1.0)
right: float = Field(default=0.0, ge=0.0, le=1.0)
top: float = Field(default=0.0, ge=0.0, le=1.0)
bottom: float = Field(default=0.0, ge=0.0, le=1.0)
# ---------- Farbsteuerung (§10.1 color_controls) ----------
class ColorControls(BaseModel):
"""Layer-Farbregler (Layer64-Kanäle 3740, §16.4)."""
hue: float = Field(default=0.0, ge=-0.5, le=0.5)
saturation: float = Field(default=1.0, ge=0.0, le=2.0)
brightness: float = Field(default=1.0, ge=0.0, le=2.0)
contrast: float = Field(default=1.0, ge=0.0, le=2.0)
# ---------- Quelle (§10.1 Source) ----------
class Source(BaseModel):
"""Medien-/Generator-Quelle eines Layers (§10.1).
- plugin_id/plugin_version: versionierter Plugin-Vertrag (§14)
- asset_id: stabile UUID des MediaAsset, nie ein Pfad (§13.2)
- in_point/out_point: normalisiert 0..1 (§12.5)
- speed: 1.0 = Normalwiedergabe; Reverse nur wenn Medium geeignet (§12.5)
"""
plugin_id: str
plugin_version: str = "1.0.0"
source_type: SourceType
asset_id: str | None = None
parameters: dict[str, float] = Field(default_factory=dict)
playback_state: TransportState = TransportState.STOPPED
in_point: float = Field(default=0.0, ge=0.0, le=1.0)
out_point: float = Field(default=1.0, ge=0.0, le=1.0)
loop_mode: LoopMode = LoopMode.LOOP
speed: float = Field(default=1.0, ge=-4.0, le=4.0)
volume: float = Field(default=1.0, ge=0.0, le=1.0)
audio_enabled: bool = True
@model_validator(mode="after")
def _validate_points(self) -> Source:
if self.in_point >= self.out_point:
raise ValueError("in_point muss kleiner als out_point sein")
if self.source_type in (SourceType.VIDEO, SourceType.IMAGE) and not self.asset_id:
raise ValueError("Medien-Quellen benötigen ein asset_id")
return self
# ---------- Effektinstanz (§10.1 EffectInstance) ----------
class EffectInstance(BaseModel):
"""Effektinstanz mit unverändertem Effektvertrag (§14.8).
- mix 0 bypassed kostengünstig (§15.3)
- requested_quality persistiert; resolved_quality ist Laufzeitzustand
und wird nicht persistiert
- bypass_on_error: Pluginfehler überbrücken, Layer bleibt aktiv (§12.2)
"""
id: str = Field(default_factory=_new_uuid)
plugin_id: str
plugin_version: str = "1.0.0"
scope: EffectScope = EffectScope.LAYER
order_index: int = 0
enabled: bool = True
mix: float = Field(default=1.0, ge=0.0, le=1.0)
effect_blend_mode: BlendMode = BlendMode.NORMAL
parameters: dict[str, float] = Field(default_factory=dict)
preset_id: str | None = None
quality_mode: QualityMode = QualityMode.AUTO
requested_quality: str = "auto"
bypass_on_error: bool = True
# ---------- Layer (§10.1 Layer) ----------
class Layer(BaseModel):
"""Layer einer Composition (§10.1).
z_index: eindeutige Z-Reihenfolge; 0 = unten (§10.1 „layers[] in
eindeutiger Z-Reihenfolge"). 8 gleichzeitig patchbare Layer im MVP
(§4.1); maximal 64 werden schema-seitig zugelassen.
"""
model_config = ConfigDict(validate_assignment=True)
id: str = Field(default_factory=_new_uuid)
name: str = "Layer"
enabled: bool = True
layer_type: LayerType
source: Source | None = None
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
blend_mode: BlendMode = BlendMode.NORMAL
transform: Transform2D = Field(default_factory=Transform2D)
crop: Crop = Field(default_factory=Crop)
color_controls: ColorControls = Field(default_factory=ColorControls)
effects: list[EffectInstance] = Field(default_factory=list)
mask_id: str | None = None
target_group_id: str | None = None
dmx_patch_id: str | None = None
@field_validator("effects")
@classmethod
def _max_two_effect_slots(cls, v: list[EffectInstance]) -> list[EffectInstance]:
if len(v) > 2:
raise ValueError("maximal zwei Effekt-Slots je Layer im MVP (§4.1)")
order = [e.order_index for e in v]
if len(order) != len(set(order)):
raise ValueError("order_index muss eindeutig sein (§14.8)")
return v
@model_validator(mode="after")
def _type_source_consistency(self) -> Layer:
media_types = (
LayerType.MEDIA,
LayerType.IMAGE,
LayerType.SOLID,
LayerType.GENERATOR,
)
if self.layer_type in media_types:
if self.source is None:
raise ValueError("Layer dieses Typs benötigen eine Quelle")
if self.layer_type in (LayerType.ADJUSTMENT, LayerType.GROUP) and self.source is not None:
raise ValueError("Adjustment-/Group-Layer besitzen keine eigene Quelle")
return self
# ---------- Composition (§10.1) ----------
class Composition(BaseModel):
"""Master-Canvas-Definition (§10.1).
- width/height: virtuelle Canvas (Desktop bis 3840×2160, §3.3)
- fps: feste Master-Bildrate (§12.2)
- layers: eindeutige Z-Reihenfolge, unten = 0
"""
model_config = ConfigDict(validate_assignment=True)
id: str = Field(default_factory=_new_uuid)
name: str = "Composition"
width: int = Field(default=1920, ge=16, le=8192)
height: int = Field(default=1080, ge=16, le=8192)
fps: float = Field(default=60.0, gt=0.0, le=240.0)
color_space: ColorSpace = ColorSpace.COLOR_SRGB
background_color: str = "#000000"
duration: float | None = None
layers: list[Layer] = Field(default_factory=list)
@field_validator("layers")
@classmethod
def _unique_z_order(cls, v: list[Layer]) -> list[Layer]:
ids = [layer.id for layer in v]
if len(ids) != len(set(ids)):
raise ValueError("Layer-IDs müssen eindeutig sein")
if len(v) > 64:
raise ValueError("maximal 64 Layer je Composition")
return v
def layer_by_id(self, layer_id: str) -> Layer | None:
return next((layer for layer in self.layers if layer.id == layer_id), None)
def sorted_layers(self) -> list[Layer]:
"""Render-Reihenfolge: unten zuerst (Z aufsteigend über Listenposition)."""
return list(self.layers)
# ---------- MediaAsset (§13.2) ----------
class MediaAsset(BaseModel):
"""Medienasset (§13.2): stabile UUID + relativer portabler Pfad.
- rel_path: relativ zum Projekt-/Media-Root, POSIX-Notation (§9.1:
keine Laufwerksbuchstaben, keine absoluten Pfade)
- content_hash: bei Projektpaketen verpflichtend (§13.2)
- seek_suitability: dokumentierte Eignung für Seek/Reverse (§12.5);
ungeprüft = unknown, niemals optimistisch
"""
id: str = Field(default_factory=_new_uuid)
rel_path: str
file_size_bytes: int = Field(default=0, ge=0)
mtime_ns: int = 0
content_hash: str | None = None
container: str | None = None
video_codec: str | None = None
audio_codec: str | None = None
width: int | None = None
height: int | None = None
fps: float | None = None
duration_s: float | None = None
has_alpha: bool = False
audio_streams: int = 0
thumbnail_rel_path: str | None = None
proxy_rel_path: str | None = None
seek_suitability: str = "unknown" # unknown|fast_seek|slow_seek|unsuitable
analysis_state: str = "pending" # pending|running|done|failed
@field_validator("rel_path")
@classmethod
def _portable_relative_path(cls, v: str) -> str:
p = PurePosixPath(v)
if p.is_absolute() or ".." in p.parts:
raise ValueError(f"rel_path muss portabel-relativ sein: {v!r} (§9.1)")
if not v:
raise ValueError("rel_path darf nicht leer sein")
return v
# ---------- OutputSurface (§10.1, §22.2) ----------
class OutputSurface(BaseModel):
"""Physische/Logische Ausgabefläche (§10.1 OutputSurface, §22.2).
Das Mapping-Datenmodell ist ab V1 vorhanden (§22.1), auch wenn der
vollständige Mapping-Editor später folgt.
"""
id: str = Field(default_factory=_new_uuid)
node_id: str
display_id: str = ""
enabled: bool = True
width: int = Field(default=1920, ge=16, le=8192)
height: int = Field(default=1080, ge=16, le=8192)
refresh_rate_hz: float = Field(default=60.0, gt=0.0)
# Canvas-Ausschnitt (§22.2): Quellrechteck in normalisierten Koordinaten
slice_x: float = Field(default=0.0, ge=0.0, le=1.0)
slice_y: float = Field(default=0.0, ge=0.0, le=1.0)
slice_w: float = Field(default=1.0, ge=0.0, le=1.0)
slice_h: float = Field(default=1.0, ge=0.0, le=1.0)
destination_x: int = 0
destination_y: int = 0
rotation_deg: float = 0.0
flip_horizontal: bool = False
flip_vertical: bool = False
test_pattern: int = 0 # 0 = aus; Enum folgt mit Mapping-Phase
fallback_policy: str = "hold_last_frame" # §26.1
# ---------- PresetScene (§18.1) ----------
class TransitionType(StrEnum):
"""Preset-Übergänge V1 (§18.3)."""
CUT = "cut"
CROSSFADE = "crossfade"
DIP_TO_BLACK = "dip_to_black"
WIPE_HORIZONTAL = "wipe_horizontal"
WIPE_VERTICAL = "wipe_vertical"
LUMA_FADE = "luma_fade"
PLUGIN = "plugin"
class PresetScene(BaseModel):
"""Szene/Preset: normalisierter Composition-Snapshot (§18.1).
Empfehlung §18.1: intern normalisierter Snapshot mit deduplizierten
Asset-/Plugin-Referenzen; Übergänge berechnen den Diff zur Laufzeit.
"""
id: str = Field(default_factory=_new_uuid)
name: str
composition_snapshot: dict # normalisiertes Composition-Serialisat
transition_type: TransitionType = TransitionType.CUT
transition_duration_s: float = Field(default=0.0, ge=0.0, le=60.0)
preload_hints: list[str] = Field(default_factory=list) # asset_ids
# ---------- Project (§10.1) ----------
class Project(BaseModel):
"""Projekt (§10.1) mit schema_version für Migrationen (§24.4)."""
model_config = ConfigDict(validate_assignment=True)
schema_version: int = DOMAIN_SCHEMA_VERSION
id: str = Field(default_factory=_new_uuid)
name: str = "Neues Projekt"
created_at: str = Field(default_factory=_now)
updated_at: str = Field(default_factory=_now)
settings: dict = Field(default_factory=dict)
media_assets: list[MediaAsset] = Field(default_factory=list)
compositions: list[Composition] = Field(default_factory=list)
scenes: list[PresetScene] = Field(default_factory=list)
timelines: list[dict] = Field(default_factory=list) # Phase 6+ (§19)
outputs: list[OutputSurface] = Field(default_factory=list)
control_bindings: list[dict] = Field(default_factory=list)
audio_profiles: list[dict] = Field(default_factory=list)
automation_policies: list[dict] = Field(default_factory=list)
plugin_requirements: list[dict] = Field(default_factory=list)
@field_validator("media_assets")
@classmethod
def _unique_asset_ids(cls, v: list[MediaAsset]) -> list[MediaAsset]:
ids = [a.id for a in v]
if len(ids) != len(set(ids)):
raise ValueError("MediaAsset-IDs müssen eindeutig sein")
paths = [a.rel_path for a in v]
if len(paths) != len(set(paths)):
raise ValueError("MediaAsset-Pfade müssen eindeutig sein (§13.3 Duplikate)")
return v
def composition_by_id(self, composition_id: str) -> Composition | None:
return next((c for c in self.compositions if c.id == composition_id), None)
def asset_by_id(self, asset_id: str) -> MediaAsset | None:
return next((a for a in self.media_assets if a.id == asset_id), None)
@@ -0,0 +1,23 @@
"""hms_parameter zentrale Parameter- und Control-Engine (PLAN.md §11)."""
from hms_parameter.engine import (
ControlSource,
MergeMode,
ParameterEngine,
ParameterFrame,
)
from hms_parameter.paths import (
layer_opacity_path,
master_intensity_path,
validate_parameter_path,
)
__all__ = [
"ControlSource",
"MergeMode",
"ParameterEngine",
"ParameterFrame",
"validate_parameter_path",
"layer_opacity_path",
"master_intensity_path",
]
@@ -0,0 +1,156 @@
"""Parameter-Engine: Prioritäten, Übernahme, Frame-Snapshot (PLAN.md §11).
Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über
diese Engine; direkte Renderer-Zugriffe sind verboten (§11, §33).
"""
from __future__ import annotations
import enum
import time
from dataclasses import dataclass, field
from hms_parameter.paths import validate_parameter_path
class ControlSource(enum.IntEnum):
"""Steuerquellen in Prioritätsordnung (§11.2)."""
SAFETY = 1 # Not-Aus/Blackout, überstimmt alles
OPERATOR = 2 # expliziter manueller Override
CONSOLE = 3 # freigegebenes Lichtpult (Art-Net)
WEB = 4 # Browser-Livebedienung
TIMELINE = 5 # reserviert
AUDIO = 6 # reserviert (Modulatoren)
AI = 7 # reserviert, niedrigste Priorität
class MergeMode(enum.Enum):
"""Übernahmeverfahren (§11.3)."""
LTP = "ltp" # letzte Änderung gewinnt (Standard)
HTP = "htp" # höchster Wert gewinnt (optional für Intensität)
@dataclass
class _Binding:
value: float
last_change_ns: int
class ParameterFrame:
"""Unveränderlicher Snapshot aller Parameter für genau einen Frame (§11.4)."""
__slots__ = ("_values", "revision", "created_ns")
def __init__(self, values: dict[str, float], revision: int) -> None:
object.__setattr__(self, "_values", dict(values))
object.__setattr__(self, "revision", revision)
object.__setattr__(self, "created_ns", time.monotonic_ns())
def get(self, path: str, default: float = 0.0) -> float:
return self._values.get(path, default)
def as_dict(self) -> dict[str, float]:
return dict(self._values)
def __contains__(self, path: str) -> bool:
return path in self._values
class RevisionConflict(Exception):
"""Erwartete Revision stimmt nicht (optimistische Sperre, §23.2)."""
def __init__(self, current: int, expected: int) -> None:
self.current = current
self.expected = expected
super().__init__(f"revision conflict: current={current}, expected={expected}")
@dataclass
class ParameterEngine:
"""Autoritative Parameter-Instanz des Control Core.
- set_value: Override einer Quelle mit Prioritätsprüfung
- release: Rückgabe an nächstniedrigere Quelle (§11.3)
- snapshot: atomarer Frame-Snapshot (§11.4)
"""
revision: int = 0
default: float = 0.0
merge_mode: MergeMode = MergeMode.LTP
_bindings: dict[str, dict[ControlSource, _Binding]] = field(
default_factory=dict, repr=False
)
_defaults: dict[str, float] = field(default_factory=dict, repr=False)
def set_value(
self,
path: str,
value: float,
source: ControlSource,
expected_revision: int | None = None,
) -> int:
"""Setzt einen Override; gibt die neue Revision zurück."""
if not validate_parameter_path(path):
raise ValueError(f"invalid parameter path: {path!r}")
value = float(value)
if value != value or value in (float("inf"), float("-inf")):
raise ValueError(f"value must be finite, got {value}")
if expected_revision is not None and expected_revision != self.revision:
raise RevisionConflict(self.revision, expected_revision)
per_source = self._bindings.setdefault(path, {})
# Priorität: eine niedrigere Quelle kann eine höhere Quelle nicht
# verdrängen, aber ihre eigene Bindung jederzeit aktualisieren.
existing = per_source.get(source)
now = time.monotonic_ns()
if existing is None:
per_source[source] = _Binding(value, now)
self.revision += 1
elif self.merge_mode is MergeMode.LTP:
# LTP: jede Übernahme aktualisiert Bindung und Revision (§11.3)
per_source[source] = _Binding(value, now)
self.revision += 1
elif value > existing.value:
# HTP: nur ein höherer Wert übernimmt; Maximum bleibt (§11.3)
per_source[source] = _Binding(value, now)
self.revision += 1
return self.revision
def effective_value(self, path: str) -> float:
"""Wirksamer Wert: höchste Priorität gewinnt; sonst Default (§11.1)."""
per_source = self._bindings.get(path)
if not per_source:
return self._defaults.get(path, self.default)
source = min(per_source) # kleinster IntEnum-Wert = höchste Priorität
return per_source[source].value
def current_source(self, path: str) -> ControlSource | None:
per_source = self._bindings.get(path)
if not per_source:
return None
return min(per_source)
def release(self, path: str, source: ControlSource) -> int:
"""Gibt den Override zurück; nächstniedrigere Quelle übernimmt (§11.3)."""
per_source = self._bindings.get(path)
if per_source and source in per_source:
del per_source[source]
if not per_source:
self._bindings.pop(path, None)
self.revision += 1
return self.revision
def snapshot(self) -> ParameterFrame:
"""Atomarer Snapshot aller wirksamen Werte für einen Frame (§11.4)."""
values = {p: self._defaults[p] for p in self._defaults}
for path, per_source in self._bindings.items():
if per_source:
values[path] = per_source[min(per_source)].value
return ParameterFrame(values, self.revision)
def set_default(self, path: str, value: float) -> None:
if not validate_parameter_path(path):
raise ValueError(f"invalid parameter path: {path!r}")
self._defaults[path] = float(value)
@@ -0,0 +1,49 @@
"""Stabile Parameterpfade (PLAN.md §10.2).
Pfade werden niemals aus sichtbaren Namen gebildet; alle Teile sind UUIDs
oder feste Schlüsselwörter.
"""
from __future__ import annotations
import uuid
_ALLOWED_ROOTS = {"composition", "output", "cluster", "master"}
def _is_uuid(value: str) -> bool:
try:
uuid.UUID(value)
return True
except (ValueError, AttributeError):
return False
def validate_parameter_path(path: str) -> bool:
"""True, wenn der Pfad dem Muster §10.2 entspricht."""
if not path or path.startswith("/") or "\\" in path or ".." in path:
return False
parts = path.split("/")
root = parts[0]
if root not in _ALLOWED_ROOTS:
return False
if root == "master":
return len(parts) == 2 and parts[1] != ""
if root in {"composition", "output"}:
if len(parts) < 3:
return False
if not _is_uuid(parts[1]):
return False
return all(p != "" for p in parts[2:])
# cluster: cluster/group/{uuid}/... oder cluster/node/{uuid}/...
if len(parts) >= 3 and parts[1] in {"group", "node"} and _is_uuid(parts[2]):
return all(p != "" for p in parts[3:])
return False
def layer_opacity_path(composition_id: str, layer_id: str) -> str:
return f"composition/{composition_id}/layer/{layer_id}/opacity"
def master_intensity_path() -> str:
return "master/intensity"
@@ -0,0 +1,263 @@
"""SQLite-Persistenz mit Migrationen (PLAN.md §24).
Regeln (§24.1, §24.4):
- WAL-Modus, Foreign Keys aktiv, kurze Transaktionen
- keine Datenbankoperation im Renderthread (nur Control Core nutzt sie)
- automatisches Backup vor Migration
- Integritätscheck beim Start nach unsauberem Shutdown
- jede Schemaänderung: Vorwärtsmigration, Test mit Altdaten, Backup,
dokumentierte Nicht-Rückwärtskompatibilität, neue schema_version
Projektdateien liegen als JSON-Dateien (§24.2: große Medien als Dateien,
SQLite speichert Index, Einstellungen, Pluginstatus, Projektmetadaten).
"""
from __future__ import annotations
import json
import shutil
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
SCHEMA_VERSION = 1
_MIGRATIONS: dict[int, str] = {
1: """
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
schema_version INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_status (
plugin_id TEXT PRIMARY KEY,
version TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'discovered',
package_hash TEXT,
updated_at TEXT NOT NULL
);
""",
}
@dataclass
class MigrationResult:
"""Ergebnis einer Migration (für TEST_REPORT und Gate-Doku)."""
from_version: int
to_version: int
backup_path: Path | None
integrity_ok: bool
class Database:
"""SQLite-Wrapper für den Control Core (nicht im Renderthread!).
- open(): öffnet mit WAL, FK, Integritätscheck
- migrate(): führt fehlende Migrationen aus, Backup vorher
- save_project/load_project/list_projects: Projektmetadaten + JSON-Daten
- set_setting/get_setting: App-Einstellungen
- upsert_plugin_status/get_plugin_status: Pluginverwaltung (§14.5)
"""
def __init__(self, path: Path) -> None:
self._path = Path(path)
self._conn: sqlite3.Connection | None = None
@property
def connection(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError("database not opened")
return self._conn
@property
def schema_version(self) -> int:
row = self.connection.execute(
"SELECT value FROM meta WHERE key='schema_version'"
).fetchone()
return int(row[0]) if row else 0
def open(self, integrity_check: bool = True) -> None:
"""Öffnet die DB: WAL, Foreign Keys, Busy-Timeout, Integritätscheck."""
self._path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(self._path, timeout=5.0, check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL") # §24.1
self._conn.execute("PRAGMA foreign_keys=ON") # §24.1
self._conn.execute("PRAGMA busy_timeout=5000")
if integrity_check: # nach unsauberem Shutdown (§24.1)
row = self._conn.execute("PRAGMA integrity_check").fetchone()
if row and row[0] != "ok":
raise sqlite3.DatabaseError(f"integrity check failed: {row[0]}")
def close(self) -> None:
if self._conn is not None:
self._conn.close()
self._conn = None
def migrate(self, backup_dir: Path | None = None) -> MigrationResult:
"""Führt Migrationen bis SCHEMA_VERSION aus; Backup vorher (§24.4).
Migrationen sind reine Vorwärtsmigrationen; jede Änderung erhöht
schema_version. Altdaten werden beim Backup erhalten.
"""
conn = self.connection
current = 0
try:
current = self.schema_version
except sqlite3.OperationalError:
pass # meta-Tabelle existiert noch nicht → Version 0
if current >= SCHEMA_VERSION:
return MigrationResult(current, current, None, True)
backup_path: Path | None = None
if self._path.exists() and backup_dir is not None:
backup_dir.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S")
backup_path = backup_dir / f"{self._path.name}.pre-migration-{stamp}.bak"
shutil.copy2(self._path, backup_path) # §24.4: Backup vor Migration
with conn: # kurze Transaktion je Version (§24.1)
for version in range(current + 1, SCHEMA_VERSION + 1):
sql = _MIGRATIONS.get(version)
if sql is None:
raise RuntimeError(f"missing migration for version {version}")
conn.executescript(sql)
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
row = conn.execute("PRAGMA integrity_check").fetchone()
ok = bool(row and row[0] == "ok")
return MigrationResult(current, SCHEMA_VERSION, backup_path, ok)
# ---------- Projekte (§24.2) ----------
def save_project(self, project: dict) -> None:
"""Speichert Projektmetadaten + JSON-Daten transaktionell."""
required = ("id", "name", "schema_version", "created_at", "updated_at")
for key in required:
if key not in project:
raise ValueError(f"project missing field {key!r}")
with self.connection:
self.connection.execute(
"""INSERT OR REPLACE INTO projects
(id, name, schema_version, created_at, updated_at, data)
VALUES (?, ?, ?, ?, ?, ?)""",
(
project["id"],
project["name"],
int(project["schema_version"]),
project["created_at"],
project["updated_at"],
json.dumps(project, ensure_ascii=False),
),
)
def load_project(self, project_id: str) -> dict | None:
row = self.connection.execute(
"SELECT data FROM projects WHERE id=?", (project_id,)
).fetchone()
if row is None:
return None
return json.loads(row[0])
def list_projects(self) -> list[dict]:
rows = self.connection.execute(
"SELECT id, name, schema_version, updated_at FROM projects"
" ORDER BY updated_at DESC"
).fetchall()
return [
{
"id": r[0],
"name": r[1],
"schema_version": r[2],
"updated_at": r[3],
}
for r in rows
]
def delete_project(self, project_id: str) -> None:
with self.connection:
self.connection.execute("DELETE FROM projects WHERE id=?", (project_id,))
# ---------- Einstellungen ----------
def set_setting(self, key: str, value: str) -> None:
with self.connection:
self.connection.execute(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
(key, value, time.strftime("%Y-%m-%dT%H:%M:%S%z")),
)
def get_setting(self, key: str, default: str | None = None) -> str | None:
row = self.connection.execute(
"SELECT value FROM settings WHERE key=?", (key,)
).fetchone()
return row[0] if row else default
# ---------- Plugin-Status (§14.5) ----------
def upsert_plugin_status(
self,
plugin_id: str,
version: str,
enabled: bool,
state: str,
package_hash: str | None = None,
) -> None:
if state not in {
"discovered",
"validated",
"installed",
"enabled",
"compiled",
"active",
"quarantined",
"incompatible",
"disabled",
}:
raise ValueError(f"invalid plugin state {state!r}")
with self.connection:
self.connection.execute(
"""INSERT OR REPLACE INTO plugin_status
(plugin_id, version, enabled, state, package_hash, updated_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(
plugin_id,
version,
int(enabled),
state,
package_hash,
time.strftime("%Y-%m-%dT%H:%M:%S%z"),
),
)
def get_plugin_status(self) -> dict[str, dict]:
rows = self.connection.execute(
"SELECT plugin_id, version, enabled, state, package_hash, updated_at"
" FROM plugin_status"
).fetchall()
return {
r[0]: {
"version": r[1],
"enabled": bool(r[2]),
"state": r[3],
"package_hash": r[4],
"updated_at": r[5],
}
for r in rows
}
@@ -0,0 +1,165 @@
"""Autoritativer Projekt- und Showzustand (PLAN.md §6.4 State Sync, §24.2).
- Vollständiger Snapshot nach Verbindung; danach inkrementelle Deltas
- monotone Revisionen: kein halber Zustand (§11.4, §6.4)
- Livezustand und dauerhafter Projektzustand sind getrennt (§24.2)
- Szenenaktivierung erzeugt eine neue State-Revision
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from hms_domain.model import PresetScene, Project
@dataclass(frozen=True)
class StateDelta:
"""Inkrementelle Änderung mit monotoner Revision (§6.4).
- project_revision: Projekt-Inhaltsrevision (Manifest-Ebene)
- state_revision: monotone Showzustands-Revision
- changes: Parameterpfad → Wert; gelöschte Pfade als None markiert
"""
state_revision: int
project_revision: int
changes: dict[str, float | None]
monotonic_ns: int
def to_dict(self) -> dict:
return {
"state_revision": self.state_revision,
"project_revision": self.project_revision,
"changes": self.changes,
"monotonic_ns": self.monotonic_ns,
}
@dataclass
class ProjectStateStore:
"""Autoritative Instanz im Control Core (§6.1B, §6.4).
Trennung (§24.2):
- project: dauerhafter Projektzustand (Domänenmodell, persistiert)
- live: Show-Livezustand (Parameterwerte je Pfad, nicht persistent)
Revisions:
- state_revision steigt bei jeder Livezustandsänderung monoton
- project_revision steigt bei Projektinhalts-Änderungen (z. B. neue
Szenen, Medien-Revision) Grundlage für Preflight (§6.5)
"""
_state_revision: int = 0
_project_revision: int = 0
_live: dict[str, float] = field(default_factory=dict)
_project: Project | None = None
_project_name: str = ""
# ---------- Projekt ----------
def activate_project(self, project: Project) -> int:
"""Aktiviert ein Projekt als autoritative Basis; erhöht die
Projektrevision. Livezustand wird zurückgesetzt (kein Mischzustand)."""
self._project = project
self._project_name = project.name
self._project_revision += 1
self._live.clear()
self._state_revision += 1 # neuer Zustand nach Projektwechsel
return self._state_revision
@property
def project(self) -> Project | None:
return self._project
@property
def project_revision(self) -> int:
return self._project_revision
@property
def state_revision(self) -> int:
return self._state_revision
# ---------- Livezustand ----------
def set_value(self, path: str, value: float) -> int:
"""Setzt einen Liveparameter; gibt die neue State-Revision zurück."""
self._live[path] = float(value)
self._state_revision += 1
return self._state_revision
def clear_value(self, path: str) -> int:
"""Entfernt einen Liveparameter (z. B. Release); neue Revision."""
self._live.pop(path, None)
self._state_revision += 1
return self._state_revision
def get_value(self, path: str) -> float | None:
return self._live.get(path)
# ---------- Snapshot / Delta (§6.4) ----------
def snapshot(self) -> dict:
"""Vollständiger Zustand nach Verbindungsaufbau (§6.2, §6.4)."""
return {
"state_revision": self._state_revision,
"project_revision": self._project_revision,
"project_name": self._project_name,
"values": dict(self._live),
"monotonic_ns": time.monotonic_ns(),
}
def delta_since(self, last_seen_revision: int, pending: dict[str, float]) -> StateDelta | None:
"""Delta seit einer gesehenen Revision; None, wenn nichts Neues.
pending: letzter BEKANNTER Zustand des Empfängers (Pfad → Wert,
wie er bei last_seen_revision beim Client stand). Das Delta enthält:
- neue Pfade (in live, nicht in pending) mit ihrem Wert
- geänderte Pfade mit dem neuen Wert
- gelöschte Pfade als None
Ein vollständiger Re-Sync (neuer Snapshot) ist Aufgabe des
Transports, wenn last_seen_revision zu alt ist (§6.2).
"""
if last_seen_revision > self._state_revision:
raise ValueError(
f"gesehene Revision {last_seen_revision} liegt in der Zukunft"
)
if last_seen_revision == self._state_revision:
return None # nichts Neues
changes: dict[str, float | None] = {}
for path, value in self._live.items():
if path not in pending:
changes[path] = value # neu seit last_seen
elif pending[path] != value:
changes[path] = value # geändert
for path in pending:
if path not in self._live:
changes[path] = None # gelöscht
return StateDelta(
state_revision=self._state_revision,
project_revision=self._project_revision,
changes=changes,
monotonic_ns=time.monotonic_ns(),
)
# ---------- Szenen (§18) ----------
def apply_scene(self, scene: PresetScene) -> int:
"""Aktiviert eine Szene direkt (§18.1): ÜBERNAHME der Snapshot-Werte
in den Livezustand als neue State-Revision. Übergänge (Crossfade
etc.) berechnet der Renderer aus vorher/nachher hier entsteht nur
der Zielzustand (§18.1: Diff zur Laufzeit).
"""
snapshot_values = scene.composition_snapshot.get("values", {})
if not isinstance(snapshot_values, dict):
raise ValueError("Szene enthält keine Werte")
for path, value in snapshot_values.items():
self._live[str(path)] = float(value)
self._state_revision += 1
return self._state_revision
def bump_project_revision(self) -> int:
"""Projektinhalt geändert (Medien/Plugins/Szenen) → neue Revision."""
self._project_revision += 1
return self._project_revision
@@ -0,0 +1,25 @@
"""hms_plugin_sdk Plugin-API, Manifest, Validierung, Lifecycle (PLAN.md §14)."""
from hms_plugin_sdk.lifecycle import (
InvalidTransitionError,
LifecycleState,
PluginLifecycleManager,
PluginRecord,
)
from hms_plugin_sdk.manifest import (
PluginKind,
load_manifest,
validate_manifest,
validate_plugin_zip,
)
__all__ = [
"PluginKind",
"load_manifest",
"validate_manifest",
"validate_plugin_zip",
"LifecycleState",
"PluginRecord",
"PluginLifecycleManager",
"InvalidTransitionError",
]
@@ -0,0 +1,227 @@
"""Plugin-Lifecycle-Manager (PLAN.md §14.5, §14.6, §26.3).
Lebenszyklus:
discovered → validated → installed → enabled → compiled → active
↘ quarantined / incompatible
Regeln:
- Übergänge nur entlang definierter Kanten; Sprünge sind Fehler
- Validierung umfasst Manifest-Schema, API-Kompatibilität,
Pfadsicherheit, Backend-/Shader-Prüfung (§14.5)
- ein fehlerhaftes Plugin wird quarantiniert, ohne ein Projekt unbrauchbar
zu machen (§3.5); der Effekt wird überbrückt (bypass_on_error)
- Show-Lock (§26.3): Installieren/Updaten ist gesperrt; Enable/Disable
und Parameter bleiben erlaubt (§17.7 Live-Modus)
- Doppelte Plugin-IDs sind Fehler, keine stillen Überschreibungen
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from hms_plugin_sdk.manifest import load_manifest
class LifecycleState(StrEnum):
"""Zustände gemäß §14.5."""
DISCOVERED = "discovered"
VALIDATED = "validated"
INSTALLED = "installed"
ENABLED = "enabled"
COMPILED = "compiled"
ACTIVE = "active"
QUARANTINED = "quarantined"
INCOMPATIBLE = "incompatible"
DISABLED = "disabled"
# Erlaubte Übergänge (§14.5-Lebenszyklusgraph)
_TRANSITIONS: dict[LifecycleState, frozenset[LifecycleState]] = {
LifecycleState.DISCOVERED: frozenset(
{LifecycleState.VALIDATED, LifecycleState.INCOMPATIBLE, LifecycleState.QUARANTINED}
),
LifecycleState.VALIDATED: frozenset(
{LifecycleState.INSTALLED, LifecycleState.INCOMPATIBLE, LifecycleState.QUARANTINED}
),
LifecycleState.INSTALLED: frozenset(
{LifecycleState.ENABLED, LifecycleState.DISABLED, LifecycleState.QUARANTINED}
),
LifecycleState.ENABLED: frozenset(
{LifecycleState.COMPILED, LifecycleState.DISABLED, LifecycleState.QUARANTINED}
),
LifecycleState.COMPILED: frozenset(
{LifecycleState.ACTIVE, LifecycleState.QUARANTINED, LifecycleState.DISABLED}
),
LifecycleState.ACTIVE: frozenset(
{LifecycleState.DISABLED, LifecycleState.QUARANTINED}
),
LifecycleState.DISABLED: frozenset(
{LifecycleState.ENABLED, LifecycleState.QUARANTINED}
),
LifecycleState.QUARANTINED: frozenset(), # manuelle Entfernung/Neuinstallation
LifecycleState.INCOMPATIBLE: frozenset(),
}
class InvalidTransitionError(Exception):
"""Unerlaubter Zustandsübergang im Lebenszyklus."""
@dataclass
class PluginRecord:
"""Ein Plugin im Lifecycle-Manager."""
plugin_id: str
version: str
state: LifecycleState = LifecycleState.DISCOVERED
package_hash: str | None = None
last_error: str | None = None
manifest: dict = field(default_factory=dict)
class PluginLifecycleManager:
"""Verwaltet den Lebenszyklus aller installierten Plugins (§14.5).
- discover(): Verzeichnis scannen, Manifest laden, Manifest-Validierung
- advance(): zustandsgeprüfter Übergang
- quarantine(): Fehlerfall mit Ursache (§3.5, §14.5)
- Show-Lock: install_validate/enable_new blockiert Strukturänderungen
(§26.3); Aktivieren bereits installierter Plugins bleibt erlaubt
"""
def __init__(self, show_lock: bool = False) -> None:
self._plugins: dict[str, PluginRecord] = {}
self._show_lock = show_lock
@property
def show_lock(self) -> bool:
return self._show_lock
def set_show_lock(self, enabled: bool) -> None:
"""§26.3: Show-Lock verhindert Plugininstallation/-update."""
self._show_lock = enabled
# ---------- Discovery & Validierung (§14.5) ----------
def discover(self, plugin_dir: Path) -> list[str]:
"""Scannt ein Verzeichnis mit Plugin-Ordnern.
Lädt plugin.json, validiert es (inkl. Shader-Existenz) und
überführt jedes Plugin in VALIDATED oder INCOMPATIBLE/QUARANTINED.
Rückgabe: Liste diagnostizierter Fehler (leer = alles sauber).
"""
errors: list[str] = []
if not plugin_dir.is_dir():
return [f"Plugin-Verzeichnis fehlt: {plugin_dir}"]
for child in sorted(plugin_dir.iterdir()):
if not child.is_dir():
continue
manifest_path = child / "plugin.json"
if not manifest_path.is_file():
continue # kein Plugin-Ordner (z. B. .git)
manifest, validation_errors = load_manifest(child)
if validation_errors:
pid = manifest.get("id") or child.name
errors.extend(f"{pid}: {e}" for e in validation_errors)
record = PluginRecord(
plugin_id=pid,
version=str(manifest.get("version", "0.0.0")),
state=LifecycleState.QUARANTINED,
last_error="; ".join(validation_errors),
manifest=manifest,
)
self._upsert(record)
continue
pid = manifest["id"]
record = PluginRecord(
plugin_id=pid,
version=manifest["version"],
state=LifecycleState.VALIDATED,
manifest=manifest,
)
self._upsert(record)
return errors
# ---------- Übergänge (§14.5) ----------
def advance(self, plugin_id: str, new_state: LifecycleState) -> LifecycleState:
"""Führt einen Lifecycle-Übergang aus; wirft bei illegaler Kante.
Show-Lock (§26.3): Übergänge, die Installation/Update bedeuten
(INSTALLED von DISCOVERED/VALIDATED), sind gesperrt. Aktivieren
bereits installierter Plugins (ENABLED/COMPILED/ACTIVE) bleibt
erlaubt (§17.7 Live: Layerparameter und Livefunktionen nutzbar).
"""
record = self._plugins.get(plugin_id)
if record is None:
raise KeyError(f"unbekanntes Plugin {plugin_id!r}")
current = record.state
if new_state not in _TRANSITIONS[current]:
raise InvalidTransitionError(
f"{plugin_id}: Übergang {current.value}{new_state.value} nicht erlaubt"
)
if (
self._show_lock
and new_state is LifecycleState.INSTALLED
and current in (LifecycleState.DISCOVERED, LifecycleState.VALIDATED)
):
raise InvalidTransitionError(
f"{plugin_id}: Installation im Show-Lock gesperrt (§26.3)"
)
record.state = new_state
record.last_error = None
return record.state
def quarantine(self, plugin_id: str, reason: str) -> None:
"""Fehlerfall: Plugin überbrücken, Projekt bleibt nutzbar (§3.5)."""
record = self._plugins.get(plugin_id)
if record is None:
raise KeyError(f"unbekanntes Plugin {plugin_id!r}")
record.state = LifecycleState.QUARANTINED
record.last_error = reason
# ---------- Abfragen ----------
def get(self, plugin_id: str) -> PluginRecord | None:
return self._plugins.get(plugin_id)
def by_state(self, state: LifecycleState) -> list[PluginRecord]:
return [r for r in self._plugins.values() if r.state is state]
def active_plugins(self) -> list[PluginRecord]:
return self.by_state(LifecycleState.ACTIVE)
def all(self) -> list[PluginRecord]:
return list(self._plugins.values())
def compatible_backends(self, plugin_id: str) -> frozenset[str]:
"""Gemeinsame Backends: deklariert im Manifest und lokal verfügbar."""
record = self._plugins.get(plugin_id)
if record is None:
return frozenset()
declared = set(record.manifest.get("entrypoints", {}))
supported = set(
record.manifest.get("capabilities", {}).get("supported_backends", [])
)
return frozenset(declared & supported)
# ---------- Interna ----------
def _upsert(self, record: PluginRecord) -> None:
existing = self._plugins.get(record.plugin_id)
if existing is not None and existing.version != record.version:
# Versionskonflikt: neuer Stand gewinnt nur ohne Show-Lock.
# Ein Update startet einen neuen Zyklus: discover() hat das
# neue Manifest bereits validiert (§14.5), also ist VALIDATED
# der korrekte Zustand eine erneute Installation ist nötig,
# der alte INSTALLED/ACTIVE-Status gilt nicht mehr.
if self._show_lock:
raise InvalidTransitionError(
f"{record.plugin_id}: Versionswechsel {existing.version}"
f"{record.version} im Show-Lock gesperrt (§26.3)"
)
self._plugins[record.plugin_id] = record
@@ -0,0 +1,227 @@
"""Plugin-Manifest und Validierung (PLAN.md §14.214.6, §27.2).
Sicherheitsgrenzen:
- Pfadsicherheit: keine absoluten Pfade, kein '..' in Manifest und ZIP
- ZIP-Bomb-Limits, Dateigrößenlimits, erlaubte Dateitypen
- eindeutige Plugin-ID (reverse-dns), SemVer, api_version
- Shader-Dateien müssen je deklariertem Backend existieren
- max. 8 generische DMX-Slots je Effektinstanz (§14.7)
"""
from __future__ import annotations
import json
import zipfile
from enum import StrEnum
from pathlib import Path, PurePosixPath
from typing import Any
MANIFEST_SCHEMA_VERSION = 1
MAX_PLUGIN_FILES = 512
MAX_TOTAL_UNPACKED = 32 * 1024 * 1024
MAX_FILE_SIZE = 8 * 1024 * 1024
_ALLOWED_SUFFIXES = {
".json",
".hlsl",
".frag",
".vert",
".glsl",
".png",
".md",
".txt",
".toml",
".csv",
}
_ALLOWED_BACKENDS = {"d3d11", "gl", "gles"}
class PluginKind(StrEnum):
SOURCE = "source"
GENERATOR = "generator"
FILTER = "filter"
TRANSITION = "transition"
MIXER = "mixer"
OUTPUT = "output"
CONTROL = "control"
AUTOMATION = "automation"
def _safe_relative(raw: str) -> PurePosixPath | None:
"""Prüft Pfadsicherheit; None wenn unsicher (absolut oder Traversal)."""
if not raw:
return None
p = PurePosixPath(raw)
if p.is_absolute() or ".." in p.parts:
return None
return p
def _validate_parameters(params: list[dict[str, Any]]) -> list[str]:
errors: list[str] = []
seen: set[str] = set()
total_dmx_slots = 0
for param in params:
pid = param.get("id")
if not pid or not isinstance(pid, str):
errors.append("parameter without id")
continue
if pid in seen:
errors.append(f"duplicate parameter id: {pid}")
seen.add(pid)
ptype = param.get("type")
if ptype not in {"float", "int", "enum", "bool", "color"}:
errors.append(f"parameter {pid}: invalid type {ptype!r}")
if ptype == "float":
for key in ("minimum", "maximum", "default"):
if key not in param:
errors.append(f"parameter {pid}: missing {key}")
slots = param.get("dmx_slots", [])
if not isinstance(slots, list) or any(not isinstance(s, int) for s in slots):
errors.append(f"parameter {pid}: dmx_slots must be int list")
slots = []
total_dmx_slots += len(slots)
if total_dmx_slots > 8:
errors.append(f"dmx slot footprint {total_dmx_slots} exceeds 8 (§14.7)")
return errors
def _valid_plugin_id(pid: str) -> bool:
if ".." in pid or len(pid) < 5:
return False
parts = pid.split(".")
if len(parts) < 2:
return False
allowed = set("abcdefghijklmnopqrstuvwxyz0123456789._-")
return all(c in allowed for c in pid)
def _valid_semver(version: str) -> bool:
parts = version.split(".")
if len(parts) != 3:
return False
try:
for p in parts:
int(p)
except ValueError:
return False
return True
def validate_manifest(
manifest: dict[str, Any], plugin_root: Path | None = None
) -> list[str]:
"""Validiert ein geparstes Manifest; leere Fehlerliste = gültig.
plugin_root: wenn gesetzt, werden deklarierte Shader auf Existenz geprüft.
"""
errors: list[str] = []
if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION:
errors.append(f"schema_version must be {MANIFEST_SCHEMA_VERSION}")
pid = manifest.get("id", "")
if not isinstance(pid, str) or not _valid_plugin_id(pid):
errors.append(f"invalid plugin id: {pid!r} (expected reverse-dns)")
for key in ("name", "version", "vendor"):
value = manifest.get(key)
if not isinstance(value, str) or not value:
errors.append(f"missing or empty {key}")
if not _valid_semver(manifest.get("version", "")):
errors.append("version must be semantic (X.Y.Z)")
if manifest.get("api_version") != MANIFEST_SCHEMA_VERSION:
errors.append(f"api_version must be {MANIFEST_SCHEMA_VERSION}")
if manifest.get("kind") not in {k.value for k in PluginKind}:
errors.append(f"invalid kind: {manifest.get('kind')!r}")
entrypoints = manifest.get("entrypoints", {})
if not isinstance(entrypoints, dict) or not entrypoints:
errors.append("entrypoints required")
else:
supported = set(manifest.get("capabilities", {}).get("supported_backends", []))
unknown = supported - _ALLOWED_BACKENDS
if unknown:
errors.append(f"unsupported backends: {sorted(unknown)}")
for backend, entry in entrypoints.items():
if backend not in _ALLOWED_BACKENDS:
errors.append(f"entrypoint backend {backend!r} not allowed")
continue
if backend in supported:
passes = entry.get("passes", [])
if not passes:
errors.append(f"entrypoint {backend}: no passes")
for pas in passes:
shader_key = "pixel_shader" if "pixel_shader" in pas else "fragment"
shader_rel = pas.get(shader_key)
if not shader_rel:
errors.append(f"entrypoint {backend}: pass without shader")
continue
sp = _safe_relative(shader_rel)
if sp is None:
errors.append(f"unsafe shader path: {shader_rel!r}")
continue
if plugin_root is not None and not (plugin_root / sp).is_file():
errors.append(f"missing shader file: {shader_rel}")
params = manifest.get("parameters", [])
if not isinstance(params, list):
errors.append("parameters must be a list")
else:
errors.extend(_validate_parameters(params))
if manifest.get("failure_mode") not in {"bypass", "hold", "black"}:
errors.append("failure_mode must be bypass|hold|black")
return errors
def validate_plugin_zip(zip_path: Path) -> list[str]:
"""Prüft ein Plugin-ZIP: Pfadsicherheit, Limits, Typen, Manifest (§27.2)."""
errors: list[str] = []
try:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
if len(names) > MAX_PLUGIN_FILES:
errors.append(f"too many files: {len(names)} > {MAX_PLUGIN_FILES}")
total = 0
for info in zf.infolist():
if info.is_dir():
continue
total += info.file_size
if info.file_size > MAX_FILE_SIZE:
errors.append(f"file too large: {info.filename}")
if _safe_relative(info.filename) is None:
errors.append(f"unsafe path in zip: {info.filename!r}")
if Path(info.filename).suffix.lower() not in _ALLOWED_SUFFIXES:
errors.append(f"disallowed file type: {info.filename}")
if total > MAX_TOTAL_UNPACKED:
errors.append(f"zip too large unpacked: {total} > {MAX_TOTAL_UNPACKED}")
manifest_name = next(
(n for n in names if n.endswith("plugin.json") and n.count("/") == 1),
None,
)
if manifest_name is None:
errors.append("plugin.json not found at package root")
else:
manifest = json.loads(zf.read(manifest_name))
errors.extend(validate_manifest(manifest))
except zipfile.BadZipFile:
errors.append("not a valid zip file")
except json.JSONDecodeError as exc:
errors.append(f"plugin.json invalid JSON: {exc}")
return errors
def load_manifest(plugin_dir: Path) -> tuple[dict[str, Any], list[str]]:
"""Lädt und validiert plugin.json aus einem Plugin-Verzeichnis."""
manifest_path = plugin_dir / "plugin.json"
if not manifest_path.is_file():
return {}, ["plugin.json missing"]
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return {}, [f"plugin.json invalid JSON: {exc}"]
return manifest, validate_manifest(manifest, plugin_root=plugin_dir)
@@ -0,0 +1,36 @@
"""hms_protocol versioniertes IPC (PLAN.md §6.2, ADR-0003).
Lokales TCP auf 127.0.0.1, length-prefixed MessagePack, Protokollversion 1.
Verbindungsschicht: Handshake, Snapshot/Delta, Heartbeat, Re-Sync.
"""
from hms_protocol.connection import (
HandshakeInfo,
IpcClient,
IpcServer,
ProtocolError,
)
from hms_protocol.envelope import Envelope, MessageType
from hms_protocol.framing import (
decode_frame,
encode_frame,
read_frame,
read_frame_async,
write_frame,
)
from hms_protocol.idempotency import IdempotencyRegistry
__all__ = [
"Envelope",
"MessageType",
"encode_frame",
"decode_frame",
"read_frame",
"read_frame_async",
"write_frame",
"IdempotencyRegistry",
"IpcServer",
"IpcClient",
"HandshakeInfo",
"ProtocolError",
]
@@ -0,0 +1,259 @@
"""IPC-Verbindung zwischen Control Core und Renderer (PLAN.md §6.2).
Server (Renderer-seitig) und Client (Control-Core-seitig) auf 127.0.0.1.
Verbindungsablauf:
1. Client verbindet, sendet hello mit Protokollversion + Capabilities
2. Server prüft Version, antwortet welcome mit eigenen Capabilities
3. Server sendet vollständigen state snapshot
4. danach inkrementelle Deltas mit monotoner Revision
5. Heartbeat mindestens alle 500 ms in beide Richtungen
6. Ack für jedes zustandsändernde Command (idempotent über message_id)
7. Nach Reconnect: Re-Sync (neuer Snapshot), Deltas erst danach akzeptiert
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from hms_protocol.envelope import PROTOCOL_VERSION, Envelope, MessageType
from hms_protocol.framing import encode_frame, read_frame_async
HEARTBEAT_INTERVAL_S = 0.5 # §6.2: mindestens alle 500 ms
@dataclass
class ProtocolError(Exception):
"""Protokollverstoß; Verbindung wird getrennt."""
code: str
message: str
def __str__(self) -> str:
return f"{self.code}: {self.message}"
@dataclass
class HandshakeInfo:
"""Ergebnis des Handshakes mit Capabilities der Gegenseite."""
peer_name: str
peer_capabilities: dict
protocol_version: int = PROTOCOL_VERSION
class IpcServer:
"""Renderer-seitiger IPC-Server. Lauscht ausschließlich auf 127.0.0.1.
Liefert dem Renderer:
- accept(handler) → wartet auf Client, führt Handshake durch
- receive() → nächste Nachricht (command/event/heartbeat)
- send(envelope) → Nachricht an Control Core
"""
def __init__(self, port: int = 0, host: str = "127.0.0.1") -> None:
if host not in ("127.0.0.1", "localhost", "::1"):
raise ValueError("IPC-Server darf nur auf Loopback lauschen (§6.2)")
self._host = host
self._port = port
self._server: asyncio.Server | None = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._handler_task: asyncio.Task | None = None
self._heartbeat_task: asyncio.Task | None = None
self._last_peer_heartbeat_ns: int = 0
self.capabilities: dict = {}
self.peer_name: str = ""
self.peer_capabilities: dict = {}
self.name: str = "hms-renderer"
@property
def port(self) -> int:
if self._server is None:
return self._port
return self._server.sockets[0].getsockname()[1] if self._server.sockets else self._port
async def start(self) -> int:
"""Startet den Listener; gibt den tatsächlichen Port zurück."""
self._server = await asyncio.start_server(self._on_client, self._host, self._port)
return self.port
async def stop(self) -> None:
if self._heartbeat_task:
self._heartbeat_task.cancel()
if self._handler_task:
self._handler_task.cancel()
if self._writer:
self._writer.close()
if self._server:
self._server.close()
await self._server.wait_closed()
async def _on_client(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
"""Nimmt genau einen Client an (V1: eine Verbindung)."""
self._reader = reader
self._writer = writer
# auf hello warten
hello_raw = await read_frame_async(reader)
action = hello_raw.get("payload", {}).get("action")
if hello_raw.get("type") != "command" or action != "hello":
got = hello_raw.get("type")
raise ProtocolError("EXPECTED_HELLO", f"got {got}")
peer_caps = hello_raw.get("payload", {}).get("capabilities", {})
peer_name = hello_raw.get("payload", {}).get("name", "unknown")
self.peer_name = peer_name
self.peer_capabilities = peer_caps
if hello_raw.get("protocol_version") != PROTOCOL_VERSION:
err = Envelope(
type=MessageType.ERROR,
payload={"code": "VERSION_MISMATCH", "expected": PROTOCOL_VERSION},
)
writer.write(_encode_envelope(err))
await writer.drain()
writer.close()
raise ProtocolError("VERSION_MISMATCH", "client protocol mismatch")
# welcome senden
welcome = Envelope(
type=MessageType.EVENT,
payload={"action": "welcome", "name": self.name, "capabilities": self.capabilities},
)
writer.write(_encode_envelope(welcome))
await writer.drain()
self._last_peer_heartbeat_ns = time.monotonic_ns()
self._heartbeat_task = asyncio.get_running_loop().create_task(self._send_heartbeats())
async def _send_heartbeats(self) -> None:
while True:
await asyncio.sleep(HEARTBEAT_INTERVAL_S)
if self._writer is None:
return
hb = Envelope(type=MessageType.HEARTBEAT, payload={"source": self.name})
self._writer.write(_encode_envelope(hb))
await self._writer.drain()
async def receive(self) -> Envelope | None:
"""Liest die nächste Nachricht; None bei Verbindungsabbruch."""
if self._reader is None:
return None
try:
raw = await read_frame_async(self._reader)
except (asyncio.IncompleteReadError, ConnectionError):
return None
if raw.get("type") == "heartbeat":
self._last_peer_heartbeat_ns = time.monotonic_ns()
return Envelope.model_validate(raw)
async def send(self, envelope: Envelope) -> None:
if self._writer is None:
raise ConnectionError("IPC client not connected")
self._writer.write(_encode_envelope(envelope))
await self._writer.drain()
@property
def peer_alive(self) -> bool:
"""True, wenn letzter Peer-Heartbeat < 2× Intervall zurückliegt."""
if self._last_peer_heartbeat_ns == 0:
return False
return (time.monotonic_ns() - self._last_peer_heartbeat_ns) < 2 * HEARTBEAT_INTERVAL_S * 1e9
class IpcClient:
"""Control-Core-seitiger IPC-Client. Verbindet sich mit 127.0.0.1:port.
- connect() → Handshake, gibt HandshakeInfo zurück
- receive() → nächste Nachricht (snapshot/event/ack/heartbeat)
- send(envelope) → Nachricht an Renderer
- Nach Reconnect: connect() erneut → neuer Snapshot (Re-Sync)
"""
def __init__(self, port: int, host: str = "127.0.0.1", name: str = "control-core") -> None:
if host not in ("127.0.0.1", "localhost", "::1"):
raise ValueError("IPC-Client darf nur Loopback verbinden (§6.2)")
self._host = host
self._port = port
self._name = name
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._heartbeat_task: asyncio.Task | None = None
self._last_peer_heartbeat_ns: int = 0
self.capabilities: dict = {}
async def connect(self) -> HandshakeInfo:
"""Baut Verbindung auf, führt Handshake durch."""
self._reader, self._writer = await asyncio.open_connection(self._host, self._port)
hello = Envelope(
type=MessageType.COMMAND,
payload={"action": "hello", "name": self._name, "capabilities": self.capabilities},
)
self._writer.write(_encode_envelope(hello))
await self._writer.drain()
raw = await read_frame_async(self._reader)
if raw.get("type") == "error":
raise ProtocolError(
raw.get("payload", {}).get("code", "REMOTE_ERROR"),
str(raw.get("payload", {})),
)
if raw.get("type") != "event" or raw.get("payload", {}).get("action") != "welcome":
raise ProtocolError("EXPECTED_WELCOME", f"got {raw.get('type')}")
if raw.get("protocol_version") != PROTOCOL_VERSION:
sent = raw.get("protocol_version")
raise ProtocolError("VERSION_MISMATCH", f"server sent version {sent}")
self._last_peer_heartbeat_ns = time.monotonic_ns()
self._heartbeat_task = asyncio.get_running_loop().create_task(self._send_heartbeats())
return HandshakeInfo(
peer_name=raw.get("payload", {}).get("name", "unknown"),
peer_capabilities=raw.get("payload", {}).get("capabilities", {}),
)
async def disconnect(self) -> None:
if self._heartbeat_task:
self._heartbeat_task.cancel()
self._heartbeat_task = None
if self._writer:
self._writer.close()
try:
await self._writer.wait_closed()
except (ConnectionError, asyncio.CancelledError):
pass
self._writer = None
self._reader = None
async def receive(self) -> Envelope | None:
if self._reader is None:
return None
try:
raw = await read_frame_async(self._reader)
except (asyncio.IncompleteReadError, ConnectionError):
return None
if raw.get("type") == "heartbeat":
self._last_peer_heartbeat_ns = time.monotonic_ns()
return Envelope.model_validate(raw)
async def send(self, envelope: Envelope) -> None:
if self._writer is None:
raise ConnectionError("IPC not connected")
self._writer.write(_encode_envelope(envelope))
await self._writer.drain()
async def _send_heartbeats(self) -> None:
while True:
await asyncio.sleep(HEARTBEAT_INTERVAL_S)
if self._writer is None:
return
hb = Envelope(type=MessageType.HEARTBEAT, payload={"source": self._name})
self._writer.write(_encode_envelope(hb))
await self._writer.drain()
@property
def peer_alive(self) -> bool:
if self._last_peer_heartbeat_ns == 0:
return False
return (time.monotonic_ns() - self._last_peer_heartbeat_ns) < 2 * HEARTBEAT_INTERVAL_S * 1e9
def _encode_envelope(envelope: Envelope) -> bytes:
return encode_frame(envelope.model_dump(mode="json"))
@@ -0,0 +1,38 @@
"""IPC-Nachrichten-Umschlag (PLAN.md §6.2)."""
from __future__ import annotations
import time
import uuid
from enum import StrEnum
from pydantic import BaseModel, Field
PROTOCOL_VERSION = 1
class MessageType(StrEnum):
COMMAND = "command"
EVENT = "event"
SNAPSHOT = "snapshot"
ACK = "ack"
ERROR = "error"
TELEMETRY = "telemetry"
HEARTBEAT = "heartbeat"
class Envelope(BaseModel):
"""Jede IPC-Nachricht besitzt mindestens diese Felder (§6.2)."""
protocol_version: int = PROTOCOL_VERSION
message_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
type: MessageType
revision: int = 0
monotonic_timestamp_ns: int = Field(default_factory=lambda: time.monotonic_ns())
payload: dict = Field(default_factory=dict)
def model_post_init(self, _ctx: object) -> None:
if self.protocol_version != PROTOCOL_VERSION:
raise ValueError(
f"protocol_version {self.protocol_version} != {PROTOCOL_VERSION}"
)
@@ -0,0 +1,72 @@
"""Length-prefixed MessagePack-Framing (ADR-0003).
4-Byte-Big-Endian-Länge, danach MessagePack-Payload. Maximale Payloadgröße
schützt vor unkontrollierten Queues/Resourcenerschöpfung (§6.2, §33).
"""
from __future__ import annotations
import asyncio
import socket
import struct
import msgpack
MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 # 16 MiB Obergrenze je Nachricht
_LENGTH = struct.Struct(">I")
def encode_frame(payload: dict) -> bytes:
"""Serialisiert ein dict zu length-prefixed MessagePack."""
body = msgpack.packb(payload, use_bin_type=True)
if len(body) > MAX_PAYLOAD_SIZE:
raise ValueError(f"payload too large: {len(body)} > {MAX_PAYLOAD_SIZE}")
return _LENGTH.pack(len(body)) + body
def decode_frame(frame: bytes) -> dict:
"""Dekodiert einen vollständigen Frame (Länge + Body)."""
if len(frame) < _LENGTH.size:
raise ValueError("frame too short")
(length,) = _LENGTH.unpack_from(frame, 0)
if length > MAX_PAYLOAD_SIZE:
raise ValueError(f"declared length {length} exceeds limit")
body = frame[_LENGTH.size : _LENGTH.size + length]
if len(body) != length:
raise ValueError(f"truncated frame: expected {length}, got {len(body)}")
return msgpack.unpackb(body, raw=False)
def read_frame(sock: socket.socket) -> dict:
"""Liest einen Frame von einem verbundenen Socket."""
header = _recv_exact(sock, _LENGTH.size)
(length,) = _LENGTH.unpack(header)
if length > MAX_PAYLOAD_SIZE:
raise ValueError(f"declared length {length} exceeds limit")
body = _recv_exact(sock, length)
return msgpack.unpackb(body, raw=False)
async def read_frame_async(reader: asyncio.StreamReader) -> dict:
"""Liest einen Frame von einem asyncio-StreamReader (IPC-Server/Client)."""
header = await reader.readexactly(_LENGTH.size)
(length,) = _LENGTH.unpack(header)
if length > MAX_PAYLOAD_SIZE:
raise ValueError(f"declared length {length} exceeds limit")
body = await reader.readexactly(length)
return msgpack.unpackb(body, raw=False)
def write_frame(sock: socket.socket, payload: dict) -> None:
"""Schreibt einen Frame auf einen verbundenen Socket."""
sock.sendall(encode_frame(payload))
def _recv_exact(sock: socket.socket, count: int) -> bytes:
buf = bytearray()
while len(buf) < count:
chunk = sock.recv(count - len(buf))
if not chunk:
raise ConnectionError("socket closed mid-frame")
buf.extend(chunk)
return bytes(buf)
@@ -0,0 +1,37 @@
"""Idempotency-Registry für wiederholbare Commands (§6.2, §23.2)."""
from __future__ import annotations
from collections import OrderedDict
from typing import Any
class IdempotencyRegistry:
"""Merkt sich command_id → Ergebnis; Wiederholungen liefern dasselbe Ack."""
def __init__(self, capacity: int = 4096) -> None:
if capacity <= 0:
raise ValueError("capacity must be positive")
self._capacity = capacity
self._entries: OrderedDict[str, Any] = OrderedDict()
def register(self, command_id: str) -> bool:
"""False, wenn die command_id bereits bekannt ist (Duplikat)."""
if command_id in self._entries:
self._entries.move_to_end(command_id)
return False
self._entries[command_id] = None # Ergebnis folgt mit complete()
if len(self._entries) > self._capacity:
self._entries.popitem(last=False)
return True
def complete(self, command_id: str, result: Any) -> None:
if command_id in self._entries:
self._entries[command_id] = result
self._entries.move_to_end(command_id)
def result(self, command_id: str) -> Any | None:
return self._entries.get(command_id)
def __len__(self) -> int:
return len(self._entries)