controller: Phase 5b+5c+6 — multi-audio + intercom ducking + dynamic overlays
5b — audio source switching:
AudioSourceCfg list + audio_filter_target в InstanceCfg
CommandDispatcher._audio_set → ZMQ astreamselect@as map <index>
REST: GET /audio/{inst}, POST /audio/{inst}/set
MQTT: cuda_grid/cmd/<inst>/audio/set <source_name>
5c — intercom ducking:
music_volume_target / intercom_volume_target / music_ducked_volume в InstanceCfg
CommandDispatcher._intercom_set → 2× ZMQ volume@music/@intercom commands
REST: POST /intercom/{inst}/start (music↓ + intercom↑) + /end (restore)
MQTT: cuda_grid/cmd/<inst>/intercom/start|end
6 — dynamic overlays (charts/chats):
dynamic_overlays.py: ChartCfg/ChatCfg + DynamicRenderer
PIL rendering: line chart + scrolling text list
Async loops пишут PNG в icon_dir + invalidate filter cache via reload_icon ZMQ
MQTT subscriptions для real data (charts: numeric topic, chats: text topic)
Demo: chart sine wave если data_topic=null
Wired в __main__.py + mqtt_loop dispatch
+ ZMQ client asyncio.Lock — REQ socket strict send/recv pattern требует
serialize requests (overlay/audio/intercom concurrent ломали "Operation
cannot be accomplished in current state")
+ Pillow в Dockerfile (для PIL render)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ffmpeg для snapshot endpoint — нужен basic CPU build (RTSP read + PNG output).
|
||||
# Размер: ~120 MB additional (ffmpeg + libs); приемлемо для controller image.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install deps первым layer — пересборка только при изменении pyproject.toml
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir \
|
||||
@@ -20,7 +26,8 @@ RUN pip install --no-cache-dir \
|
||||
pyyaml \
|
||||
structlog \
|
||||
typer \
|
||||
sse-starlette
|
||||
sse-starlette \
|
||||
pillow
|
||||
|
||||
# Source code
|
||||
COPY cuda_grid_controller ./cuda_grid_controller
|
||||
|
||||
@@ -13,6 +13,7 @@ import uvicorn
|
||||
|
||||
from .config import Config
|
||||
from .dispatch import CommandDispatcher
|
||||
from .dynamic_overlays import ChartCfg, ChatCfg, DynamicRenderer
|
||||
from .frigate_bridge import FrigateBridge, FrigateBridgeCfg
|
||||
from .http_api import create_app
|
||||
from .mqtt_loop import MqttLoop
|
||||
@@ -55,7 +56,26 @@ async def _run(cfg: Config) -> None:
|
||||
"frigate_bridge.config_invalid", error=str(e)
|
||||
)
|
||||
|
||||
mqtt = MqttLoop(cfg, state, dispatcher.handle, frigate_bridge=frigate_bridge)
|
||||
# Dynamic overlays (charts/chats) — Phase 6
|
||||
dynamic_renderer: DynamicRenderer | None = None
|
||||
if cfg.dynamic_overlays:
|
||||
try:
|
||||
d = cfg.dynamic_overlays
|
||||
charts = [ChartCfg.model_validate(c) for c in (d.get("charts") or [])]
|
||||
chats = [ChatCfg.model_validate(c) for c in (d.get("chats") or [])]
|
||||
if charts or chats:
|
||||
dynamic_renderer = DynamicRenderer(
|
||||
icon_dir=Path(cfg.icon_dir),
|
||||
dispatcher=dispatcher,
|
||||
charts=charts,
|
||||
chats=chats,
|
||||
)
|
||||
except Exception as e:
|
||||
structlog.get_logger().warning("dynamic_overlays.config_invalid", error=str(e))
|
||||
|
||||
mqtt = MqttLoop(cfg, state, dispatcher.handle,
|
||||
frigate_bridge=frigate_bridge,
|
||||
dynamic_renderer=dynamic_renderer)
|
||||
|
||||
# Wire dispatcher events → MQTT publishes
|
||||
dispatcher.on_state_change = mqtt.publish_state
|
||||
@@ -80,6 +100,10 @@ async def _run(cfg: Config) -> None:
|
||||
http=f"{cfg.http.host}:{cfg.http.port}",
|
||||
)
|
||||
|
||||
# Start dynamic renderer задачи (если есть)
|
||||
if dynamic_renderer:
|
||||
await dynamic_renderer.start()
|
||||
|
||||
try:
|
||||
await asyncio.gather(
|
||||
mqtt.run(),
|
||||
@@ -88,6 +112,8 @@ async def _run(cfg: Config) -> None:
|
||||
except asyncio.CancelledError:
|
||||
log.info("controller.shutdown")
|
||||
finally:
|
||||
if dynamic_renderer:
|
||||
await dynamic_renderer.stop()
|
||||
await dispatcher.close()
|
||||
await mqtt.stop()
|
||||
|
||||
|
||||
@@ -64,6 +64,37 @@ class InstanceCfg(BaseModel):
|
||||
default="Parsed_cuda_grid_0",
|
||||
description="Filter target name в FFmpeg filter graph (для process_command)",
|
||||
)
|
||||
output_rtsp_url: str | None = Field(
|
||||
default=None,
|
||||
description="URL куда pipeline push'ит composed stream — controller read'ит для snapshot/preview endpoints",
|
||||
)
|
||||
audio_sources: list["AudioSourceCfg"] = Field(
|
||||
default_factory=list,
|
||||
description="Audio sources для astreamselect switching (Phase 5b)",
|
||||
)
|
||||
audio_filter_target: str = Field(
|
||||
default="astreamselect@as",
|
||||
description="Target filter для ZMQ команд переключения audio (должен соответствовать pipeline filter_complex)",
|
||||
)
|
||||
music_volume_target: str = Field(
|
||||
default="volume@music",
|
||||
description="Target filter для управления громкостью music chain (Phase 5c ducking)",
|
||||
)
|
||||
intercom_volume_target: str = Field(
|
||||
default="volume@intercom",
|
||||
description="Target filter для управления громкостью intercom (Phase 5c)",
|
||||
)
|
||||
music_ducked_volume: float = Field(
|
||||
default=0.2, ge=0.0, le=1.0,
|
||||
description="Громкость music когда intercom активен (0.2 = -14 dB)",
|
||||
)
|
||||
|
||||
|
||||
class AudioSourceCfg(BaseModel):
|
||||
"""Описание audio source в порядке как они добавлены в pipeline -i ..."""
|
||||
name: str = Field(description="Уникальное имя для API (e.g. 'europa_plus')")
|
||||
index: int = Field(ge=0, description="Index в astreamselect inputs (соответствует порядку -i в pipeline)")
|
||||
label: str | None = Field(default=None, description="UI-friendly label (default = name)")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -97,6 +128,12 @@ class Config(BaseModel):
|
||||
log: LogCfg = LogCfg()
|
||||
# Frigate bridge — late import чтобы избежать circular dep
|
||||
frigate: dict | None = None # parsed в FrigateBridgeCfg при runtime
|
||||
# Dynamic overlays (charts/chats) — late import тоже
|
||||
dynamic_overlays: dict | None = None # parsed в DynamicRenderer cfg
|
||||
icon_dir: str = Field(
|
||||
default="/var/lib/cuda-grid/icons",
|
||||
description="Shared volume куда controller пишет dynamic PNG; filter (`icon_dir=` option) читает оттуда",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path | str) -> Self:
|
||||
|
||||
@@ -127,11 +127,83 @@ class CommandDispatcher:
|
||||
await self._overlay_remove(inst, payload.strip())
|
||||
case "overlay.clear":
|
||||
await self._overlay_clear(inst)
|
||||
case "audio.set":
|
||||
await self._audio_set(inst, payload.strip())
|
||||
case "intercom.start":
|
||||
await self._intercom_set(inst, active=True)
|
||||
case "intercom.end":
|
||||
await self._intercom_set(inst, active=False)
|
||||
case _:
|
||||
log.warning(
|
||||
"dispatch.unknown_kind", instance=instance_name, kind=kind
|
||||
)
|
||||
|
||||
# ─── Audio ─────────────────────────────────────────────────────
|
||||
|
||||
async def _audio_set(self, inst: InstanceCfg, source_name: str) -> None:
|
||||
"""Switch audio к source_name через ZMQ команду astreamselect map <index>."""
|
||||
if not inst.audio_sources:
|
||||
log.warning("dispatch.no_audio_sources", instance=inst.name)
|
||||
return
|
||||
src = next((s for s in inst.audio_sources if s.name == source_name), None)
|
||||
if src is None:
|
||||
log.warning("dispatch.unknown_audio_source",
|
||||
instance=inst.name, source=source_name,
|
||||
available=[s.name for s in inst.audio_sources])
|
||||
return
|
||||
|
||||
client = self._client(inst)
|
||||
try:
|
||||
reply = await client.send_command(
|
||||
inst.audio_filter_target, "map", str(src.index)
|
||||
)
|
||||
log.info("dispatch.audio_set", instance=inst.name,
|
||||
source=source_name, index=src.index, ffmpeg_reply=reply)
|
||||
except (TimeoutError, Exception) as e:
|
||||
log.error("dispatch.audio_zmq_fail", instance=inst.name, error=str(e))
|
||||
return
|
||||
|
||||
if self.on_state_change:
|
||||
await self.on_state_change(inst.name, "audio_source", source_name)
|
||||
if self.on_event:
|
||||
await self.on_event(
|
||||
inst.name, "audio_switched", {"to": source_name, "index": src.index}
|
||||
)
|
||||
|
||||
async def _reload_icon(self, instance: str, icon_name: str) -> None:
|
||||
"""Invalidate cached icon atlas в filter — used by DynamicRenderer."""
|
||||
inst = self._find_instance(instance)
|
||||
if inst is None:
|
||||
return
|
||||
client = self._client(inst)
|
||||
try:
|
||||
await client.send_command(inst.filter_target, "reload_icon", icon_name)
|
||||
except (TimeoutError, Exception) as e:
|
||||
log.warning("dispatch.reload_icon_fail", instance=instance, icon=icon_name, error=str(e))
|
||||
|
||||
async def _intercom_set(self, inst: InstanceCfg, active: bool) -> None:
|
||||
"""Ducking pattern: при intercom ON → music volume ↓ + intercom ↑.
|
||||
После end — restore. Volume commands отправляются параллельно."""
|
||||
client = self._client(inst)
|
||||
music_vol = inst.music_ducked_volume if active else 1.0
|
||||
intercom_vol = 1.0 if active else 0.0
|
||||
|
||||
try:
|
||||
r1 = await client.send_command(inst.music_volume_target, "volume", str(music_vol))
|
||||
r2 = await client.send_command(inst.intercom_volume_target, "volume", str(intercom_vol))
|
||||
log.info("dispatch.intercom",
|
||||
instance=inst.name, active=active,
|
||||
music_vol=music_vol, intercom_vol=intercom_vol,
|
||||
music_reply=r1, intercom_reply=r2)
|
||||
except (TimeoutError, Exception) as e:
|
||||
log.error("dispatch.intercom_zmq_fail", instance=inst.name, error=str(e))
|
||||
return
|
||||
|
||||
if self.on_event:
|
||||
await self.on_event(
|
||||
inst.name, "intercom", {"active": active, "music_vol": music_vol}
|
||||
)
|
||||
|
||||
# ─── Layout ────────────────────────────────────────────────────
|
||||
|
||||
async def _set_layout(self, inst: InstanceCfg, layout: str) -> None:
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Dynamic overlay renderer (Phase 6).
|
||||
|
||||
Controller рендерит chart/chat overlays через PIL → PNG в shared volume,
|
||||
затем посылает filter команду `reload_icon` чтобы invalidate cache. Filter
|
||||
re-reads PNG file при следующем render.
|
||||
|
||||
Файлы кладутся в `${icon_dir}/${overlay_id}.png` где icon_dir = mount к
|
||||
volume общий с pipeline (filter ищет тут).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .overlays import IconOverlay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .dispatch import CommandDispatcher
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
# ─── Config ───────────────────────────────────────────────────────────
|
||||
|
||||
class ChartCfg(BaseModel):
|
||||
"""Live chart overlay."""
|
||||
id: str = Field(description="Уникальное имя — становится PNG file name + overlay_id")
|
||||
target_instance: str
|
||||
cell: int | None = None # None = absolute
|
||||
x: float = Field(ge=0.0, le=1.0)
|
||||
y: float = Field(ge=0.0, le=1.0)
|
||||
w_px: int = Field(default=320, ge=64, le=1920)
|
||||
h_px: int = Field(default=120, ge=32, le=1080)
|
||||
title: str = ""
|
||||
data_topic: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT topic с numeric payload. Если None — fake sine wave для demo.",
|
||||
)
|
||||
refresh_sec: float = Field(default=2.0, ge=0.5, le=60.0)
|
||||
max_points: int = Field(default=60, ge=10, le=600)
|
||||
line_color: tuple[int, int, int] = (0, 255, 128)
|
||||
bg_color: tuple[int, int, int, int] = (0, 0, 0, 180)
|
||||
opacity: float = 1.0
|
||||
z_order: int = 25
|
||||
|
||||
|
||||
class ChatCfg(BaseModel):
|
||||
"""Scrolling text notifications."""
|
||||
id: str
|
||||
target_instance: str
|
||||
cell: int | None = None
|
||||
x: float = Field(ge=0.0, le=1.0)
|
||||
y: float = Field(ge=0.0, le=1.0)
|
||||
w_px: int = Field(default=400, ge=64, le=1920)
|
||||
h_px: int = Field(default=200, ge=32, le=1080)
|
||||
source_topic: str | None = Field(default=None, description="MQTT topic — каждое сообщение = новая строка")
|
||||
max_messages: int = Field(default=10, ge=1, le=50)
|
||||
font_size: int = Field(default=18, ge=10, le=64)
|
||||
text_color: tuple[int, int, int] = (255, 255, 255)
|
||||
bg_color: tuple[int, int, int, int] = (0, 0, 0, 150)
|
||||
opacity: float = 1.0
|
||||
z_order: int = 26
|
||||
|
||||
|
||||
# ─── Renderers ────────────────────────────────────────────────────────
|
||||
|
||||
class _FontCache:
|
||||
_font: ImageFont.FreeTypeFont | None = None
|
||||
|
||||
@classmethod
|
||||
def get(cls, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
try:
|
||||
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size=size)
|
||||
except OSError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def render_chart(cfg: ChartCfg, data: list[float]) -> Image.Image:
|
||||
"""Line chart с title. Returns RGBA Image."""
|
||||
img = Image.new("RGBA", (cfg.w_px, cfg.h_px), cfg.bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
pad = 8
|
||||
|
||||
# Title
|
||||
if cfg.title:
|
||||
font = _FontCache.get(14)
|
||||
draw.text((pad, 2), cfg.title, fill=(255, 255, 255), font=font)
|
||||
chart_top = 20
|
||||
else:
|
||||
chart_top = pad
|
||||
chart_bottom = cfg.h_px - pad
|
||||
chart_left = pad
|
||||
chart_right = cfg.w_px - pad
|
||||
chart_h = chart_bottom - chart_top
|
||||
chart_w = chart_right - chart_left
|
||||
|
||||
# Border
|
||||
draw.rectangle([chart_left, chart_top, chart_right, chart_bottom],
|
||||
outline=(128, 128, 128, 200), width=1)
|
||||
|
||||
if len(data) >= 2:
|
||||
lo, hi = min(data), max(data)
|
||||
span = (hi - lo) or 1.0
|
||||
points: list[tuple[float, float]] = []
|
||||
for i, v in enumerate(data):
|
||||
px = chart_left + (i / (len(data) - 1)) * chart_w
|
||||
py = chart_bottom - ((v - lo) / span) * chart_h
|
||||
points.append((px, py))
|
||||
draw.line(points, fill=cfg.line_color, width=2)
|
||||
# Last value label
|
||||
font = _FontCache.get(12)
|
||||
last = data[-1]
|
||||
draw.text((chart_right - 50, chart_top + 2), f"{last:.1f}",
|
||||
fill=cfg.line_color, font=font)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def render_chat(cfg: ChatCfg, messages: list[str]) -> Image.Image:
|
||||
"""Vertical list — last N messages at bottom."""
|
||||
img = Image.new("RGBA", (cfg.w_px, cfg.h_px), cfg.bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = _FontCache.get(cfg.font_size)
|
||||
pad = 6
|
||||
|
||||
line_h = cfg.font_size + 4
|
||||
visible = messages[-cfg.max_messages:]
|
||||
y = cfg.h_px - pad - line_h
|
||||
for msg in reversed(visible):
|
||||
if y < pad:
|
||||
break
|
||||
draw.text((pad, y), msg[:80], fill=cfg.text_color, font=font)
|
||||
y -= line_h
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ─── Runner ──────────────────────────────────────────────────────────
|
||||
|
||||
class DynamicRenderer:
|
||||
"""Управляет рендерингом charts/chats — пишет PNG + посылает reload_icon."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
icon_dir: Path,
|
||||
dispatcher: "CommandDispatcher",
|
||||
charts: list[ChartCfg],
|
||||
chats: list[ChatCfg],
|
||||
) -> None:
|
||||
self.icon_dir = icon_dir
|
||||
self.dispatcher = dispatcher
|
||||
self.charts = charts
|
||||
self.chats = chats
|
||||
# State
|
||||
self._chart_data: dict[str, deque[float]] = {
|
||||
c.id: deque(maxlen=c.max_points) for c in charts
|
||||
}
|
||||
self._chat_messages: dict[str, deque[str]] = {
|
||||
c.id: deque(maxlen=c.max_messages) for c in chats
|
||||
}
|
||||
self._tasks: list[asyncio.Task] = []
|
||||
self._start_time = time.time()
|
||||
|
||||
def topics_to_subscribe(self) -> list[str]:
|
||||
topics = []
|
||||
for c in self.charts:
|
||||
if c.data_topic:
|
||||
topics.append(c.data_topic)
|
||||
for c in self.chats:
|
||||
if c.source_topic:
|
||||
topics.append(c.source_topic)
|
||||
return topics
|
||||
|
||||
def handle_message(self, topic: str, payload: str) -> None:
|
||||
"""Update buffer when MQTT data arrives. Caller — mqtt_loop."""
|
||||
for c in self.charts:
|
||||
if c.data_topic == topic:
|
||||
try:
|
||||
self._chart_data[c.id].append(float(payload.strip()))
|
||||
except ValueError:
|
||||
log.warning("dynamic.bad_chart_value", id=c.id, payload=payload[:50])
|
||||
return
|
||||
for c in self.chats:
|
||||
if c.source_topic == topic:
|
||||
self._chat_messages[c.id].append(payload.strip())
|
||||
return
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Spawn render tasks per overlay."""
|
||||
self.icon_dir.mkdir(parents=True, exist_ok=True)
|
||||
for cfg in self.charts:
|
||||
self._tasks.append(asyncio.create_task(self._chart_loop(cfg)))
|
||||
for cfg in self.chats:
|
||||
self._tasks.append(asyncio.create_task(self._chat_loop(cfg)))
|
||||
log.info("dynamic.started", charts=len(self.charts), chats=len(self.chats))
|
||||
|
||||
async def stop(self) -> None:
|
||||
for t in self._tasks:
|
||||
t.cancel()
|
||||
for t in self._tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._tasks.clear()
|
||||
|
||||
async def _chart_loop(self, cfg: ChartCfg) -> None:
|
||||
registered = False
|
||||
while True:
|
||||
try:
|
||||
buf = self._chart_data[cfg.id]
|
||||
# Demo data если нет MQTT topic
|
||||
if cfg.data_topic is None:
|
||||
t = time.time() - self._start_time
|
||||
buf.append(20.0 + 10.0 * math.sin(t / 5))
|
||||
await self._render_and_publish(cfg, lambda: render_chart(cfg, list(buf)))
|
||||
if not registered:
|
||||
await self._register_overlay(cfg.id, cfg.target_instance, cfg.cell,
|
||||
cfg.x, cfg.y, cfg.opacity, cfg.z_order)
|
||||
registered = True
|
||||
await asyncio.sleep(cfg.refresh_sec)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error("dynamic.chart_loop_fail", id=cfg.id, error=str(e))
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _chat_loop(self, cfg: ChatCfg) -> None:
|
||||
registered = False
|
||||
last_signature: tuple[str, ...] = ()
|
||||
while True:
|
||||
try:
|
||||
msgs = list(self._chat_messages[cfg.id])
|
||||
sig = tuple(msgs)
|
||||
if sig != last_signature or not registered:
|
||||
await self._render_and_publish(cfg, lambda: render_chat(cfg, msgs))
|
||||
last_signature = sig
|
||||
if not registered:
|
||||
await self._register_overlay(cfg.id, cfg.target_instance, cfg.cell,
|
||||
cfg.x, cfg.y, cfg.opacity, cfg.z_order)
|
||||
registered = True
|
||||
await asyncio.sleep(0.5) # tight check для chat reactivity
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error("dynamic.chat_loop_fail", id=cfg.id, error=str(e))
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _render_and_publish(self, cfg, render_fn) -> None:
|
||||
path = self.icon_dir / f"{cfg.id}.png"
|
||||
tmp = path.with_suffix(".png.tmp")
|
||||
img = render_fn()
|
||||
img.save(tmp, "PNG")
|
||||
tmp.replace(path)
|
||||
# Tell filter to invalidate cached atlas — next render re-reads file
|
||||
await self.dispatcher._reload_icon(cfg.target_instance, cfg.id)
|
||||
|
||||
async def _register_overlay(self, ov_id, instance, cell, x, y, opacity, z_order) -> None:
|
||||
"""Initial register — add IconOverlay referencing icon_name=<id>."""
|
||||
ov = IconOverlay(
|
||||
id=ov_id, cell=cell, name=ov_id, # icon name = same as overlay id
|
||||
x=x, y=y, size=0.1, # size игнорируется filter'ом — use native PNG dims
|
||||
opacity=opacity, z_order=z_order,
|
||||
)
|
||||
await self.dispatcher.handle(instance, "overlay.add", ov.model_dump_json())
|
||||
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING
|
||||
import structlog
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .overlays import RectOverlay, TextOverlay
|
||||
from .overlays import DimOverlay, RectOverlay, TextOverlay
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .dispatch import CommandDispatcher
|
||||
@@ -54,12 +54,24 @@ class BorderTheme(BaseModel):
|
||||
motion_opacity: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class FocusTheme(BaseModel):
|
||||
"""Auto-focus: при motion на одной cell — затемнение остальных.
|
||||
Если motion на 2+ cells одновременно — focus не применяется (no obvious focus)."""
|
||||
|
||||
enabled: bool = True
|
||||
dim_color: str = "#000000"
|
||||
dim_factor: float = Field(default=0.6, ge=0.0, le=1.0,
|
||||
description="0.0=без затемнения, 1.0=полное черное")
|
||||
|
||||
|
||||
class FrigateBridgeCfg(BaseModel):
|
||||
enabled: bool = False
|
||||
base_topic: str = "frigate"
|
||||
mappings: list[FrigateCameraMapping] = []
|
||||
border_theme: BorderTheme = Field(default_factory=BorderTheme,
|
||||
description="Стиль cell borders — idle/motion цвета")
|
||||
focus_theme: FocusTheme = Field(default_factory=FocusTheme,
|
||||
description="Auto-focus: dim non-active cells когда motion на одной")
|
||||
|
||||
|
||||
class FrigateBridge:
|
||||
@@ -78,6 +90,7 @@ class FrigateBridge:
|
||||
self._event_overlays: dict[str, tuple[str, str]] = {}
|
||||
self._borders_initialized: dict[str, bool] = {} # target_instance → bool
|
||||
self._cell_states: dict[str, set[str]] = {} # "<inst>:<cell>" → set of active cameras с motion
|
||||
self._focus_dims: dict[str, set[int]] = {} # instance → set of cells which сейчас затемнены
|
||||
|
||||
def topics_to_subscribe(self) -> list[str]:
|
||||
if not self.cfg.enabled:
|
||||
@@ -176,6 +189,57 @@ class FrigateBridge:
|
||||
|
||||
if is_active != was_active:
|
||||
await self._set_border_state(mapping.target_instance, mapping.cell, motion=is_active)
|
||||
await self._update_focus(mapping.target_instance)
|
||||
|
||||
def _cell_dim_id(self, cell: int) -> str:
|
||||
return f"cell_{cell}_focus_dim"
|
||||
|
||||
async def _update_focus(self, instance: str) -> None:
|
||||
"""Auto-focus logic:
|
||||
0 active cells → no dim (remove all focus dims)
|
||||
1 active cell → dim non-focus cells (focus mode ON)
|
||||
2+ active cells → no dim (too many — no obvious single focus)
|
||||
"""
|
||||
if not self.dispatcher or not self.cfg.focus_theme.enabled:
|
||||
return
|
||||
|
||||
# Active cells для этого instance
|
||||
active_cells = {
|
||||
int(key.split(":", 1)[1])
|
||||
for key, cams in self._cell_states.items()
|
||||
if cams and key.startswith(f"{instance}:")
|
||||
}
|
||||
all_cells = sorted({m.cell for m in self.cfg.mappings if m.target_instance == instance})
|
||||
currently_dimmed = self._focus_dims.setdefault(instance, set())
|
||||
|
||||
target_dimmed: set[int]
|
||||
if len(active_cells) == 1:
|
||||
focus_cell = next(iter(active_cells))
|
||||
target_dimmed = {c for c in all_cells if c != focus_cell}
|
||||
else:
|
||||
target_dimmed = set()
|
||||
|
||||
to_add = target_dimmed - currently_dimmed
|
||||
to_remove = currently_dimmed - target_dimmed
|
||||
|
||||
theme = self.cfg.focus_theme
|
||||
for cell in to_add:
|
||||
ov = DimOverlay(
|
||||
id=self._cell_dim_id(cell),
|
||||
cell=cell,
|
||||
x=0.0, y=0.0, w=1.0, h=1.0,
|
||||
color=theme.dim_color,
|
||||
dim_factor=theme.dim_factor,
|
||||
z_order=1, # под bbox (20) и border (5) но над cell content
|
||||
)
|
||||
await self.dispatcher.handle(instance, "overlay.add", ov.model_dump_json())
|
||||
currently_dimmed.add(cell)
|
||||
for cell in to_remove:
|
||||
await self.dispatcher.handle(instance, "overlay.remove", self._cell_dim_id(cell))
|
||||
currently_dimmed.discard(cell)
|
||||
|
||||
if to_add or to_remove:
|
||||
log.info("focus.updated", instance=instance, active=sorted(active_cells), dimmed=sorted(target_dimmed))
|
||||
|
||||
async def _handle_event(self, event: dict) -> None:
|
||||
event_type = event.get("type", "?")
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
|
||||
from .config import Config
|
||||
@@ -21,6 +23,10 @@ class LayoutSetReq(BaseModel):
|
||||
layout: str
|
||||
|
||||
|
||||
class AudioSetReq(BaseModel):
|
||||
source: str
|
||||
|
||||
|
||||
def create_app(
|
||||
cfg: Config, state: ControllerState, dispatcher: CommandDispatcher
|
||||
) -> FastAPI:
|
||||
@@ -100,6 +106,80 @@ def create_app(
|
||||
await dispatcher.handle(instance, "overlay.clear", "")
|
||||
return {"ok": True}
|
||||
|
||||
# ─── Audio ─────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/audio/{instance}")
|
||||
async def audio_list(instance: str) -> dict[str, Any]:
|
||||
inst = _check_instance(instance)
|
||||
return {
|
||||
"instance": instance,
|
||||
"sources": [
|
||||
{"name": s.name, "index": s.index, "label": s.label or s.name}
|
||||
for s in inst.audio_sources
|
||||
],
|
||||
}
|
||||
|
||||
@app.post("/audio/{instance}/set")
|
||||
async def audio_set(instance: str, req: AudioSetReq) -> dict[str, Any]:
|
||||
inst = _check_instance(instance)
|
||||
names = {s.name for s in inst.audio_sources}
|
||||
if req.source not in names:
|
||||
raise HTTPException(
|
||||
400, f"unknown audio source '{req.source}'. Доступны: {sorted(names)}"
|
||||
)
|
||||
await dispatcher.handle(instance, "audio.set", req.source)
|
||||
return {"ok": True, "instance": instance, "source": req.source}
|
||||
|
||||
@app.post("/intercom/{instance}/start")
|
||||
async def intercom_start(instance: str) -> dict[str, Any]:
|
||||
_check_instance(instance)
|
||||
await dispatcher.handle(instance, "intercom.start", "")
|
||||
return {"ok": True, "instance": instance, "intercom": "active"}
|
||||
|
||||
@app.post("/intercom/{instance}/end")
|
||||
async def intercom_end(instance: str) -> dict[str, Any]:
|
||||
_check_instance(instance)
|
||||
await dispatcher.handle(instance, "intercom.end", "")
|
||||
return {"ok": True, "instance": instance, "intercom": "idle"}
|
||||
|
||||
# ─── Snapshot ──────────────────────────────────────────────────
|
||||
|
||||
@app.post(
|
||||
"/snapshot/{instance}",
|
||||
responses={
|
||||
200: {"content": {"image/png": {}}, "description": "PNG snapshot of live grid"},
|
||||
504: {"description": "ffmpeg timeout"},
|
||||
},
|
||||
)
|
||||
async def snapshot(instance: str) -> Response:
|
||||
"""Capture single frame от output_rtsp_url. Returns PNG bytes (image/png)."""
|
||||
inst = _check_instance(instance)
|
||||
if not inst.output_rtsp_url:
|
||||
raise HTTPException(400, f"instance '{instance}' has no output_rtsp_url configured")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-rtsp_transport", "tcp",
|
||||
"-i", inst.output_rtsp_url,
|
||||
"-frames:v", "1",
|
||||
"-f", "image2pipe", "-c:v", "png", "-",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
png_data, err = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
raise HTTPException(504, "snapshot timeout (ffmpeg >10s)")
|
||||
|
||||
if proc.returncode != 0 or not png_data:
|
||||
err_msg = err.decode(errors="replace")[:300] if err else "no output"
|
||||
log.warning("snapshot.failed", instance=instance, err=err_msg)
|
||||
raise HTTPException(500, f"ffmpeg failed: {err_msg}")
|
||||
|
||||
log.info("snapshot.ok", instance=instance, bytes=len(png_data))
|
||||
return Response(content=png_data, media_type="image/png")
|
||||
|
||||
@app.patch("/overlay/{instance}/{overlay_id}")
|
||||
async def overlay_update(
|
||||
instance: str, overlay_id: str, overlay: Overlay = Body(...)
|
||||
|
||||
@@ -24,6 +24,7 @@ import aiomqtt
|
||||
import structlog
|
||||
|
||||
from .config import Config
|
||||
from .dynamic_overlays import DynamicRenderer
|
||||
from .frigate_bridge import FrigateBridge
|
||||
from .ha_discovery import availability_topic, discovery_payloads
|
||||
from .state import ControllerState
|
||||
@@ -41,11 +42,13 @@ class MqttLoop:
|
||||
state: ControllerState,
|
||||
command_handler: CommandHandler,
|
||||
frigate_bridge: FrigateBridge | None = None,
|
||||
dynamic_renderer: DynamicRenderer | None = None,
|
||||
) -> None:
|
||||
self.cfg = cfg
|
||||
self.state = state
|
||||
self.command_handler = command_handler
|
||||
self.frigate_bridge = frigate_bridge
|
||||
self.dynamic_renderer = dynamic_renderer
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
@@ -85,6 +88,11 @@ class MqttLoop:
|
||||
for t in self.frigate_bridge.topics_to_subscribe():
|
||||
await client.subscribe(t, qos=0)
|
||||
log.info("mqtt.frigate.subscribed", topic=t)
|
||||
# Dynamic overlays MQTT data subscriptions
|
||||
if self.dynamic_renderer:
|
||||
for t in self.dynamic_renderer.topics_to_subscribe():
|
||||
await client.subscribe(t, qos=0)
|
||||
log.info("mqtt.dynamic.subscribed", topic=t)
|
||||
|
||||
async for msg in client.messages:
|
||||
await self._handle_message(msg)
|
||||
@@ -126,6 +134,11 @@ class MqttLoop:
|
||||
await self.frigate_bridge.handle_message(topic, payload)
|
||||
return
|
||||
|
||||
# Dynamic overlays — chart data + chat messages
|
||||
if self.dynamic_renderer:
|
||||
self.dynamic_renderer.handle_message(topic, payload)
|
||||
return
|
||||
|
||||
log.warning("mqtt.unknown_topic", topic=topic, payload=payload)
|
||||
|
||||
async def publish_state(self, instance: str, scope: str, value: str, retain: bool = True) -> None:
|
||||
|
||||
@@ -11,6 +11,8 @@ FFmpeg zmq filter принимает строки формата:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
import zmq
|
||||
import zmq.asyncio
|
||||
@@ -26,6 +28,10 @@ class FFmpegZmqClient:
|
||||
self.request_timeout_ms = request_timeout_ms
|
||||
self._ctx = zmq.asyncio.Context.instance()
|
||||
self._sock: zmq.asyncio.Socket | None = None
|
||||
# REQ socket требует strict send→recv→send→recv pattern. Без lock'а
|
||||
# concurrent send_command (overlay + audio) ломает state в "Operation
|
||||
# cannot be accomplished in current state". Serialize requests.
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._sock is not None:
|
||||
@@ -55,6 +61,7 @@ class FFmpegZmqClient:
|
||||
cmd_str = f"{cmd_str} '{escaped}'"
|
||||
|
||||
log.debug("zmq.send", endpoint=self.endpoint, cmd=cmd_str)
|
||||
async with self._lock:
|
||||
try:
|
||||
await self._sock.send_string(cmd_str)
|
||||
reply = await self._sock.recv_string()
|
||||
@@ -62,10 +69,16 @@ class FFmpegZmqClient:
|
||||
return reply
|
||||
except zmq.error.Again:
|
||||
log.warning("zmq.timeout", endpoint=self.endpoint, cmd=cmd_str)
|
||||
# Reset REQ socket state — после timeout REQ нельзя re-use
|
||||
self._sock.close(linger=0)
|
||||
self._sock = None
|
||||
raise TimeoutError(f"zmq command timeout: {cmd_str}")
|
||||
except Exception as e:
|
||||
# Любая другая ошибка тоже ломает REQ state — сбрасываем socket
|
||||
log.warning("zmq.error", endpoint=self.endpoint, error=str(e))
|
||||
if self._sock is not None:
|
||||
self._sock.close(linger=0)
|
||||
self._sock = None
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._sock is not None:
|
||||
|
||||
Reference in New Issue
Block a user