Files
hms-mediaengine/packages/protocol/hms_protocol/connection.py
T
2026-09-11 00:50:55 +02:00

260 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"))