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:
@@ -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()
|
||||
Reference in New Issue
Block a user