controller: Phase 4a — overlay infrastructure (data models + API + Frigate bridge skeleton)
Phase 4a deliverable (no filter rendering yet — это Phase 4b).
End-to-end pipeline: HA/HTTP/MQTT → controller → ZMQ → FFmpeg (logged).
Modules:
- overlays.py — 7 discriminated union types через pydantic:
rect, text, icon, image, dim, graph, chat. Normalized coords (0.0-1.0),
optional cell binding, z_order, opacity, visible.
- state.py — overlay storage per instance (CRUD: add/remove/update/get/clear)
- dispatch.py — overlay.add/remove/clear actions:
- parses JSON payload в Overlay через TypeAdapter
- serializes to ZMQ string: "<id> <type> <full-json>"
- sends via FFmpeg process_command (filter will парсить в Phase 4b)
- updates state + publishes events (overlay_added, overlay_removed, overlays_cleared)
- http_api.py — REST endpoints:
- POST /overlay/{inst}/add (body = Overlay JSON, returns id)
- GET /overlay/{inst} — list all
- DELETE /overlay/{inst}/{id} — single
- DELETE /overlay/{inst} — clear all
- PATCH /overlay/{inst}/{id} — update
- mqtt_loop.py — already subscribes cuda_grid/cmd/<inst>/+/+; teper handles
overlay/add (JSON payload), overlay/remove (id), overlay/clear
- frigate_bridge.py — FrigateBridge skeleton:
- subscribe frigate/+/motion + frigate/events
- mapping camera_name → target_instance + cell index
- Phase 4a: log received events (rendering в Phase 4b)
- config.py — frigate: optional section
- examples/controller.yaml — frigate mappings для 4 наших камер
State management:
- ControllerState.add/remove/update/get/clear_overlay (asyncio.Lock guarded)
- InstanceState.overlays: dict[str, Overlay]
- IDs generated via uuid4()[:8]
Phase 4a limitations:
- Filter side ничего не рендерит (just logs ZMQ commands)
- Frigate bridge принимает events но не auto-generates overlays
- HA Discovery не имеет overlay-specific entities (overlays через REST API)
Phase 4b: filter-side AVFrame side data + CUDA kernels (rect first, NPP-based,
потом text via freetype atlas, потом icon sprite blit).
This commit is contained in:
@@ -2,27 +2,44 @@
|
||||
|
||||
Action kinds:
|
||||
layout.set — set_layout <name>
|
||||
(future Phase 4+: auto_mode.set, focus_camera.set, overlay.add, ...)
|
||||
overlay.add — add overlay (JSON payload)
|
||||
overlay.remove — remove overlay by id
|
||||
overlay.clear — remove all overlays
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import structlog
|
||||
|
||||
from .config import Config, InstanceCfg
|
||||
from .layouts import PREDEFINED_LAYOUTS
|
||||
from .overlays import Overlay
|
||||
from .state import ControllerState
|
||||
from .zmq_client import FFmpegZmqClient
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
def _serialize_overlay_to_zmq(overlay: Overlay) -> str:
|
||||
"""Сериализовать overlay в одну строку для FFmpeg process_command.
|
||||
|
||||
Формат: `add_overlay <id> <type> <json-base64-payload>`
|
||||
Filter-side (Phase 4b) парсит JSON и применяет.
|
||||
|
||||
JSON используем потому что overlay имеет вложенные поля (style для graph
|
||||
и т.п.); проще чем positional args.
|
||||
"""
|
||||
payload = overlay.model_dump_json()
|
||||
return f"{overlay.id} {overlay.type} {payload}"
|
||||
|
||||
|
||||
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]
|
||||
|
||||
@@ -42,10 +59,21 @@ class CommandDispatcher:
|
||||
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)
|
||||
match kind:
|
||||
case "layout.set":
|
||||
await self._set_layout(inst, payload.strip())
|
||||
case "overlay.add":
|
||||
await self._overlay_add(inst, payload)
|
||||
case "overlay.remove":
|
||||
await self._overlay_remove(inst, payload.strip())
|
||||
case "overlay.clear":
|
||||
await self._overlay_clear(inst)
|
||||
case _:
|
||||
log.warning(
|
||||
"dispatch.unknown_kind", instance=instance_name, kind=kind
|
||||
)
|
||||
|
||||
# ─── Layout ────────────────────────────────────────────────────
|
||||
|
||||
async def _set_layout(self, inst: InstanceCfg, layout: str) -> None:
|
||||
if layout not in PREDEFINED_LAYOUTS:
|
||||
@@ -84,6 +112,99 @@ class CommandDispatcher:
|
||||
{"from": old, "to": layout, "reason": "mqtt"},
|
||||
)
|
||||
|
||||
# ─── Overlays ──────────────────────────────────────────────────
|
||||
|
||||
async def _overlay_add(self, inst: InstanceCfg, payload: str) -> None:
|
||||
"""Payload = JSON совместимый с Overlay discriminated union."""
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
try:
|
||||
overlay: Overlay = TypeAdapter(Overlay).validate_json(payload)
|
||||
except Exception as e:
|
||||
log.warning("dispatch.overlay_parse_fail", error=str(e), payload=payload[:200])
|
||||
return
|
||||
|
||||
# ZMQ send → filter (Phase 4b will actually render)
|
||||
client = self._client(inst)
|
||||
try:
|
||||
zmq_arg = _serialize_overlay_to_zmq(overlay)
|
||||
reply = await client.send_command(
|
||||
inst.filter_target, "add_overlay", zmq_arg
|
||||
)
|
||||
log.info(
|
||||
"dispatch.overlay_add",
|
||||
instance=inst.name,
|
||||
id=overlay.id,
|
||||
type=overlay.type,
|
||||
ffmpeg_reply=reply,
|
||||
)
|
||||
except (TimeoutError, Exception) as e:
|
||||
# Filter side might not support yet — log warn but persist
|
||||
# в state (controller behaves correctly даже если filter ignore'ит).
|
||||
log.warning(
|
||||
"dispatch.overlay_zmq_fail",
|
||||
instance=inst.name,
|
||||
id=overlay.id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
await self.state.add_overlay(inst.name, overlay)
|
||||
|
||||
if self.on_state_change:
|
||||
count = len(await self.state.get_overlays(inst.name))
|
||||
await self.on_state_change(inst.name, "overlays_count", str(count))
|
||||
if self.on_event:
|
||||
await self.on_event(
|
||||
inst.name,
|
||||
"overlay_added",
|
||||
{"id": overlay.id, "type": overlay.type},
|
||||
)
|
||||
|
||||
async def _overlay_remove(self, inst: InstanceCfg, overlay_id: str) -> None:
|
||||
existed = await self.state.remove_overlay(inst.name, overlay_id)
|
||||
if not existed:
|
||||
log.warning(
|
||||
"dispatch.overlay_unknown",
|
||||
instance=inst.name,
|
||||
id=overlay_id,
|
||||
)
|
||||
return
|
||||
|
||||
client = self._client(inst)
|
||||
try:
|
||||
await client.send_command(
|
||||
inst.filter_target, "remove_overlay", overlay_id
|
||||
)
|
||||
except (TimeoutError, Exception) as e:
|
||||
log.warning("dispatch.overlay_remove_zmq_fail", error=str(e))
|
||||
|
||||
log.info("dispatch.overlay_removed", instance=inst.name, id=overlay_id)
|
||||
|
||||
if self.on_state_change:
|
||||
count = len(await self.state.get_overlays(inst.name))
|
||||
await self.on_state_change(inst.name, "overlays_count", str(count))
|
||||
if self.on_event:
|
||||
await self.on_event(
|
||||
inst.name, "overlay_removed", {"id": overlay_id}
|
||||
)
|
||||
|
||||
async def _overlay_clear(self, inst: InstanceCfg) -> None:
|
||||
n = await self.state.clear_overlays(inst.name)
|
||||
client = self._client(inst)
|
||||
try:
|
||||
await client.send_command(inst.filter_target, "clear_overlays", "")
|
||||
except (TimeoutError, Exception) as e:
|
||||
log.warning("dispatch.overlay_clear_zmq_fail", error=str(e))
|
||||
|
||||
log.info("dispatch.overlays_cleared", instance=inst.name, count=n)
|
||||
|
||||
if self.on_state_change:
|
||||
await self.on_state_change(inst.name, "overlays_count", "0")
|
||||
if self.on_event:
|
||||
await self.on_event(inst.name, "overlays_cleared", {"count": n})
|
||||
|
||||
# ─── Cleanup ───────────────────────────────────────────────────
|
||||
|
||||
async def close(self) -> None:
|
||||
for c in self._zmq_clients.values():
|
||||
await c.close()
|
||||
|
||||
Reference in New Issue
Block a user