AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
"""hms_protocol – versioniertes IPC (PLAN.md §6.2, ADR-0003).
|
||||
|
||||
Lokales TCP auf 127.0.0.1, length-prefixed MessagePack, Protokollversion 1.
|
||||
Verbindungsschicht: Handshake, Snapshot/Delta, Heartbeat, Re-Sync.
|
||||
"""
|
||||
|
||||
from hms_protocol.connection import (
|
||||
HandshakeInfo,
|
||||
IpcClient,
|
||||
IpcServer,
|
||||
ProtocolError,
|
||||
)
|
||||
from hms_protocol.envelope import Envelope, MessageType
|
||||
from hms_protocol.framing import (
|
||||
decode_frame,
|
||||
encode_frame,
|
||||
read_frame,
|
||||
read_frame_async,
|
||||
write_frame,
|
||||
)
|
||||
from hms_protocol.idempotency import IdempotencyRegistry
|
||||
|
||||
__all__ = [
|
||||
"Envelope",
|
||||
"MessageType",
|
||||
"encode_frame",
|
||||
"decode_frame",
|
||||
"read_frame",
|
||||
"read_frame_async",
|
||||
"write_frame",
|
||||
"IdempotencyRegistry",
|
||||
"IpcServer",
|
||||
"IpcClient",
|
||||
"HandshakeInfo",
|
||||
"ProtocolError",
|
||||
]
|
||||
@@ -0,0 +1,259 @@
|
||||
"""IPC-Verbindung zwischen Control Core und Renderer (PLAN.md §6.2).
|
||||
|
||||
Server (Renderer-seitig) und Client (Control-Core-seitig) auf 127.0.0.1.
|
||||
Verbindungsablauf:
|
||||
|
||||
1. Client verbindet, sendet hello mit Protokollversion + Capabilities
|
||||
2. Server prüft Version, antwortet welcome mit eigenen Capabilities
|
||||
3. Server sendet vollständigen state snapshot
|
||||
4. danach inkrementelle Deltas mit monotoner Revision
|
||||
5. Heartbeat mindestens alle 500 ms in beide Richtungen
|
||||
6. Ack für jedes zustandsändernde Command (idempotent über message_id)
|
||||
7. Nach Reconnect: Re-Sync (neuer Snapshot), Deltas erst danach akzeptiert
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hms_protocol.envelope import PROTOCOL_VERSION, Envelope, MessageType
|
||||
from hms_protocol.framing import encode_frame, read_frame_async
|
||||
|
||||
HEARTBEAT_INTERVAL_S = 0.5 # §6.2: mindestens alle 500 ms
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProtocolError(Exception):
|
||||
"""Protokollverstoß; Verbindung wird getrennt."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.code}: {self.message}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandshakeInfo:
|
||||
"""Ergebnis des Handshakes mit Capabilities der Gegenseite."""
|
||||
|
||||
peer_name: str
|
||||
peer_capabilities: dict
|
||||
protocol_version: int = PROTOCOL_VERSION
|
||||
|
||||
|
||||
class IpcServer:
|
||||
"""Renderer-seitiger IPC-Server. Lauscht ausschließlich auf 127.0.0.1.
|
||||
|
||||
Liefert dem Renderer:
|
||||
- accept(handler) → wartet auf Client, führt Handshake durch
|
||||
- receive() → nächste Nachricht (command/event/heartbeat)
|
||||
- send(envelope) → Nachricht an Control Core
|
||||
"""
|
||||
|
||||
def __init__(self, port: int = 0, host: str = "127.0.0.1") -> None:
|
||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||
raise ValueError("IPC-Server darf nur auf Loopback lauschen (§6.2)")
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._server: asyncio.Server | None = None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._handler_task: asyncio.Task | None = None
|
||||
self._heartbeat_task: asyncio.Task | None = None
|
||||
self._last_peer_heartbeat_ns: int = 0
|
||||
self.capabilities: dict = {}
|
||||
self.peer_name: str = ""
|
||||
self.peer_capabilities: dict = {}
|
||||
self.name: str = "hms-renderer"
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
if self._server is None:
|
||||
return self._port
|
||||
return self._server.sockets[0].getsockname()[1] if self._server.sockets else self._port
|
||||
|
||||
async def start(self) -> int:
|
||||
"""Startet den Listener; gibt den tatsächlichen Port zurück."""
|
||||
self._server = await asyncio.start_server(self._on_client, self._host, self._port)
|
||||
return self.port
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._heartbeat_task:
|
||||
self._heartbeat_task.cancel()
|
||||
if self._handler_task:
|
||||
self._handler_task.cancel()
|
||||
if self._writer:
|
||||
self._writer.close()
|
||||
if self._server:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
|
||||
async def _on_client(
|
||||
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
"""Nimmt genau einen Client an (V1: eine Verbindung)."""
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
# auf hello warten
|
||||
hello_raw = await read_frame_async(reader)
|
||||
action = hello_raw.get("payload", {}).get("action")
|
||||
if hello_raw.get("type") != "command" or action != "hello":
|
||||
got = hello_raw.get("type")
|
||||
raise ProtocolError("EXPECTED_HELLO", f"got {got}")
|
||||
peer_caps = hello_raw.get("payload", {}).get("capabilities", {})
|
||||
peer_name = hello_raw.get("payload", {}).get("name", "unknown")
|
||||
self.peer_name = peer_name
|
||||
self.peer_capabilities = peer_caps
|
||||
if hello_raw.get("protocol_version") != PROTOCOL_VERSION:
|
||||
err = Envelope(
|
||||
type=MessageType.ERROR,
|
||||
payload={"code": "VERSION_MISMATCH", "expected": PROTOCOL_VERSION},
|
||||
)
|
||||
writer.write(_encode_envelope(err))
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
raise ProtocolError("VERSION_MISMATCH", "client protocol mismatch")
|
||||
# welcome senden
|
||||
welcome = Envelope(
|
||||
type=MessageType.EVENT,
|
||||
payload={"action": "welcome", "name": self.name, "capabilities": self.capabilities},
|
||||
)
|
||||
writer.write(_encode_envelope(welcome))
|
||||
await writer.drain()
|
||||
self._last_peer_heartbeat_ns = time.monotonic_ns()
|
||||
self._heartbeat_task = asyncio.get_running_loop().create_task(self._send_heartbeats())
|
||||
|
||||
async def _send_heartbeats(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL_S)
|
||||
if self._writer is None:
|
||||
return
|
||||
hb = Envelope(type=MessageType.HEARTBEAT, payload={"source": self.name})
|
||||
self._writer.write(_encode_envelope(hb))
|
||||
await self._writer.drain()
|
||||
|
||||
async def receive(self) -> Envelope | None:
|
||||
"""Liest die nächste Nachricht; None bei Verbindungsabbruch."""
|
||||
if self._reader is None:
|
||||
return None
|
||||
try:
|
||||
raw = await read_frame_async(self._reader)
|
||||
except (asyncio.IncompleteReadError, ConnectionError):
|
||||
return None
|
||||
if raw.get("type") == "heartbeat":
|
||||
self._last_peer_heartbeat_ns = time.monotonic_ns()
|
||||
return Envelope.model_validate(raw)
|
||||
|
||||
async def send(self, envelope: Envelope) -> None:
|
||||
if self._writer is None:
|
||||
raise ConnectionError("IPC client not connected")
|
||||
self._writer.write(_encode_envelope(envelope))
|
||||
await self._writer.drain()
|
||||
|
||||
@property
|
||||
def peer_alive(self) -> bool:
|
||||
"""True, wenn letzter Peer-Heartbeat < 2× Intervall zurückliegt."""
|
||||
if self._last_peer_heartbeat_ns == 0:
|
||||
return False
|
||||
return (time.monotonic_ns() - self._last_peer_heartbeat_ns) < 2 * HEARTBEAT_INTERVAL_S * 1e9
|
||||
|
||||
|
||||
class IpcClient:
|
||||
"""Control-Core-seitiger IPC-Client. Verbindet sich mit 127.0.0.1:port.
|
||||
|
||||
- connect() → Handshake, gibt HandshakeInfo zurück
|
||||
- receive() → nächste Nachricht (snapshot/event/ack/heartbeat)
|
||||
- send(envelope) → Nachricht an Renderer
|
||||
- Nach Reconnect: connect() erneut → neuer Snapshot (Re-Sync)
|
||||
"""
|
||||
|
||||
def __init__(self, port: int, host: str = "127.0.0.1", name: str = "control-core") -> None:
|
||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||
raise ValueError("IPC-Client darf nur Loopback verbinden (§6.2)")
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._name = name
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._heartbeat_task: asyncio.Task | None = None
|
||||
self._last_peer_heartbeat_ns: int = 0
|
||||
self.capabilities: dict = {}
|
||||
|
||||
async def connect(self) -> HandshakeInfo:
|
||||
"""Baut Verbindung auf, führt Handshake durch."""
|
||||
self._reader, self._writer = await asyncio.open_connection(self._host, self._port)
|
||||
hello = Envelope(
|
||||
type=MessageType.COMMAND,
|
||||
payload={"action": "hello", "name": self._name, "capabilities": self.capabilities},
|
||||
)
|
||||
self._writer.write(_encode_envelope(hello))
|
||||
await self._writer.drain()
|
||||
raw = await read_frame_async(self._reader)
|
||||
if raw.get("type") == "error":
|
||||
raise ProtocolError(
|
||||
raw.get("payload", {}).get("code", "REMOTE_ERROR"),
|
||||
str(raw.get("payload", {})),
|
||||
)
|
||||
if raw.get("type") != "event" or raw.get("payload", {}).get("action") != "welcome":
|
||||
raise ProtocolError("EXPECTED_WELCOME", f"got {raw.get('type')}")
|
||||
if raw.get("protocol_version") != PROTOCOL_VERSION:
|
||||
sent = raw.get("protocol_version")
|
||||
raise ProtocolError("VERSION_MISMATCH", f"server sent version {sent}")
|
||||
self._last_peer_heartbeat_ns = time.monotonic_ns()
|
||||
self._heartbeat_task = asyncio.get_running_loop().create_task(self._send_heartbeats())
|
||||
return HandshakeInfo(
|
||||
peer_name=raw.get("payload", {}).get("name", "unknown"),
|
||||
peer_capabilities=raw.get("payload", {}).get("capabilities", {}),
|
||||
)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._heartbeat_task:
|
||||
self._heartbeat_task.cancel()
|
||||
self._heartbeat_task = None
|
||||
if self._writer:
|
||||
self._writer.close()
|
||||
try:
|
||||
await self._writer.wait_closed()
|
||||
except (ConnectionError, asyncio.CancelledError):
|
||||
pass
|
||||
self._writer = None
|
||||
self._reader = None
|
||||
|
||||
async def receive(self) -> Envelope | None:
|
||||
if self._reader is None:
|
||||
return None
|
||||
try:
|
||||
raw = await read_frame_async(self._reader)
|
||||
except (asyncio.IncompleteReadError, ConnectionError):
|
||||
return None
|
||||
if raw.get("type") == "heartbeat":
|
||||
self._last_peer_heartbeat_ns = time.monotonic_ns()
|
||||
return Envelope.model_validate(raw)
|
||||
|
||||
async def send(self, envelope: Envelope) -> None:
|
||||
if self._writer is None:
|
||||
raise ConnectionError("IPC not connected")
|
||||
self._writer.write(_encode_envelope(envelope))
|
||||
await self._writer.drain()
|
||||
|
||||
async def _send_heartbeats(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL_S)
|
||||
if self._writer is None:
|
||||
return
|
||||
hb = Envelope(type=MessageType.HEARTBEAT, payload={"source": self._name})
|
||||
self._writer.write(_encode_envelope(hb))
|
||||
await self._writer.drain()
|
||||
|
||||
@property
|
||||
def peer_alive(self) -> bool:
|
||||
if self._last_peer_heartbeat_ns == 0:
|
||||
return False
|
||||
return (time.monotonic_ns() - self._last_peer_heartbeat_ns) < 2 * HEARTBEAT_INTERVAL_S * 1e9
|
||||
|
||||
|
||||
def _encode_envelope(envelope: Envelope) -> bytes:
|
||||
return encode_frame(envelope.model_dump(mode="json"))
|
||||
@@ -0,0 +1,38 @@
|
||||
"""IPC-Nachrichten-Umschlag (PLAN.md §6.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
|
||||
class MessageType(StrEnum):
|
||||
COMMAND = "command"
|
||||
EVENT = "event"
|
||||
SNAPSHOT = "snapshot"
|
||||
ACK = "ack"
|
||||
ERROR = "error"
|
||||
TELEMETRY = "telemetry"
|
||||
HEARTBEAT = "heartbeat"
|
||||
|
||||
|
||||
class Envelope(BaseModel):
|
||||
"""Jede IPC-Nachricht besitzt mindestens diese Felder (§6.2)."""
|
||||
|
||||
protocol_version: int = PROTOCOL_VERSION
|
||||
message_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
type: MessageType
|
||||
revision: int = 0
|
||||
monotonic_timestamp_ns: int = Field(default_factory=lambda: time.monotonic_ns())
|
||||
payload: dict = Field(default_factory=dict)
|
||||
|
||||
def model_post_init(self, _ctx: object) -> None:
|
||||
if self.protocol_version != PROTOCOL_VERSION:
|
||||
raise ValueError(
|
||||
f"protocol_version {self.protocol_version} != {PROTOCOL_VERSION}"
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Length-prefixed MessagePack-Framing (ADR-0003).
|
||||
|
||||
4-Byte-Big-Endian-Länge, danach MessagePack-Payload. Maximale Payloadgröße
|
||||
schützt vor unkontrollierten Queues/Resourcenerschöpfung (§6.2, §33).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
|
||||
import msgpack
|
||||
|
||||
MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 # 16 MiB Obergrenze je Nachricht
|
||||
_LENGTH = struct.Struct(">I")
|
||||
|
||||
|
||||
def encode_frame(payload: dict) -> bytes:
|
||||
"""Serialisiert ein dict zu length-prefixed MessagePack."""
|
||||
body = msgpack.packb(payload, use_bin_type=True)
|
||||
if len(body) > MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(f"payload too large: {len(body)} > {MAX_PAYLOAD_SIZE}")
|
||||
return _LENGTH.pack(len(body)) + body
|
||||
|
||||
|
||||
def decode_frame(frame: bytes) -> dict:
|
||||
"""Dekodiert einen vollständigen Frame (Länge + Body)."""
|
||||
if len(frame) < _LENGTH.size:
|
||||
raise ValueError("frame too short")
|
||||
(length,) = _LENGTH.unpack_from(frame, 0)
|
||||
if length > MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(f"declared length {length} exceeds limit")
|
||||
body = frame[_LENGTH.size : _LENGTH.size + length]
|
||||
if len(body) != length:
|
||||
raise ValueError(f"truncated frame: expected {length}, got {len(body)}")
|
||||
return msgpack.unpackb(body, raw=False)
|
||||
|
||||
|
||||
def read_frame(sock: socket.socket) -> dict:
|
||||
"""Liest einen Frame von einem verbundenen Socket."""
|
||||
header = _recv_exact(sock, _LENGTH.size)
|
||||
(length,) = _LENGTH.unpack(header)
|
||||
if length > MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(f"declared length {length} exceeds limit")
|
||||
body = _recv_exact(sock, length)
|
||||
return msgpack.unpackb(body, raw=False)
|
||||
|
||||
|
||||
async def read_frame_async(reader: asyncio.StreamReader) -> dict:
|
||||
"""Liest einen Frame von einem asyncio-StreamReader (IPC-Server/Client)."""
|
||||
header = await reader.readexactly(_LENGTH.size)
|
||||
(length,) = _LENGTH.unpack(header)
|
||||
if length > MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(f"declared length {length} exceeds limit")
|
||||
body = await reader.readexactly(length)
|
||||
return msgpack.unpackb(body, raw=False)
|
||||
|
||||
|
||||
def write_frame(sock: socket.socket, payload: dict) -> None:
|
||||
"""Schreibt einen Frame auf einen verbundenen Socket."""
|
||||
sock.sendall(encode_frame(payload))
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, count: int) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < count:
|
||||
chunk = sock.recv(count - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-frame")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Idempotency-Registry für wiederholbare Commands (§6.2, §23.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
|
||||
class IdempotencyRegistry:
|
||||
"""Merkt sich command_id → Ergebnis; Wiederholungen liefern dasselbe Ack."""
|
||||
|
||||
def __init__(self, capacity: int = 4096) -> None:
|
||||
if capacity <= 0:
|
||||
raise ValueError("capacity must be positive")
|
||||
self._capacity = capacity
|
||||
self._entries: OrderedDict[str, Any] = OrderedDict()
|
||||
|
||||
def register(self, command_id: str) -> bool:
|
||||
"""False, wenn die command_id bereits bekannt ist (Duplikat)."""
|
||||
if command_id in self._entries:
|
||||
self._entries.move_to_end(command_id)
|
||||
return False
|
||||
self._entries[command_id] = None # Ergebnis folgt mit complete()
|
||||
if len(self._entries) > self._capacity:
|
||||
self._entries.popitem(last=False)
|
||||
return True
|
||||
|
||||
def complete(self, command_id: str, result: Any) -> None:
|
||||
if command_id in self._entries:
|
||||
self._entries[command_id] = result
|
||||
self._entries.move_to_end(command_id)
|
||||
|
||||
def result(self, command_id: str) -> Any | None:
|
||||
return self._entries.get(command_id)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
Reference in New Issue
Block a user