Recreate HomeKit accessories when calling the reset_accessory service (#53199)
parent
4d122fc366
commit
2a65c5f93c
|
@ -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,19 +694,18 @@ class HomeKit:
|
|||
for device_id in devices_to_purge:
|
||||
dev_reg.async_remove_device(device_id)
|
||||
|
||||
async def _async_create_accessories(self):
|
||||
"""Create the accessories."""
|
||||
entity_states = await self.async_configure_accessories()
|
||||
if self._homekit_mode == HOMEKIT_MODE_ACCESSORY:
|
||||
@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 False
|
||||
return None
|
||||
state = entity_states[0]
|
||||
conf = self._config.pop(state.entity_id, {})
|
||||
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(
|
||||
|
@ -685,13 +713,26 @@ class HomeKit:
|
|||
self._name,
|
||||
self._filter.config,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
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)
|
||||
acc = self.bridge
|
||||
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:
|
||||
acc = self._async_create_single_accessory(entity_states)
|
||||
else:
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue