diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 30b1d1ad56d..60829223e2f 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -26,6 +26,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( CONF_ALL_UPDATES, + CONF_IGNORED, CONF_OVERRIDE_CHOST, DEFAULT_SCAN_INTERVAL, DEVICES_FOR_SUBSCRIBE, @@ -35,11 +36,11 @@ from .const import ( OUTDATED_LOG_MESSAGE, PLATFORMS, ) -from .data import ProtectData, async_ufp_instance_for_config_entry_ids +from .data import ProtectData from .discovery import async_start_discovery from .migrate import async_migrate_data from .services import async_cleanup_services, async_setup_services -from .utils import _async_unifi_mac_from_hass, async_get_devices +from .utils import async_unifi_mac, convert_mac_list from .views import ThumbnailProxyView, VideoProxyView _LOGGER = logging.getLogger(__name__) @@ -106,6 +107,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options.""" + + data: ProtectData = hass.data[DOMAIN][entry.entry_id] + changed = data.async_get_changed_options(entry) + + if len(changed) == 1 and CONF_IGNORED in changed: + new_macs = convert_mac_list(entry.options.get(CONF_IGNORED, "")) + added_macs = new_macs - data.ignored_macs + removed_macs = data.ignored_macs - new_macs + # if only ignored macs are added, we can handle without reloading + if not removed_macs and added_macs: + data.async_add_new_ignored_macs(added_macs) + return + await hass.config_entries.async_reload(entry.entry_id) @@ -125,15 +139,15 @@ async def async_remove_config_entry_device( ) -> bool: """Remove ufp config entry from a device.""" unifi_macs = { - _async_unifi_mac_from_hass(connection[1]) + async_unifi_mac(connection[1]) for connection in device_entry.connections if connection[0] == dr.CONNECTION_NETWORK_MAC } - api = async_ufp_instance_for_config_entry_ids(hass, {config_entry.entry_id}) - assert api is not None - if api.bootstrap.nvr.mac in unifi_macs: + data: ProtectData = hass.data[DOMAIN][config_entry.entry_id] + if data.api.bootstrap.nvr.mac in unifi_macs: return False - for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT): + for device in data.get_by_types(DEVICES_THAT_ADOPT): if device.is_adopted_by_us and device.mac in unifi_macs: - return False + data.async_ignore_mac(device.mac) + break return True diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index f07ca923a53..1907a201c8d 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -35,6 +35,7 @@ from homeassistant.util.network import is_ip_address from .const import ( CONF_ALL_UPDATES, CONF_DISABLE_RTSP, + CONF_IGNORED, CONF_MAX_MEDIA, CONF_OVERRIDE_CHOST, DEFAULT_MAX_MEDIA, @@ -46,7 +47,7 @@ from .const import ( ) from .data import async_last_update_was_successful from .discovery import async_start_discovery -from .utils import _async_resolve, _async_short_mac, _async_unifi_mac_from_hass +from .utils import _async_resolve, async_short_mac, async_unifi_mac, convert_mac_list _LOGGER = logging.getLogger(__name__) @@ -120,7 +121,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle integration discovery.""" self._discovered_device = discovery_info - mac = _async_unifi_mac_from_hass(discovery_info["hw_addr"]) + mac = async_unifi_mac(discovery_info["hw_addr"]) await self.async_set_unique_id(mac) source_ip = discovery_info["source_ip"] direct_connect_domain = discovery_info["direct_connect_domain"] @@ -182,7 +183,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): placeholders = { "name": discovery_info["hostname"] or discovery_info["platform"] - or f"NVR {_async_short_mac(discovery_info['hw_addr'])}", + or f"NVR {async_short_mac(discovery_info['hw_addr'])}", "ip_address": discovery_info["source_ip"], } self.context["title_placeholders"] = placeholders @@ -224,6 +225,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_ALL_UPDATES: False, CONF_OVERRIDE_CHOST: False, CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA, + CONF_IGNORED: "", }, ) @@ -365,33 +367,53 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" + + values = user_input or self.config_entry.options + schema = vol.Schema( + { + vol.Optional( + CONF_DISABLE_RTSP, + description={ + "suggested_value": values.get(CONF_DISABLE_RTSP, False) + }, + ): bool, + vol.Optional( + CONF_ALL_UPDATES, + description={ + "suggested_value": values.get(CONF_ALL_UPDATES, False) + }, + ): bool, + vol.Optional( + CONF_OVERRIDE_CHOST, + description={ + "suggested_value": values.get(CONF_OVERRIDE_CHOST, False) + }, + ): bool, + vol.Optional( + CONF_MAX_MEDIA, + description={ + "suggested_value": values.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) + }, + ): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)), + vol.Optional( + CONF_IGNORED, + description={"suggested_value": values.get(CONF_IGNORED, "")}, + ): str, + } + ) + errors: dict[str, str] = {} + if user_input is not None: - return self.async_create_entry(title="", data=user_input) + try: + convert_mac_list(user_input.get(CONF_IGNORED, ""), raise_exception=True) + except vol.Invalid: + errors[CONF_IGNORED] = "invalid_mac_list" + + if not errors: + return self.async_create_entry(title="", data=user_input) return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_DISABLE_RTSP, - default=self.config_entry.options.get(CONF_DISABLE_RTSP, False), - ): bool, - vol.Optional( - CONF_ALL_UPDATES, - default=self.config_entry.options.get(CONF_ALL_UPDATES, False), - ): bool, - vol.Optional( - CONF_OVERRIDE_CHOST, - default=self.config_entry.options.get( - CONF_OVERRIDE_CHOST, False - ), - ): bool, - vol.Optional( - CONF_MAX_MEDIA, - default=self.config_entry.options.get( - CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA - ), - ): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)), - } - ), + data_schema=schema, + errors=errors, ) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 93a0fa5ff74..080dc41f358 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -20,6 +20,7 @@ CONF_DISABLE_RTSP = "disable_rtsp" CONF_ALL_UPDATES = "all_updates" CONF_OVERRIDE_CHOST = "override_connection_host" CONF_MAX_MEDIA = "max_media" +CONF_IGNORED = "ignored_devices" CONFIG_OPTIONS = [ CONF_ALL_UPDATES, diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 20b5747a342..c17b99d639f 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -28,6 +28,7 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( CONF_DISABLE_RTSP, + CONF_IGNORED, CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA, DEVICES_THAT_ADOPT, @@ -36,7 +37,11 @@ from .const import ( DISPATCH_CHANNELS, DOMAIN, ) -from .utils import async_dispatch_id as _ufpd, async_get_devices_by_type +from .utils import ( + async_dispatch_id as _ufpd, + async_get_devices_by_type, + convert_mac_list, +) _LOGGER = logging.getLogger(__name__) ProtectDeviceType = Union[ProtectAdoptableDeviceModel, NVR] @@ -67,6 +72,7 @@ class ProtectData: self._hass = hass self._entry = entry + self._existing_options = dict(entry.options) self._hass = hass self._update_interval = update_interval self._subscriptions: dict[str, list[Callable[[ProtectDeviceType], None]]] = {} @@ -74,6 +80,8 @@ class ProtectData: self._unsub_interval: CALLBACK_TYPE | None = None self._unsub_websocket: CALLBACK_TYPE | None = None self._auth_failures = 0 + self._ignored_macs: set[str] | None = None + self._ignore_update_cancel: Callable[[], None] | None = None self.last_update_success = False self.api = protect @@ -88,6 +96,47 @@ class ProtectData: """Max number of events to load at once.""" return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) + @property + def ignored_macs(self) -> set[str]: + """Set of ignored MAC addresses.""" + + if self._ignored_macs is None: + self._ignored_macs = convert_mac_list( + self._entry.options.get(CONF_IGNORED, "") + ) + + return self._ignored_macs + + @callback + def async_get_changed_options(self, entry: ConfigEntry) -> dict[str, Any]: + """Get changed options for when entry is updated.""" + + return dict( + set(self._entry.options.items()) - set(self._existing_options.items()) + ) + + @callback + def async_ignore_mac(self, mac: str) -> None: + """Ignores a MAC address for a UniFi Protect device.""" + + new_macs = (self._ignored_macs or set()).copy() + new_macs.add(mac) + _LOGGER.debug("Updating ignored_devices option: %s", self.ignored_macs) + options = dict(self._entry.options) + options[CONF_IGNORED] = ",".join(new_macs) + self._hass.config_entries.async_update_entry(self._entry, options=options) + + @callback + def async_add_new_ignored_macs(self, new_macs: set[str]) -> None: + """Add new ignored MAC addresses and ensures the devices are removed.""" + + for mac in new_macs: + device = self.api.bootstrap.get_device_from_mac(mac) + if device is not None: + self._async_remove_device(device) + self._ignored_macs = None + self._existing_options = dict(self._entry.options) + def get_by_types( self, device_types: Iterable[ModelType], ignore_unadopted: bool = True ) -> Generator[ProtectAdoptableDeviceModel, None, None]: @@ -99,6 +148,8 @@ class ProtectData: for device in devices: if ignore_unadopted and not device.is_adopted_by_us: continue + if device.mac in self.ignored_macs: + continue yield device async def async_setup(self) -> None: @@ -108,6 +159,11 @@ class ProtectData: ) await self.async_refresh() + for mac in self.ignored_macs: + device = self.api.bootstrap.get_device_from_mac(mac) + if device is not None: + self._async_remove_device(device) + async def async_stop(self, *args: Any) -> None: """Stop processing data.""" if self._unsub_websocket: @@ -172,6 +228,7 @@ class ProtectData: @callback def _async_remove_device(self, device: ProtectAdoptableDeviceModel) -> None: + registry = dr.async_get(self._hass) device_entry = registry.async_get_device( identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, device.mac)} @@ -296,13 +353,13 @@ class ProtectData: @callback -def async_ufp_instance_for_config_entry_ids( +def async_ufp_data_for_config_entry_ids( hass: HomeAssistant, config_entry_ids: set[str] -) -> ProtectApiClient | None: +) -> ProtectData | None: """Find the UFP instance for the config entry ids.""" domain_data = hass.data[DOMAIN] for config_entry_id in config_entry_ids: if config_entry_id in domain_data: protect_data: ProtectData = domain_data[config_entry_id] - return protect_data.api + return protect_data return None diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 915c51b6c0a..914803e9c45 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -25,7 +25,7 @@ from homeassistant.helpers.service import async_extract_referenced_entity_ids from homeassistant.util.read_only_dict import ReadOnlyDict from .const import ATTR_MESSAGE, DOMAIN -from .data import async_ufp_instance_for_config_entry_ids +from .data import async_ufp_data_for_config_entry_ids SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text" SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text" @@ -70,8 +70,8 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl return _async_get_ufp_instance(hass, device_entry.via_device_id) config_entry_ids = device_entry.config_entries - if ufp_instance := async_ufp_instance_for_config_entry_ids(hass, config_entry_ids): - return ufp_instance + if ufp_data := async_ufp_data_for_config_entry_ids(hass, config_entry_ids): + return ufp_data.api raise HomeAssistantError(f"No device found for device id: {device_id}") diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index d3cfe24abd2..d9750d31ae1 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -50,9 +50,13 @@ "disable_rtsp": "Disable the RTSP stream", "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", "override_connection_host": "Override Connection Host", - "max_media": "Max number of event to load for Media Browser (increases RAM usage)" + "max_media": "Max number of event to load for Media Browser (increases RAM usage)", + "ignored_devices": "Comma separated list of MAC addresses of devices to ignore" } } + }, + "error": { + "invalid_mac_list": "Must be a list of MAC addresses seperated by commas" } } } diff --git a/homeassistant/components/unifiprotect/translations/en.json b/homeassistant/components/unifiprotect/translations/en.json index 5d690e3fd3e..c6050d05284 100644 --- a/homeassistant/components/unifiprotect/translations/en.json +++ b/homeassistant/components/unifiprotect/translations/en.json @@ -42,11 +42,15 @@ } }, "options": { + "error": { + "invalid_mac_list": "Must be a list of MAC addresses seperated by commas" + }, "step": { "init": { "data": { "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", "disable_rtsp": "Disable the RTSP stream", + "ignored_devices": "Comma separated list of MAC addresses of devices to ignore", "max_media": "Max number of event to load for Media Browser (increases RAM usage)", "override_connection_host": "Override Connection Host" }, diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 808117aac9e..8c368da1c40 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -1,9 +1,9 @@ """UniFi Protect Integration utils.""" from __future__ import annotations -from collections.abc import Generator, Iterable import contextlib from enum import Enum +import re import socket from typing import Any @@ -14,12 +14,16 @@ from pyunifiprotect.data import ( LightModeType, ProtectAdoptableDeviceModel, ) +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from .const import DOMAIN, ModelType +MAC_RE = re.compile(r"[0-9A-F]{12}") + def get_nested_attr(obj: Any, attr: str) -> Any: """Fetch a nested attribute.""" @@ -38,15 +42,16 @@ def get_nested_attr(obj: Any, attr: str) -> Any: @callback -def _async_unifi_mac_from_hass(mac: str) -> str: +def async_unifi_mac(mac: str) -> str: + """Convert MAC address to format from UniFi Protect.""" # MAC addresses in UFP are always caps - return mac.replace(":", "").upper() + return mac.replace(":", "").replace("-", "").replace("_", "").upper() @callback -def _async_short_mac(mac: str) -> str: +def async_short_mac(mac: str) -> str: """Get the short mac address from the full mac.""" - return _async_unifi_mac_from_hass(mac)[-6:] + return async_unifi_mac(mac)[-6:] async def _async_resolve(hass: HomeAssistant, host: str) -> str | None: @@ -77,18 +82,6 @@ def async_get_devices_by_type( return devices -@callback -def async_get_devices( - bootstrap: Bootstrap, model_type: Iterable[ModelType] -) -> Generator[ProtectAdoptableDeviceModel, None, None]: - """Return all device by type.""" - return ( - device - for device_type in model_type - for device in async_get_devices_by_type(bootstrap, device_type).values() - ) - - @callback def async_get_light_motion_current(obj: Light) -> str: """Get light motion mode for Flood Light.""" @@ -106,3 +99,22 @@ def async_dispatch_id(entry: ConfigEntry, dispatch: str) -> str: """Generate entry specific dispatch ID.""" return f"{DOMAIN}.{entry.entry_id}.{dispatch}" + + +@callback +def convert_mac_list(option: str, raise_exception: bool = False) -> set[str]: + """Convert csv list of MAC addresses.""" + + macs = set() + values = cv.ensure_list_csv(option) + for value in values: + if value == "": + continue + value = async_unifi_mac(value) + if not MAC_RE.match(value): + if raise_exception: + raise vol.Invalid("invalid_mac_list") + continue + macs.add(value) + + return macs diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index b006dfbd004..fa245e8b1cc 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -68,6 +68,7 @@ def mock_ufp_config_entry(): "port": 443, "verify_ssl": False, }, + options={"ignored_devices": "FFFFFFFFFFFF,test"}, version=2, ) diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index d0fb0dba9f2..26a9dd73ee8 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components import dhcp, ssdp from homeassistant.components.unifiprotect.const import ( CONF_ALL_UPDATES, CONF_DISABLE_RTSP, + CONF_IGNORED, CONF_OVERRIDE_CHOST, DOMAIN, ) @@ -269,10 +270,52 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - "all_updates": True, "disable_rtsp": True, "override_connection_host": True, - "max_media": 1000, } +async def test_form_options_invalid_mac( + hass: HomeAssistant, ufp_client: ProtectApiClient +) -> None: + """Test we handle options flows.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + "max_media": 1000, + }, + version=2, + unique_id=dr.format_mac(MAC_ADDR), + ) + mock_config.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.ProtectApiClient" + ) as mock_api: + mock_api.return_value = ufp_client + + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + assert mock_config.state == config_entries.ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(mock_config.entry_id) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_IGNORED: "test,test2"}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {CONF_IGNORED: "invalid_mac_list"} + + @pytest.mark.parametrize( "source, data", [ diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 9392caa30ac..7a1e590b87d 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -7,20 +7,21 @@ from unittest.mock import AsyncMock, patch import aiohttp from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient -from pyunifiprotect.data import NVR, Bootstrap, Light +from pyunifiprotect.data import NVR, Bootstrap, Doorlock, Light, Sensor from homeassistant.components.unifiprotect.const import ( CONF_DISABLE_RTSP, + CONF_IGNORED, DEFAULT_SCAN_INTERVAL, DOMAIN, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import _patch_discovery -from .utils import MockUFPFixture, init_entry, time_changed +from .utils import MockUFPFixture, get_device_from_ufp_device, init_entry, time_changed from tests.common import MockConfigEntry @@ -211,28 +212,38 @@ async def test_device_remove_devices( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, + doorlock: Doorlock, + sensor: Sensor, hass_ws_client: Callable[ [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] ], ) -> None: """Test we can only remove a device that no longer exists.""" - await init_entry(hass, ufp, [light]) - assert await async_setup_component(hass, "config", {}) - entity_id = "light.test_light" - entry_id = ufp.entry.entry_id + sensor.mac = "FFFFFFFFFFFF" - registry: er.EntityRegistry = er.async_get(hass) - entity = registry.async_get(entity_id) - assert entity is not None + await init_entry(hass, ufp, [light, doorlock, sensor], regenerate_ids=False) + assert await async_setup_component(hass, "config", {}) + + entry_id = ufp.entry.entry_id device_registry = dr.async_get(hass) - live_device_entry = device_registry.async_get(entity.device_id) + light_device = get_device_from_ufp_device(hass, light) + assert light_device is not None assert ( - await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) - is False + await remove_device(await hass_ws_client(hass), light_device.id, entry_id) + is True ) + doorlock_device = get_device_from_ufp_device(hass, doorlock) + assert ( + await remove_device(await hass_ws_client(hass), doorlock_device.id, entry_id) + is True + ) + + sensor_device = get_device_from_ufp_device(hass, sensor) + assert sensor_device is None + dead_device_entry = device_registry.async_get_or_create( config_entry_id=entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "e9:88:e7:b8:b4:40")}, @@ -242,6 +253,10 @@ async def test_device_remove_devices( is True ) + await time_changed(hass, 60) + entry = hass.config_entries.async_get_entry(entry_id) + entry.options[CONF_IGNORED] == f"{light.mac},{doorlock.mac}" + async def test_device_remove_devices_nvr( hass: HomeAssistant, diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index bee479b8e2b..3376db4ec51 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -23,7 +23,7 @@ from pyunifiprotect.test_util.anonymize import random_hex from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityDescription import homeassistant.util.dt as dt_util @@ -229,3 +229,13 @@ async def adopt_devices( ufp.ws_msg(mock_msg) await hass.async_block_till_done() + + +def get_device_from_ufp_device( + hass: HomeAssistant, device: ProtectAdoptableDeviceModel +) -> dr.DeviceEntry | None: + """Return all device by type.""" + registry = dr.async_get(hass) + return registry.async_get_device( + identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, device.mac)} + )