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:
@@ -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 17–21 und 31–32 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 (40960−32768)/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.4–16.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, 2–3 = 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
|
||||
100–300 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 100–300 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; 100–300 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 37–40, §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.2–14.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)
|
||||
Reference in New Issue
Block a user