mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 17:57:11 +00:00
Cache device trigger info during ZHA startup (#99764)
* Do not connect to the radio hardware within `_connect_zigpy_app` * Make `connect_zigpy_app` public * Create radio manager instances from config entries * Cache device triggers on startup * reorg zha init * don't reuse gateway * don't nuke yaml configuration * review comments * Fix existing unit tests * Ensure `app.shutdown` is called, not just `app.disconnect` * Revert creating group entities and device registry entries early * Add unit tests --------- Co-authored-by: David F. Mulcahey <david.mulcahey@icloud.com>
This commit is contained in:
parent
42046a3ce2
commit
a6f325d05a
@ -1,5 +1,6 @@
|
|||||||
"""Support for Zigbee Home Automation devices."""
|
"""Support for Zigbee Home Automation devices."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -33,13 +34,16 @@ from .core.const import (
|
|||||||
CONF_ZIGPY,
|
CONF_ZIGPY,
|
||||||
DATA_ZHA,
|
DATA_ZHA,
|
||||||
DATA_ZHA_CONFIG,
|
DATA_ZHA_CONFIG,
|
||||||
|
DATA_ZHA_DEVICE_TRIGGER_CACHE,
|
||||||
DATA_ZHA_GATEWAY,
|
DATA_ZHA_GATEWAY,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
SIGNAL_ADD_ENTITIES,
|
SIGNAL_ADD_ENTITIES,
|
||||||
RadioType,
|
RadioType,
|
||||||
)
|
)
|
||||||
|
from .core.device import get_device_automation_triggers
|
||||||
from .core.discovery import GROUP_PROBE
|
from .core.discovery import GROUP_PROBE
|
||||||
|
from .radio_manager import ZhaRadioManager
|
||||||
|
|
||||||
DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string})
|
DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string})
|
||||||
ZHA_CONFIG_SCHEMA = {
|
ZHA_CONFIG_SCHEMA = {
|
||||||
@ -134,9 +138,43 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||||||
else:
|
else:
|
||||||
_LOGGER.debug("ZHA storage file does not exist or was already removed")
|
_LOGGER.debug("ZHA storage file does not exist or was already removed")
|
||||||
|
|
||||||
# Re-use the gateway object between ZHA reloads
|
# Load and cache device trigger information early
|
||||||
if (zha_gateway := zha_data.get(DATA_ZHA_GATEWAY)) is None:
|
zha_data.setdefault(DATA_ZHA_DEVICE_TRIGGER_CACHE, {})
|
||||||
zha_gateway = ZHAGateway(hass, config, config_entry)
|
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry)
|
||||||
|
|
||||||
|
async with radio_mgr.connect_zigpy_app() as app:
|
||||||
|
for dev in app.devices.values():
|
||||||
|
dev_entry = device_registry.async_get_device(
|
||||||
|
identifiers={(DOMAIN, str(dev.ieee))},
|
||||||
|
connections={(dr.CONNECTION_ZIGBEE, str(dev.ieee))},
|
||||||
|
)
|
||||||
|
|
||||||
|
if dev_entry is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE][dev_entry.id] = (
|
||||||
|
str(dev.ieee),
|
||||||
|
get_device_automation_triggers(dev),
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug("Trigger cache: %s", zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE])
|
||||||
|
|
||||||
|
zha_gateway = ZHAGateway(hass, config, config_entry)
|
||||||
|
|
||||||
|
async def async_zha_shutdown():
|
||||||
|
"""Handle shutdown tasks."""
|
||||||
|
await zha_gateway.shutdown()
|
||||||
|
# clean up any remaining entity metadata
|
||||||
|
# (entities that have been discovered but not yet added to HA)
|
||||||
|
# suppress KeyError because we don't know what state we may
|
||||||
|
# be in when we get here in failure cases
|
||||||
|
with contextlib.suppress(KeyError):
|
||||||
|
for platform in PLATFORMS:
|
||||||
|
del hass.data[DATA_ZHA][platform]
|
||||||
|
|
||||||
|
config_entry.async_on_unload(async_zha_shutdown)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await zha_gateway.async_initialize()
|
await zha_gateway.async_initialize()
|
||||||
@ -155,9 +193,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||||||
|
|
||||||
repairs.async_delete_blocking_issues(hass)
|
repairs.async_delete_blocking_issues(hass)
|
||||||
|
|
||||||
config_entry.async_on_unload(zha_gateway.shutdown)
|
|
||||||
|
|
||||||
device_registry = dr.async_get(hass)
|
|
||||||
device_registry.async_get_or_create(
|
device_registry.async_get_or_create(
|
||||||
config_entry_id=config_entry.entry_id,
|
config_entry_id=config_entry.entry_id,
|
||||||
connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))},
|
connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))},
|
||||||
|
@ -186,6 +186,7 @@ DATA_ZHA = "zha"
|
|||||||
DATA_ZHA_CONFIG = "config"
|
DATA_ZHA_CONFIG = "config"
|
||||||
DATA_ZHA_BRIDGE_ID = "zha_bridge_id"
|
DATA_ZHA_BRIDGE_ID = "zha_bridge_id"
|
||||||
DATA_ZHA_CORE_EVENTS = "zha_core_events"
|
DATA_ZHA_CORE_EVENTS = "zha_core_events"
|
||||||
|
DATA_ZHA_DEVICE_TRIGGER_CACHE = "zha_device_trigger_cache"
|
||||||
DATA_ZHA_GATEWAY = "zha_gateway"
|
DATA_ZHA_GATEWAY = "zha_gateway"
|
||||||
|
|
||||||
DEBUG_COMP_BELLOWS = "bellows"
|
DEBUG_COMP_BELLOWS = "bellows"
|
||||||
|
@ -93,6 +93,16 @@ _UPDATE_ALIVE_INTERVAL = (60, 90)
|
|||||||
_CHECKIN_GRACE_PERIODS = 2
|
_CHECKIN_GRACE_PERIODS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_automation_triggers(
|
||||||
|
device: zigpy.device.Device,
|
||||||
|
) -> dict[tuple[str, str], dict[str, str]]:
|
||||||
|
"""Get the supported device automation triggers for a zigpy device."""
|
||||||
|
return {
|
||||||
|
("device_offline", "device_offline"): {"device_event_type": "device_offline"},
|
||||||
|
**getattr(device, "device_automation_triggers", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DeviceStatus(Enum):
|
class DeviceStatus(Enum):
|
||||||
"""Status of a device."""
|
"""Status of a device."""
|
||||||
|
|
||||||
@ -311,16 +321,7 @@ class ZHADevice(LogMixin):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]:
|
def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]:
|
||||||
"""Return the device automation triggers for this device."""
|
"""Return the device automation triggers for this device."""
|
||||||
triggers = {
|
return get_device_automation_triggers(self._zigpy_device)
|
||||||
("device_offline", "device_offline"): {
|
|
||||||
"device_event_type": "device_offline"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasattr(self._zigpy_device, "device_automation_triggers"):
|
|
||||||
triggers.update(self._zigpy_device.device_automation_triggers)
|
|
||||||
|
|
||||||
return triggers
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available_signal(self) -> str:
|
def available_signal(self) -> str:
|
||||||
|
@ -149,12 +149,6 @@ class ZHAGateway:
|
|||||||
self.config_entry = config_entry
|
self.config_entry = config_entry
|
||||||
self._unsubs: list[Callable[[], None]] = []
|
self._unsubs: list[Callable[[], None]] = []
|
||||||
|
|
||||||
discovery.PROBE.initialize(self._hass)
|
|
||||||
discovery.GROUP_PROBE.initialize(self._hass)
|
|
||||||
|
|
||||||
self.ha_device_registry = dr.async_get(self._hass)
|
|
||||||
self.ha_entity_registry = er.async_get(self._hass)
|
|
||||||
|
|
||||||
def get_application_controller_data(self) -> tuple[ControllerApplication, dict]:
|
def get_application_controller_data(self) -> tuple[ControllerApplication, dict]:
|
||||||
"""Get an uninitialized instance of a zigpy `ControllerApplication`."""
|
"""Get an uninitialized instance of a zigpy `ControllerApplication`."""
|
||||||
radio_type = self.config_entry.data[CONF_RADIO_TYPE]
|
radio_type = self.config_entry.data[CONF_RADIO_TYPE]
|
||||||
@ -197,6 +191,12 @@ class ZHAGateway:
|
|||||||
|
|
||||||
async def async_initialize(self) -> None:
|
async def async_initialize(self) -> None:
|
||||||
"""Initialize controller and connect radio."""
|
"""Initialize controller and connect radio."""
|
||||||
|
discovery.PROBE.initialize(self._hass)
|
||||||
|
discovery.GROUP_PROBE.initialize(self._hass)
|
||||||
|
|
||||||
|
self.ha_device_registry = dr.async_get(self._hass)
|
||||||
|
self.ha_entity_registry = er.async_get(self._hass)
|
||||||
|
|
||||||
app_controller_cls, app_config = self.get_application_controller_data()
|
app_controller_cls, app_config = self.get_application_controller_data()
|
||||||
self.application_controller = await app_controller_cls.new(
|
self.application_controller = await app_controller_cls.new(
|
||||||
config=app_config,
|
config=app_config,
|
||||||
@ -204,23 +204,6 @@ class ZHAGateway:
|
|||||||
start_radio=False,
|
start_radio=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self
|
|
||||||
|
|
||||||
self.async_load_devices()
|
|
||||||
|
|
||||||
# Groups are attached to the coordinator device so we need to load it early
|
|
||||||
coordinator = self._find_coordinator_device()
|
|
||||||
loaded_groups = False
|
|
||||||
|
|
||||||
# We can only load groups early if the coordinator's model info has been stored
|
|
||||||
# in the zigpy database
|
|
||||||
if coordinator.model is not None:
|
|
||||||
self.coordinator_zha_device = self._async_get_or_create_device(
|
|
||||||
coordinator, restored=True
|
|
||||||
)
|
|
||||||
self.async_load_groups()
|
|
||||||
loaded_groups = True
|
|
||||||
|
|
||||||
for attempt in range(STARTUP_RETRIES):
|
for attempt in range(STARTUP_RETRIES):
|
||||||
try:
|
try:
|
||||||
await self.application_controller.startup(auto_form=True)
|
await self.application_controller.startup(auto_form=True)
|
||||||
@ -242,14 +225,15 @@ class ZHAGateway:
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self
|
||||||
|
self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee)
|
||||||
|
|
||||||
self.coordinator_zha_device = self._async_get_or_create_device(
|
self.coordinator_zha_device = self._async_get_or_create_device(
|
||||||
self._find_coordinator_device(), restored=True
|
self._find_coordinator_device(), restored=True
|
||||||
)
|
)
|
||||||
self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee)
|
|
||||||
|
|
||||||
# If ZHA groups could not load early, we can safely load them now
|
self.async_load_devices()
|
||||||
if not loaded_groups:
|
self.async_load_groups()
|
||||||
self.async_load_groups()
|
|
||||||
|
|
||||||
self.application_controller.add_listener(self)
|
self.application_controller.add_listener(self)
|
||||||
self.application_controller.groups.add_listener(self)
|
self.application_controller.groups.add_listener(self)
|
||||||
@ -766,7 +750,15 @@ class ZHAGateway:
|
|||||||
unsubscribe()
|
unsubscribe()
|
||||||
for device in self.devices.values():
|
for device in self.devices.values():
|
||||||
device.async_cleanup_handles()
|
device.async_cleanup_handles()
|
||||||
await self.application_controller.shutdown()
|
# shutdown is called when the config entry unloads are processed
|
||||||
|
# there are cases where unloads are processed because of a failure of
|
||||||
|
# some sort and the application controller may not have been
|
||||||
|
# created yet
|
||||||
|
if (
|
||||||
|
hasattr(self, "application_controller")
|
||||||
|
and self.application_controller is not None
|
||||||
|
):
|
||||||
|
await self.application_controller.shutdown()
|
||||||
|
|
||||||
def handle_message(
|
def handle_message(
|
||||||
self,
|
self,
|
||||||
|
@ -9,12 +9,12 @@ from homeassistant.components.device_automation.exceptions import (
|
|||||||
from homeassistant.components.homeassistant.triggers import event as event_trigger
|
from homeassistant.components.homeassistant.triggers import event as event_trigger
|
||||||
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
|
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError, IntegrationError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import DOMAIN as ZHA_DOMAIN
|
from . import DOMAIN as ZHA_DOMAIN
|
||||||
from .core.const import ZHA_EVENT
|
from .core.const import DATA_ZHA, DATA_ZHA_DEVICE_TRIGGER_CACHE, ZHA_EVENT
|
||||||
from .core.helpers import async_get_zha_device
|
from .core.helpers import async_get_zha_device
|
||||||
|
|
||||||
CONF_SUBTYPE = "subtype"
|
CONF_SUBTYPE = "subtype"
|
||||||
@ -26,21 +26,32 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str, dict]:
|
||||||
|
"""Get device trigger data for a device, falling back to the cache if possible."""
|
||||||
|
|
||||||
|
# First, try checking to see if the device itself is accessible
|
||||||
|
try:
|
||||||
|
zha_device = async_get_zha_device(hass, device_id)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return str(zha_device.ieee), zha_device.device_automation_triggers
|
||||||
|
|
||||||
|
# If not, check the trigger cache but allow any `KeyError`s to propagate
|
||||||
|
return hass.data[DATA_ZHA][DATA_ZHA_DEVICE_TRIGGER_CACHE][device_id]
|
||||||
|
|
||||||
|
|
||||||
async def async_validate_trigger_config(
|
async def async_validate_trigger_config(
|
||||||
hass: HomeAssistant, config: ConfigType
|
hass: HomeAssistant, config: ConfigType
|
||||||
) -> ConfigType:
|
) -> ConfigType:
|
||||||
"""Validate config."""
|
"""Validate config."""
|
||||||
config = TRIGGER_SCHEMA(config)
|
config = TRIGGER_SCHEMA(config)
|
||||||
|
|
||||||
|
# Trigger validation will not occur if the config entry is not loaded
|
||||||
|
_, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID])
|
||||||
|
|
||||||
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
|
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
|
||||||
try:
|
if trigger not in triggers:
|
||||||
zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID])
|
|
||||||
except (KeyError, AttributeError, IntegrationError) as err:
|
|
||||||
raise InvalidDeviceAutomationConfig from err
|
|
||||||
if (
|
|
||||||
zha_device.device_automation_triggers is None
|
|
||||||
or trigger not in zha_device.device_automation_triggers
|
|
||||||
):
|
|
||||||
raise InvalidDeviceAutomationConfig(f"device does not have trigger {trigger}")
|
raise InvalidDeviceAutomationConfig(f"device does not have trigger {trigger}")
|
||||||
|
|
||||||
return config
|
return config
|
||||||
@ -53,26 +64,26 @@ async def async_attach_trigger(
|
|||||||
trigger_info: TriggerInfo,
|
trigger_info: TriggerInfo,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Listen for state changes based on configuration."""
|
"""Listen for state changes based on configuration."""
|
||||||
trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE])
|
|
||||||
try:
|
try:
|
||||||
zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID])
|
ieee, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID])
|
||||||
except (KeyError, AttributeError) as err:
|
except KeyError as err:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
f"Unable to get zha device {config[CONF_DEVICE_ID]}"
|
f"Unable to get zha device {config[CONF_DEVICE_ID]}"
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
if trigger_key not in zha_device.device_automation_triggers:
|
trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE])
|
||||||
|
|
||||||
|
if trigger_key not in triggers:
|
||||||
raise HomeAssistantError(f"Unable to find trigger {trigger_key}")
|
raise HomeAssistantError(f"Unable to find trigger {trigger_key}")
|
||||||
|
|
||||||
trigger = zha_device.device_automation_triggers[trigger_key]
|
event_config = event_trigger.TRIGGER_SCHEMA(
|
||||||
|
{
|
||||||
event_config = {
|
event_trigger.CONF_PLATFORM: "event",
|
||||||
event_trigger.CONF_PLATFORM: "event",
|
event_trigger.CONF_EVENT_TYPE: ZHA_EVENT,
|
||||||
event_trigger.CONF_EVENT_TYPE: ZHA_EVENT,
|
event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: ieee, **triggers[trigger_key]},
|
||||||
event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: str(zha_device.ieee), **trigger},
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
event_config = event_trigger.TRIGGER_SCHEMA(event_config)
|
|
||||||
return await event_trigger.async_attach_trigger(
|
return await event_trigger.async_attach_trigger(
|
||||||
hass, event_config, action, trigger_info, platform_type="device"
|
hass, event_config, action, trigger_info, platform_type="device"
|
||||||
)
|
)
|
||||||
@ -83,24 +94,20 @@ async def async_get_triggers(
|
|||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
"""List device triggers.
|
"""List device triggers.
|
||||||
|
|
||||||
Make sure the device supports device automations and
|
Make sure the device supports device automations and return the trigger list.
|
||||||
if it does return the trigger list.
|
|
||||||
"""
|
"""
|
||||||
zha_device = async_get_zha_device(hass, device_id)
|
try:
|
||||||
|
_, triggers = _get_device_trigger_data(hass, device_id)
|
||||||
|
except KeyError as err:
|
||||||
|
raise InvalidDeviceAutomationConfig from err
|
||||||
|
|
||||||
if not zha_device.device_automation_triggers:
|
return [
|
||||||
return []
|
{
|
||||||
|
CONF_DEVICE_ID: device_id,
|
||||||
triggers = []
|
CONF_DOMAIN: ZHA_DOMAIN,
|
||||||
for trigger, subtype in zha_device.device_automation_triggers:
|
CONF_PLATFORM: DEVICE,
|
||||||
triggers.append(
|
CONF_TYPE: trigger,
|
||||||
{
|
CONF_SUBTYPE: subtype,
|
||||||
CONF_DEVICE_ID: device_id,
|
}
|
||||||
CONF_DOMAIN: ZHA_DOMAIN,
|
for trigger, subtype in triggers
|
||||||
CONF_PLATFORM: DEVICE,
|
]
|
||||||
CONF_TYPE: trigger,
|
|
||||||
CONF_SUBTYPE: subtype,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return triggers
|
|
||||||
|
@ -8,7 +8,7 @@ import copy
|
|||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any, Self
|
||||||
|
|
||||||
from bellows.config import CONF_USE_THREAD
|
from bellows.config import CONF_USE_THREAD
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@ -127,8 +127,21 @@ class ZhaRadioManager:
|
|||||||
self.backups: list[zigpy.backups.NetworkBackup] = []
|
self.backups: list[zigpy.backups.NetworkBackup] = []
|
||||||
self.chosen_backup: zigpy.backups.NetworkBackup | None = None
|
self.chosen_backup: zigpy.backups.NetworkBackup | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config_entry(
|
||||||
|
cls, hass: HomeAssistant, config_entry: config_entries.ConfigEntry
|
||||||
|
) -> Self:
|
||||||
|
"""Create an instance from a config entry."""
|
||||||
|
mgr = cls()
|
||||||
|
mgr.hass = hass
|
||||||
|
mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||||
|
mgr.device_settings = config_entry.data[CONF_DEVICE]
|
||||||
|
mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
|
||||||
|
|
||||||
|
return mgr
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def _connect_zigpy_app(self) -> ControllerApplication:
|
async def connect_zigpy_app(self) -> ControllerApplication:
|
||||||
"""Connect to the radio with the current config and then clean up."""
|
"""Connect to the radio with the current config and then clean up."""
|
||||||
assert self.radio_type is not None
|
assert self.radio_type is not None
|
||||||
|
|
||||||
@ -155,10 +168,9 @@ class ZhaRadioManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await app.connect()
|
|
||||||
yield app
|
yield app
|
||||||
finally:
|
finally:
|
||||||
await app.disconnect()
|
await app.shutdown()
|
||||||
await asyncio.sleep(CONNECT_DELAY_S)
|
await asyncio.sleep(CONNECT_DELAY_S)
|
||||||
|
|
||||||
async def restore_backup(
|
async def restore_backup(
|
||||||
@ -170,7 +182,8 @@ class ZhaRadioManager:
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
async with self._connect_zigpy_app() as app:
|
async with self.connect_zigpy_app() as app:
|
||||||
|
await app.connect()
|
||||||
await app.backups.restore_backup(backup, **kwargs)
|
await app.backups.restore_backup(backup, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -218,7 +231,9 @@ class ZhaRadioManager:
|
|||||||
"""Connect to the radio and load its current network settings."""
|
"""Connect to the radio and load its current network settings."""
|
||||||
backup = None
|
backup = None
|
||||||
|
|
||||||
async with self._connect_zigpy_app() as app:
|
async with self.connect_zigpy_app() as app:
|
||||||
|
await app.connect()
|
||||||
|
|
||||||
# Check if the stick has any settings and load them
|
# Check if the stick has any settings and load them
|
||||||
try:
|
try:
|
||||||
await app.load_network_info()
|
await app.load_network_info()
|
||||||
@ -241,12 +256,14 @@ class ZhaRadioManager:
|
|||||||
|
|
||||||
async def async_form_network(self) -> None:
|
async def async_form_network(self) -> None:
|
||||||
"""Form a brand-new network."""
|
"""Form a brand-new network."""
|
||||||
async with self._connect_zigpy_app() as app:
|
async with self.connect_zigpy_app() as app:
|
||||||
|
await app.connect()
|
||||||
await app.form_network()
|
await app.form_network()
|
||||||
|
|
||||||
async def async_reset_adapter(self) -> None:
|
async def async_reset_adapter(self) -> None:
|
||||||
"""Reset the current adapter."""
|
"""Reset the current adapter."""
|
||||||
async with self._connect_zigpy_app() as app:
|
async with self.connect_zigpy_app() as app:
|
||||||
|
await app.connect()
|
||||||
await app.reset_network_info()
|
await app.reset_network_info()
|
||||||
|
|
||||||
async def async_restore_backup_step_1(self) -> bool:
|
async def async_restore_backup_step_1(self) -> bool:
|
||||||
|
@ -23,7 +23,7 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]:
|
|||||||
with patch(
|
with patch(
|
||||||
"bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe
|
"bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||||
return_value=mock_connect_app,
|
return_value=mock_connect_app,
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.zha.async_setup_entry",
|
"homeassistant.components.zha.async_setup_entry",
|
||||||
|
@ -25,7 +25,7 @@ def mock_zha():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||||
return_value=mock_connect_app,
|
return_value=mock_connect_app,
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.zha.async_setup_entry",
|
"homeassistant.components.zha.async_setup_entry",
|
||||||
|
@ -45,7 +45,7 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]:
|
|||||||
with patch(
|
with patch(
|
||||||
"bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe
|
"bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||||
return_value=mock_connect_app,
|
return_value=mock_connect_app,
|
||||||
):
|
):
|
||||||
yield
|
yield
|
||||||
|
@ -23,7 +23,7 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]:
|
|||||||
with patch(
|
with patch(
|
||||||
"bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe
|
"bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||||
return_value=mock_connect_app,
|
return_value=mock_connect_app,
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.zha.async_setup_entry",
|
"homeassistant.components.zha.async_setup_entry",
|
||||||
|
@ -293,14 +293,20 @@ def zigpy_device_mock(zigpy_app_controller):
|
|||||||
return _mock_dev
|
return _mock_dev
|
||||||
|
|
||||||
|
|
||||||
|
@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True))
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def zha_device_joined(hass, setup_zha):
|
def zha_device_joined(hass, setup_zha):
|
||||||
"""Return a newly joined ZHA device."""
|
"""Return a newly joined ZHA device."""
|
||||||
|
setup_zha_fixture = setup_zha
|
||||||
|
|
||||||
async def _zha_device(zigpy_dev):
|
async def _zha_device(zigpy_dev, *, setup_zha: bool = True):
|
||||||
zigpy_dev.last_seen = time.time()
|
zigpy_dev.last_seen = time.time()
|
||||||
await setup_zha()
|
|
||||||
|
if setup_zha:
|
||||||
|
await setup_zha_fixture()
|
||||||
|
|
||||||
zha_gateway = common.get_zha_gateway(hass)
|
zha_gateway = common.get_zha_gateway(hass)
|
||||||
|
zha_gateway.application_controller.devices[zigpy_dev.ieee] = zigpy_dev
|
||||||
await zha_gateway.async_device_initialized(zigpy_dev)
|
await zha_gateway.async_device_initialized(zigpy_dev)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
return zha_gateway.get_device(zigpy_dev.ieee)
|
return zha_gateway.get_device(zigpy_dev.ieee)
|
||||||
@ -308,17 +314,21 @@ def zha_device_joined(hass, setup_zha):
|
|||||||
return _zha_device
|
return _zha_device
|
||||||
|
|
||||||
|
|
||||||
|
@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True))
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def zha_device_restored(hass, zigpy_app_controller, setup_zha):
|
def zha_device_restored(hass, zigpy_app_controller, setup_zha):
|
||||||
"""Return a restored ZHA device."""
|
"""Return a restored ZHA device."""
|
||||||
|
setup_zha_fixture = setup_zha
|
||||||
|
|
||||||
async def _zha_device(zigpy_dev, last_seen=None):
|
async def _zha_device(zigpy_dev, *, last_seen=None, setup_zha: bool = True):
|
||||||
zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev
|
zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev
|
||||||
|
|
||||||
if last_seen is not None:
|
if last_seen is not None:
|
||||||
zigpy_dev.last_seen = last_seen
|
zigpy_dev.last_seen = last_seen
|
||||||
|
|
||||||
await setup_zha()
|
if setup_zha:
|
||||||
|
await setup_zha_fixture()
|
||||||
|
|
||||||
zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY]
|
zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY]
|
||||||
return zha_gateway.get_device(zigpy_dev.ieee)
|
return zha_gateway.get_device(zigpy_dev.ieee)
|
||||||
|
|
||||||
@ -376,3 +386,10 @@ def hass_disable_services(hass):
|
|||||||
hass, "services", MagicMock(has_service=MagicMock(return_value=True))
|
hass, "services", MagicMock(has_service=MagicMock(return_value=True))
|
||||||
):
|
):
|
||||||
yield hass
|
yield hass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def speed_up_radio_mgr():
|
||||||
|
"""Speed up the radio manager connection time by removing delays."""
|
||||||
|
with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.00001):
|
||||||
|
yield
|
||||||
|
@ -63,13 +63,6 @@ def mock_multipan_platform():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def reduce_reconnect_timeout():
|
|
||||||
"""Reduces reconnect timeout to speed up tests."""
|
|
||||||
with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.01):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def mock_app():
|
def mock_app():
|
||||||
"""Mock zigpy app interface."""
|
"""Mock zigpy app interface."""
|
||||||
|
@ -9,6 +9,9 @@ import zigpy.zcl.clusters.general as general
|
|||||||
|
|
||||||
import homeassistant.components.automation as automation
|
import homeassistant.components.automation as automation
|
||||||
from homeassistant.components.device_automation import DeviceAutomationType
|
from homeassistant.components.device_automation import DeviceAutomationType
|
||||||
|
from homeassistant.components.device_automation.exceptions import (
|
||||||
|
InvalidDeviceAutomationConfig,
|
||||||
|
)
|
||||||
from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID
|
from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -20,6 +23,7 @@ from .common import async_enable_traffic
|
|||||||
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
|
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
|
||||||
|
|
||||||
from tests.common import (
|
from tests.common import (
|
||||||
|
MockConfigEntry,
|
||||||
async_fire_time_changed,
|
async_fire_time_changed,
|
||||||
async_get_device_automations,
|
async_get_device_automations,
|
||||||
async_mock_service,
|
async_mock_service,
|
||||||
@ -45,6 +49,16 @@ LONG_PRESS = "remote_button_long_press"
|
|||||||
LONG_RELEASE = "remote_button_long_release"
|
LONG_RELEASE = "remote_button_long_release"
|
||||||
|
|
||||||
|
|
||||||
|
SWITCH_SIGNATURE = {
|
||||||
|
1: {
|
||||||
|
SIG_EP_INPUT: [general.Basic.cluster_id],
|
||||||
|
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
|
||||||
|
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
|
||||||
|
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def sensor_platforms_only():
|
def sensor_platforms_only():
|
||||||
"""Only set up the sensor platform and required base platforms to speed up tests."""
|
"""Only set up the sensor platform and required base platforms to speed up tests."""
|
||||||
@ -72,16 +86,7 @@ def calls(hass):
|
|||||||
async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored):
|
async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored):
|
||||||
"""IAS device fixture."""
|
"""IAS device fixture."""
|
||||||
|
|
||||||
zigpy_device = zigpy_device_mock(
|
zigpy_device = zigpy_device_mock(SWITCH_SIGNATURE)
|
||||||
{
|
|
||||||
1: {
|
|
||||||
SIG_EP_INPUT: [general.Basic.cluster_id],
|
|
||||||
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
|
|
||||||
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
|
|
||||||
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
zha_device = await zha_device_joined_restored(zigpy_device)
|
zha_device = await zha_device_joined_restored(zigpy_device)
|
||||||
zha_device.update_available(True)
|
zha_device.update_available(True)
|
||||||
@ -397,3 +402,108 @@ async def test_exception_bad_trigger(
|
|||||||
"Unnamed automation failed to setup triggers and has been disabled: "
|
"Unnamed automation failed to setup triggers and has been disabled: "
|
||||||
"device does not have trigger ('junk', 'junk')" in caplog.text
|
"device does not have trigger ('junk', 'junk')" in caplog.text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_validate_trigger_config_missing_info(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
zigpy_device_mock,
|
||||||
|
mock_zigpy_connect,
|
||||||
|
zha_device_joined,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test device triggers referring to a missing device."""
|
||||||
|
|
||||||
|
# Join a device
|
||||||
|
switch = zigpy_device_mock(SWITCH_SIGNATURE)
|
||||||
|
await zha_device_joined(switch)
|
||||||
|
|
||||||
|
# After we unload the config entry, trigger info was not cached on startup, nor can
|
||||||
|
# it be pulled from the current device, making it impossible to validate triggers
|
||||||
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
|
||||||
|
ha_device_registry = dr.async_get(hass)
|
||||||
|
reg_device = ha_device_registry.async_get_device(
|
||||||
|
identifiers={("zha", str(switch.ieee))}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
automation.DOMAIN,
|
||||||
|
{
|
||||||
|
automation.DOMAIN: [
|
||||||
|
{
|
||||||
|
"trigger": {
|
||||||
|
"device_id": reg_device.id,
|
||||||
|
"domain": "zha",
|
||||||
|
"platform": "device",
|
||||||
|
"type": "junk",
|
||||||
|
"subtype": "junk",
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"service": "test.automation",
|
||||||
|
"data": {"message": "service called"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Unable to get zha device" in caplog.text
|
||||||
|
|
||||||
|
with pytest.raises(InvalidDeviceAutomationConfig):
|
||||||
|
await async_get_device_automations(
|
||||||
|
hass, DeviceAutomationType.TRIGGER, reg_device.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_validate_trigger_config_unloaded_bad_info(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
zigpy_device_mock,
|
||||||
|
mock_zigpy_connect,
|
||||||
|
zha_device_joined,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test device triggers referring to a missing device."""
|
||||||
|
|
||||||
|
# Join a device
|
||||||
|
switch = zigpy_device_mock(SWITCH_SIGNATURE)
|
||||||
|
await zha_device_joined(switch)
|
||||||
|
|
||||||
|
# After we unload the config entry, trigger info was not cached on startup, nor can
|
||||||
|
# it be pulled from the current device, making it impossible to validate triggers
|
||||||
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
|
||||||
|
# Reload ZHA to persist the device info in the cache
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
|
||||||
|
ha_device_registry = dr.async_get(hass)
|
||||||
|
reg_device = ha_device_registry.async_get_device(
|
||||||
|
identifiers={("zha", str(switch.ieee))}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
automation.DOMAIN,
|
||||||
|
{
|
||||||
|
automation.DOMAIN: [
|
||||||
|
{
|
||||||
|
"trigger": {
|
||||||
|
"device_id": reg_device.id,
|
||||||
|
"domain": "zha",
|
||||||
|
"platform": "device",
|
||||||
|
"type": "junk",
|
||||||
|
"subtype": "junk",
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"service": "test.automation",
|
||||||
|
"data": {"message": "service called"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Unable to find trigger" in caplog.text
|
||||||
|
@ -6,7 +6,6 @@ import pytest
|
|||||||
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
|
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
|
||||||
from zigpy.exceptions import TransientConnectionError
|
from zigpy.exceptions import TransientConnectionError
|
||||||
|
|
||||||
from homeassistant.components.zha import async_setup_entry
|
|
||||||
from homeassistant.components.zha.core.const import (
|
from homeassistant.components.zha.core.const import (
|
||||||
CONF_BAUDRATE,
|
CONF_BAUDRATE,
|
||||||
CONF_RADIO_TYPE,
|
CONF_RADIO_TYPE,
|
||||||
@ -22,7 +21,7 @@ from .test_light import LIGHT_ON_OFF
|
|||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
DATA_RADIO_TYPE = "deconz"
|
DATA_RADIO_TYPE = "ezsp"
|
||||||
DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0"
|
DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0"
|
||||||
|
|
||||||
|
|
||||||
@ -137,7 +136,7 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None:
|
|||||||
"homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True)
|
"homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True)
|
||||||
)
|
)
|
||||||
async def test_setup_with_v3_cleaning_uri(
|
async def test_setup_with_v3_cleaning_uri(
|
||||||
hass: HomeAssistant, path: str, cleaned_path: str
|
hass: HomeAssistant, path: str, cleaned_path: str, mock_zigpy_connect
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test migration of config entry from v3, applying corrections to the port path."""
|
"""Test migration of config entry from v3, applying corrections to the port path."""
|
||||||
config_entry_v3 = MockConfigEntry(
|
config_entry_v3 = MockConfigEntry(
|
||||||
@ -150,14 +149,9 @@ async def test_setup_with_v3_cleaning_uri(
|
|||||||
)
|
)
|
||||||
config_entry_v3.add_to_hass(hass)
|
config_entry_v3.add_to_hass(hass)
|
||||||
|
|
||||||
with patch(
|
await hass.config_entries.async_setup(config_entry_v3.entry_id)
|
||||||
"homeassistant.components.zha.ZHAGateway", return_value=AsyncMock()
|
await hass.async_block_till_done()
|
||||||
) as mock_gateway:
|
await hass.config_entries.async_unload(config_entry_v3.entry_id)
|
||||||
mock_gateway.return_value.coordinator_ieee = "mock_ieee"
|
|
||||||
mock_gateway.return_value.radio_description = "mock_radio"
|
|
||||||
|
|
||||||
assert await async_setup_entry(hass, config_entry_v3)
|
|
||||||
hass.data[DOMAIN]["zha_gateway"] = mock_gateway.return_value
|
|
||||||
|
|
||||||
assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE
|
assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE
|
||||||
assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path
|
assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path
|
||||||
|
@ -32,9 +32,7 @@ def disable_platform_only():
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reduce_reconnect_timeout():
|
def reduce_reconnect_timeout():
|
||||||
"""Reduces reconnect timeout to speed up tests."""
|
"""Reduces reconnect timeout to speed up tests."""
|
||||||
with patch(
|
with patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001):
|
||||||
"homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.0001
|
|
||||||
), patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001):
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@ -99,7 +97,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock, None, None]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||||
return_value=mock_connect_app,
|
return_value=mock_connect_app,
|
||||||
):
|
):
|
||||||
yield mock_connect_app
|
yield mock_connect_app
|
||||||
|
Loading…
x
Reference in New Issue
Block a user