Revert "Add ability to ignore devices for UniFi Protect" (#77916)

This commit is contained in:
Franck Nijhof 2022-09-06 20:13:01 +02:00 committed by GitHub
parent 5632e33426
commit dbb556a812
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 75 additions and 258 deletions

View file

@ -26,7 +26,6 @@ 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,
@ -36,11 +35,11 @@ from .const import (
OUTDATED_LOG_MESSAGE,
PLATFORMS,
)
from .data import ProtectData
from .data import ProtectData, async_ufp_instance_for_config_entry_ids
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, convert_mac_list
from .utils import _async_unifi_mac_from_hass, async_get_devices
from .views import ThumbnailProxyView, VideoProxyView
_LOGGER = logging.getLogger(__name__)
@ -107,19 +106,6 @@ 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)
@ -139,15 +125,15 @@ async def async_remove_config_entry_device(
) -> bool:
"""Remove ufp config entry from a device."""
unifi_macs = {
async_unifi_mac(connection[1])
_async_unifi_mac_from_hass(connection[1])
for connection in device_entry.connections
if connection[0] == dr.CONNECTION_NETWORK_MAC
}
data: ProtectData = hass.data[DOMAIN][config_entry.entry_id]
if data.api.bootstrap.nvr.mac in unifi_macs:
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:
return False
for device in data.get_by_types(DEVICES_THAT_ADOPT):
for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT):
if device.is_adopted_by_us and device.mac in unifi_macs:
data.async_ignore_mac(device.mac)
break
return False
return True

View file

@ -35,7 +35,6 @@ 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,
@ -47,7 +46,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, convert_mac_list
from .utils import _async_resolve, _async_short_mac, _async_unifi_mac_from_hass
_LOGGER = logging.getLogger(__name__)
@ -121,7 +120,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle integration discovery."""
self._discovered_device = discovery_info
mac = async_unifi_mac(discovery_info["hw_addr"])
mac = _async_unifi_mac_from_hass(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"]
@ -183,7 +182,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
@ -225,7 +224,6 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
CONF_ALL_UPDATES: False,
CONF_OVERRIDE_CHOST: False,
CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA,
CONF_IGNORED: "",
},
)
@ -367,53 +365,33 @@ 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:
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_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=schema,
errors=errors,
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)),
}
),
)

View file

@ -20,7 +20,6 @@ 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,

View file

@ -28,7 +28,6 @@ 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,
@ -37,11 +36,7 @@ from .const import (
DISPATCH_CHANNELS,
DOMAIN,
)
from .utils import (
async_dispatch_id as _ufpd,
async_get_devices_by_type,
convert_mac_list,
)
from .utils import async_dispatch_id as _ufpd, async_get_devices_by_type
_LOGGER = logging.getLogger(__name__)
ProtectDeviceType = Union[ProtectAdoptableDeviceModel, NVR]
@ -72,7 +67,6 @@ 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]]] = {}
@ -80,8 +74,6 @@ 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
@ -96,47 +88,6 @@ 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]:
@ -148,8 +99,6 @@ 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:
@ -159,11 +108,6 @@ 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:
@ -228,7 +172,6 @@ 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)}
@ -353,13 +296,13 @@ class ProtectData:
@callback
def async_ufp_data_for_config_entry_ids(
def async_ufp_instance_for_config_entry_ids(
hass: HomeAssistant, config_entry_ids: set[str]
) -> ProtectData | None:
) -> ProtectApiClient | 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
return protect_data.api
return None

View file

@ -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_data_for_config_entry_ids
from .data import async_ufp_instance_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_data := async_ufp_data_for_config_entry_ids(hass, config_entry_ids):
return ufp_data.api
if ufp_instance := async_ufp_instance_for_config_entry_ids(hass, config_entry_ids):
return ufp_instance
raise HomeAssistantError(f"No device found for device id: {device_id}")

View file

@ -50,13 +50,9 @@
"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)",
"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)"
}
}
},
"error": {
"invalid_mac_list": "Must be a list of MAC addresses seperated by commas"
}
}
}

View file

@ -42,15 +42,11 @@
}
},
"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"
},

View file

@ -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,16 +14,12 @@ 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."""
@ -42,16 +38,15 @@ def get_nested_attr(obj: Any, attr: str) -> Any:
@callback
def async_unifi_mac(mac: str) -> str:
"""Convert MAC address to format from UniFi Protect."""
def _async_unifi_mac_from_hass(mac: str) -> str:
# MAC addresses in UFP are always caps
return mac.replace(":", "").replace("-", "").replace("_", "").upper()
return mac.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(mac)[-6:]
return _async_unifi_mac_from_hass(mac)[-6:]
async def _async_resolve(hass: HomeAssistant, host: str) -> str | None:
@ -82,6 +77,18 @@ 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."""
@ -99,22 +106,3 @@ 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

View file

@ -68,7 +68,6 @@ def mock_ufp_config_entry():
"port": 443,
"verify_ssl": False,
},
options={"ignored_devices": "FFFFFFFFFFFF,test"},
version=2,
)

View file

@ -14,7 +14,6 @@ from homeassistant.components import dhcp, ssdp
from homeassistant.components.unifiprotect.const import (
CONF_ALL_UPDATES,
CONF_DISABLE_RTSP,
CONF_IGNORED,
CONF_OVERRIDE_CHOST,
DOMAIN,
)
@ -270,52 +269,10 @@ 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",
[

View file

@ -7,21 +7,20 @@ from unittest.mock import AsyncMock, patch
import aiohttp
from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient
from pyunifiprotect.data import NVR, Bootstrap, Doorlock, Light, Sensor
from pyunifiprotect.data import NVR, Bootstrap, Light
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
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from . import _patch_discovery
from .utils import MockUFPFixture, get_device_from_ufp_device, init_entry, time_changed
from .utils import MockUFPFixture, init_entry, time_changed
from tests.common import MockConfigEntry
@ -212,38 +211,28 @@ 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."""
sensor.mac = "FFFFFFFFFFFF"
await init_entry(hass, ufp, [light, doorlock, sensor], regenerate_ids=False)
await init_entry(hass, ufp, [light])
assert await async_setup_component(hass, "config", {})
entity_id = "light.test_light"
entry_id = ufp.entry.entry_id
registry: er.EntityRegistry = er.async_get(hass)
entity = registry.async_get(entity_id)
assert entity is not None
device_registry = dr.async_get(hass)
light_device = get_device_from_ufp_device(hass, light)
assert light_device is not None
live_device_entry = device_registry.async_get(entity.device_id)
assert (
await remove_device(await hass_ws_client(hass), light_device.id, entry_id)
is True
await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id)
is False
)
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")},
@ -253,10 +242,6 @@ 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,

View file

@ -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 device_registry as dr, entity_registry as er
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import EntityDescription
import homeassistant.util.dt as dt_util
@ -229,13 +229,3 @@ 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)}
)