Compare commits

...

17 Commits

Author SHA1 Message Date
Franck Nijhof
f3ddffb5ff 2025.11.1 (#156076) 2025-11-07 13:29:37 -08:00
Franck Nijhof
9bdfa77fa0 Merge branch 'master' into rc 2025-11-07 12:41:56 -08:00
Franck Nijhof
c65003009f Bump version to 2025.11.1 2025-11-07 20:36:12 +00:00
Michael Hansen
0f722109b7 Bump intents to 2025.11.7 (#156063) 2025-11-07 20:35:56 +00:00
Foscam-wangzhengyu
f7d86dec3c Fix the exception caused by the missing Foscam integration key (#156022) 2025-11-07 20:35:55 +00:00
Josef Zweck
6b49c8a70c Bump onedrive-personal-sdk to 0.0.16 (#156021) 2025-11-07 20:35:54 +00:00
epenet
ab9a8f3e53 Bump tuya-device-sharing-sdk to 0.2.5 (#156014) 2025-11-07 20:35:53 +00:00
johanzander
4e12628266 Fix Growatt integration authentication error for legacy config entries (#155993)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-07 20:35:51 +00:00
Simone Chemelli
e6d8d4de42 Bump aioamazondevices to 8.0.1 (#155989) 2025-11-07 20:35:50 +00:00
tronikos
6620b90eb4 Fix SolarEdge unload failing when there are no sensors (#155979) 2025-11-07 20:35:49 +00:00
tronikos
6fd3af8891 Handle empty fields in SolarEdge config flow (#155978) 2025-11-07 20:35:48 +00:00
Åke Strandberg
46979b8418 Fix for corrupt restored state in miele consumption sensors (#155966) 2025-11-07 20:35:47 +00:00
Marc Mueller
1718a11de2 Truncate password before sending it to bcrypt (#155950) 2025-11-07 20:35:45 +00:00
Matthias Alphart
2016b1d8c7 Fix KNX Climate humidity DPT (#155942) 2025-11-07 20:35:44 +00:00
puddly
4b72e45fc2 Remove @progress_step decorator from ZHA and Hardware integration (#155867)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-07 20:35:43 +00:00
Ståle Storø Hauknes
ead5ce905b Improve scan interval for Airthings Corentium Home 2 (#155694)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-07 20:35:42 +00:00
Franck Nijhof
f233f2da3f Bump version to 2025.11.0 2025-11-05 19:21:40 +00:00
49 changed files with 960 additions and 189 deletions

View File

@@ -179,12 +179,18 @@ class Data:
user_hash = base64.b64decode(found["password"]) user_hash = base64.b64decode(found["password"])
# bcrypt.checkpw is timing-safe # bcrypt.checkpw is timing-safe
if not bcrypt.checkpw(password.encode(), user_hash): # With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
# Previously the password was silently truncated.
# https://github.com/pyca/bcrypt/pull/1000
if not bcrypt.checkpw(password.encode()[:72], user_hash):
raise InvalidAuth raise InvalidAuth
def hash_password(self, password: str, for_storage: bool = False) -> bytes: def hash_password(self, password: str, for_storage: bool = False) -> bytes:
"""Encode a password.""" """Encode a password."""
hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) # With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
# Previously the password was silently truncated.
# https://github.com/pyca/bcrypt/pull/1000
hashed: bytes = bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt(rounds=12))
if for_storage: if for_storage:
hashed = base64.b64encode(hashed) hashed = base64.b64encode(hashed)

View File

@@ -23,7 +23,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN, MFCT_ID from .const import DEVICE_MODEL, DOMAIN, MFCT_ID
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -128,15 +128,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Confirm discovery.""" """Confirm discovery."""
assert self._discovered_device is not None
if user_input is not None: if user_input is not None:
if ( if self._discovered_device.device.firmware.need_firmware_upgrade:
self._discovered_device is not None
and self._discovered_device.device.firmware.need_firmware_upgrade
):
return self.async_abort(reason="firmware_upgrade_required") return self.async_abort(reason="firmware_upgrade_required")
return self.async_create_entry( return self.async_create_entry(
title=self.context["title_placeholders"]["name"], data={} title=self.context["title_placeholders"]["name"],
data={DEVICE_MODEL: self._discovered_device.device.model.value},
) )
self._set_confirm_only() self._set_confirm_only()
@@ -164,7 +164,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device = discovery self._discovered_device = discovery
return self.async_create_entry(title=discovery.name, data={}) return self.async_create_entry(
title=discovery.name,
data={DEVICE_MODEL: discovery.device.model.value},
)
current_addresses = self._async_current_ids(include_ignore=False) current_addresses = self._async_current_ids(include_ignore=False)
devices: list[BluetoothServiceInfoBleak] = [] devices: list[BluetoothServiceInfoBleak] = []

View File

@@ -1,11 +1,16 @@
"""Constants for Airthings BLE.""" """Constants for Airthings BLE."""
from airthings_ble import AirthingsDeviceType
DOMAIN = "airthings_ble" DOMAIN = "airthings_ble"
MFCT_ID = 820 MFCT_ID = 820
VOLUME_BECQUEREL = "Bq/m³" VOLUME_BECQUEREL = "Bq/m³"
VOLUME_PICOCURIE = "pCi/L" VOLUME_PICOCURIE = "pCi/L"
DEVICE_MODEL = "device_model"
DEFAULT_SCAN_INTERVAL = 300 DEFAULT_SCAN_INTERVAL = 300
DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800}
MAX_RETRIES_AFTER_STARTUP = 5 MAX_RETRIES_AFTER_STARTUP = 5

View File

@@ -16,7 +16,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .const import (
DEFAULT_SCAN_INTERVAL,
DEVICE_MODEL,
DEVICE_SPECIFIC_SCAN_INTERVAL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -34,12 +39,18 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
self.airthings = AirthingsBluetoothDeviceData( self.airthings = AirthingsBluetoothDeviceData(
_LOGGER, hass.config.units is METRIC_SYSTEM _LOGGER, hass.config.units is METRIC_SYSTEM
) )
device_model = entry.data.get(DEVICE_MODEL)
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
device_model, DEFAULT_SCAN_INTERVAL
)
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry, config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), update_interval=timedelta(seconds=interval),
) )
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
@@ -58,11 +69,29 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
) )
self.ble_device = ble_device self.ble_device = ble_device
if DEVICE_MODEL not in self.config_entry.data:
_LOGGER.debug("Fetching device info for migration")
try:
data = await self.airthings.update_device(self.ble_device)
except Exception as err:
raise UpdateFailed(
f"Unable to fetch data for migration: {err}"
) from err
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, DEVICE_MODEL: data.model.value},
)
self.update_interval = timedelta(
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
data.model.value, DEFAULT_SCAN_INTERVAL
)
)
async def _async_update_data(self) -> AirthingsDevice: async def _async_update_data(self) -> AirthingsDevice:
"""Get data from Airthings BLE.""" """Get data from Airthings BLE."""
try: try:
data = await self.airthings.update_device(self.ble_device) data = await self.airthings.update_device(self.ble_device)
except Exception as err: except Exception as err:
raise UpdateFailed(f"Unable to fetch data: {err}") from err raise UpdateFailed(f"Unable to fetch data: {err}") from err
return data return data

View File

@@ -6,8 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Final from typing import Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.metadata import SENSOR_STATE_OFF
from aioamazondevices.const import SENSOR_STATE_OFF from aioamazondevices.structures import AmazonDevice
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN, DOMAIN as BINARY_SENSOR_DOMAIN,

View File

@@ -2,12 +2,13 @@
from datetime import timedelta from datetime import timedelta
from aioamazondevices.api import AmazonDevice, AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import ( from aioamazondevices.exceptions import (
CannotAuthenticate, CannotAuthenticate,
CannotConnect, CannotConnect,
CannotRetrieveData, CannotRetrieveData,
) )
from aioamazondevices.structures import AmazonDevice
from aiohttp import ClientSession from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from aioamazondevices.api import AmazonDevice from aioamazondevices.structures import AmazonDevice
from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME

View File

@@ -1,7 +1,7 @@
"""Defines a base Alexa Devices entity.""" """Defines a base Alexa Devices entity."""
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL
from aioamazondevices.const import SPEAKER_GROUP_MODEL from aioamazondevices.structures import AmazonDevice
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioamazondevices"], "loggers": ["aioamazondevices"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioamazondevices==6.5.6"] "requirements": ["aioamazondevices==8.0.1"]
} }

View File

@@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Final from typing import Any, Final
from aioamazondevices.api import AmazonDevice, AmazonEchoApi from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.const import SPEAKER_GROUP_FAMILY from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.structures import AmazonDevice
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant

View File

@@ -7,12 +7,12 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Final from typing import Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.const.schedules import (
from aioamazondevices.const import (
NOTIFICATION_ALARM, NOTIFICATION_ALARM,
NOTIFICATION_REMINDER, NOTIFICATION_REMINDER,
NOTIFICATION_TIMER, NOTIFICATION_TIMER,
) )
from aioamazondevices.structures import AmazonDevice
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,

View File

@@ -1,6 +1,6 @@
"""Support for services.""" """Support for services."""
from aioamazondevices.sounds import SOUNDS_LIST from aioamazondevices.const.sounds import SOUNDS_LIST
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
from aioamazondevices.api import AmazonDevice from aioamazondevices.structures import AmazonDevice
from homeassistant.components.switch import ( from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN, DOMAIN as SWITCH_DOMAIN,

View File

@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps from functools import wraps
from typing import Any, Concatenate from typing import Any, Concatenate
from aioamazondevices.const import SPEAKER_GROUP_FAMILY from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity", "integration_type": "entity",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.10.28"] "requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.7"]
} }

View File

@@ -37,6 +37,7 @@ class FoscamDeviceInfo:
supports_speak_volume_adjustment: bool supports_speak_volume_adjustment: bool
supports_pet_adjustment: bool supports_pet_adjustment: bool
supports_car_adjustment: bool supports_car_adjustment: bool
supports_human_adjustment: bool
supports_wdr_adjustment: bool supports_wdr_adjustment: bool
supports_hdr_adjustment: bool supports_hdr_adjustment: bool
@@ -144,24 +145,32 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
if ret_sw == 0 if ret_sw == 0
else False else False
) )
ret_md, mothion_config_val = self.session.get_motion_detect_config() human_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities2")) & 128)
if ret_sw == 0
else False
)
ret_md, motion_config_val = self.session.get_motion_detect_config()
if pet_adjustment_val: if pet_adjustment_val:
is_pet_detection_on_val = ( is_pet_detection_on_val = (
mothion_config_val["petEnable"] == "1" if ret_md == 0 else False motion_config_val.get("petEnable") == "1" if ret_md == 0 else False
) )
else: else:
is_pet_detection_on_val = False is_pet_detection_on_val = False
if car_adjustment_val: if car_adjustment_val:
is_car_detection_on_val = ( is_car_detection_on_val = (
mothion_config_val["carEnable"] == "1" if ret_md == 0 else False motion_config_val.get("carEnable") == "1" if ret_md == 0 else False
) )
else: else:
is_car_detection_on_val = False is_car_detection_on_val = False
is_human_detection_on_val = ( if human_adjustment_val:
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False is_human_detection_on_val = (
) motion_config_val.get("humanEnable") == "1" if ret_md == 0 else False
)
else:
is_human_detection_on_val = False
return FoscamDeviceInfo( return FoscamDeviceInfo(
dev_info=dev_info, dev_info=dev_info,
@@ -179,6 +188,7 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val, supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
supports_pet_adjustment=pet_adjustment_val, supports_pet_adjustment=pet_adjustment_val,
supports_car_adjustment=car_adjustment_val, supports_car_adjustment=car_adjustment_val,
supports_human_adjustment=human_adjustment_val,
supports_hdr_adjustment=supports_hdr_adjustment_val, supports_hdr_adjustment=supports_hdr_adjustment_val,
supports_wdr_adjustment=supports_wdr_adjustment_val, supports_wdr_adjustment=supports_wdr_adjustment_val,
is_open_wdr=is_open_wdr, is_open_wdr=is_open_wdr,

View File

@@ -143,6 +143,7 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
native_value_fn=lambda data: data.is_human_detection_on, native_value_fn=lambda data: data.is_human_detection_on,
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False), turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True), turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
exists_fn=lambda coordinator: coordinator.data.supports_human_adjustment,
), ),
] ]

View File

@@ -136,6 +136,21 @@ async def async_setup_entry(
new_data[CONF_URL] = url new_data[CONF_URL] = url
hass.config_entries.async_update_entry(config_entry, data=new_data) hass.config_entries.async_update_entry(config_entry, data=new_data)
# Migrate legacy config entries without auth_type field
if CONF_AUTH_TYPE not in config:
new_data = dict(config_entry.data)
# Detect auth type based on which fields are present
if CONF_TOKEN in config:
new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN
elif CONF_USERNAME in config:
new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD
else:
raise ConfigEntryError(
"Unable to determine authentication type from config entry."
)
hass.config_entries.async_update_entry(config_entry, data=new_data)
config = config_entry.data
# Determine API version # Determine API version
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN: if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
api_version = "v1" api_version = "v1"

View File

@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
OptionsFlow, OptionsFlow,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, progress_step from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
@@ -97,6 +97,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self.addon_uninstall_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task[None] | None = None self.firmware_install_task: asyncio.Task[None] | None = None
self.installing_firmware_name: str | None = None self.installing_firmware_name: str | None = None
self._install_otbr_addon_task: asyncio.Task[None] | None = None
self._start_otbr_addon_task: asyncio.Task[None] | None = None
# Progress flow steps cannot abort so we need to store the abort reason and then
# re-raise it in a dedicated step
self._progress_error: AbortFlow | None = None
def _get_translation_placeholders(self) -> dict[str, str]: def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders.""" """Shared translation placeholders."""
@@ -106,6 +112,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
if self._probed_firmware_info is not None if self._probed_firmware_info is not None
else "unknown" else "unknown"
), ),
"firmware_name": (
self.installing_firmware_name
if self.installing_firmware_name is not None
else "unknown"
),
"model": self._hardware_name, "model": self._hardware_name,
} }
@@ -182,22 +193,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return self.async_show_progress( return self.async_show_progress(
step_id=step_id, step_id=step_id,
progress_action="install_firmware", progress_action="install_firmware",
description_placeholders={ description_placeholders=self._get_translation_placeholders(),
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
progress_task=self.firmware_install_task, progress_task=self.firmware_install_task,
) )
try: try:
await self.firmware_install_task await self.firmware_install_task
except AbortFlow as err: except AbortFlow as err:
return self.async_show_progress_done( self._progress_error = err
next_step_id=err.reason, return self.async_show_progress_done(next_step_id="progress_failed")
)
except HomeAssistantError: except HomeAssistantError:
_LOGGER.exception("Failed to flash firmware") _LOGGER.exception("Failed to flash firmware")
return self.async_show_progress_done(next_step_id="firmware_install_failed") self._progress_error = AbortFlow(
reason="fw_install_failed",
description_placeholders=self._get_translation_placeholders(),
)
return self.async_show_progress_done(next_step_id="progress_failed")
finally: finally:
self.firmware_install_task = None self.firmware_install_task = None
@@ -241,7 +252,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
_LOGGER.debug("Skipping firmware upgrade due to index download failure") _LOGGER.debug("Skipping firmware upgrade due to index download failure")
return return
raise AbortFlow(reason="firmware_download_failed") from err raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
if not firmware_install_required: if not firmware_install_required:
assert self._probed_firmware_info is not None assert self._probed_firmware_info is not None
@@ -270,7 +284,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return return
# Otherwise, fail # Otherwise, fail
raise AbortFlow(reason="firmware_download_failed") from err raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
self._probed_firmware_info = await async_flash_silabs_firmware( self._probed_firmware_info = await async_flash_silabs_firmware(
hass=self.hass, hass=self.hass,
@@ -313,41 +330,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
await otbr_manager.async_start_addon_waiting() await otbr_manager.async_start_addon_waiting()
async def async_step_firmware_download_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware download failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_download_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_firmware_install_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when firmware install failed."""
assert self.installing_firmware_name is not None
return self.async_abort(
reason="fw_install_failed",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": self.installing_firmware_name,
},
)
async def async_step_unsupported_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when unsupported firmware is detected."""
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
async def async_step_zigbee_installation_type( async def async_step_zigbee_installation_type(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -511,16 +493,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Thread firmware.""" """Install Thread firmware."""
raise NotImplementedError raise NotImplementedError
@progress_step( async def async_step_progress_failed(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon.""" """Abort when progress step failed."""
assert self._progress_error is not None
raise self._progress_error
async def _async_install_otbr_addon(self) -> None:
"""Do the work of installing the OTBR addon."""
addon_manager = get_otbr_addon_manager(self.hass) addon_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(addon_manager) addon_info = await self._async_get_addon_info(addon_manager)
@@ -538,18 +519,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
}, },
) from err ) from err
return await self.async_step_finish_thread_installation() async def async_step_install_otbr_addon(
@progress_step(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon.""" """Show progress dialog for installing the OTBR addon."""
if self._install_otbr_addon_task is None:
self._install_otbr_addon_task = self.hass.async_create_task(
self._async_install_otbr_addon(),
"Install OTBR addon",
)
if not self._install_otbr_addon_task.done():
return self.async_show_progress(
step_id="install_otbr_addon",
progress_action="install_otbr_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
},
progress_task=self._install_otbr_addon_task,
)
try:
await self._install_otbr_addon_task
except AbortFlow as err:
self._progress_error = err
return self.async_show_progress_done(next_step_id="progress_failed")
finally:
self._install_otbr_addon_task = None
return self.async_show_progress_done(next_step_id="finish_thread_installation")
async def _async_start_otbr_addon(self) -> None:
"""Do the work of starting the OTBR addon."""
try: try:
await self._configure_and_start_otbr_addon() await self._configure_and_start_otbr_addon()
except AddonError as err: except AddonError as err:
@@ -562,7 +564,36 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
}, },
) from err ) from err
return await self.async_step_pre_confirm_otbr() async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon."""
if self._start_otbr_addon_task is None:
self._start_otbr_addon_task = self.hass.async_create_task(
self._async_start_otbr_addon(),
"Start OTBR addon",
)
if not self._start_otbr_addon_task.done():
return self.async_show_progress(
step_id="start_otbr_addon",
progress_action="start_otbr_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
},
progress_task=self._start_otbr_addon_task,
)
try:
await self._start_otbr_addon_task
except AbortFlow as err:
self._progress_error = err
return self.async_show_progress_done(next_step_id="progress_failed")
finally:
self._start_otbr_addon_task = None
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
async def async_step_pre_confirm_otbr( async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None

View File

@@ -359,7 +359,7 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
write=False, state_required=True, valid_dpt="9.001" write=False, state_required=True, valid_dpt="9.001"
), ),
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector( vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
write=False, valid_dpt="9.002" write=False, valid_dpt="9.007"
), ),
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect( vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
GroupSelectOption( GroupSelectOption(

View File

@@ -943,13 +943,19 @@ class MieleConsumptionSensor(MieleRestorableSensor):
"""Update the last value of the sensor.""" """Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device) current_value = self.entity_description.value_fn(self.device)
current_status = StateStatus(self.device.state_status) current_status = StateStatus(self.device.state_status)
# Guard for corrupt restored value
restored_value = (
self._attr_native_value
if isinstance(self._attr_native_value, (int, float))
else 0
)
last_value = ( last_value = (
float(cast(str, self._attr_native_value)) float(cast(str, restored_value))
if self._attr_native_value is not None if self._attr_native_value is not None
else 0 else 0
) )
# force unknown when appliance is not able to report consumption # Force unknown when appliance is not able to report consumption
if current_status in ( if current_status in (
StateStatus.ON, StateStatus.ON,
StateStatus.OFF, StateStatus.OFF,

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"], "loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.0.15"] "requirements": ["onedrive-personal-sdk==0.0.16"]
} }

View File

@@ -57,4 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) -> bool:
"""Unload SolarEdge config entry.""" """Unload SolarEdge config entry."""
if DATA_API_CLIENT not in entry.runtime_data:
return True # Nothing to unload
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -133,8 +133,11 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN):
if api_key_ok and web_login_ok: if api_key_ok and web_login_ok:
data = {CONF_SITE_ID: site_id} data = {CONF_SITE_ID: site_id}
data.update(api_auth) if api_key:
data.update(web_auth) data[CONF_API_KEY] = api_key
if username:
data[CONF_USERNAME] = username
data[CONF_PASSWORD] = web_auth[CONF_PASSWORD]
if self.source == SOURCE_RECONFIGURE: if self.source == SOURCE_RECONFIGURE:
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@@ -43,5 +43,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["tuya_sharing"], "loggers": ["tuya_sharing"],
"requirements": ["tuya-device-sharing-sdk==0.2.4"] "requirements": ["tuya-device-sharing-sdk==0.2.5"]
} }

View File

@@ -39,7 +39,7 @@ from homeassistant.config_entries import (
) )
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow, progress_step from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
@@ -191,8 +191,14 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._hass = None # type: ignore[assignment] self._hass = None # type: ignore[assignment]
self._radio_mgr = ZhaRadioManager() self._radio_mgr = ZhaRadioManager()
self._restore_backup_task: asyncio.Task[None] | None = None self._restore_backup_task: asyncio.Task[None] | None = None
self._reset_old_radio_task: asyncio.Task[None] | None = None
self._form_network_task: asyncio.Task[None] | None = None
self._extra_network_config: dict[str, Any] = {} self._extra_network_config: dict[str, Any] = {}
# Progress flow steps cannot abort so we need to store the abort reason and then
# re-raise it in a dedicated step
self._progress_error: AbortFlow | None = None
@property @property
def hass(self) -> HomeAssistant: def hass(self) -> HomeAssistant:
"""Return hass.""" """Return hass."""
@@ -224,6 +230,13 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
async def _async_create_radio_entry(self) -> ConfigFlowResult: async def _async_create_radio_entry(self) -> ConfigFlowResult:
"""Create a config entry with the current flow state.""" """Create a config entry with the current flow state."""
async def async_step_progress_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when progress step failed."""
assert self._progress_error is not None
raise self._progress_error
async def async_step_choose_serial_port( async def async_step_choose_serial_port(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -464,7 +477,22 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._radio_mgr.chosen_backup = self._radio_mgr.backups[0] self._radio_mgr.chosen_backup = self._radio_mgr.backups[0]
return await self.async_step_maybe_reset_old_radio() return await self.async_step_maybe_reset_old_radio()
@progress_step() async def _async_reset_old_radio(self, config_entry: ConfigEntry) -> None:
"""Do the work of resetting the old radio."""
# Unload ZHA before connecting to the old adapter
with suppress(OperationNotAllowed):
await self.hass.config_entries.async_unload(config_entry.entry_id)
# Create a radio manager to connect to the old stick to reset it
temp_radio_mgr = ZhaRadioManager()
temp_radio_mgr.hass = self.hass
temp_radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
await temp_radio_mgr.async_reset_adapter()
async def async_step_maybe_reset_old_radio( async def async_step_maybe_reset_old_radio(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -475,30 +503,36 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
DOMAIN, include_ignore=False DOMAIN, include_ignore=False
) )
if config_entries: if not config_entries:
return await self.async_step_restore_backup()
if self._reset_old_radio_task is None:
# This will only ever be called during migration, so there must be an
# existing config entry
assert len(config_entries) == 1 assert len(config_entries) == 1
config_entry = config_entries[0] config_entry = config_entries[0]
# Unload ZHA before connecting to the old adapter self._reset_old_radio_task = self.hass.async_create_task(
with suppress(OperationNotAllowed): self._async_reset_old_radio(config_entry),
await self.hass.config_entries.async_unload(config_entry.entry_id) "Reset old radio",
)
# Create a radio manager to connect to the old stick to reset it if not self._reset_old_radio_task.done():
temp_radio_mgr = ZhaRadioManager() return self.async_show_progress(
temp_radio_mgr.hass = self.hass step_id="maybe_reset_old_radio",
temp_radio_mgr.device_path = config_entry.data[CONF_DEVICE][ progress_action="maybe_reset_old_radio",
CONF_DEVICE_PATH progress_task=self._reset_old_radio_task,
] )
temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
try: try:
await temp_radio_mgr.async_reset_adapter() await self._reset_old_radio_task
except HomeAssistantError: except HomeAssistantError:
# Old adapter not found or cannot connect, show prompt to plug back in # Old adapter not found or cannot connect, show prompt to plug back in
return await self.async_step_plug_in_old_radio() return self.async_show_progress_done(next_step_id="plug_in_old_radio")
finally:
self._reset_old_radio_task = None
return await self.async_step_restore_backup() return self.async_show_progress_done(next_step_id="restore_backup")
async def async_step_plug_in_old_radio( async def async_step_plug_in_old_radio(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -618,16 +652,35 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
# This step exists only for translations, it does nothing new # This step exists only for translations, it does nothing new
return await self.async_step_form_new_network(user_input) return await self.async_step_form_new_network(user_input)
@progress_step() async def _async_form_new_network(self) -> None:
"""Do the work of forming a new network."""
await self._radio_mgr.async_form_network(config=self._extra_network_config)
# Load the newly formed network settings to get the network info
await self._radio_mgr.async_load_network_settings()
async def async_step_form_new_network( async def async_step_form_new_network(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Form a brand-new network.""" """Form a brand-new network."""
await self._radio_mgr.async_form_network(config=self._extra_network_config) if self._form_network_task is None:
self._form_network_task = self.hass.async_create_task(
self._async_form_new_network(),
"Form new network",
)
# Load the newly formed network settings to get the network info if not self._form_network_task.done():
await self._radio_mgr.async_load_network_settings() return self.async_show_progress(
return await self._async_create_radio_entry() step_id="form_new_network",
progress_action="form_new_network",
progress_task=self._form_network_task,
)
try:
await self._form_network_task
finally:
self._form_network_task = None
return self.async_show_progress_done(next_step_id="create_entry")
def _parse_uploaded_backup( def _parse_uploaded_backup(
self, uploaded_file_id: str self, uploaded_file_id: str
@@ -735,10 +788,11 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
# User unplugged the new adapter, allow retry # User unplugged the new adapter, allow retry
return self.async_show_progress_done(next_step_id="pre_plug_in_new_radio") return self.async_show_progress_done(next_step_id="pre_plug_in_new_radio")
except CannotWriteNetworkSettings as exc: except CannotWriteNetworkSettings as exc:
return self.async_abort( self._progress_error = AbortFlow(
reason="cannot_restore_backup", reason="cannot_restore_backup",
description_placeholders={"error": str(exc)}, description_placeholders={"error": str(exc)},
) )
return self.async_show_progress_done(next_step_id="progress_failed")
finally: finally:
self._restore_backup_task = None self._restore_backup_task = None

View File

@@ -24,7 +24,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025 MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 11 MINOR_VERSION: Final = 11
PATCH_VERSION: Final = "0" PATCH_VERSION: Final = "1"
__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, 13, 2) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)

View File

@@ -40,7 +40,7 @@ hass-nabucasa==1.5.1
hassil==3.4.0 hassil==3.4.0
home-assistant-bluetooth==1.13.1 home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251105.0 home-assistant-frontend==20251105.0
home-assistant-intents==2025.10.28 home-assistant-intents==2025.11.7
httpx==0.28.1 httpx==0.28.1
ifaddr==0.2.0 ifaddr==0.2.0
Jinja2==3.1.6 Jinja2==3.1.6

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2025.11.0" version = "2025.11.1"
license = "Apache-2.0" license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."

8
requirements_all.txt generated
View File

@@ -194,7 +194,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.2 aioairzone==1.0.2
# homeassistant.components.alexa_devices # homeassistant.components.alexa_devices
aioamazondevices==6.5.6 aioamazondevices==8.0.1
# homeassistant.components.ambient_network # homeassistant.components.ambient_network
# homeassistant.components.ambient_station # homeassistant.components.ambient_station
@@ -1204,7 +1204,7 @@ holidays==0.84
home-assistant-frontend==20251105.0 home-assistant-frontend==20251105.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.10.28 home-assistant-intents==2025.11.7
# homeassistant.components.homematicip_cloud # homeassistant.components.homematicip_cloud
homematicip==2.3.1 homematicip==2.3.1
@@ -1630,7 +1630,7 @@ omnilogic==0.4.5
ondilo==0.5.0 ondilo==0.5.0
# homeassistant.components.onedrive # homeassistant.components.onedrive
onedrive-personal-sdk==0.0.15 onedrive-personal-sdk==0.0.16
# homeassistant.components.onvif # homeassistant.components.onvif
onvif-zeep-async==4.0.4 onvif-zeep-async==4.0.4
@@ -3051,7 +3051,7 @@ ttls==1.8.3
ttn_client==1.2.3 ttn_client==1.2.3
# homeassistant.components.tuya # homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.4 tuya-device-sharing-sdk==0.2.5
# homeassistant.components.twentemilieu # homeassistant.components.twentemilieu
twentemilieu==2.2.1 twentemilieu==2.2.1

View File

@@ -182,7 +182,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.2 aioairzone==1.0.2
# homeassistant.components.alexa_devices # homeassistant.components.alexa_devices
aioamazondevices==6.5.6 aioamazondevices==8.0.1
# homeassistant.components.ambient_network # homeassistant.components.ambient_network
# homeassistant.components.ambient_station # homeassistant.components.ambient_station
@@ -1053,7 +1053,7 @@ holidays==0.84
home-assistant-frontend==20251105.0 home-assistant-frontend==20251105.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.10.28 home-assistant-intents==2025.11.7
# homeassistant.components.homematicip_cloud # homeassistant.components.homematicip_cloud
homematicip==2.3.1 homematicip==2.3.1
@@ -1401,7 +1401,7 @@ omnilogic==0.4.5
ondilo==0.5.0 ondilo==0.5.0
# homeassistant.components.onedrive # homeassistant.components.onedrive
onedrive-personal-sdk==0.0.15 onedrive-personal-sdk==0.0.16
# homeassistant.components.onvif # homeassistant.components.onvif
onvif-zeep-async==4.0.4 onvif-zeep-async==4.0.4
@@ -2528,7 +2528,7 @@ ttls==1.8.3
ttn_client==1.2.3 ttn_client==1.2.3
# homeassistant.components.tuya # homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.4 tuya-device-sharing-sdk==0.2.5
# homeassistant.components.twentemilieu # homeassistant.components.twentemilieu
twentemilieu==2.2.1 twentemilieu==2.2.1

View File

@@ -32,7 +32,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \
go2rtc-client==0.2.1 \ go2rtc-client==0.2.1 \
ha-ffmpeg==3.2.2 \ ha-ffmpeg==3.2.2 \
hassil==3.4.0 \ hassil==3.4.0 \
home-assistant-intents==2025.10.28 \ home-assistant-intents==2025.11.7 \
mutagen==1.47.0 \ mutagen==1.47.0 \
pymicro-vad==1.0.1 \ pymicro-vad==1.0.1 \
pyspeex-noise==1.0.2 pyspeex-noise==1.0.2

View File

@@ -133,6 +133,24 @@ async def test_changing_password(data: hass_auth.Data) -> None:
data.validate_login("test-UsEr", "new-pass") data.validate_login("test-UsEr", "new-pass")
async def test_password_truncated(data: hass_auth.Data) -> None:
"""Test long passwords are truncated before they are send to bcrypt for hashing.
With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
Previously the password was silently truncated.
https://github.com/pyca/bcrypt/pull/1000
"""
pwd_truncated = "hWwjDpFiYtDTaaMbXdjzeuKAPI3G4Di2mC92" * 4 # 72 chars
long_pwd = pwd_truncated * 2 # 144 chars
data.add_auth("test-user", long_pwd)
data.validate_login("test-user", long_pwd)
# As pwd are truncated, login will technically work with only the first 72 bytes.
data.validate_login("test-user", pwd_truncated)
with pytest.raises(hass_auth.InvalidAuth):
data.validate_login("test-user", pwd_truncated[:71])
async def test_login_flow_validates(data: hass_auth.Data, hass: HomeAssistant) -> None: async def test_login_flow_validates(data: hass_auth.Data, hass: HomeAssistant) -> None:
"""Test login flow.""" """Test login flow."""
data.add_auth("test-user", "test-pass") data.add_auth("test-user", "test-pass")

View File

@@ -135,6 +135,27 @@ WAVE_ENHANCE_SERVICE_INFO = BluetoothServiceInfoBleak(
tx_power=0, tx_power=0,
) )
CORENTIUM_HOME_2_SERVICE_INFO = BluetoothServiceInfoBleak(
name="cc-cc-cc-cc-cc-cc",
address="cc:cc:cc:cc:cc:cc",
device=generate_ble_device(
address="cc:cc:cc:cc:cc:cc",
name="Airthings Corentium Home 2",
),
rssi=-61,
manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"},
service_data={},
service_uuids=[],
source="local",
advertisement=generate_advertisement_data(
manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"},
service_uuids=[],
),
connectable=True,
time=0,
tx_power=0,
)
VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak( VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak(
name="cc-cc-cc-cc-cc-cc", name="cc-cc-cc-cc-cc-cc",
address="cc:cc:cc:cc:cc:cc", address="cc:cc:cc:cc:cc:cc",
@@ -265,6 +286,24 @@ WAVE_ENHANCE_DEVICE_INFO = AirthingsDevice(
address="cc:cc:cc:cc:cc:cc", address="cc:cc:cc:cc:cc:cc",
) )
CORENTIUM_HOME_2_DEVICE_INFO = AirthingsDevice(
manufacturer="Airthings AS",
hw_version="REV X",
sw_version="R-SUB-1.3.4-master+0",
model=AirthingsDeviceType.CORENTIUM_HOME_2,
name="Airthings Corentium Home 2",
identifier="123456",
sensors={
"connectivity_mode": "Bluetooth",
"battery": 90,
"temperature": 20.0,
"humidity": 55.0,
"radon_1day_avg": 45,
"radon_1day_level": "low",
},
address="cc:cc:cc:cc:cc:cc",
)
TEMPERATURE_V1 = MockEntity( TEMPERATURE_V1 = MockEntity(
unique_id="Airthings Wave Plus 123456_temperature", unique_id="Airthings Wave Plus 123456_temperature",
name="Airthings Wave Plus 123456 Temperature", name="Airthings Wave Plus 123456 Temperature",

View File

@@ -7,7 +7,7 @@ from bleak import BleakError
from home_assistant_bluetooth import BluetoothServiceInfoBleak from home_assistant_bluetooth import BluetoothServiceInfoBleak
import pytest import pytest
from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.airthings_ble.const import DEVICE_MODEL, DOMAIN
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER
from homeassistant.const import CONF_ADDRESS from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -29,12 +29,13 @@ from tests.common import MockConfigEntry
async def test_bluetooth_discovery(hass: HomeAssistant) -> None: async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
"""Test discovery via bluetooth with a valid device.""" """Test discovery via bluetooth with a valid device."""
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
with ( with (
patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_async_ble_device_from_address(WAVE_SERVICE_INFO),
patch_airthings_ble( patch_airthings_ble(
AirthingsDevice( AirthingsDevice(
manufacturer="Airthings AS", manufacturer="Airthings AS",
model=AirthingsDeviceType.WAVE_PLUS, model=wave_plus_device,
name="Airthings Wave Plus", name="Airthings Wave Plus",
identifier="123456", identifier="123456",
) )
@@ -60,6 +61,8 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Airthings Wave Plus (2930123456)" assert result["title"] == "Airthings Wave Plus (2930123456)"
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
assert result["data"] == {DEVICE_MODEL: wave_plus_device.value}
assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value}
async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant) -> None: async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant) -> None:
@@ -118,6 +121,7 @@ async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None:
async def test_user_setup(hass: HomeAssistant) -> None: async def test_user_setup(hass: HomeAssistant) -> None:
"""Test the user initiated form.""" """Test the user initiated form."""
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
with ( with (
patch( patch(
"homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info",
@@ -127,7 +131,7 @@ async def test_user_setup(hass: HomeAssistant) -> None:
patch_airthings_ble( patch_airthings_ble(
AirthingsDevice( AirthingsDevice(
manufacturer="Airthings AS", manufacturer="Airthings AS",
model=AirthingsDeviceType.WAVE_PLUS, model=wave_plus_device,
name="Airthings Wave Plus", name="Airthings Wave Plus",
identifier="123456", identifier="123456",
) )
@@ -158,6 +162,8 @@ async def test_user_setup(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Airthings Wave Plus (2930123456)" assert result["title"] == "Airthings Wave Plus (2930123456)"
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
assert result["data"] == {DEVICE_MODEL: wave_plus_device.value}
assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value}
async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
@@ -168,6 +174,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
source=SOURCE_IGNORE, source=SOURCE_IGNORE,
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
with ( with (
patch( patch(
"homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info",
@@ -177,7 +184,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
patch_airthings_ble( patch_airthings_ble(
AirthingsDevice( AirthingsDevice(
manufacturer="Airthings AS", manufacturer="Airthings AS",
model=AirthingsDeviceType.WAVE_PLUS, model=wave_plus_device,
name="Airthings Wave Plus", name="Airthings Wave Plus",
identifier="123456", identifier="123456",
) )
@@ -208,6 +215,8 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Airthings Wave Plus (2930123456)" assert result["title"] == "Airthings Wave Plus (2930123456)"
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
assert result["data"] == {DEVICE_MODEL: wave_plus_device.value}
assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value}
async def test_user_setup_no_device(hass: HomeAssistant) -> None: async def test_user_setup_no_device(hass: HomeAssistant) -> None:

View File

@@ -0,0 +1,192 @@
"""Test the Airthings BLE integration init."""
from copy import deepcopy
from airthings_ble import AirthingsDeviceType
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.airthings_ble.const import (
DEFAULT_SCAN_INTERVAL,
DEVICE_MODEL,
DEVICE_SPECIFIC_SCAN_INTERVAL,
DOMAIN,
)
from homeassistant.core import HomeAssistant
from . import (
CORENTIUM_HOME_2_DEVICE_INFO,
CORENTIUM_HOME_2_SERVICE_INFO,
WAVE_DEVICE_INFO,
WAVE_ENHANCE_DEVICE_INFO,
WAVE_ENHANCE_SERVICE_INFO,
WAVE_SERVICE_INFO,
patch_airthings_ble,
patch_async_ble_device_from_address,
)
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.mark.parametrize(
("service_info", "device_info"),
[
(WAVE_SERVICE_INFO, WAVE_DEVICE_INFO),
(WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO),
(CORENTIUM_HOME_2_SERVICE_INFO, CORENTIUM_HOME_2_DEVICE_INFO),
],
)
async def test_migration_existing_entries(
hass: HomeAssistant,
service_info,
device_info,
) -> None:
"""Test migration of existing config entry without device model."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=service_info.address,
data={},
)
entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, service_info)
assert DEVICE_MODEL not in entry.data
with (
patch_async_ble_device_from_address(service_info.device),
patch_airthings_ble(device_info),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Migration should have added device_model to entry data
assert DEVICE_MODEL in entry.data
assert entry.data[DEVICE_MODEL] == device_info.model.value
async def test_no_migration_when_device_model_exists(
hass: HomeAssistant,
) -> None:
"""Test that migration does not run when device_model already exists."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=WAVE_SERVICE_INFO.address,
data={DEVICE_MODEL: WAVE_DEVICE_INFO.model.value},
)
entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, WAVE_SERVICE_INFO)
with (
patch_async_ble_device_from_address(WAVE_SERVICE_INFO.device),
patch_airthings_ble(WAVE_DEVICE_INFO) as mock_update,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Should have only 1 call for initial refresh (no migration call)
assert mock_update.call_count == 1
assert entry.data[DEVICE_MODEL] == WAVE_DEVICE_INFO.model.value
async def test_scan_interval_corentium_home_2(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test that coordinator uses radon scan interval for Corentium Home 2."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=WAVE_SERVICE_INFO.address,
data={DEVICE_MODEL: CORENTIUM_HOME_2_DEVICE_INFO.model.value},
)
entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, WAVE_SERVICE_INFO)
with (
patch_async_ble_device_from_address(WAVE_SERVICE_INFO.device),
patch_airthings_ble(CORENTIUM_HOME_2_DEVICE_INFO),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.airthings_corentium_home_2_123456_battery").state
== "90"
)
changed_info = deepcopy(CORENTIUM_HOME_2_DEVICE_INFO)
changed_info.sensors["battery"] = 89
with patch_airthings_ble(changed_info):
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.airthings_corentium_home_2_123456_battery").state
== "90"
)
freezer.tick(
DEVICE_SPECIFIC_SCAN_INTERVAL.get(
AirthingsDeviceType.CORENTIUM_HOME_2.value
)
- DEFAULT_SCAN_INTERVAL
)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.airthings_corentium_home_2_123456_battery").state
== "89"
)
@pytest.mark.parametrize(
("service_info", "device_info", "battery_entity_id"),
[
(WAVE_SERVICE_INFO, WAVE_DEVICE_INFO, "sensor.airthings_wave_123456_battery"),
(
WAVE_ENHANCE_SERVICE_INFO,
WAVE_ENHANCE_DEVICE_INFO,
"sensor.airthings_wave_enhance_123456_battery",
),
],
)
async def test_coordinator_default_scan_interval(
hass: HomeAssistant,
service_info,
device_info,
freezer: FrozenDateTimeFactory,
battery_entity_id: str,
) -> None:
"""Test that coordinator uses default scan interval."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=service_info.address,
data={DEVICE_MODEL: device_info.model.value},
)
entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, service_info)
with (
patch_async_ble_device_from_address(service_info.device),
patch_airthings_ble(device_info),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(battery_entity_id).state == "85"
changed_info = deepcopy(device_info)
changed_info.sensors["battery"] = 84
with patch_airthings_ble(changed_info):
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(battery_entity_id).state == "84"

View File

@@ -1,10 +1,17 @@
"""Test the Airthings Wave sensor.""" """Test the Airthings Wave sensor."""
from datetime import timedelta
import logging import logging
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.airthings_ble.const import (
DEFAULT_SCAN_INTERVAL,
DEVICE_MODEL,
DEVICE_SPECIFIC_SCAN_INTERVAL,
DOMAIN,
)
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -12,6 +19,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import ( from . import (
CO2_V1, CO2_V1,
CO2_V2, CO2_V2,
CORENTIUM_HOME_2_DEVICE_INFO,
HUMIDITY_V2, HUMIDITY_V2,
TEMPERATURE_V1, TEMPERATURE_V1,
VOC_V1, VOC_V1,
@@ -21,6 +29,8 @@ from . import (
WAVE_ENHANCE_DEVICE_INFO, WAVE_ENHANCE_DEVICE_INFO,
WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_SERVICE_INFO,
WAVE_SERVICE_INFO, WAVE_SERVICE_INFO,
AirthingsDevice,
BluetoothServiceInfoBleak,
create_device, create_device,
create_entry, create_entry,
patch_airthings_ble, patch_airthings_ble,
@@ -29,6 +39,7 @@ from . import (
patch_async_discovered_service_info, patch_async_discovered_service_info,
) )
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.bluetooth import inject_bluetooth_service_info from tests.components.bluetooth import inject_bluetooth_service_info
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -267,3 +278,102 @@ async def test_translation_keys(
expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_name}" expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_name}"
assert state.attributes.get("friendly_name") == expected_name assert state.attributes.get("friendly_name") == expected_name
async def test_scan_interval_migration_corentium_home_2(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that radon device migration uses 30-minute scan interval."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=WAVE_SERVICE_INFO.address,
data={},
)
entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, WAVE_SERVICE_INFO)
with (
patch_async_ble_device_from_address(WAVE_SERVICE_INFO.device),
patch_airthings_ble(CORENTIUM_HOME_2_DEVICE_INFO) as mock_update,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Migration should have added device_model to entry data
assert DEVICE_MODEL in entry.data
assert entry.data[DEVICE_MODEL] == CORENTIUM_HOME_2_DEVICE_INFO.model.value
# Coordinator should have been configured with radon scan interval
coordinator = entry.runtime_data
assert coordinator.update_interval == timedelta(
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
CORENTIUM_HOME_2_DEVICE_INFO.model.value
)
)
# Should have 2 calls: 1 for migration + 1 for initial refresh
assert mock_update.call_count == 2
# Fast forward by default interval (300s) - should NOT trigger update
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_update.call_count == 2
# Fast forward to radon interval (1800s) - should trigger update
freezer.tick(
DEVICE_SPECIFIC_SCAN_INTERVAL.get(CORENTIUM_HOME_2_DEVICE_INFO.model.value)
)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_update.call_count == 3
@pytest.mark.parametrize(
("service_info", "device_info"),
[
(WAVE_SERVICE_INFO, WAVE_DEVICE_INFO),
(WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO),
],
)
async def test_default_scan_interval_migration(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_info: BluetoothServiceInfoBleak,
device_info: AirthingsDevice,
) -> None:
"""Test that non-radon device migration uses default 5-minute scan interval."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=service_info.address,
data={},
)
entry.add_to_hass(hass)
inject_bluetooth_service_info(hass, service_info)
with (
patch_async_ble_device_from_address(service_info.device),
patch_airthings_ble(device_info) as mock_update,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Migration should have added device_model to entry data
assert DEVICE_MODEL in entry.data
assert entry.data[DEVICE_MODEL] == device_info.model.value
# Coordinator should have been configured with default scan interval
coordinator = entry.runtime_data
assert coordinator.update_interval == timedelta(seconds=DEFAULT_SCAN_INTERVAL)
# Should have 2 calls: 1 for migration + 1 for initial refresh
assert mock_update.call_count == 2
# Fast forward by default interval (300s) - SHOULD trigger update
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_update.call_count == 3

View File

@@ -4,7 +4,7 @@ from collections.abc import Generator
from copy import deepcopy from copy import deepcopy
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from aioamazondevices.const import DEVICE_TYPE_TO_MODEL from aioamazondevices.const.devices import DEVICE_TYPE_TO_MODEL
import pytest import pytest
from homeassistant.components.alexa_devices.const import ( from homeassistant.components.alexa_devices.const import (

View File

@@ -2,12 +2,12 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor, AmazonSchedule from aioamazondevices.const.schedules import (
from aioamazondevices.const import (
NOTIFICATION_ALARM, NOTIFICATION_ALARM,
NOTIFICATION_REMINDER, NOTIFICATION_REMINDER,
NOTIFICATION_TIMER, NOTIFICATION_TIMER,
) )
from aioamazondevices.structures import AmazonDevice, AmazonDeviceSensor, AmazonSchedule
TEST_CODE = "023123" TEST_CODE = "023123"
TEST_PASSWORD = "fake_password" TEST_PASSWORD = "fake_password"

View File

@@ -14,11 +14,11 @@
'online': True, 'online': True,
'sensors': dict({ 'sensors': dict({
'dnd': dict({ 'dnd': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>", '__type': "<class 'aioamazondevices.structures.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)", 'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)",
}), }),
'temperature': dict({ 'temperature': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>", '__type': "<class 'aioamazondevices.structures.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')", 'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')",
}), }),
}), }),
@@ -44,11 +44,11 @@
'online': True, 'online': True,
'sensors': dict({ 'sensors': dict({
'dnd': dict({ 'dnd': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>", '__type': "<class 'aioamazondevices.structures.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)", 'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)",
}), }),
'temperature': dict({ 'temperature': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>", '__type': "<class 'aioamazondevices.structures.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')", 'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')",
}), }),
}), }),

View File

@@ -2,7 +2,7 @@
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from aioamazondevices.const import SPEAKER_GROUP_FAMILY, SPEAKER_GROUP_MODEL from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY, SPEAKER_GROUP_MODEL
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
import pytest import pytest

View File

@@ -79,7 +79,7 @@ def setup_mock_foscam_camera(mock_foscam_camera):
0, 0,
{ {
"swCapabilities1": "100", "swCapabilities1": "100",
"swCapabilities2": "768", "swCapabilities2": "896",
"swCapabilities3": "100", "swCapabilities3": "100",
"swCapabilities4": "100", "swCapabilities4": "100",
}, },

View File

@@ -9,8 +9,14 @@ import growattServer
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.growatt_server.const import DOMAIN from homeassistant.components.growatt_server.const import (
AUTH_API_TOKEN,
AUTH_PASSWORD,
CONF_AUTH_TYPE,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
@@ -174,3 +180,79 @@ async def test_multiple_devices_discovered(
assert device1 == snapshot(name="device_min123456") assert device1 == snapshot(name="device_min123456")
assert device2 is not None assert device2 is not None
assert device2 == snapshot(name="device_min789012") assert device2 == snapshot(name="device_min789012")
async def test_migrate_legacy_api_token_config(
hass: HomeAssistant,
mock_growatt_v1_api,
) -> None:
"""Test migration of legacy config entry with API token but no auth_type."""
# Create a legacy config entry without CONF_AUTH_TYPE
legacy_config = {
CONF_TOKEN: "test_token_123",
CONF_URL: "https://openapi.growatt.com/",
"plant_id": "plant_123",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=legacy_config,
unique_id="plant_123",
)
await setup_integration(hass, mock_config_entry)
# Verify migration occurred and auth_type was added
assert mock_config_entry.data[CONF_AUTH_TYPE] == AUTH_API_TOKEN
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_migrate_legacy_password_config(
hass: HomeAssistant,
mock_growatt_classic_api,
) -> None:
"""Test migration of legacy config entry with password auth but no auth_type."""
# Create a legacy config entry without CONF_AUTH_TYPE
legacy_config = {
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
"plant_id": "plant_456",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=legacy_config,
unique_id="plant_456",
)
# Classic API doesn't support MIN devices - use TLX device instead
mock_growatt_classic_api.device_list.return_value = [
{"deviceSn": "TLX123456", "deviceType": "tlx"}
]
await setup_integration(hass, mock_config_entry)
# Verify migration occurred and auth_type was added
assert mock_config_entry.data[CONF_AUTH_TYPE] == AUTH_PASSWORD
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_migrate_legacy_config_no_auth_fields(
hass: HomeAssistant,
) -> None:
"""Test that config entry with no recognizable auth fields raises error."""
# Create a config entry without any auth fields
invalid_config = {
CONF_URL: "https://openapi.growatt.com/",
"plant_id": "plant_789",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=invalid_config,
unique_id="plant_789",
)
await setup_integration(hass, mock_config_entry)
# The ConfigEntryError is caught by the config entry system
# and the entry state is set to SETUP_ERROR
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR

View File

@@ -792,10 +792,11 @@ async def test_config_flow_thread(
assert pick_result["type"] is FlowResultType.SHOW_PROGRESS assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
assert pick_result["progress_action"] == "install_firmware" assert pick_result["progress_action"] == "install_firmware"
assert pick_result["step_id"] == "install_thread_firmware" assert pick_result["step_id"] == "install_thread_firmware"
description_placeholders = pick_result["description_placeholders"] assert pick_result["description_placeholders"] == {
assert description_placeholders is not None "firmware_type": "ezsp",
assert description_placeholders["firmware_type"] == "ezsp" "model": TEST_HARDWARE_NAME,
assert description_placeholders["model"] == TEST_HARDWARE_NAME "firmware_name": "Thread",
}
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
@@ -919,10 +920,11 @@ async def test_options_flow_zigbee_to_thread(
result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.MENU assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "pick_firmware" assert result["step_id"] == "pick_firmware"
description_placeholders = result["description_placeholders"] assert result["description_placeholders"] == {
assert description_placeholders is not None "firmware_type": "ezsp",
assert description_placeholders["firmware_type"] == "ezsp" "model": TEST_HARDWARE_NAME,
assert description_placeholders["model"] == TEST_HARDWARE_NAME "firmware_name": "unknown",
}
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
@@ -995,10 +997,11 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None:
result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.MENU assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "pick_firmware" assert result["step_id"] == "pick_firmware"
description_placeholders = result["description_placeholders"] assert result["description_placeholders"] == {
assert description_placeholders is not None "firmware_type": "spinel",
assert description_placeholders["firmware_type"] == "spinel" "model": TEST_HARDWARE_NAME,
assert description_placeholders["model"] == TEST_HARDWARE_NAME "firmware_name": "unknown",
}
with mock_firmware_info( with mock_firmware_info(
probe_app_type=ApplicationType.SPINEL, probe_app_type=ApplicationType.SPINEL,

View File

@@ -69,6 +69,11 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "not_hassio_thread" assert result["reason"] == "not_hassio_thread"
assert result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "spinel",
"firmware_name": "Thread",
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -110,6 +115,12 @@ async def test_config_flow_thread_addon_info_fails(
# Cannot get addon info # Cannot get addon info
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "addon_info_failed" assert result["reason"] == "addon_info_failed"
assert result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "spinel",
"firmware_name": "Thread",
"addon_name": "OpenThread Border Router",
}
@pytest.mark.usefixtures("addon_not_installed") @pytest.mark.usefixtures("addon_not_installed")
@@ -155,6 +166,12 @@ async def test_config_flow_thread_addon_install_fails(
# Cannot install addon # Cannot install addon
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "addon_install_failed" assert result["reason"] == "addon_install_failed"
assert result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "ezsp",
"firmware_name": "Thread",
"addon_name": "OpenThread Border Router",
}
@pytest.mark.usefixtures("addon_installed") @pytest.mark.usefixtures("addon_installed")
@@ -196,6 +213,12 @@ async def test_config_flow_thread_addon_set_config_fails(
assert pick_thread_progress_result["type"] == FlowResultType.ABORT assert pick_thread_progress_result["type"] == FlowResultType.ABORT
assert pick_thread_progress_result["reason"] == "addon_set_config_failed" assert pick_thread_progress_result["reason"] == "addon_set_config_failed"
assert pick_thread_progress_result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "ezsp",
"firmware_name": "Thread",
"addon_name": "OpenThread Border Router",
}
@pytest.mark.usefixtures("addon_installed") @pytest.mark.usefixtures("addon_installed")
@@ -236,6 +259,12 @@ async def test_config_flow_thread_flasher_run_fails(
assert pick_thread_progress_result["type"] == FlowResultType.ABORT assert pick_thread_progress_result["type"] == FlowResultType.ABORT
assert pick_thread_progress_result["reason"] == "addon_start_failed" assert pick_thread_progress_result["reason"] == "addon_start_failed"
assert pick_thread_progress_result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "ezsp",
"firmware_name": "Thread",
"addon_name": "OpenThread Border Router",
}
@pytest.mark.usefixtures("addon_running") @pytest.mark.usefixtures("addon_running")
@@ -273,6 +302,11 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non
assert pick_thread_progress_result["type"] is FlowResultType.ABORT assert pick_thread_progress_result["type"] is FlowResultType.ABORT
assert pick_thread_progress_result["reason"] == "fw_install_failed" assert pick_thread_progress_result["reason"] == "fw_install_failed"
assert pick_thread_progress_result["description_placeholders"] == {
"firmware_name": "Thread",
"model": TEST_HARDWARE_NAME,
"firmware_type": "ezsp",
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -310,6 +344,11 @@ async def test_config_flow_firmware_index_download_fails_and_required(
assert pick_result["type"] is FlowResultType.ABORT assert pick_result["type"] is FlowResultType.ABORT
assert pick_result["reason"] == "fw_download_failed" assert pick_result["reason"] == "fw_download_failed"
assert pick_result["description_placeholders"] == {
"firmware_name": "Zigbee",
"model": TEST_HARDWARE_NAME,
"firmware_type": "spinel",
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -347,6 +386,11 @@ async def test_config_flow_firmware_download_fails_and_required(
assert pick_result["type"] is FlowResultType.ABORT assert pick_result["type"] is FlowResultType.ABORT
assert pick_result["reason"] == "fw_download_failed" assert pick_result["reason"] == "fw_download_failed"
assert pick_result["description_placeholders"] == {
"firmware_name": "Zigbee",
"model": TEST_HARDWARE_NAME,
"firmware_type": "spinel",
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -395,6 +439,11 @@ async def test_options_flow_zigbee_to_thread_zha_configured(
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "zha_still_using_stick" assert result["reason"] == "zha_still_using_stick"
assert result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "ezsp",
"firmware_name": "unknown",
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -442,3 +491,8 @@ async def test_options_flow_thread_to_zigbee_otbr_configured(
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "otbr_still_using_stick" assert result["reason"] == "otbr_still_using_stick"
assert result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "spinel",
"firmware_name": "unknown",
}

View File

@@ -122,7 +122,7 @@
'validDPTs': list([ 'validDPTs': list([
dict({ dict({
'main': 9, 'main': 9,
'sub': 2, 'sub': 7,
}), }),
]), ]),
'write': False, 'write': False,

View File

@@ -54,6 +54,10 @@ async def test_user_api_key(
CONF_NAME: NAME, CONF_NAME: NAME,
CONF_SITE_ID: SITE_ID, CONF_SITE_ID: SITE_ID,
CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY}, CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY},
CONF_SECTION_WEB_AUTH: {
CONF_USERNAME: "",
CONF_PASSWORD: "",
},
}, },
) )
assert result.get("type") is FlowResultType.CREATE_ENTRY assert result.get("type") is FlowResultType.CREATE_ENTRY
@@ -85,6 +89,7 @@ async def test_user_web_login(
{ {
CONF_NAME: NAME, CONF_NAME: NAME,
CONF_SITE_ID: SITE_ID, CONF_SITE_ID: SITE_ID,
CONF_SECTION_API_AUTH: {CONF_API_KEY: ""},
CONF_SECTION_WEB_AUTH: { CONF_SECTION_WEB_AUTH: {
CONF_USERNAME: USERNAME, CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD, CONF_PASSWORD: PASSWORD,

View File

@@ -1,6 +1,6 @@
"""Tests for the SolarEdge integration.""" """Tests for the SolarEdge integration."""
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock, patch
from aiohttp import ClientError from aiohttp import ClientError
@@ -15,8 +15,15 @@ from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@patch(
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
return_value=True,
)
async def test_setup_unload_api_key( async def test_setup_unload_api_key(
recorder_mock: Recorder, hass: HomeAssistant, solaredge_api: Mock mock_unload_platforms: AsyncMock,
recorder_mock: Recorder,
hass: HomeAssistant,
solaredge_api: Mock,
) -> None: ) -> None:
"""Test successful setup and unload of a config entry with API key.""" """Test successful setup and unload of a config entry with API key."""
entry = MockConfigEntry( entry = MockConfigEntry(
@@ -33,11 +40,21 @@ async def test_setup_unload_api_key(
assert await hass.config_entries.async_unload(entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
# Unloading should be attempted because sensors were set up.
mock_unload_platforms.assert_awaited_once()
assert entry.state is ConfigEntryState.NOT_LOADED assert entry.state is ConfigEntryState.NOT_LOADED
@patch(
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
return_value=True,
)
async def test_setup_unload_web_login( async def test_setup_unload_web_login(
recorder_mock: Recorder, hass: HomeAssistant, solaredge_web_api: AsyncMock mock_unload_platforms: AsyncMock,
recorder_mock: Recorder,
hass: HomeAssistant,
solaredge_web_api: AsyncMock,
) -> None: ) -> None:
"""Test successful setup and unload of a config entry with web login.""" """Test successful setup and unload of a config entry with web login."""
entry = MockConfigEntry( entry = MockConfigEntry(
@@ -59,10 +76,18 @@ async def test_setup_unload_web_login(
assert await hass.config_entries.async_unload(entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
# Unloading should NOT be attempted because sensors were not set up.
mock_unload_platforms.assert_not_called()
assert entry.state is ConfigEntryState.NOT_LOADED assert entry.state is ConfigEntryState.NOT_LOADED
@patch(
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
return_value=True,
)
async def test_setup_unload_both( async def test_setup_unload_both(
mock_unload_platforms: AsyncMock,
recorder_mock: Recorder, recorder_mock: Recorder,
hass: HomeAssistant, hass: HomeAssistant,
solaredge_api: Mock, solaredge_api: Mock,
@@ -90,6 +115,8 @@ async def test_setup_unload_both(
assert await hass.config_entries.async_unload(entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
mock_unload_platforms.assert_awaited_once()
assert entry.state is ConfigEntryState.NOT_LOADED assert entry.state is ConfigEntryState.NOT_LOADED

View File

@@ -215,6 +215,15 @@ async def consume_progress_flow(
return result return result
class DelayedAsyncMock(AsyncMock):
"""AsyncMock that waits a moment before returning, useful for progress steps."""
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""Overridden `__call__` with an added delay."""
await asyncio.sleep(0)
return await super().__call__(*args, **kwargs)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("entry_name", "unique_id", "radio_type", "service_info"), ("entry_name", "unique_id", "radio_type", "service_info"),
[ [
@@ -720,6 +729,7 @@ async def test_migration_strategy_recommended_cannot_write(
with patch( with patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=CannotWriteNetworkSettings("test error"), side_effect=CannotWriteNetworkSettings("test error"),
) as mock_restore_backup: ) as mock_restore_backup:
result_migrate = await hass.config_entries.flow.async_configure( result_migrate = await hass.config_entries.flow.async_configure(
@@ -1735,7 +1745,7 @@ async def test_strategy_no_network_settings(
advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant
) -> None: ) -> None:
"""Test formation strategy when no network settings are present.""" """Test formation strategy when no network settings are present."""
mock_app.load_network_info = MagicMock(side_effect=NetworkNotFormed()) mock_app.load_network_info = DelayedAsyncMock(side_effect=NetworkNotFormed())
result = await advanced_pick_radio(RadioType.ezsp) result = await advanced_pick_radio(RadioType.ezsp)
assert ( assert (
@@ -1773,7 +1783,7 @@ async def test_formation_strategy_form_initial_network(
) -> None: ) -> None:
"""Test forming a new network, with no previous settings on the radio.""" """Test forming a new network, with no previous settings on the radio."""
# Initially, no network is formed # Initially, no network is formed
mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) mock_app.load_network_info = DelayedAsyncMock(side_effect=NetworkNotFormed())
# After form_network is called, load_network_info should return the network settings # After form_network is called, load_network_info should return the network settings
async def form_network_side_effect(*args, **kwargs): async def form_network_side_effect(*args, **kwargs):
@@ -1807,7 +1817,7 @@ async def test_onboarding_auto_formation_new_hardware(
) -> None: ) -> None:
"""Test auto network formation with new hardware during onboarding.""" """Test auto network formation with new hardware during onboarding."""
# Initially, no network is formed # Initially, no network is formed
mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) mock_app.load_network_info = DelayedAsyncMock(side_effect=NetworkNotFormed())
mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device)) mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device))
# After form_network is called, load_network_info should return the network settings # After form_network is called, load_network_info should return the network settings
@@ -1951,6 +1961,7 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
), ),
patch( patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=[ side_effect=[
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
None, None,
@@ -1981,8 +1992,14 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
) )
assert result_confirm["type"] is FlowResultType.CREATE_ENTRY result_final = await consume_progress_flow(
assert result_confirm["data"][CONF_RADIO_TYPE] == "ezsp" hass,
flow_id=result_confirm["flow_id"],
valid_step_ids=("restore_backup",),
)
assert result_final["type"] is FlowResultType.CREATE_ENTRY
assert result_final["data"][CONF_RADIO_TYPE] == "ezsp"
assert mock_restore_backup.call_count == 1 assert mock_restore_backup.call_count == 1
assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True
@@ -2014,6 +2031,7 @@ async def test_formation_strategy_restore_manual_backup_ezsp(
), ),
patch( patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=[ side_effect=[
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
None, None,
@@ -2316,6 +2334,7 @@ async def test_options_flow_defaults(
# ZHA gets unloaded # ZHA gets unloaded
with patch( with patch(
"homeassistant.config_entries.ConfigEntries.async_unload", "homeassistant.config_entries.ConfigEntries.async_unload",
new_callable=DelayedAsyncMock,
side_effect=[async_unload_effect], side_effect=[async_unload_effect],
) as mock_async_unload: ) as mock_async_unload:
result1 = await hass.config_entries.options.async_configure( result1 = await hass.config_entries.options.async_configure(
@@ -2853,6 +2872,7 @@ async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None:
patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True),
patch( patch(
"homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info", "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info",
new_callable=DelayedAsyncMock,
side_effect=AddonError, side_effect=AddonError,
), ),
patch( patch(
@@ -3075,6 +3095,7 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ
), ),
patch( patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=[ side_effect=[
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
CannotWriteNetworkSettings("Failed to write settings"), CannotWriteNetworkSettings("Failed to write settings"),
@@ -3100,11 +3121,17 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ
assert confirm_restore_result["type"] is FlowResultType.FORM assert confirm_restore_result["type"] is FlowResultType.FORM
assert confirm_restore_result["step_id"] == "confirm_ezsp_ieee_overwrite" assert confirm_restore_result["step_id"] == "confirm_ezsp_ieee_overwrite"
final_result = await hass.config_entries.flow.async_configure( confirm_result = await hass.config_entries.flow.async_configure(
confirm_restore_result["flow_id"], confirm_restore_result["flow_id"],
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
) )
final_result = await consume_progress_flow(
hass,
flow_id=confirm_result["flow_id"],
valid_step_ids=("restore_backup",),
)
assert final_result["type"] is FlowResultType.ABORT assert final_result["type"] is FlowResultType.ABORT
assert final_result["reason"] == "cannot_restore_backup" assert final_result["reason"] == "cannot_restore_backup"
assert ( assert (
@@ -3191,6 +3218,7 @@ async def test_plug_in_new_radio_retry(
), ),
patch( patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=[ side_effect=[
HomeAssistantError( HomeAssistantError(
"Failed to connect to Zigbee adapter: [Errno 2] No such file or directory" "Failed to connect to Zigbee adapter: [Errno 2] No such file or directory"
@@ -3203,43 +3231,67 @@ async def test_plug_in_new_radio_retry(
], ],
) as mock_restore_backup, ) as mock_restore_backup,
): ):
result3 = await hass.config_entries.flow.async_configure( upload_result = await hass.config_entries.flow.async_configure(
result2["flow_id"], result2["flow_id"],
user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())},
) )
result3 = await consume_progress_flow(
hass,
flow_id=upload_result["flow_id"],
valid_step_ids=("restore_backup",),
)
# Prompt user to plug old adapter back in when restore fails # Prompt user to plug old adapter back in when restore fails
assert result3["type"] is FlowResultType.FORM assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "plug_in_new_radio" assert result3["step_id"] == "plug_in_new_radio"
assert result3["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"} assert result3["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"}
# Submit retry attempt with plugged in adapter # Submit retry attempt with plugged in adapter
result4 = await hass.config_entries.flow.async_configure( retry_result = await hass.config_entries.flow.async_configure(
result3["flow_id"], result3["flow_id"],
user_input={}, user_input={},
) )
result4 = await consume_progress_flow(
hass,
flow_id=retry_result["flow_id"],
valid_step_ids=("restore_backup",),
)
# This adapter requires user confirmation for restore # This adapter requires user confirmation for restore
assert result4["type"] is FlowResultType.FORM assert result4["type"] is FlowResultType.FORM
assert result4["step_id"] == "confirm_ezsp_ieee_overwrite" assert result4["step_id"] == "confirm_ezsp_ieee_overwrite"
# Confirm destructive rewrite, but adapter is unplugged again # Confirm destructive rewrite, but adapter is unplugged again
result5 = await hass.config_entries.flow.async_configure( confirm_result = await hass.config_entries.flow.async_configure(
result3["flow_id"], result4["flow_id"],
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
) )
result5 = await consume_progress_flow(
hass,
flow_id=confirm_result["flow_id"],
valid_step_ids=("restore_backup",),
)
# Prompt user to plug old adapter back in again # Prompt user to plug old adapter back in again
assert result5["type"] is FlowResultType.FORM assert result5["type"] is FlowResultType.FORM
assert result5["step_id"] == "plug_in_new_radio" assert result5["step_id"] == "plug_in_new_radio"
assert result5["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"} assert result5["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"}
# User confirms they plugged in the adapter # User confirms they plugged in the adapter
result6 = await hass.config_entries.flow.async_configure( final_retry_result = await hass.config_entries.flow.async_configure(
result4["flow_id"], result5["flow_id"],
user_input={}, user_input={},
) )
result6 = await consume_progress_flow(
hass,
flow_id=final_retry_result["flow_id"],
valid_step_ids=("restore_backup",),
)
# Entry created successfully # Entry created successfully
assert result6["type"] is FlowResultType.CREATE_ENTRY assert result6["type"] is FlowResultType.CREATE_ENTRY
assert result6["data"][CONF_RADIO_TYPE] == "ezsp" assert result6["data"][CONF_RADIO_TYPE] == "ezsp"
@@ -3277,7 +3329,7 @@ async def test_plug_in_old_radio_retry(hass: HomeAssistant, backup, mock_app) ->
) )
mock_temp_radio_mgr = AsyncMock() mock_temp_radio_mgr = AsyncMock()
mock_temp_radio_mgr.async_reset_adapter = AsyncMock( mock_temp_radio_mgr.async_reset_adapter = DelayedAsyncMock(
side_effect=HomeAssistantError( side_effect=HomeAssistantError(
"Failed to connect to Zigbee adapter: [Errno 2] No such file or directory" "Failed to connect to Zigbee adapter: [Errno 2] No such file or directory"
) )
@@ -3303,11 +3355,17 @@ async def test_plug_in_old_radio_retry(hass: HomeAssistant, backup, mock_app) ->
assert result_confirm["step_id"] == "choose_migration_strategy" assert result_confirm["step_id"] == "choose_migration_strategy"
result_recommended = await hass.config_entries.flow.async_configure( recommended_result = await hass.config_entries.flow.async_configure(
result_confirm["flow_id"], result_confirm["flow_id"],
user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED},
) )
result_recommended = await consume_progress_flow(
hass,
flow_id=recommended_result["flow_id"],
valid_step_ids=("maybe_reset_old_radio",),
)
# Prompt user to plug old adapter back in when reset fails # Prompt user to plug old adapter back in when reset fails
assert result_recommended["type"] is FlowResultType.MENU assert result_recommended["type"] is FlowResultType.MENU
assert result_recommended["step_id"] == "plug_in_old_radio" assert result_recommended["step_id"] == "plug_in_old_radio"
@@ -3321,11 +3379,17 @@ async def test_plug_in_old_radio_retry(hass: HomeAssistant, backup, mock_app) ->
] ]
# Retry with unplugged adapter # Retry with unplugged adapter
result_retry = await hass.config_entries.flow.async_configure( retry_result = await hass.config_entries.flow.async_configure(
result_recommended["flow_id"], result_recommended["flow_id"],
user_input={"next_step_id": "retry_old_radio"}, user_input={"next_step_id": "retry_old_radio"},
) )
result_retry = await consume_progress_flow(
hass,
flow_id=retry_result["flow_id"],
valid_step_ids=("maybe_reset_old_radio",),
)
# Prompt user again to plug old adapter back in # Prompt user again to plug old adapter back in
assert result_retry["type"] is FlowResultType.MENU assert result_retry["type"] is FlowResultType.MENU
assert result_retry["step_id"] == "plug_in_old_radio" assert result_retry["step_id"] == "plug_in_old_radio"