diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 557e68272c2..476423631c3 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -119,3 +119,24 @@ def _async_remove_old_device_identifiers( continue if config_entry_id in dev.config_entries: device_registry.async_remove_device(dev.id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove bond config entry from a device.""" + hub: BondHub = hass.data[DOMAIN][config_entry.entry_id][HUB] + for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN or len(identifier) != 3: + continue + bond_id: str = identifier[1] + # Bond still uses the 3 arg tuple before + # the identifiers were typed + device_id: str = identifier[2] # type: ignore[misc] + # If device_id is no longer present on + # the hub, we allow removal. + if hub.bond_id != bond_id or not any( + device_id == device.device_id for device in hub.devices + ): + return True + return False diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 4b45a4016c0..c5a649ab30a 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -19,6 +19,20 @@ from homeassistant.util import utcnow from tests.common import MockConfigEntry, async_fire_time_changed +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + def ceiling_fan_with_breeze(name: str): """Create a ceiling fan with given name with breeze support.""" return { @@ -246,3 +260,12 @@ async def help_test_entity_available( async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + +def ceiling_fan(name: str): + """Create a ceiling fan with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": ["SetSpeed", "SetDirection"], + } diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 7c860e68efc..305c131125f 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -33,6 +33,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( + ceiling_fan, help_test_entity_available, patch_bond_action, patch_bond_action_returns_clientresponseerror, @@ -43,15 +44,6 @@ from .common import ( from tests.common import async_fire_time_changed -def ceiling_fan(name: str): - """Create a ceiling fan with given name.""" - return { - "name": name, - "type": DeviceType.CEILING_FAN, - "actions": ["SetSpeed", "SetDirection"], - } - - def ceiling_fan_with_breeze(name: str): """Create a ceiling fan with given name with breeze support.""" return { diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 03eb490b65e..5db5d8e65bf 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -7,13 +7,16 @@ from bond_async import DeviceType import pytest from homeassistant.components.bond.const import DOMAIN +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST 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.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .common import ( + ceiling_fan, patch_bond_bridge, patch_bond_device, patch_bond_device_ids, @@ -22,7 +25,9 @@ from .common import ( patch_bond_version, patch_setup_entry, patch_start_bpup, + remove_device, setup_bond_entity, + setup_platform, ) from tests.common import MockConfigEntry @@ -279,3 +284,62 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant): device = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) assert device is not None assert device.suggested_area == "Office" + + +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + config_entry = await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities["fan.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, config_entry.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test-hub-id", "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "wrong-hub-id", "test-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) + + hub_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test-hub-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), hub_device_entry.id, config_entry.entry_id + ) + is False + )