Coverage for custom_components/camera_autonotify/discovery.py: 95%
74 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:32 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:32 +0000
1"""Find Frigate cameras, and zones if wanted, and set each up or offer it as a discovered config entry"""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import TYPE_CHECKING
8from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_INTEGRATION_DISCOVERY
9from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_TYPE
10from homeassistant.core import Event, HomeAssistant, callback
11from homeassistant.helpers import device_registry as dr
12from homeassistant.helpers import discovery_flow
13from homeassistant.helpers import entity_registry as er
15from .const import (
16 CONF_AUTO_DISCOVER,
17 CONF_DISCOVER_ZONES,
18 CONF_SOURCE,
19 CONF_SOURCE_ENTRY_ID,
20 CONF_SOURCE_NAME,
21 DOMAIN,
22 FRIGATE_DOMAIN,
23 SOURCE_FRIGATE,
24 TYPE_CAMERA,
25 TYPE_DISCOVERY,
26 TYPE_ZONE,
27)
29if TYPE_CHECKING:
30 from homeassistant.helpers.entity_registry import EventEntityRegistryUpdatedData
32CAMERA_DOMAIN = "camera"
33# the unique id the Frigate integration gives a camera's entity, `{entry_id}:camera:{name}`, the name as in Frigate
34CAMERA_UNIQUE_ID = ":camera:"
37@dataclass(frozen=True)
38class Found:
39 """A Frigate camera or zone, each a device of the Frigate integration"""
41 device_id: str
42 name: str
43 type: str
44 source_entry_id: str
45 # the camera's name as Frigate has it, as reviews give it, or a zone's as slugified for its device, since reviews
46 # give a zone as written in the Frigate config, with whatever case it has there
47 source_name: str
50def _device_name(device: dr.DeviceEntry) -> str:
51 return device.name_by_user or device.name or device.id
54@callback
55def find_cameras(hass: HomeAssistant) -> list[Found]:
56 """The device of each Frigate camera, known by its camera entity"""
57 device_registry = dr.async_get(hass)
58 found: list[Found] = []
59 for entity in er.async_get(hass).entities.values():
60 if entity.platform != FRIGATE_DOMAIN or entity.domain != CAMERA_DOMAIN or entity.disabled:
61 continue
62 source_entry_id, sep, camera = entity.unique_id.partition(CAMERA_UNIQUE_ID)
63 device = device_registry.async_get(entity.device_id) if entity.device_id else None
64 if not sep or not isinstance(device, dr.DeviceEntry):
65 continue
66 found.append(Found(device.id, _device_name(device), TYPE_CAMERA, source_entry_id, camera))
67 return found
70@callback
71def find_zones(hass: HomeAssistant) -> list[Found]:
72 """The device of each Frigate zone, the devices of a Frigate server that are neither the server nor a camera"""
73 device_registry = dr.async_get(hass)
74 cameras = {camera.device_id for camera in find_cameras(hass)}
75 found: list[Found] = []
76 for entry in hass.config_entries.async_entries(FRIGATE_DOMAIN):
77 prefix = f"{entry.entry_id}:"
78 for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
79 if device.id in cameras or device.disabled:
80 continue
81 zone = next(
82 (
83 value[len(prefix) :]
84 for domain, value in device.identifiers
85 if domain == FRIGATE_DOMAIN and value.startswith(prefix)
86 ),
87 None,
88 )
89 if zone:
90 found.append(Found(device.id, _device_name(device), TYPE_ZONE, entry.entry_id, zone))
91 return found
94@callback
95def _discovery_options(hass: HomeAssistant) -> dict[str, bool]:
96 """The choices of the discovery entry, nothing found while it's disabled or deleted"""
97 for entry in hass.config_entries.async_entries(DOMAIN, include_disabled=False):
98 if entry.data.get(CONF_TYPE) == TYPE_DISCOVERY:
99 return {
100 CONF_AUTO_DISCOVER: bool(entry.options.get(CONF_AUTO_DISCOVER, True)),
101 CONF_DISCOVER_ZONES: bool(entry.options.get(CONF_DISCOVER_ZONES, False)),
102 }
103 return {}
106@callback
107def async_discover(hass: HomeAssistant) -> None:
108 """Set up every camera found, or with automatic discovery off, offer each to be added, and zones only if wanted"""
109 options = _discovery_options(hass)
110 if not options:
111 return
112 source = SOURCE_IMPORT if options[CONF_AUTO_DISCOVER] else SOURCE_INTEGRATION_DISCOVERY
113 found = find_cameras(hass) + (find_zones(hass) if options[CONF_DISCOVER_ZONES] else [])
114 for item in found:
115 # those already configured, ignored or in progress are dropped by the flow's unique id
116 discovery_flow.async_create_flow(
117 hass,
118 DOMAIN,
119 context={"source": source},
120 data={
121 CONF_DEVICE_ID: item.device_id,
122 CONF_NAME: item.name,
123 CONF_TYPE: item.type,
124 CONF_SOURCE: SOURCE_FRIGATE,
125 CONF_SOURCE_ENTRY_ID: item.source_entry_id,
126 CONF_SOURCE_NAME: item.source_name,
127 },
128 )
131@callback
132def async_start_discovery(hass: HomeAssistant) -> None:
133 """Look for cameras now, and again whenever Frigate adds an entity, as it does for a new camera or zone"""
135 @callback
136 def entity_registry_updated(event: Event[EventEntityRegistryUpdatedData]) -> None:
137 if event.data["action"] != "create":
138 return
139 entity = er.async_get(hass).async_get(event.data["entity_id"])
140 if entity is not None and entity.platform == FRIGATE_DOMAIN:
141 async_discover(hass)
143 hass.bus.async_listen(er.EVENT_ENTITY_REGISTRY_UPDATED, entity_registry_updated)
144 async_discover(hass)