This commit is contained in:
Bram Kragten 2025-01-09 23:25:42 +01:00 committed by GitHub
commit bceccd85ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 175 additions and 56 deletions

View File

@ -7,6 +7,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import StrEnum from enum import StrEnum
import random
from typing import TYPE_CHECKING, Self, TypedDict from typing import TYPE_CHECKING, Self, TypedDict
from cronsim import CronSim from cronsim import CronSim
@ -28,6 +29,10 @@ if TYPE_CHECKING:
CRON_PATTERN_DAILY = "45 4 * * *" CRON_PATTERN_DAILY = "45 4 * * *"
CRON_PATTERN_WEEKLY = "45 4 * * {}" CRON_PATTERN_WEEKLY = "45 4 * * {}"
# Randomize the start time of the backup by up to 60 minutes to avoid
# all backups running at the same time.
BACKUP_START_TIME_JITTER = 60 * 60
class StoredBackupConfig(TypedDict): class StoredBackupConfig(TypedDict):
"""Represent the stored backup config.""" """Represent the stored backup config."""
@ -329,6 +334,8 @@ class BackupSchedule:
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
LOGGER.exception("Unexpected error creating automatic backup") LOGGER.exception("Unexpected error creating automatic backup")
next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
LOGGER.debug("Scheduling next automatic backup at %s", next_time)
manager.remove_next_backup_event = async_track_point_in_time( manager.remove_next_backup_event = async_track_point_in_time(
manager.hass, _create_backup, next_time manager.hass, _create_backup, next_time
) )

View File

@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib import hashlib
import logging import logging
import random
from typing import Any, Self from typing import Any, Self
from aiohttp import ClientError, ClientTimeout, StreamReader from aiohttp import ClientError, ClientTimeout, StreamReader
@ -26,6 +28,9 @@ from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_STORAGE_BACKUP = "backup" _STORAGE_BACKUP = "backup"
_RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600
async def _b64md5(stream: AsyncIterator[bytes]) -> str: async def _b64md5(stream: AsyncIterator[bytes]) -> str:
@ -138,13 +143,55 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Failed to get download details") from err raise BackupAgentError("Failed to get download details") from err
try: try:
resp = await self._cloud.websession.get(details["url"]) resp = await self._cloud.websession.get(
details["url"],
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
resp.raise_for_status() resp.raise_for_status()
except ClientError as err: except ClientError as err:
raise BackupAgentError("Failed to download backup") from err raise BackupAgentError("Failed to download backup") from err
return ChunkAsyncStreamIterator(resp.content) return ChunkAsyncStreamIterator(resp.content)
async def _async_do_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
filename: str,
base64md5hash: str,
metadata: dict[str, Any],
size: int,
) -> None:
"""Upload a backup."""
try:
details = await async_files_upload_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=filename,
metadata=metadata,
size=size,
base64md5hash=base64md5hash,
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get upload details") from err
try:
upload_status = await self._cloud.websession.put(
details["url"],
data=await open_stream(),
headers=details["headers"] | {"content-length": str(size)},
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
_LOGGER.log(
logging.DEBUG if upload_status.status < 400 else logging.WARNING,
"Backup upload status: %s",
upload_status.status,
)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
async def async_upload_backup( async def async_upload_backup(
self, self,
*, *,
@ -161,34 +208,34 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Cloud backups must be protected") raise BackupAgentError("Cloud backups must be protected")
base64md5hash = await _b64md5(await open_stream()) base64md5hash = await _b64md5(await open_stream())
filename = self._get_backup_filename()
metadata = backup.as_dict()
size = backup.size
try: tries = 1
details = await async_files_upload_details( while tries <= _RETRY_LIMIT:
self._cloud, try:
storage_type=_STORAGE_BACKUP, await self._async_do_upload_backup(
filename=self._get_backup_filename(), open_stream=open_stream,
metadata=backup.as_dict(), filename=filename,
size=backup.size, base64md5hash=base64md5hash,
base64md5hash=base64md5hash, metadata=metadata,
) size=size,
except (ClientError, CloudError) as err: )
raise BackupAgentError("Failed to get upload details") from err break
except BackupAgentError as err:
try: if tries == _RETRY_LIMIT:
upload_status = await self._cloud.websession.put( raise
details["url"], tries += 1
data=await open_stream(), retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
headers=details["headers"] | {"content-length": str(backup.size)}, _LOGGER.info(
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h "Failed to upload backup, retrying (%s/%s) in %ss: %s",
) tries,
_LOGGER.log( _RETRY_LIMIT,
logging.DEBUG if upload_status.status < 400 else logging.WARNING, retry_timer,
"Backup upload status: %s", err,
upload_status.status, )
) await asyncio.sleep(retry_timer)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
async def async_delete_backup( async def async_delete_backup(
self, self,

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["cookidoo_api"], "loggers": ["cookidoo_api"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["cookidoo-api==0.11.2"] "requirements": ["cookidoo-api==0.12.2"]
} }

View File

@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pyflick"], "loggers": ["pyflick"],
"requirements": ["PyFlick==1.1.2"] "requirements": ["PyFlick==1.1.3"]
} }

View File

@ -51,19 +51,19 @@ class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], Sensor
_LOGGER.warning( _LOGGER.warning(
"Unexpected quantity for unit price: %s", self.coordinator.data "Unexpected quantity for unit price: %s", self.coordinator.data
) )
return self.coordinator.data.cost return self.coordinator.data.cost * 100
@property @property
def extra_state_attributes(self) -> dict[str, Any] | None: def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes.""" """Return the state attributes."""
components: dict[str, Decimal] = {} components: dict[str, float] = {}
for component in self.coordinator.data.components: for component in self.coordinator.data.components:
if component.charge_setter not in ATTR_COMPONENTS: if component.charge_setter not in ATTR_COMPONENTS:
_LOGGER.warning("Found unknown component: %s", component.charge_setter) _LOGGER.warning("Found unknown component: %s", component.charge_setter)
continue continue
components[component.charge_setter] = component.value components[component.charge_setter] = float(component.value * 100)
return { return {
ATTR_START_AT: self.coordinator.data.start_at, ATTR_START_AT: self.coordinator.data.start_at,

View File

@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250106.0"] "requirements": ["home-assistant-frontend==20250109.0"]
} }

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioautomower"], "loggers": ["aioautomower"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aioautomower==2024.12.0"] "requirements": ["aioautomower==2025.1.0"]
} }

View File

@ -6,6 +6,7 @@ import logging
from meteofrance_api.client import MeteoFranceClient from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.helpers import is_valid_warning_department from meteofrance_api.helpers import is_valid_warning_department
from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain
from requests import RequestException
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -83,7 +84,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
update_method=_async_update_data_rain, update_method=_async_update_data_rain,
update_interval=SCAN_INTERVAL_RAIN, update_interval=SCAN_INTERVAL_RAIN,
) )
await coordinator_rain.async_config_entry_first_refresh() try:
await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001
except RequestException:
_LOGGER.warning(
"1 hour rain forecast not available: %s is not in covered zone",
entry.title,
)
department = coordinator_forecast.data.position.get("dept") department = coordinator_forecast.data.position.get("dept")
_LOGGER.debug( _LOGGER.debug(
@ -128,8 +135,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id] = { hass.data[DOMAIN][entry.entry_id] = {
UNDO_UPDATE_LISTENER: undo_listener, UNDO_UPDATE_LISTENER: undo_listener,
COORDINATOR_FORECAST: coordinator_forecast, COORDINATOR_FORECAST: coordinator_forecast,
COORDINATOR_RAIN: coordinator_rain,
} }
if coordinator_rain and coordinator_rain.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain
if coordinator_alert and coordinator_alert.last_update_success: if coordinator_alert and coordinator_alert.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert

View File

@ -187,7 +187,7 @@ async def async_setup_entry(
"""Set up the Meteo-France sensor platform.""" """Set up the Meteo-France sensor platform."""
data = hass.data[DOMAIN][entry.entry_id] data = hass.data[DOMAIN][entry.entry_id]
coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST]
coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN] coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN)
coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get(
COORDINATOR_ALERT COORDINATOR_ALERT
) )

View File

@ -6,7 +6,7 @@ from typing import Any, cast
from urllib.parse import urlparse from urllib.parse import urlparse
from pyoverkiz.enums import OverkizCommand, Protocol from pyoverkiz.enums import OverkizCommand, Protocol
from pyoverkiz.exceptions import OverkizException from pyoverkiz.exceptions import BaseOverkizException
from pyoverkiz.models import Command, Device, StateDefinition from pyoverkiz.models import Command, Device, StateDefinition
from pyoverkiz.types import StateType as OverkizStateType from pyoverkiz.types import StateType as OverkizStateType
@ -105,7 +105,7 @@ class OverkizExecutor:
"Home Assistant", "Home Assistant",
) )
# Catch Overkiz exceptions to support `continue_on_error` functionality # Catch Overkiz exceptions to support `continue_on_error` functionality
except OverkizException as exception: except BaseOverkizException as exception:
raise HomeAssistantError(exception) from exception raise HomeAssistantError(exception) from exception
# ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here

View File

@ -82,7 +82,8 @@ def get_device_uid_and_ch(
ch = int(device_uid[1][5:]) ch = int(device_uid[1][5:])
is_chime = True is_chime = True
else: else:
ch = host.api.channel_for_uid(device_uid[1]) device_uid_part = "_".join(device_uid[1:])
ch = host.api.channel_for_uid(device_uid_part)
return (device_uid, ch, is_chime) return (device_uid, ch, is_chime)

View File

@ -7,5 +7,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pysuez", "regex"], "loggers": ["pysuez", "regex"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pysuezV2==2.0.1"] "requirements": ["pysuezV2==2.0.3"]
} }

View File

@ -87,7 +87,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
manufacturer=zha_device_info[ATTR_MANUFACTURER], manufacturer=zha_device_info[ATTR_MANUFACTURER],
model=zha_device_info[ATTR_MODEL], model=zha_device_info[ATTR_MODEL],
name=zha_device_info[ATTR_NAME], name=zha_device_info[ATTR_NAME],
via_device=(DOMAIN, zha_gateway.state.node_info.ieee), via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)),
) )
@callback @callback

View File

@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025 MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 1 MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "1" PATCH_VERSION: Final = "2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

@ -35,7 +35,7 @@ habluetooth==3.7.0
hass-nabucasa==0.87.0 hass-nabucasa==0.87.0
hassil==2.1.0 hassil==2.1.0
home-assistant-bluetooth==1.13.0 home-assistant-bluetooth==1.13.0
home-assistant-frontend==20250106.0 home-assistant-frontend==20250109.0
home-assistant-intents==2025.1.1 home-assistant-intents==2025.1.1
httpx==0.27.2 httpx==0.27.2
ifaddr==0.2.0 ifaddr==0.2.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2025.1.1" version = "2025.1.2"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"

View File

@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3
PyChromecast==14.0.5 PyChromecast==14.0.5
# homeassistant.components.flick_electric # homeassistant.components.flick_electric
PyFlick==1.1.2 PyFlick==1.1.3
# homeassistant.components.flume # homeassistant.components.flume
PyFlume==0.6.5 PyFlume==0.6.5
@ -201,7 +201,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0 aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower # homeassistant.components.husqvarna_automower
aioautomower==2024.12.0 aioautomower==2025.1.0
# homeassistant.components.azure_devops # homeassistant.components.azure_devops
aioazuredevops==2.2.1 aioazuredevops==2.2.1
@ -704,7 +704,7 @@ connect-box==0.3.1
construct==2.10.68 construct==2.10.68
# homeassistant.components.cookidoo # homeassistant.components.cookidoo
cookidoo-api==0.11.2 cookidoo-api==0.12.2
# homeassistant.components.backup # homeassistant.components.backup
# homeassistant.components.utility_meter # homeassistant.components.utility_meter
@ -1134,7 +1134,7 @@ hole==0.8.0
holidays==0.64 holidays==0.64
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20250106.0 home-assistant-frontend==20250109.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.1.1 home-assistant-intents==2025.1.1
@ -2309,7 +2309,7 @@ pysqueezebox==0.10.0
pystiebeleltron==0.0.1.dev2 pystiebeleltron==0.0.1.dev2
# homeassistant.components.suez_water # homeassistant.components.suez_water
pysuezV2==2.0.1 pysuezV2==2.0.3
# homeassistant.components.switchbee # homeassistant.components.switchbee
pyswitchbee==1.8.3 pyswitchbee==1.8.3

View File

@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3
PyChromecast==14.0.5 PyChromecast==14.0.5
# homeassistant.components.flick_electric # homeassistant.components.flick_electric
PyFlick==1.1.2 PyFlick==1.1.3
# homeassistant.components.flume # homeassistant.components.flume
PyFlume==0.6.5 PyFlume==0.6.5
@ -189,7 +189,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0 aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower # homeassistant.components.husqvarna_automower
aioautomower==2024.12.0 aioautomower==2025.1.0
# homeassistant.components.azure_devops # homeassistant.components.azure_devops
aioazuredevops==2.2.1 aioazuredevops==2.2.1
@ -600,7 +600,7 @@ colorthief==0.2.1
construct==2.10.68 construct==2.10.68
# homeassistant.components.cookidoo # homeassistant.components.cookidoo
cookidoo-api==0.11.2 cookidoo-api==0.12.2
# homeassistant.components.backup # homeassistant.components.backup
# homeassistant.components.utility_meter # homeassistant.components.utility_meter
@ -963,7 +963,7 @@ hole==0.8.0
holidays==0.64 holidays==0.64
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20250106.0 home-assistant-frontend==20250109.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.1.1 home-assistant-intents==2025.1.1
@ -1875,7 +1875,7 @@ pyspeex-noise==1.0.2
pysqueezebox==0.10.0 pysqueezebox==0.10.0
# homeassistant.components.suez_water # homeassistant.components.suez_water
pysuezV2==2.0.1 pysuezV2==2.0.3
# homeassistant.components.switchbee # homeassistant.components.switchbee
pyswitchbee==1.8.3 pyswitchbee==1.8.3

View File

@ -1345,6 +1345,7 @@ async def test_config_update_errors(
), ),
], ],
) )
@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0)
async def test_config_schedule_logic( async def test_config_schedule_logic(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
@ -1787,6 +1788,7 @@ async def test_config_schedule_logic(
), ),
], ],
) )
@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0)
async def test_config_retention_copies_logic( async def test_config_retention_copies_logic(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,

View File

@ -389,6 +389,7 @@ async def test_agents_upload_fail_put(
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
mock_get_upload_details: Mock, mock_get_upload_details: Mock,
put_mock_kwargs: dict[str, Any], put_mock_kwargs: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test agent upload backup fails.""" """Test agent upload backup fails."""
client = await hass_client() client = await hass_client()
@ -417,6 +418,9 @@ async def test_agents_upload_fail_put(
return_value=test_backup, return_value=test_backup,
), ),
patch("pathlib.Path.open") as mocked_open, patch("pathlib.Path.open") as mocked_open,
patch("homeassistant.components.cloud.backup.asyncio.sleep"),
patch("homeassistant.components.cloud.backup.random.randint", return_value=60),
patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2),
): ):
mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
fetch_backup.return_value = test_backup fetch_backup.return_value = test_backup
@ -426,6 +430,8 @@ async def test_agents_upload_fail_put(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 2
assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text
assert resp.status == 201 assert resp.status == 201
store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"]
assert len(store_backups) == 1 assert len(store_backups) == 1
@ -469,6 +475,7 @@ async def test_agents_upload_fail_cloud(
return_value=test_backup, return_value=test_backup,
), ),
patch("pathlib.Path.open") as mocked_open, patch("pathlib.Path.open") as mocked_open,
patch("homeassistant.components.cloud.backup.asyncio.sleep"),
): ):
mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
fetch_backup.return_value = test_backup fetch_backup.return_value = test_backup

View File

@ -0,0 +1,47 @@
"""Test ZHA entities."""
from zigpy.profiles import zha
from zigpy.zcl.clusters import general
from homeassistant.components.zha.helpers import get_zha_gateway
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
async def test_device_registry_via_device(
hass: HomeAssistant,
setup_zha,
zigpy_device_mock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test ZHA `via_device` is set correctly."""
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
)
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
reg_coordinator_device = device_registry.async_get_device(
identifiers={("zha", str(gateway.state.node_info.ieee))}
)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
)
assert reg_device.via_device_id == reg_coordinator_device.id