diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 5d9f2037610..a1203b25478 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -23,6 +23,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_IP_ADDRESS, CONF_NAME, @@ -34,11 +35,12 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.exceptions import Unauthorized +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_extract_referenced_entity_ids from homeassistant.loader import IntegrationNotFound, async_get_integration from . import ( # noqa: F401 @@ -93,6 +95,7 @@ from .const import ( MANUFACTURER, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, + SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) from .util import ( @@ -170,6 +173,12 @@ RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema( ) +UNPAIR_SERVICE_SCHEMA = vol.All( + vol.Schema(cv.ENTITY_SERVICE_FIELDS), + cv.has_at_least_one_key(ATTR_DEVICE_ID), +) + + def _async_get_entries_by_name(current_entries): """Return a dict of the entries by name.""" @@ -356,7 +365,7 @@ def _async_register_events_and_services(hass: HomeAssistant): hass.http.register_view(HomeKitPairingQRView) async def async_handle_homekit_reset_accessory(service): - """Handle start HomeKit service call.""" + """Handle reset accessory HomeKit service call.""" for entry_id in hass.data[DOMAIN]: if HOMEKIT not in hass.data[DOMAIN][entry_id]: continue @@ -378,6 +387,44 @@ def _async_register_events_and_services(hass: HomeAssistant): schema=RESET_ACCESSORY_SERVICE_SCHEMA, ) + async def async_handle_homekit_unpair(service): + """Handle unpair HomeKit service call.""" + referenced = await async_extract_referenced_entity_ids(hass, service) + dev_reg = device_registry.async_get(hass) + for device_id in referenced.referenced_devices: + dev_reg_ent = dev_reg.async_get(device_id) + if not dev_reg_ent: + raise HomeAssistantError(f"No device found for device id: {device_id}") + macs = [ + cval + for ctype, cval in dev_reg_ent.connections + if ctype == device_registry.CONNECTION_NETWORK_MAC + ] + domain_data = hass.data[DOMAIN] + matching_instances = [ + domain_data[entry_id][HOMEKIT] + for entry_id in domain_data + if HOMEKIT in domain_data[entry_id] + and domain_data[entry_id][HOMEKIT].driver + and device_registry.format_mac( + domain_data[entry_id][HOMEKIT].driver.state.mac + ) + in macs + ] + if not matching_instances: + raise HomeAssistantError( + f"No homekit accessory found for device id: {device_id}" + ) + for homekit in matching_instances: + homekit.async_unpair() + + hass.services.async_register( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + async_handle_homekit_unpair, + schema=UNPAIR_SERVICE_SCHEMA, + ) + async def async_handle_homekit_service_start(service): """Handle start HomeKit service call.""" tasks = [] @@ -639,7 +686,11 @@ class HomeKit: if self.driver.state.paired: return + self._async_show_setup_message() + @callback + def _async_show_setup_message(self): + """Show the pairing setup message.""" show_setup_message( self.hass, self._entry_id, @@ -648,6 +699,16 @@ class HomeKit: self.driver.accessory.xhm_uri(), ) + @callback + def async_unpair(self): + """Remove all pairings for an accessory so it can be repaired.""" + state = self.driver.state + for client_uuid in list(state.paired_clients): + state.remove_paired_client(client_uuid) + self.driver.async_persist() + self.driver.async_update_advertisement() + self._async_show_setup_message() + @callback def _async_register_bridge(self): """Register the bridge as a device so homekit_controller and exclude it from discovery.""" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 37788f9dca7..4fecd64b2b2 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -99,6 +99,7 @@ HOMEKIT_MODES = [HOMEKIT_MODE_BRIDGE, HOMEKIT_MODE_ACCESSORY] # #### HomeKit Component Services #### SERVICE_HOMEKIT_START = "start" SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" +SERVICE_HOMEKIT_UNPAIR = "unpair" # #### String Constants #### BRIDGE_MODEL = "Bridge" diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index 315a612241f..68e7804697b 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -14,3 +14,9 @@ reset_accessory: target: entity: {} +unpair: + name: Unpair an accessory or bridge + description: Forcefully remove all pairings from an accessory to allow re-pairing. Use this service if the accessory is no longer responsive, and you want to avoid deleting and re-adding the entry. Room locations, and accessory preferences will be lost. + target: + device: + integration: homekit diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index ba34830f381..6539a7137d3 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -35,11 +35,13 @@ from homeassistant.components.homekit.const import ( HOMEKIT_MODE_BRIDGE, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, + SERVICE_HOMEKIT_UNPAIR, ) from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_IP_ADDRESS, @@ -52,7 +54,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_ON, ) -from homeassistant.core import State +from homeassistant.core import HomeAssistantError, State from homeassistant.helpers import device_registry from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, @@ -668,6 +670,126 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): homekit.status = STATUS_READY +async def test_homekit_unpair(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + formatted_mac = device_registry.format_mac(state.mac) + hk_bridge_dev = device_reg.async_get_device( + {}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)} + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: hk_bridge_dev.id}, + blocking=True, + ) + await hass.async_block_till_done() + assert state.paired_clients == {} + homekit.status = STATUS_STOPPED + + +async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories with invalid device id.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: "notvalid"}, + blocking=True, + ) + await hass.async_block_till_done() + state.paired_clients = {"client1": "any"} + homekit.status = STATUS_STOPPED + + +async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories with a non-homekit device id.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + not_homekit_entry = MockConfigEntry( + domain="not_homekit", data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + device_entry = device_reg.async_get_or_create( + config_entry_id=not_homekit_entry.entry_id, + sw_version="0.16.0", + model="Powerwall 2", + manufacturer="Tesla", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: device_entry.id}, + blocking=True, + ) + await hass.async_block_till_done() + state.paired_clients = {"client1": "any"} + homekit.status = STATUS_STOPPED + + async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf): """Test resetting HomeKit accessories with an unsupported entity.""" await async_setup_component(hass, "persistent_notification", {})