Recreate HomeKit accessories when calling the reset_accessory service (#53199)

pull/53265/head
J. Nick Koston 2021-07-20 18:45:21 -10:00 committed by GitHub
parent 4d122fc366
commit 2a65c5f93c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 345 additions and 44 deletions

View File

@ -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

View File

@ -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)