controller: Phase 3 — Python sidecar skeleton (MQTT + ZMQ + HTTP + HA Discovery)

cuda-grid-controller (Python 3.11+) — control plane между HA/MQTT/HTTP
и FFmpeg's vf_cuda_grid filter через ZMQ.

Modules (~700 LOC Python):
- config.py — Pydantic schema (broker, instances[], ha_discovery, http, log) + YAML loader
- layouts.py — registry известных layouts (sync с vf_cuda_grid.c Phase 2)
- ha_discovery.py — HA MQTT Discovery payloads (select.layout, sensor.current_layout,
  binary_sensor.online per instance + global device entry)
- zmq_client.py — async ZMQ REQ socket к FFmpeg zmq filter
  (target command args → reply parsing)
- state.py — in-memory ControllerState (active_layout per instance, asyncio.Lock)
- mqtt_loop.py — aiomqtt async loop: subscribe cuda_grid/cmd/<inst>/+/+,
  publish cuda_grid/state/* (retained) + cuda_grid/event/*, LWT, HA status reconnect
- dispatch.py — CommandDispatcher: layout.set action → ZMQ send_command + state update + events
- http_api.py — FastAPI: /health, /layouts, /state, POST /layout/{inst}/set
- __main__.py — typer CLI, asyncio.gather(mqtt_loop, uvicorn.server)

Examples + Dockerfile:
- examples/controller.yaml — 2 instances (livingroom_tv, public_stream)
- Dockerfile — python:3.11-slim, ENTRYPOINT cuda-grid-controller
- README — overview, usage, FFmpeg side filter graph

End-to-end flow ready:
  HA dashboard → MQTT → controller → ZMQ → FFmpeg process_command → layout switch
  ↓
  state публикуется обратно в MQTT → HA UI обновляется

Phase 3 deliverable per gx/vf-cuda-grid#1. Phase 4 = overlays (rect/text/icon).
This commit is contained in:
gx
2026-05-19 21:52:11 +01:00
parent 06be41d245
commit 37232ae1b9
14 changed files with 878 additions and 0 deletions
@@ -0,0 +1,89 @@
"""Command dispatch — между MQTT/HTTP командами и ZMQ выходом.
Action kinds:
layout.set — set_layout <name>
(future Phase 4+: auto_mode.set, focus_camera.set, overlay.add, ...)
"""
from __future__ import annotations
import structlog
from .config import Config, InstanceCfg
from .layouts import PREDEFINED_LAYOUTS
from .state import ControllerState
from .zmq_client import FFmpegZmqClient
log = structlog.get_logger()
class CommandDispatcher:
def __init__(self, cfg: Config, state: ControllerState) -> None:
self.cfg = cfg
self.state = state
self._zmq_clients: dict[str, FFmpegZmqClient] = {}
# publish callback устанавливается из вне (MqttLoop)
self.on_state_change = None # type: ignore[var-annotated]
self.on_event = None # type: ignore[var-annotated]
def _client(self, inst: InstanceCfg) -> FFmpegZmqClient:
c = self._zmq_clients.get(inst.name)
if c is None:
c = FFmpegZmqClient(inst.zmq_endpoint)
self._zmq_clients[inst.name] = c
return c
def _find_instance(self, name: str) -> InstanceCfg | None:
return next((i for i in self.cfg.instances if i.name == name), None)
async def handle(self, instance_name: str, kind: str, payload: str) -> None:
inst = self._find_instance(instance_name)
if inst is None:
log.warning("dispatch.unknown_instance", instance=instance_name)
return
if kind == "layout.set":
await self._set_layout(inst, payload.strip())
else:
log.warning("dispatch.unknown_kind", instance=instance_name, kind=kind)
async def _set_layout(self, inst: InstanceCfg, layout: str) -> None:
if layout not in PREDEFINED_LAYOUTS:
log.warning(
"dispatch.unknown_layout",
instance=inst.name,
layout=layout,
available=PREDEFINED_LAYOUTS,
)
return
old = await self.state.get_layout(inst.name)
client = self._client(inst)
try:
reply = await client.send_command(
inst.filter_target, "layout", layout
)
log.info(
"dispatch.layout_set",
instance=inst.name,
layout=layout,
ffmpeg_reply=reply,
)
except (TimeoutError, Exception) as e:
log.error("dispatch.zmq_fail", instance=inst.name, error=str(e))
return
await self.state.set_layout(inst.name, layout)
if self.on_state_change:
await self.on_state_change(inst.name, "layout", layout)
if self.on_event:
await self.on_event(
inst.name,
"layout_switched",
{"from": old, "to": layout, "reason": "mqtt"},
)
async def close(self) -> None:
for c in self._zmq_clients.values():
await c.close()