"""Base class for protect data.""" import asyncio from collections import defaultdict from collections.abc import Callable, Generator, Iterable from datetime import datetime, timedelta from functools import partial import logging from typing import TYPE_CHECKING, Any, cast from aiohttp.client_exceptions import ServerDisconnectedError from uiprotect import EventChange, ProtectApiClient, ProtectEvent from uiprotect.api import RTSPSStreams from uiprotect.data import ( NVR, Camera, Event, EventType, Light, ModelType, ProtectAdoptableDeviceModel, PTZPatrol, PublicDeviceModel, WSAction, WSSubscriptionMessage, ) from uiprotect.data.public_devices import PublicCamera, PublicLight from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.utils import log_event from uiprotect.websocket import WebsocketState from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.event import async_track_time_interval from .const import ( AUTH_RETRIES, CONF_DISABLE_RTSP, CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA, DEVICES_THAT_ADOPT, DISPATCH_ADD, DISPATCH_ADOPT, DISPATCH_CHANNELS, DISPATCH_PUBLIC_ADD, DOMAIN, ) from .utils import async_get_devices_by_type _LOGGER = logging.getLogger(__name__) type ProtectDeviceType = ProtectAdoptableDeviceModel | NVR type UFPConfigEntry = ConfigEntry[ProtectData] @callback def async_last_update_was_successful( hass: HomeAssistant, entry: UFPConfigEntry ) -> bool: """Check if the last update was successful for a config entry. Public-only entries have no private poll; their health is the public websocket (otherwise the discovery host self-heal could never trigger). """ if entry.state is not ConfigEntryState.LOADED: return False data = entry.runtime_data if data.api.is_public_only: return data.last_public_update_success return data.last_update_success @callback def _async_dispatch_id(entry: UFPConfigEntry, dispatch: str) -> str: """Generate entry specific dispatch ID.""" return f"{DOMAIN}.{entry.entry_id}.{dispatch}" def _pair_public_private[ PublicDeviceT: PublicDeviceModel, PrivateDeviceT: ProtectAdoptableDeviceModel, ]( public_devices: dict[str, PublicDeviceT], private_devices: dict[str, PrivateDeviceT], ) -> Generator[tuple[PublicDeviceT | None, PrivateDeviceT | None]]: """Pair public-master devices with their private fill by shared id. The public map is the master list; the matching private device is attached when present (hybrid) and ``None`` in public-only mode. An adopted private device not (yet) mirrored publicly is yielded as ``(None, private)`` so the caller can defer it. Devices not adopted by us are skipped on both sides. """ for device_id, public in public_devices.items(): private = private_devices.get(device_id) if private is not None and not private.is_adopted_by_us: continue yield public, private for device_id, private in private_devices.items(): if device_id in public_devices or not private.is_adopted_by_us: continue yield None, private class ProtectData: """Coordinate updates.""" # Resolved once during setup (either mode), before any entity is created. nvr_device_id: str def __init__( self, hass: HomeAssistant, protect: ProtectApiClient, update_interval: timedelta, entry: UFPConfigEntry, ) -> None: """Initialize an subscriber.""" self._entry = entry self._hass = hass self._update_interval = update_interval self._subscriptions: defaultdict[ str, set[Callable[[ProtectDeviceType], None]] ] = defaultdict(set) self._public_event_subscriptions: defaultdict[ tuple[str, EventType], set[Callable[[ProtectEvent], None]] ] = defaultdict(set) self._public_subscriptions: defaultdict[ str, set[Callable[[PublicDeviceModel | None], None]] ] = defaultdict(set) self._pending_camera_ids: set[str] = set() self._known_public_macs: set[str] = set() self._public_baseline_taken = False self._unsubs: list[CALLBACK_TYPE] = [] self._auth_failures = 0 self.auth_retries = 0 self.last_update_success = False self.last_public_update_success = False self.last_events_update_success = False self.api = protect self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT) self.add_signal = _async_dispatch_id(entry, DISPATCH_ADD) self.channels_signal = _async_dispatch_id(entry, DISPATCH_CHANNELS) self.public_add_signal = _async_dispatch_id(entry, DISPATCH_PUBLIC_ADD) # PTZ patrol cache: camera_id -> list of patrols self.ptz_patrols: dict[str, list[PTZPatrol]] = {} @property def disable_stream(self) -> bool: """Check if RTSP is disabled.""" return self._entry.options.get(CONF_DISABLE_RTSP, False) # type: ignore[no-any-return] @property def max_events(self) -> int: """Max number of events to load at once.""" return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) # type: ignore[no-any-return] def get_rtsps_streams(self, camera_id: str) -> RTSPSStreams | None: """Return the library-owned public-API RTSPS streams for a camera. The library primes ``PublicCamera.rtsps_streams`` during ``update_public()`` and keeps it fresh (reconnect refresh + create/delete write-through), so the integration reads it synchronously and stores nothing itself. """ api = self.api if not api.has_public_bootstrap: return None camera = api.public_bootstrap.cameras.get(camera_id) return camera.rtsps_streams if camera is not None else None @callback def async_subscribe_adopt( self, add_callback: Callable[[ProtectAdoptableDeviceModel], None] ) -> None: """Add an callback for on device adopt.""" self._entry.async_on_unload( async_dispatcher_connect(self._hass, self.adopt_signal, add_callback) ) def get_by_types( self, device_types: Iterable[ModelType], ignore_unadopted: bool = True ) -> Generator[ProtectAdoptableDeviceModel]: """Get all devices matching types.""" bootstrap = self.api.bootstrap for device_type in device_types: for device in async_get_devices_by_type(bootstrap, device_type).values(): if ignore_unadopted and not device.is_adopted_by_us: continue yield device def get_cameras(self, ignore_unadopted: bool = True) -> Generator[Camera]: """Get all cameras.""" return cast( Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted) ) def get_public_cameras( self, ) -> Generator[tuple[PublicCamera | None, Camera | None]]: """Yield ``(public, private)`` camera pairs (see _pair_public_private).""" api = self.api if not api.has_public_bootstrap: return # An API-key-only client never initializes the private bootstrap; # accessing it would raise. private_cameras: dict[str, Camera] = ( {} if api.is_public_only else api.bootstrap.cameras ) yield from _pair_public_private(api.public_bootstrap.cameras, private_cameras) def get_public_lights( self, ) -> Generator[tuple[PublicLight | None, Light | None]]: """Yield ``(public, private)`` light pairs (see _pair_public_private).""" api = self.api if not api.has_public_bootstrap: return # An API-key-only client never initializes the private bootstrap; # accessing it would raise. private_lights: dict[str, Light] = ( {} if api.is_public_only else api.bootstrap.lights ) yield from _pair_public_private(api.public_bootstrap.lights, private_lights) async def async_load_ptz_patrols(self) -> None: """Load PTZ patrols for all PTZ cameras.""" await asyncio.gather( *( self.async_load_ptz_patrols_for_camera(camera) for camera in self.get_cameras() ) ) async def async_load_ptz_patrols_for_camera(self, camera: Camera) -> None: """Load PTZ patrols for a specific camera.""" if camera.feature_flags.is_ptz: try: self.ptz_patrols[camera.id] = await camera.get_ptz_patrols() except ClientError: _LOGGER.debug( "Failed to load PTZ patrols for camera %s", camera.display_name, ) self.ptz_patrols[camera.id] = [] @callback def async_setup(self) -> None: """Subscribe and do the refresh.""" self.last_update_success = True self.last_public_update_success = True self.last_events_update_success = True self._async_update_change(True, force_update=True) api = self.api # Subscribe to the public devices websocket in both modes; frames # arriving while update_public() primes are buffered and replayed by # the library, so the subscribe/prime order is not load-bearing. self._unsubs = [ api.subscribe_devices_websocket( self._async_process_public_devices_ws_message ), api.subscribe_devices_websocket_state(self._async_public_ws_state_changed), # The events websocket is public in both modes (see # ``async_subscribe_public_events``), so track its state in both. api.subscribe_events_websocket_state(self._async_events_ws_state_changed), ] if api.is_public_only: # No private session: the private websocket and the private poll # (which calls the private ``update()``) do not apply. return self._unsubs += [ api.subscribe_websocket_state(self._async_websocket_state_changed), api.subscribe_websocket(self._async_process_ws_message), async_track_time_interval( self._hass, self._async_poll, self._update_interval ), ] @callback def async_subscribe_public_events(self) -> None: """Subscribe to the public events websocket. Must run *after* ``update_public()`` has primed the public bootstrap; the setup flow guarantees this (a failed prime aborts setup), and ``subscribe_events()`` raises otherwise. This is the source of truth for the event entities driven off the public websocket: the doorbell ring, and the smart-detect events (e.g. package) that the private API only surfaces as the unhandled ``smartDetectObject`` model. uiprotect owns websocket reconnection and keeps the callback attached, so this is a one-shot. """ self._unsubs.append(self.api.subscribe_events(self._async_process_public_event)) async def async_update_public(self) -> None: """Refresh the public bootstrap through the library. The first successful refresh fixes the add-dedup baseline: platforms enumerate that snapshot at setup, so only devices appearing later are offered through the add signal. Public-only mode only, matching the dispatch gate: hybrid never dispatches adds. """ await self.api.update_public() if self._public_baseline_taken: return self._public_baseline_taken = True api = self.api if not api.is_public_only or not api.has_public_bootstrap: return self._known_public_macs.update( device.mac for device in api.public_bootstrap.all_devices() if isinstance(device, PublicDeviceModel) ) @callback def async_reset_public_add_baseline(self) -> None: """Drop the add-dedup baseline so the next refresh retakes it. The baseline belongs to one setup generation, but this object outlives a setup that never loaded (HA only drops ``runtime_data`` after a successful unload), including one whose mode changed in between. """ self._public_baseline_taken = False self._known_public_macs.clear() @callback def _async_dispatch_new_public_device(self, device: PublicDeviceModel) -> None: """Offer a public device to the platforms, once per mac. Public-only mode only: hybrid discovers new devices through the private adopt path, and a second add would clash on unique_id. Cameras are excluded, the channels signal owns their (re-)enumeration. Dedup happens here so platforms can add without their own duplicate checks. """ api = self.api if ( not api.is_public_only or not api.has_public_bootstrap or device.model is ModelType.CAMERA or device.mac in self._known_public_macs ): return self._known_public_macs.add(device.mac) async_dispatcher_send(self._hass, self.public_add_signal, device) @callback def _async_process_public_devices_ws_message( self, message: WSSubscriptionMessage ) -> None: """Process a message from the public devices websocket. DEVICES_WS_SUBSCRIBED_MODELS is an empty set, which the API client treats as "all models", so messages are not pre-filtered. NVR messages signal the mode's NVR object so alarm entities pick up the new arm state. Every other public device inherits ``PublicDeviceModel`` and is dispatched by mac. Frames without a merged object dispatch ``None`` and subscribers re-read the public bootstrap: on a delete the library has already removed the object (it reads as missing and entities go unavailable), while a frame the library could not merge leaves the previous object cached. """ new_obj = message.new_obj if new_obj is None: old_obj = message.old_obj if isinstance(old_obj, PublicDeviceModel): self._async_signal_public_update(old_obj.mac, None) return if new_obj.model is ModelType.NVR: self._async_signal_nvr_update() return if isinstance(new_obj, PublicDeviceModel): if new_obj.model is ModelType.CAMERA: self._async_reenumerate_camera_on_public_change(new_obj, message) elif message.action is WSAction.ADD: self._async_dispatch_new_public_device(new_obj) self._async_signal_public_update(new_obj.mac, new_obj) @callback def _async_reenumerate_camera_on_public_change( self, new_obj: PublicDeviceModel, message: WSSubscriptionMessage ) -> None: """Re-run camera enumeration when a public frame can add entities. Three cases dispatch the public camera to the channels signal: - A camera deferred at enumeration because its public mirror had not arrived yet (the private channels-update path cannot be relied on to fire again). - A camera whose RTSPS streams the library primes in the background after it comes online or is added, announced by an ``rtsps_streams`` change: the quality tiers that just became active still need their entities. - In public-only mode, a newly added camera — there is no private adopt path that could discover it. The platform adds only entities that do not exist yet, so overlapping re-enumerations are safe. """ if new_obj.id in self._pending_camera_ids: self._pending_camera_ids.remove(new_obj.id) elif "rtsps_streams" not in message.changed_data and not ( self.api.is_public_only and message.action is WSAction.ADD ): return async_dispatcher_send(self._hass, self.channels_signal, new_obj) @callback def _async_process_public_event( self, event: ProtectEvent, change: EventChange ) -> None: """Dispatch a public events websocket event to its subscribers. Each non-eviction change is dispatched — a detection type may surface at the event start, on a later update, or only as it ends — routed to the subscribers registered for this device and event type; entities fire each ``(event, type)`` once. Subscriptions are keyed by ``device_id`` (the stable cross-API join key, shared by the private and public bootstraps), so the event routes directly without a bootstrap lookup. """ if change is EventChange.REMOVED: return if not ( subscriptions := self._public_event_subscriptions.get( (event.device_id, event.type) ) ): return for update_callback in subscriptions: update_callback(event) @callback def _async_websocket_state_changed(self, state: WebsocketState) -> None: """Handle a change in the websocket state.""" self._async_update_change(state is WebsocketState.CONNECTED) @callback def _async_public_ws_state_changed(self, state: WebsocketState) -> None: """Handle a change in the public devices websocket state.""" if state is WebsocketState.AUTH_FAILED: # A revoked API key cannot self-recover; route to reauth. The # library always emits DISCONNECTED first (uiprotect 15.12.2+), # so the data is already marked stale by the branch below. Fires # in both modes (the public websocket runs in hybrid too), and is # the only reauth trigger in public-only mode. While the entry is # still setting up, the 401 buffering there owns the decision. if self._entry.state is ConfigEntryState.LOADED: self._entry.async_start_reauth(self._hass) return success = state is WebsocketState.CONNECTED if success == self.last_public_update_success: return self.last_public_update_success = success self._async_process_public_updates() if success: # The library resyncs its public bootstrap on reconnect, but the # resync applies silently and races this callback, so the re-read # above may see the pre-disconnect cache. Refresh again behind a # guaranteed-fresh snapshot (``update_public`` is serialized) so a # change from the disconnect gap cannot stay stale. self._entry.async_create_background_task( self._hass, self._async_resignal_after_public_resync(), "unifiprotect public reconnect refresh", ) async def _async_resignal_after_public_resync(self) -> None: """Re-signal public entities once a fresh public snapshot is applied.""" try: await self.async_update_public() except NotAuthorized: # A revoked API key cannot self-recover. self._entry.async_start_reauth(self._hass) return except (TimeoutError, ClientError, ServerDisconnectedError) as err: # Retried on the next reconnect, but this now gates discovery of # devices that appeared during the gap, so make it visible. _LOGGER.warning("Public refresh after reconnect failed: %s", err) return self._async_process_public_updates() # A device that appeared during the gap gets no add frame, so re-offer # everything; the dispatch helper drops what platforms already know. if self.api.has_public_bootstrap: for public in list(self.api.public_bootstrap.cameras.values()): async_dispatcher_send(self._hass, self.channels_signal, public) if self.api.is_public_only: for device in list(self.api.public_bootstrap.all_devices()): if isinstance(device, PublicDeviceModel): self._async_dispatch_new_public_device(device) @callback def _async_signal_nvr_update(self) -> None: """Signal the NVR entities (the alarm panel reads the public arm mode). Public-only has no private NVR, so signal the public one; it carries the resolved mac the alarm panel is keyed on. """ if self.api.is_public_only: if (nvr := self.api.public_bootstrap.nvr) is not None: self._async_signal_device_update(cast("ProtectDeviceType", nvr)) else: self._async_signal_device_update(self.api.bootstrap.nvr) @callback def _async_events_ws_state_changed(self, state: WebsocketState) -> None: """Handle a change in the public events websocket state. Entities whose values are derived from the events stream (the detection booleans and the public event entities) include this in their availability, since the devices websocket alone cannot tell whether detections still flow. """ success = state is WebsocketState.CONNECTED if success == self.last_events_update_success: return self.last_events_update_success = success self._async_process_public_updates() @callback def _async_process_public_updates(self) -> None: """Re-signal public-API entities after a public websocket state change.""" if not self.api.has_public_bootstrap: return self._async_signal_nvr_update() # Subscribers recompute from the public bootstrap on ``None``. for subscriptions in self._public_subscriptions.values(): for update_callback in subscriptions: update_callback(None) def _async_update_change( self, success: bool, force_update: bool = False, exception: Exception | None = None, ) -> None: """Process a change in update success.""" was_success = self.last_update_success self.last_update_success = success if not success: level = logging.ERROR if was_success else logging.DEBUG title = self._entry.title _LOGGER.log(level, "%s: Connection lost", title, exc_info=exception) self._async_process_updates() return self._auth_failures = 0 if not was_success: _LOGGER.warning("%s: Connection restored", self._entry.title) self._async_process_updates() elif force_update: self._async_process_updates() async def async_stop(self, *args: Any) -> None: """Stop processing data.""" for unsub in self._unsubs: unsub() self._unsubs.clear() await self.api.async_disconnect_ws() async def async_refresh(self) -> None: """Update the data.""" if self.api.is_public_only: # The private ``update()`` cannot run without a session; refresh # the public snapshot and re-render its subscribers instead. try: await self.async_update_public() except NotAuthorized: # Buffer transient 401s like the private path; a persistently # revoked API key cannot self-recover and needs reauth. if self._auth_failures < AUTH_RETRIES: _LOGGER.exception("Auth error while updating") self._auth_failures += 1 else: self._entry.async_start_reauth(self._hass) return except (TimeoutError, ClientError, ServerDisconnectedError) as err: _LOGGER.debug("Manual public refresh failed: %s", err) return self._auth_failures = 0 self._async_process_public_updates() return try: await self.api.update() except NotAuthorized as ex: if self._auth_failures < AUTH_RETRIES: _LOGGER.exception("Auth error while updating") self._auth_failures += 1 else: await self.async_stop() _LOGGER.exception("Reauthentication required") self._entry.async_start_reauth(self._hass) self._async_update_change(False, exception=ex) except ClientError as ex: self._async_update_change(False, exception=ex) else: self._async_update_change(True, force_update=True) @callback def async_add_pending_camera_id(self, camera_id: str) -> None: """Add pending camera. A "pending camera" is one that has been adopted by not had its camera channels initialized yet. Will cause Websocket code to check for channels to be initialized for the camera and issue a dispatch once they do. """ self._pending_camera_ids.add(camera_id) @callback def _async_add_device(self, device: ProtectAdoptableDeviceModel) -> None: if device.is_adopted_by_us: _LOGGER.debug("Device adopted: %s", device.id) if isinstance(device, Camera) and device.feature_flags.is_ptz: self._hass.async_create_task( self._async_adopt_ptz_camera(device), name="unifiprotect_adopt_ptz_camera", ) else: async_dispatcher_send(self._hass, self.adopt_signal, device) else: _LOGGER.debug("New device detected: %s", device.id) async_dispatcher_send(self._hass, self.add_signal, device) async def _async_adopt_ptz_camera(self, camera: Camera) -> None: """Load PTZ patrol data and dispatch adopt signal for a PTZ camera.""" await self.async_load_ptz_patrols_for_camera(camera) async_dispatcher_send(self._hass, self.adopt_signal, camera) @callback def _async_remove_device(self, device: ProtectAdoptableDeviceModel) -> None: registry = dr.async_get(self._hass) device_entry = registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, device.mac), self._entry.entry_id ) if device_entry: _LOGGER.debug("Device removed: %s", device.id) registry.async_remove_device(device_entry.id) @callback def _async_update_device( self, device: ProtectAdoptableDeviceModel | NVR, changed_data: dict[str, Any] ) -> None: self._async_signal_device_update(device) if ( device.model is ModelType.CAMERA and device.id in self._pending_camera_ids and "channels" in changed_data ): self._pending_camera_ids.remove(device.id) async_dispatcher_send(self._hass, self.channels_signal, device) # trigger update for all Cameras with LCD screens # when NVR Doorbell settings updates if "doorbell_settings" in changed_data: _LOGGER.debug( "Doorbell messages updated. Updating devices with LCD screens" ) self.api.bootstrap.nvr.update_all_messages() for camera in self.get_cameras(): if camera.feature_flags.has_lcd_screen: self._async_signal_device_update(camera) @callback def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: """Process a message from the websocket.""" if (new_obj := message.new_obj) is None: if isinstance(message.old_obj, ProtectAdoptableDeviceModel): self._async_remove_device(message.old_obj) return model_type = new_obj.model if model_type is ModelType.EVENT: if TYPE_CHECKING: assert isinstance(new_obj, Event) if _LOGGER.isEnabledFor(logging.DEBUG): log_event(new_obj) if ( (new_obj.type is EventType.DEVICE_ADOPTED) and (metadata := new_obj.metadata) and (device_id := metadata.device_id) and (device := self.api.bootstrap.get_device_from_id(device_id)) ): self._async_add_device(device) elif camera := new_obj.camera: self._async_signal_device_update(camera) elif light := new_obj.light: self._async_signal_device_update(light) elif sensor := new_obj.sensor: self._async_signal_device_update(sensor) return if model_type is ModelType.LIVEVIEW and len(self.api.bootstrap.viewers) > 0: # alert user viewport needs restart so voice clients can get new options _LOGGER.warning( "Liveviews updated. Restart Home Assistant to update Viewport select" " options" ) return if message.old_obj is None and isinstance(new_obj, ProtectAdoptableDeviceModel): self._async_add_device(new_obj) return if getattr(new_obj, "is_adopted_by_us", True) and hasattr(new_obj, "mac"): if TYPE_CHECKING: assert isinstance(new_obj, (ProtectAdoptableDeviceModel, NVR)) self._async_update_device(new_obj, message.changed_data) @callback def _async_process_updates(self) -> None: """Process an update from the private protect connection. Public-API entities (relay/siren/alarm) are driven by the public websocket via ``_async_process_public_updates``, not from here. """ if self.api.is_public_only: return self._async_signal_device_update(self.api.bootstrap.nvr) for device in self.get_by_types(DEVICES_THAT_ADOPT): self._async_signal_device_update(device) @callback def _async_poll(self, now: datetime) -> None: """Poll the Protect API.""" self._entry.async_create_background_task( self._hass, self.async_refresh(), name=f"{DOMAIN} {self._entry.title} refresh", eager_start=True, ) @callback def async_subscribe( self, mac: str, update_callback: Callable[[ProtectDeviceType], None] ) -> CALLBACK_TYPE: """Add an callback subscriber.""" self._subscriptions[mac].add(update_callback) return partial(self._async_unsubscribe, mac, update_callback) @callback def _async_unsubscribe( self, mac: str, update_callback: Callable[[ProtectDeviceType], None] ) -> None: """Remove a callback subscriber.""" self._subscriptions[mac].remove(update_callback) if not self._subscriptions[mac]: del self._subscriptions[mac] @callback def async_subscribe_public_event( self, device_id: str, event_type: EventType, update_callback: Callable[[ProtectEvent], None], ) -> CALLBACK_TYPE: """Add a callback subscriber for public events of a type by device id.""" key = (device_id, event_type) self._public_event_subscriptions[key].add(update_callback) return partial(self._async_unsubscribe_public_event, key, update_callback) @callback def _async_unsubscribe_public_event( self, key: tuple[str, EventType], update_callback: Callable[[ProtectEvent], None], ) -> None: """Remove a public event callback subscriber.""" self._public_event_subscriptions[key].remove(update_callback) if not self._public_event_subscriptions[key]: del self._public_event_subscriptions[key] @callback def async_subscribe_public( self, mac: str, update_callback: Callable[[PublicDeviceModel | None], None] ) -> CALLBACK_TYPE: """Add a callback subscriber for public devices WS updates by mac.""" self._public_subscriptions[mac].add(update_callback) return partial(self._async_unsubscribe_public, mac, update_callback) @callback def _async_unsubscribe_public( self, mac: str, update_callback: Callable[[PublicDeviceModel | None], None] ) -> None: """Remove a public callback subscriber.""" self._public_subscriptions[mac].remove(update_callback) if not self._public_subscriptions[mac]: del self._public_subscriptions[mac] @callback def async_get_public_device( self, device: ProtectDeviceType | PublicDeviceModel ) -> PublicDeviceModel | None: """Return the public-API object matching a device, if available.""" api = self.api if not api.has_public_bootstrap or device.model is None: return None # Migrated entities target dedicated public device models, which all # inherit from PublicDeviceModel (carrying mac/state). return cast( "PublicDeviceModel | None", api.public_bootstrap.get(device.model, device.id), ) @callback def _async_signal_device_update(self, device: ProtectDeviceType) -> None: """Call the callbacks for a device_id.""" mac = device.mac if not (subscriptions := self._subscriptions.get(mac)): return _LOGGER.debug("Updating device: %s (%s)", device.name, mac) for update_callback in subscriptions: update_callback(device) @callback def _async_signal_public_update( self, mac: str, obj: PublicDeviceModel | None ) -> None: """Call the public-device callbacks for a mac. ``None`` means the object is gone from (or must be re-read from) the public bootstrap. """ if not (subscriptions := self._public_subscriptions.get(mac)): return if obj is not None: _LOGGER.debug("Updating public device: %s (%s)", obj.id, mac) else: _LOGGER.debug("Re-reading public device from bootstrap: %s", mac) for update_callback in subscriptions: update_callback(obj) @callback def async_ufp_instance_for_config_entry_ids( hass: HomeAssistant, config_entry_ids: set[str] ) -> ProtectApiClient | None: """Find the UFP instance for the config entry ids.""" return next( iter( entry.runtime_data.api for entry_id in config_entry_ids if (entry := hass.config_entries.async_get_entry(entry_id)) and entry.domain == DOMAIN and hasattr(entry, "runtime_data") ), None, ) @callback def async_get_ufp_entries(hass: HomeAssistant) -> list[UFPConfigEntry]: """Get all the UFP entries.""" return cast( list[UFPConfigEntry], [ entry for entry in hass.config_entries.async_entries( DOMAIN, include_ignore=True, include_disabled=True ) if hasattr(entry, "runtime_data") ], ) @callback def async_get_data_for_nvr_id(hass: HomeAssistant, nvr_id: str) -> ProtectData | None: """Find the ProtectData instance for the NVR id. Public-only entries are skipped: they have no private bootstrap to read the id from, and the event-media consumers of this lookup are private-API-backed anyway. """ return next( iter( entry.runtime_data for entry in async_get_ufp_entries(hass) if not entry.runtime_data.api.is_public_only and entry.runtime_data.api.bootstrap.nvr.id == nvr_id ), None, ) @callback def async_get_data_for_entry_id( hass: HomeAssistant, entry_id: str ) -> ProtectData | None: """Find the ProtectData instance for a config entry id.""" if (entry := hass.config_entries.async_get_entry(entry_id)) and hasattr( entry, "runtime_data" ): entry = cast(UFPConfigEntry, entry) return entry.runtime_data return None