Phase 0: Repository-Initialisierung nach Bauplan v1.2
- Struktur gemäß §8 (Eigentumsgrenzen), PLAN.md als normative Basis - Pflichtdokumente: STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADRs - ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1 - Kernpakete: hms_protocol, hms_domain, hms_parameter, hms_artnet, hms_adaptive, hms_capabilities, hms_plugin_sdk - Renderer-Spike: D3D11-Primärpfad + Dev-GL-Pfad (§36 Nr. 4-5) - Control Core: FastAPI REST + WebSocket (§36 Nr. 9) - Beispielplugins: Passthrough + Gaussian Blur (3 Adaptive-Quality- Varianten, HLSL/GLSL/GLES) - Tools: Art-Net-Emulator, Fixture-Generator (Master32/Layer64-CSV), Capability-Probe - JSON-Schemas: IPC, Plugin, Projekt, Cluster - 121 Unit-/Integrationstests grün, Ruff grün Gate 0 bleibt offen: Hardwaremessungen nur auf echter Windows-Referenz- hardware gültig (§29.7, §33).
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,36 @@
|
||||
"""hms_artnet – Art-Net 4 Steuerung (PLAN.md §16).
|
||||
|
||||
ArtDMX-Empfang, ArtPoll/ArtPollReply (Discovery als Media Server, Style 0x02),
|
||||
konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten.
|
||||
Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert.
|
||||
"""
|
||||
|
||||
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.receiver import ArtNetReceiver, DmxUpdate, LossBehavior
|
||||
|
||||
__all__ = [
|
||||
"OPDMX",
|
||||
"OPPOLL",
|
||||
"OPPOLLREPLY",
|
||||
"UDP_PORT",
|
||||
"build_dmx",
|
||||
"parse_dmx",
|
||||
"build_poll",
|
||||
"parse_poll",
|
||||
"build_artpoll_reply",
|
||||
"parse_poll_reply",
|
||||
"ArtNetReceiver",
|
||||
"DmxUpdate",
|
||||
"LossBehavior",
|
||||
]
|
||||
@@ -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,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,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,5 @@
|
||||
"""hms_domain – plattformneutrale Domänenobjekte (PLAN.md §10)."""
|
||||
|
||||
from hms_domain.ids import new_node_id, new_uuid, persistent_node_id
|
||||
|
||||
__all__ = ["new_uuid", "new_node_id", "persistent_node_id"]
|
||||
@@ -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,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,15 @@
|
||||
"""hms_plugin_sdk – Plugin-API, Manifest, Validierung (PLAN.md §14)."""
|
||||
|
||||
from hms_plugin_sdk.manifest import (
|
||||
PluginKind,
|
||||
load_manifest,
|
||||
validate_manifest,
|
||||
validate_plugin_zip,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PluginKind",
|
||||
"load_manifest",
|
||||
"validate_manifest",
|
||||
"validate_plugin_zip",
|
||||
]
|
||||
@@ -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,18 @@
|
||||
"""hms_protocol – versioniertes IPC (PLAN.md §6.2, ADR-0003).
|
||||
|
||||
Lokales TCP auf 127.0.0.1, length-prefixed MessagePack, Protokollversion 1.
|
||||
"""
|
||||
|
||||
from hms_protocol.envelope import Envelope, MessageType
|
||||
from hms_protocol.framing import decode_frame, encode_frame, read_frame, write_frame
|
||||
from hms_protocol.idempotency import IdempotencyRegistry
|
||||
|
||||
__all__ = [
|
||||
"Envelope",
|
||||
"MessageType",
|
||||
"encode_frame",
|
||||
"decode_frame",
|
||||
"read_frame",
|
||||
"write_frame",
|
||||
"IdempotencyRegistry",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""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"
|
||||
|
||||
|
||||
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,61 @@
|
||||
"""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 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)
|
||||
|
||||
|
||||
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