diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 5abc9adb9ca..5d9f2037610 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -120,6 +120,10 @@ PORT_CLEANUP_CHECK_INTERVAL_SECS = 1 MDNS_TARGET_IP = "224.0.0.251" +_HOMEKIT_CONFIG_UPDATE_TIME = ( + 5 # number of seconds to wait for homekit to see the c# change +) + def _has_all_unique_names_and_ports(bridges): """Validate that each homekit bridge configured has a unique name.""" @@ -351,7 +355,7 @@ def _async_register_events_and_services(hass: HomeAssistant): """Register events and services for HomeKit.""" hass.http.register_view(HomeKitPairingQRView) - def handle_homekit_reset_accessory(service): + async def async_handle_homekit_reset_accessory(service): """Handle start HomeKit service call.""" for entry_id in hass.data[DOMAIN]: if HOMEKIT not in hass.data[DOMAIN][entry_id]: @@ -365,12 +369,12 @@ def _async_register_events_and_services(hass: HomeAssistant): continue entity_ids = service.data.get("entity_id") - homekit.reset_accessories(entity_ids) + await homekit.async_reset_accessories(entity_ids) hass.services.async_register( DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY, - handle_homekit_reset_accessory, + async_handle_homekit_reset_accessory, schema=RESET_ACCESSORY_SERVICE_SCHEMA, ) @@ -486,36 +490,61 @@ class HomeKit: self.driver.persist() - def reset_accessories(self, entity_ids): + async def async_reset_accessories(self, entity_ids): """Reset the accessory to load the latest configuration.""" if not self.bridge: - self.driver.config_changed() + await self.async_reset_accessories_in_accessory_mode(entity_ids) return + await self.async_reset_accessories_in_bridge_mode(entity_ids) - removed = [] + async def async_reset_accessories_in_accessory_mode(self, entity_ids): + """Reset accessories in accessory mode.""" + acc = self.driver.accessory + if acc.entity_id not in entity_ids: + return + acc.async_stop() + if not (state := self.hass.states.get(acc.entity_id)): + _LOGGER.warning( + "The underlying entity %s disappeared during reset", acc.entity + ) + return + if new_acc := self._async_create_single_accessory([state]): + self.driver.accessory = new_acc + await self.async_config_changed() + + async def async_reset_accessories_in_bridge_mode(self, entity_ids): + """Reset accessories in bridge mode.""" + new = [] for entity_id in entity_ids: aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) if aid not in self.bridge.accessories: continue - _LOGGER.info( "HomeKit Bridge %s will reset accessory with linked entity_id %s", self._name, entity_id, ) - acc = self.remove_bridge_accessory(aid) - removed.append(acc) + if state := self.hass.states.get(acc.entity_id): + new.append(state) + else: + _LOGGER.warning( + "The underlying entity %s disappeared during reset", acc.entity + ) - if not removed: + if not new: # No matched accessories, probably on another bridge return - self.driver.config_changed() + await self.async_config_changed() + await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) + for state in new: + self.add_bridge_accessory(state) + await self.async_config_changed() - for acc in removed: - self.bridge.add_accessory(acc) - self.driver.config_changed() + async def async_config_changed(self): + """Call config changed which writes out the new config to disk.""" + await self.hass.async_add_executor_job(self.driver.config_changed) def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" @@ -541,7 +570,7 @@ class HomeKit: ) aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) - conf = self._config.pop(state.entity_id, {}) + conf = self._config.get(state.entity_id, {}).copy() # If an accessory cannot be created or added due to an exception # of any kind (usually in pyhap) it should not prevent # the rest of the accessories from being created @@ -556,9 +585,9 @@ class HomeKit: def remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" - acc = None - if aid in self.bridge.accessories: - acc = self.bridge.accessories.pop(aid) + acc = self.bridge.accessories.pop(aid, None) + if acc: + acc.async_stop() return acc async def async_configure_accessories(self): @@ -665,33 +694,45 @@ class HomeKit: for device_id in devices_to_purge: dev_reg.async_remove_device(device_id) + @callback + def _async_create_single_accessory(self, entity_states): + """Create a single HomeKit accessory (accessory mode).""" + if not entity_states: + _LOGGER.error( + "HomeKit %s cannot startup: entity not available: %s", + self._name, + self._filter.config, + ) + return None + state = entity_states[0] + conf = self._config.get(state.entity_id, {}).copy() + acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) + if acc is None: + _LOGGER.error( + "HomeKit %s cannot startup: entity not supported: %s", + self._name, + self._filter.config, + ) + return acc + + @callback + def _async_create_bridge_accessory(self, entity_states): + """Create a HomeKit bridge with accessories. (bridge mode).""" + self.bridge = HomeBridge(self.hass, self.driver, self._name) + for state in entity_states: + self.add_bridge_accessory(state) + return self.bridge + async def _async_create_accessories(self): """Create the accessories.""" entity_states = await self.async_configure_accessories() if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: - if not entity_states: - _LOGGER.error( - "HomeKit %s cannot startup: entity not available: %s", - self._name, - self._filter.config, - ) - return False - state = entity_states[0] - conf = self._config.pop(state.entity_id, {}) - acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) - if acc is None: - _LOGGER.error( - "HomeKit %s cannot startup: entity not supported: %s", - self._name, - self._filter.config, - ) - return False + acc = self._async_create_single_accessory(entity_states) else: - self.bridge = HomeBridge(self.hass, self.driver, self._name) - for state in entity_states: - self.add_bridge_accessory(state) - acc = self.bridge + acc = self._async_create_bridge_accessory(entity_states) + if acc is None: + return False # No need to load/persist as we do it in setup self.driver.accessory = acc return True diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 235c3027c98..ba34830f381 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -434,10 +434,12 @@ async def test_homekit_remove_accessory(hass, mock_zeroconf): homekit.driver = "driver" homekit.bridge = _mock_pyhap_bridge() - homekit.bridge.accessories = {"light.demo": "acc"} + acc_mock = MagicMock() + homekit.bridge.accessories = {6: acc_mock} - acc = homekit.remove_bridge_accessory("light.demo") - assert acc == "acc" + acc = homekit.remove_bridge_accessory(6) + assert acc is acc_mock + assert acc_mock.async_stop.called assert len(homekit.bridge.accessories) == 0 @@ -627,12 +629,13 @@ async def test_homekit_stop(hass): async def test_homekit_reset_accessories(hass, mock_zeroconf): - """Test adding too many accessories to HomeKit.""" + """Test resetting 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( @@ -641,11 +644,15 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): "pyhap.accessory_driver.AccessoryDriver.config_changed" ) as hk_driver_config_changed, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): 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"} + homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING await hass.services.async_call( @@ -661,6 +668,259 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): homekit.status = STATUS_READY +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", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "not_supported.demo" + hass.states.async_set("not_supported.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + 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 + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 2 + assert not mock_add_accessory.called + assert len(homekit.bridge.accessories) == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_accessories_state_missing(hass, mock_zeroconf): + """Test resetting HomeKit accessories when the state goes missing.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + 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 + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + assert not mock_add_accessory.called + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_accessories_not_bridged(hass, mock_zeroconf): + """Test resetting HomeKit accessories when the state is not bridged.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + 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 + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: "light.not_bridged"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + assert not mock_add_accessory.called + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory(hass, mock_zeroconf): + """Test resetting HomeKit single accessory.""" + 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_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 1 + homekit.status = STATUS_READY + + +async def test_homekit_reset_single_accessory_unsupported(hass, mock_zeroconf): + """Test resetting HomeKit single accessory with an unsupported entity.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "not_supported.demo" + hass.states.async_set("not_supported.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory_state_missing(hass, mock_zeroconf): + """Test resetting HomeKit single accessory when the state goes missing.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory_no_match(hass, mock_zeroconf): + """Test resetting HomeKit single accessory when the entity id does not match.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: "light.no_match"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroconf): """Test adding too many accessories to HomeKit.""" entry = await async_init_integration(hass)