controller: FrigateBridge cell borders с idle/motion state machine
Permanent 1-2px рамка вокруг каждой cell для visual разделения.
При motion ON → рамка светится красным (config'и BorderTheme).
Logic:
_ensure_borders() Lazy init 4 borders (id="cell_N_border") при первом event
с idle стилем (#808080 width=2 opacity=0.4)
_set_border_state(motion=bool) Upsert тот же overlay с motion (#FF0000 width=4 opacity=1.0)
или idle styling
_cell_states set'у активных cams per cell ("inst:cell" → set(cam_names))
— border ON если хоть один cam имеет motion, OFF только когда
все cams cleared
BorderTheme:
idle_color/width/opacity subtle разделитель
motion_color/width/opacity alarm подсветка
configurable через cfg.frigate.border_theme
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -42,10 +42,24 @@ class FrigateCameraMapping(BaseModel):
|
||||
bbox_overlay: bool = Field(default=True, description="Рисовать bbox для object detection events")
|
||||
|
||||
|
||||
class BorderTheme(BaseModel):
|
||||
"""Стиль cell border в трёх состояниях. Color = HEX RGB."""
|
||||
|
||||
idle_color: str = Field(default="#808080", description="Нейтральная разделительная рамка")
|
||||
idle_width: int = Field(default=2, ge=1, le=8)
|
||||
idle_opacity: float = Field(default=0.4, ge=0.0, le=1.0)
|
||||
|
||||
motion_color: str = Field(default="#FF0000", description="Цвет при frigate motion ON")
|
||||
motion_width: int = Field(default=4, ge=1, le=16)
|
||||
motion_opacity: float = Field(default=1.0, ge=0.0, le=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 цвета")
|
||||
|
||||
|
||||
class FrigateBridge:
|
||||
@@ -60,8 +74,10 @@ class FrigateBridge:
|
||||
self.cfg = cfg
|
||||
self.dispatcher = dispatcher
|
||||
self._by_camera = {m.frigate_camera: m for m in cfg.mappings if m.enabled}
|
||||
self._motion_overlays: dict[str, str] = {}
|
||||
self._motion_overlays: dict[str, str] = {} # legacy — unused if border theme active
|
||||
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
|
||||
|
||||
def topics_to_subscribe(self) -> list[str]:
|
||||
if not self.cfg.enabled:
|
||||
@@ -69,10 +85,55 @@ class FrigateBridge:
|
||||
base = self.cfg.base_topic.rstrip("/")
|
||||
return [f"{base}/+/motion", f"{base}/events"]
|
||||
|
||||
def _cell_border_id(self, cell: int) -> str:
|
||||
return f"cell_{cell}_border"
|
||||
|
||||
async def _ensure_borders(self, instance: str) -> None:
|
||||
"""Lazy init: 4 idle borders для каждого cell в instance. Idempotent."""
|
||||
if self._borders_initialized.get(instance) or not self.dispatcher:
|
||||
return
|
||||
cells = sorted({m.cell for m in self.cfg.mappings if m.target_instance == instance})
|
||||
theme = self.cfg.border_theme
|
||||
for cell in cells:
|
||||
ov = RectOverlay(
|
||||
id=self._cell_border_id(cell),
|
||||
cell=cell,
|
||||
x=0.0, y=0.0, w=1.0, h=1.0,
|
||||
color=theme.idle_color,
|
||||
border_only=True,
|
||||
border_width=theme.idle_width,
|
||||
opacity=theme.idle_opacity,
|
||||
z_order=5,
|
||||
)
|
||||
await self.dispatcher.handle(instance, "overlay.add", ov.model_dump_json())
|
||||
self._borders_initialized[instance] = True
|
||||
log.info("frigate_bridge.borders_initialized", instance=instance, cells=cells)
|
||||
|
||||
async def _set_border_state(self, instance: str, cell: int, motion: bool) -> None:
|
||||
"""Upsert cell border overlay в idle или motion стиль."""
|
||||
if not self.dispatcher:
|
||||
return
|
||||
theme = self.cfg.border_theme
|
||||
ov = RectOverlay(
|
||||
id=self._cell_border_id(cell),
|
||||
cell=cell,
|
||||
x=0.0, y=0.0, w=1.0, h=1.0,
|
||||
color=theme.motion_color if motion else theme.idle_color,
|
||||
border_only=True,
|
||||
border_width=theme.motion_width if motion else theme.idle_width,
|
||||
opacity=theme.motion_opacity if motion else theme.idle_opacity,
|
||||
z_order=5,
|
||||
)
|
||||
await self.dispatcher.handle(instance, "overlay.add", ov.model_dump_json())
|
||||
|
||||
async def handle_message(self, topic: str, payload: str) -> None:
|
||||
if not self.cfg.enabled:
|
||||
return
|
||||
|
||||
# Lazy init borders для всех target instances при первом event
|
||||
for inst in {m.target_instance for m in self.cfg.mappings}:
|
||||
await self._ensure_borders(inst)
|
||||
|
||||
base = self.cfg.base_topic.rstrip("/")
|
||||
|
||||
# frigate/<cam>/motion
|
||||
@@ -100,27 +161,19 @@ class FrigateBridge:
|
||||
if not self.dispatcher:
|
||||
return
|
||||
|
||||
ov_id = f"motion_{cam}"
|
||||
|
||||
# Cell может содержать multiple cameras (теоретически) — border ON если
|
||||
# хотя бы один cam имеет motion. Tracking через set'у per cell.
|
||||
cell_key = f"{mapping.target_instance}:{mapping.cell}"
|
||||
active = self._cell_states.setdefault(cell_key, set())
|
||||
was_active = bool(active)
|
||||
if state == "ON":
|
||||
if cam in self._motion_overlays:
|
||||
return # уже активен
|
||||
ov = RectOverlay(
|
||||
id=ov_id,
|
||||
cell=mapping.cell,
|
||||
x=0.0, y=0.0, w=1.0, h=1.0,
|
||||
color="#FF8800",
|
||||
border_only=True,
|
||||
border_width=6,
|
||||
z_order=10,
|
||||
opacity=0.9,
|
||||
)
|
||||
self._motion_overlays[cam] = ov_id
|
||||
await self.dispatcher.handle(mapping.target_instance, "overlay.add", ov.model_dump_json())
|
||||
active.add(cam)
|
||||
elif state == "OFF":
|
||||
stored = self._motion_overlays.pop(cam, None)
|
||||
if stored:
|
||||
await self.dispatcher.handle(mapping.target_instance, "overlay.remove", stored)
|
||||
active.discard(cam)
|
||||
is_active = bool(active)
|
||||
|
||||
if is_active != was_active:
|
||||
await self._set_border_state(mapping.target_instance, mapping.cell, motion=is_active)
|
||||
|
||||
async def _handle_event(self, event: dict) -> None:
|
||||
event_type = event.get("type", "?")
|
||||
|
||||
Reference in New Issue
Block a user