d807cd2c23
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>
275 lines
10 KiB
Python
275 lines
10 KiB
Python
"""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())
|