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:
HMS MediaEngine Agent
2026-09-11 00:36:59 +02:00
commit 0922cc1d68
133 changed files with 7939 additions and 0 deletions
View File
@@ -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
View File
+36
View File
@@ -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",
]
+111
View File
@@ -0,0 +1,111 @@
"""DMX→Parameter-Mapping (PLAN.md §16.416.6, §36 Nr. 8).
Bildet DMX-Kanäle eines Universes auf stabile Parameterpfade ab:
- 8-Bit: Byte / 255
- 16-Bit: (MSB << 8 | LSB) / 65535, MSB zuerst (DMX-Konvention)
- Flankenerkennung für Trigger (steigende Flanke, §16.3/§16.5)
- Signalverlust je konfigurierter Policy; HOLD ist V1-Standard (§11.3)
Alle Werte fließen ausschließlich über die Parameter-Engine in den Control
Core (§11); der Renderer wird nie direkt berührt.
"""
from __future__ import annotations
from dataclasses import dataclass
from hms_parameter.engine import ControlSource, ParameterEngine
from hms_parameter.paths import layer_opacity_path
from hms_artnet.receiver import DmxUpdate, LossBehavior
class RisingEdge:
"""Erkennt steigende Flanken über einer Schwelle (§16.5).
Ein Trigger ist ein Ereignis, kein Dauerzustand: derselbe gehaltene
Faderwert löst genau einmal aus; erst nach Rückkehr unter die Schwelle
kann erneut getriggert werden.
"""
def __init__(self, threshold: int = 64) -> None:
if not 0 <= threshold <= 255:
raise ValueError("threshold must be 0..255")
self.threshold = threshold
self._was_active = False
def feed(self, value: int) -> bool:
active = value >= self.threshold
triggered = active and not self._was_active
self._was_active = active
return triggered
@dataclass(frozen=True)
class LayerDmxMapping:
"""Layer-Fixture-Belegung (Auszug Layer64, §16.4).
base_address: 1-basierte DMX-Startadresse des Layer-Fixtures
Kanäle relativ: 1 = Enable, 23 = Opacity (16 Bit, MSB zuerst)
"""
universe: int
base_address: int
composition_id: str
layer_id: str
loss_behavior: LossBehavior = LossBehavior.HOLD
def __post_init__(self) -> None:
if not 0 <= self.universe < 0x8000:
raise ValueError("universe must be 0..0x7FFF")
if not 1 <= self.base_address <= 512 - 2:
raise ValueError("base_address must leave room for channels 1..3")
@property
def enable_path(self) -> str:
return f"composition/{self.composition_id}/layer/{self.layer_id}/enabled"
@property
def opacity_path(self) -> str:
return layer_opacity_path(self.composition_id, self.layer_id)
class DmxLayerMapper:
"""Wandelt DmxUpdates eines Universes in Parameter-Engine-Werte.
Phase-0-Umfang (§36 Nr. 8): DMX-Kanal auf Layer-Opacity mappen.
Media-Auswahl mit Load/Commit-Semantik (§16.5) folgt in Phase 2;
RisingEdge ist bereits getestet verfügbar.
"""
def __init__(
self,
mapping: LayerDmxMapping,
engine: ParameterEngine,
source: ControlSource = ControlSource.CONSOLE,
) -> None:
self._mapping = mapping
self._engine = engine
self._source = source
def _channel(self, data: bytes, relative: int) -> int:
"""Liest Kanal relativ zur Base-Adresse (1-basiert); 0 wenn zu kurz."""
idx = self._mapping.base_address - 1 + (relative - 1)
if 0 <= idx < len(data):
return data[idx]
return 0
def handle(self, update: DmxUpdate) -> None:
if update.universe != self._mapping.universe:
return
if update.sequence == -1 and not update.data:
# Signalverlust (§11.3, §16.1): Policy anwenden, niemals still
if self._mapping.loss_behavior is LossBehavior.FADE_TO_BLACK:
self._engine.release(self._mapping.opacity_path, self._source)
self._engine.release(self._mapping.enable_path, self._source)
# HOLD: letzten Zustand behalten keine Aktion
return
enable = 1.0 if self._channel(update.data, 1) >= 128 else 0.0
opacity = ((self._channel(update.data, 2) << 8) | self._channel(update.data, 3)) / 65535.0
self._engine.set_value(self._mapping.enable_path, enable, self._source)
self._engine.set_value(self._mapping.opacity_path, opacity, self._source)
+225
View File
@@ -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,
)
+169
View File
@@ -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)
View File
View File
@@ -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,
}
View File
View File
View File
+5
View File
@@ -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"]
+53
View File
@@ -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
View File
@@ -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"
View File
View File
@@ -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.214.6, §27.2).
Sicherheitsgrenzen:
- Pfadsicherheit: keine absoluten Pfade, kein '..' in Manifest und ZIP
- ZIP-Bomb-Limits, Dateigrößenlimits, erlaubte Dateitypen
- eindeutige Plugin-ID (reverse-dns), SemVer, api_version
- Shader-Dateien müssen je deklariertem Backend existieren
- max. 8 generische DMX-Slots je Effektinstanz (§14.7)
"""
from __future__ import annotations
import json
import zipfile
from enum import StrEnum
from pathlib import Path, PurePosixPath
from typing import Any
MANIFEST_SCHEMA_VERSION = 1
MAX_PLUGIN_FILES = 512
MAX_TOTAL_UNPACKED = 32 * 1024 * 1024
MAX_FILE_SIZE = 8 * 1024 * 1024
_ALLOWED_SUFFIXES = {
".json",
".hlsl",
".frag",
".vert",
".glsl",
".png",
".md",
".txt",
".toml",
".csv",
}
_ALLOWED_BACKENDS = {"d3d11", "gl", "gles"}
class PluginKind(StrEnum):
SOURCE = "source"
GENERATOR = "generator"
FILTER = "filter"
TRANSITION = "transition"
MIXER = "mixer"
OUTPUT = "output"
CONTROL = "control"
AUTOMATION = "automation"
def _safe_relative(raw: str) -> PurePosixPath | None:
"""Prüft Pfadsicherheit; None wenn unsicher (absolut oder Traversal)."""
if not raw:
return None
p = PurePosixPath(raw)
if p.is_absolute() or ".." in p.parts:
return None
return p
def _validate_parameters(params: list[dict[str, Any]]) -> list[str]:
errors: list[str] = []
seen: set[str] = set()
total_dmx_slots = 0
for param in params:
pid = param.get("id")
if not pid or not isinstance(pid, str):
errors.append("parameter without id")
continue
if pid in seen:
errors.append(f"duplicate parameter id: {pid}")
seen.add(pid)
ptype = param.get("type")
if ptype not in {"float", "int", "enum", "bool", "color"}:
errors.append(f"parameter {pid}: invalid type {ptype!r}")
if ptype == "float":
for key in ("minimum", "maximum", "default"):
if key not in param:
errors.append(f"parameter {pid}: missing {key}")
slots = param.get("dmx_slots", [])
if not isinstance(slots, list) or any(not isinstance(s, int) for s in slots):
errors.append(f"parameter {pid}: dmx_slots must be int list")
slots = []
total_dmx_slots += len(slots)
if total_dmx_slots > 8:
errors.append(f"dmx slot footprint {total_dmx_slots} exceeds 8 (§14.7)")
return errors
def _valid_plugin_id(pid: str) -> bool:
if ".." in pid or len(pid) < 5:
return False
parts = pid.split(".")
if len(parts) < 2:
return False
allowed = set("abcdefghijklmnopqrstuvwxyz0123456789._-")
return all(c in allowed for c in pid)
def _valid_semver(version: str) -> bool:
parts = version.split(".")
if len(parts) != 3:
return False
try:
for p in parts:
int(p)
except ValueError:
return False
return True
def validate_manifest(
manifest: dict[str, Any], plugin_root: Path | None = None
) -> list[str]:
"""Validiert ein geparstes Manifest; leere Fehlerliste = gültig.
plugin_root: wenn gesetzt, werden deklarierte Shader auf Existenz geprüft.
"""
errors: list[str] = []
if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION:
errors.append(f"schema_version must be {MANIFEST_SCHEMA_VERSION}")
pid = manifest.get("id", "")
if not isinstance(pid, str) or not _valid_plugin_id(pid):
errors.append(f"invalid plugin id: {pid!r} (expected reverse-dns)")
for key in ("name", "version", "vendor"):
value = manifest.get(key)
if not isinstance(value, str) or not value:
errors.append(f"missing or empty {key}")
if not _valid_semver(manifest.get("version", "")):
errors.append("version must be semantic (X.Y.Z)")
if manifest.get("api_version") != MANIFEST_SCHEMA_VERSION:
errors.append(f"api_version must be {MANIFEST_SCHEMA_VERSION}")
if manifest.get("kind") not in {k.value for k in PluginKind}:
errors.append(f"invalid kind: {manifest.get('kind')!r}")
entrypoints = manifest.get("entrypoints", {})
if not isinstance(entrypoints, dict) or not entrypoints:
errors.append("entrypoints required")
else:
supported = set(manifest.get("capabilities", {}).get("supported_backends", []))
unknown = supported - _ALLOWED_BACKENDS
if unknown:
errors.append(f"unsupported backends: {sorted(unknown)}")
for backend, entry in entrypoints.items():
if backend not in _ALLOWED_BACKENDS:
errors.append(f"entrypoint backend {backend!r} not allowed")
continue
if backend in supported:
passes = entry.get("passes", [])
if not passes:
errors.append(f"entrypoint {backend}: no passes")
for pas in passes:
shader_key = "pixel_shader" if "pixel_shader" in pas else "fragment"
shader_rel = pas.get(shader_key)
if not shader_rel:
errors.append(f"entrypoint {backend}: pass without shader")
continue
sp = _safe_relative(shader_rel)
if sp is None:
errors.append(f"unsafe shader path: {shader_rel!r}")
continue
if plugin_root is not None and not (plugin_root / sp).is_file():
errors.append(f"missing shader file: {shader_rel}")
params = manifest.get("parameters", [])
if not isinstance(params, list):
errors.append("parameters must be a list")
else:
errors.extend(_validate_parameters(params))
if manifest.get("failure_mode") not in {"bypass", "hold", "black"}:
errors.append("failure_mode must be bypass|hold|black")
return errors
def validate_plugin_zip(zip_path: Path) -> list[str]:
"""Prüft ein Plugin-ZIP: Pfadsicherheit, Limits, Typen, Manifest (§27.2)."""
errors: list[str] = []
try:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
if len(names) > MAX_PLUGIN_FILES:
errors.append(f"too many files: {len(names)} > {MAX_PLUGIN_FILES}")
total = 0
for info in zf.infolist():
if info.is_dir():
continue
total += info.file_size
if info.file_size > MAX_FILE_SIZE:
errors.append(f"file too large: {info.filename}")
if _safe_relative(info.filename) is None:
errors.append(f"unsafe path in zip: {info.filename!r}")
if Path(info.filename).suffix.lower() not in _ALLOWED_SUFFIXES:
errors.append(f"disallowed file type: {info.filename}")
if total > MAX_TOTAL_UNPACKED:
errors.append(f"zip too large unpacked: {total} > {MAX_TOTAL_UNPACKED}")
manifest_name = next(
(n for n in names if n.endswith("plugin.json") and n.count("/") == 1),
None,
)
if manifest_name is None:
errors.append("plugin.json not found at package root")
else:
manifest = json.loads(zf.read(manifest_name))
errors.extend(validate_manifest(manifest))
except zipfile.BadZipFile:
errors.append("not a valid zip file")
except json.JSONDecodeError as exc:
errors.append(f"plugin.json invalid JSON: {exc}")
return errors
def load_manifest(plugin_dir: Path) -> tuple[dict[str, Any], list[str]]:
"""Lädt und validiert plugin.json aus einem Plugin-Verzeichnis."""
manifest_path = plugin_dir / "plugin.json"
if not manifest_path.is_file():
return {}, ["plugin.json missing"]
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return {}, [f"plugin.json invalid JSON: {exc}"]
return manifest, validate_manifest(manifest, plugin_root=plugin_dir)
View File
@@ -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}"
)
+61
View File
@@ -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)
View File