f5ea2e3005
При startup controller iterate'т FrigateBridge mappings и рендерит:
- offline_<cell>.png с camera label из placeholder_label (или frigate_camera
как fallback) — для каждого pad
- offline.png generic — для cells без mapping
Filter cuda_grid looks up "offline_<pad>.png" first per-cell, fallback к
"offline.png". User меняет yaml placeholder_label → restart controller →
PNGs регенерируются. Никаких ручных PNG manipulations.
Russian text supported (PIL + freetype, vs single-byte path в filter).
Pattern:
frigate.mappings:
- frigate_camera: parking_overview
placeholder_label: Парковка
cell: 0
...
При stale input на cell 0 → filter blits offline_0.png с "Парковка — НЕТ
СИГНАЛА" (текст желтый, transparent bg — filter сам fill'ит black за иконкой).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
211 lines
7.0 KiB
Python
211 lines
7.0 KiB
Python
"""Entry point: `cuda-grid-controller --config controller.yaml`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import structlog
|
|
import typer
|
|
import uvicorn
|
|
|
|
from .config import Config
|
|
from .dispatch import CommandDispatcher
|
|
from .browser_overlays import BrowserRenderer, DashboardCfg
|
|
from .dynamic_overlays import ChartCfg, ChatCfg, DynamicRenderer
|
|
from .pipeline_monitor import PipelineMonitor
|
|
from .placeholder_renderer import generate_placeholders
|
|
from .frigate_bridge import FrigateBridge, FrigateBridgeCfg
|
|
from .http_api import create_app
|
|
from .mqtt_loop import MqttLoop
|
|
from .snapshot_history import SnapshotHistory
|
|
from .state import ControllerState
|
|
from .watchdog import StreamWatchdog, WatchdogCfg
|
|
|
|
cli = typer.Typer(add_completion=False)
|
|
|
|
|
|
def _configure_logging(level: str) -> None:
|
|
logging.basicConfig(
|
|
format="%(message)s",
|
|
level=getattr(logging, level.upper(), logging.INFO),
|
|
)
|
|
structlog.configure(
|
|
processors=[
|
|
structlog.processors.add_log_level,
|
|
structlog.processors.TimeStamper(fmt="iso"),
|
|
structlog.dev.ConsoleRenderer(),
|
|
]
|
|
)
|
|
|
|
|
|
async def _run(cfg: Config) -> None:
|
|
state = ControllerState()
|
|
# Init active_layout = default_layout per instance
|
|
for inst in cfg.instances:
|
|
await state.set_layout(inst.name, inst.default_layout)
|
|
|
|
dispatcher = CommandDispatcher(cfg, state)
|
|
|
|
# Frigate bridge (опционально) — передаём dispatcher для auto-overlay generation
|
|
frigate_bridge: FrigateBridge | None = None
|
|
if cfg.frigate:
|
|
try:
|
|
fcfg = FrigateBridgeCfg.model_validate(cfg.frigate)
|
|
if fcfg.enabled:
|
|
frigate_bridge = FrigateBridge(fcfg, dispatcher=dispatcher)
|
|
# Generate per-cell placeholder PNGs based на mappings — filter
|
|
# blits offline_<cell>.png когда input pad stale. Empty
|
|
# placeholder_label → fallback к frigate_camera name.
|
|
labels = {
|
|
m.cell: (m.placeholder_label or m.frigate_camera)
|
|
for m in fcfg.mappings if m.enabled
|
|
}
|
|
if labels:
|
|
try:
|
|
generate_placeholders(Path(cfg.icon_dir), labels)
|
|
except Exception as e:
|
|
structlog.get_logger().warning(
|
|
"placeholders.gen_fail", error=str(e))
|
|
except Exception as e:
|
|
structlog.get_logger().warning(
|
|
"frigate_bridge.config_invalid", error=str(e)
|
|
)
|
|
|
|
# Dynamic overlays (charts/chats) — Phase 6
|
|
dynamic_renderer: DynamicRenderer | None = None
|
|
browser_renderer: BrowserRenderer | 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,
|
|
)
|
|
dashboards = [DashboardCfg.model_validate(b)
|
|
for b in (d.get("dashboards") or [])]
|
|
if dashboards:
|
|
browser_renderer = BrowserRenderer(
|
|
icon_dir=Path(cfg.icon_dir),
|
|
dispatcher=dispatcher,
|
|
dashboards=dashboards,
|
|
)
|
|
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
|
|
dispatcher.on_event = mqtt.publish_event
|
|
|
|
# Snapshot history (Phase 6+) — periodic capture per instance
|
|
snapshot_hist = SnapshotHistory(cfg)
|
|
|
|
# Stream watchdog (Phase 1 resilience, issue #3) — monitor mediamtx paths
|
|
watchdog: StreamWatchdog | None = None
|
|
if cfg.watchdog:
|
|
try:
|
|
wcfg = WatchdogCfg.model_validate(cfg.watchdog)
|
|
if wcfg.enabled:
|
|
watchdog = StreamWatchdog(wcfg, dispatcher,
|
|
mqtt_publish_event=None) # set after mqtt
|
|
except Exception as e:
|
|
structlog.get_logger().warning("watchdog.config_invalid", error=str(e))
|
|
|
|
# HTTP REST
|
|
app = create_app(cfg, state, dispatcher,
|
|
snapshot_history=snapshot_hist,
|
|
frigate_bridge=frigate_bridge)
|
|
server = uvicorn.Server(
|
|
uvicorn.Config(
|
|
app,
|
|
host=cfg.http.host,
|
|
port=cfg.http.port,
|
|
log_level=cfg.log.level.lower(),
|
|
)
|
|
)
|
|
|
|
log = structlog.get_logger()
|
|
log.info(
|
|
"controller.starting",
|
|
instances=[i.name for i in cfg.instances],
|
|
mqtt=f"{cfg.broker.host}:{cfg.broker.port}",
|
|
http=f"{cfg.http.host}:{cfg.http.port}",
|
|
)
|
|
|
|
# Start dynamic renderer задачи (если есть)
|
|
if dynamic_renderer:
|
|
await dynamic_renderer.start()
|
|
if browser_renderer:
|
|
await browser_renderer.start()
|
|
await snapshot_hist.start()
|
|
if watchdog:
|
|
watchdog._publish_event = mqtt.publish_event
|
|
await watchdog.start()
|
|
|
|
# Pipeline monitor — detect ffmpeg restart + auto-restore overlay state +
|
|
# encoder stall alerts (через MQTT pipeline.stalled event).
|
|
pipeline_monitor = PipelineMonitor(
|
|
cfg=cfg, state=state, dispatcher=dispatcher,
|
|
browser_renderer=browser_renderer,
|
|
dynamic_renderer=dynamic_renderer,
|
|
frigate_bridge=frigate_bridge,
|
|
)
|
|
pipeline_monitor.on_event = mqtt.publish_event
|
|
await pipeline_monitor.start()
|
|
|
|
try:
|
|
await asyncio.gather(
|
|
mqtt.run(),
|
|
server.serve(),
|
|
)
|
|
except asyncio.CancelledError:
|
|
log.info("controller.shutdown")
|
|
finally:
|
|
await pipeline_monitor.stop()
|
|
if dynamic_renderer:
|
|
await dynamic_renderer.stop()
|
|
if browser_renderer:
|
|
await browser_renderer.stop()
|
|
await snapshot_hist.stop()
|
|
if watchdog:
|
|
await watchdog.stop()
|
|
await dispatcher.close()
|
|
await mqtt.stop()
|
|
|
|
|
|
@cli.command()
|
|
def run(
|
|
config: Path = typer.Option(
|
|
Path("controller.yaml"),
|
|
"--config",
|
|
"-c",
|
|
help="YAML config path",
|
|
),
|
|
) -> None:
|
|
"""Запустить controller."""
|
|
if not config.exists():
|
|
typer.echo(f"config not found: {config}", err=True)
|
|
raise typer.Exit(1)
|
|
cfg = Config.from_yaml(config)
|
|
_configure_logging(cfg.log.level)
|
|
asyncio.run(_run(cfg))
|
|
|
|
|
|
def main() -> None:
|
|
cli()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|