"""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=.""" 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())