From 9d56d3bb3113fa9ea0f58aa8aaa5a3b73084cbcc Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 30 Sep 2020 20:11:41 +0200 Subject: [PATCH] Add deCONZ service to clean up orphaned device and entity entries (#40629) * Add service that can clean up by deCONZ orphaned entries from device and entity registry * Fix existing tests * Add new test * Add some inline comment to the service --- homeassistant/components/deconz/gateway.py | 4 +- homeassistant/components/deconz/services.py | 71 +++++++++++++- homeassistant/components/deconz/services.yaml | 7 ++ tests/components/deconz/test_services.py | 93 ++++++++++++++++++- 4 files changed, 169 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 30460725a8f..5ec07c40754 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -51,6 +51,7 @@ class DeconzGateway: self.ignore_state_updates = False self.deconz_ids = {} + self.device_id = None self.entities = {} self.events = [] self.listeners = [] @@ -139,7 +140,7 @@ class DeconzGateway: async def async_update_device_registry(self) -> None: """Update device registry.""" device_registry = await self.hass.helpers.device_registry.async_get_registry() - device_registry.async_get_or_create( + entry = device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, connections={(CONNECTION_NETWORK_MAC, self.api.config.mac)}, identifiers={(DOMAIN, self.api.config.bridgeid)}, @@ -148,6 +149,7 @@ class DeconzGateway: name=self.api.config.name, sw_version=self.api.config.swversion, ) + self.device_id = entry.id async def async_setup(self) -> bool: """Set up a deCONZ gateway.""" diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index aafa0880109..bf503376321 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -3,6 +3,10 @@ from pydeconz.utils import normalize_bridge_id import voluptuous as vol from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_entries_for_device, +) from .config_flow import get_master_gateway from .const import ( @@ -35,7 +39,8 @@ SERVICE_CONFIGURE_DEVICE_SCHEMA = vol.All( ) SERVICE_DEVICE_REFRESH = "device_refresh" -SERVICE_DEVICE_REFRESH_SCHEMA = vol.All(vol.Schema({vol.Optional(CONF_BRIDGE_ID): str})) +SERVICE_REMOVE_ORPHANED_ENTRIES = "remove_orphaned_entries" +SELECT_GATEWAY_SCHEMA = vol.All(vol.Schema({vol.Optional(CONF_BRIDGE_ID): str})) async def async_setup_services(hass): @@ -56,6 +61,9 @@ async def async_setup_services(hass): elif service == SERVICE_DEVICE_REFRESH: await async_refresh_devices_service(hass, service_data) + elif service == SERVICE_REMOVE_ORPHANED_ENTRIES: + await async_remove_orphaned_entries_service(hass, service_data) + hass.services.async_register( DOMAIN, SERVICE_CONFIGURE_DEVICE, @@ -67,7 +75,14 @@ async def async_setup_services(hass): DOMAIN, SERVICE_DEVICE_REFRESH, async_call_deconz_service, - schema=SERVICE_DEVICE_REFRESH_SCHEMA, + schema=SELECT_GATEWAY_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_ORPHANED_ENTRIES, + async_call_deconz_service, + schema=SELECT_GATEWAY_SCHEMA, ) @@ -80,6 +95,7 @@ async def async_unload_services(hass): hass.services.async_remove(DOMAIN, SERVICE_CONFIGURE_DEVICE) hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE_ORPHANED_ENTRIES) async def async_configure_service(hass, data): @@ -166,3 +182,54 @@ async def async_refresh_devices_service(hass, data): if sensor_id not in sensors ], ) + + +async def async_remove_orphaned_entries_service(hass, data): + """Remove orphaned deCONZ entries from device and entity registries.""" + gateway = get_master_gateway(hass) + if CONF_BRIDGE_ID in data: + gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + device_registry = await hass.helpers.device_registry.async_get_registry() + + entity_entries = async_entries_for_config_entry( + entity_registry, gateway.config_entry.entry_id + ) + + entities_to_be_removed = [] + devices_to_be_removed = [ + entry.id + for entry in device_registry.devices.values() + if gateway.config_entry.entry_id in entry.config_entries + ] + + # Don't remove the Gateway device + if gateway.device_id in devices_to_be_removed: + devices_to_be_removed.remove(gateway.device_id) + + # Don't remove devices belonging to available events + for event in gateway.events: + if event.device_id in devices_to_be_removed: + devices_to_be_removed.remove(event.device_id) + + for entry in entity_entries: + + # Don't remove available entities + if entry.unique_id in gateway.entities[entry.domain]: + + # Don't remove devices with available entities + if entry.device_id in devices_to_be_removed: + devices_to_be_removed.remove(entry.device_id) + continue + # Remove entities that are not available + entities_to_be_removed.append(entry.entity_id) + + # Remove unavailable entities + for entity_id in entities_to_be_removed: + entity_registry.async_remove(entity_id) + + # Remove devices that don't belong to any entity + for device_id in devices_to_be_removed: + if len(async_entries_for_device(entity_registry, device_id)) == 0: + device_registry.async_remove_device(device_id) diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index d8bf3e4d994..9d85e76d8d3 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -23,3 +23,10 @@ device_refresh: bridgeid: description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name. example: "00212EFFFF012345" + +remove_orphaned_entries: + description: Clean up device and entity registry entries orphaned by deCONZ. + fields: + bridgeid: + description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name. + example: "00212EFFFF012345" diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index e880ea1000b..cfbc8cae56c 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -1,12 +1,16 @@ """deCONZ service tests.""" +from copy import deepcopy + import pytest import voluptuous as vol from homeassistant.components import deconz from homeassistant.components.deconz.const import CONF_BRIDGE_ID +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.helpers.entity_registry import async_entries_for_config_entry -from .test_gateway import BRIDGEID, setup_deconz_integration +from .test_gateway import BRIDGEID, DECONZ_WEB_REQUEST, setup_deconz_integration from tests.async_mock import Mock, patch @@ -43,6 +47,17 @@ SENSOR = { } } +SWITCH = { + "1": { + "id": "Switch 1 id", + "name": "Switch 1", + "type": "ZHASwitch", + "state": {"buttonevent": 1000, "gesture": 1}, + "config": {"battery": 100}, + "uniqueid": "00:00:00:00:00:00:00:03-00", + }, +} + async def test_service_setup(hass): """Verify service setup works.""" @@ -52,7 +67,7 @@ async def test_service_setup(hass): ) as async_register: await deconz.services.async_setup_services(hass) assert hass.data[deconz.services.DECONZ_SERVICES] is True - assert async_register.call_count == 2 + assert async_register.call_count == 3 async def test_service_setup_already_registered(hass): @@ -73,7 +88,7 @@ async def test_service_unload(hass): ) as async_remove: await deconz.services.async_unload_services(hass) assert hass.data[deconz.services.DECONZ_SERVICES] is False - assert async_remove.call_count == 2 + assert async_remove.call_count == 3 async def test_service_unload_not_registered(hass): @@ -198,3 +213,75 @@ async def test_service_refresh_devices(hass): "scene.group_1_name_scene_1": "/groups/1/scenes/1", "sensor.sensor_1_name": "/sensors/1", } + + +async def test_remove_orphaned_entries_service(hass): + """Test service works and also don't remove more than expected.""" + data = deepcopy(DECONZ_WEB_REQUEST) + data["lights"] = deepcopy(LIGHT) + data["sensors"] = deepcopy(SWITCH) + gateway = await setup_deconz_integration(hass, get_state_response=data) + + data = {CONF_BRIDGE_ID: BRIDGEID} + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get_or_create( + config_entry_id=gateway.config_entry.entry_id, identifiers={("mac", "123")} + ) + + assert ( + len( + [ + entry + for entry in device_registry.devices.values() + if gateway.config_entry.entry_id in entry.config_entries + ] + ) + == 4 # Gateway, light, switch and orphan + ) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + deconz.DOMAIN, + "12345", + suggested_object_id="Orphaned sensor", + config_entry=gateway.config_entry, + device_id=device.id, + ) + + assert ( + len( + async_entries_for_config_entry( + entity_registry, gateway.config_entry.entry_id + ) + ) + == 3 # Light, switch battery and orphan + ) + + await hass.services.async_call( + deconz.DOMAIN, + deconz.services.SERVICE_REMOVE_ORPHANED_ENTRIES, + service_data=data, + ) + await hass.async_block_till_done() + + assert ( + len( + [ + entry + for entry in device_registry.devices.values() + if gateway.config_entry.entry_id in entry.config_entries + ] + ) + == 3 # Gateway, light and switch + ) + + assert ( + len( + async_entries_for_config_entry( + entity_registry, gateway.config_entry.entry_id + ) + ) + == 2 # Light and switch battery + )