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"])
# 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
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
"""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:
hashed = base64.b64encode(hashed)

View File

@@ -23,7 +23,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN, MFCT_ID
from .const import DEVICE_MODEL, DOMAIN, MFCT_ID
_LOGGER = logging.getLogger(__name__)
@@ -128,15 +128,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
assert self._discovered_device is not None
if user_input is not None:
if (
self._discovered_device is not None
and self._discovered_device.device.firmware.need_firmware_upgrade
):
if self._discovered_device.device.firmware.need_firmware_upgrade:
return self.async_abort(reason="firmware_upgrade_required")
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()
@@ -164,7 +164,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
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)
devices: list[BluetoothServiceInfoBleak] = []

View File

@@ -1,11 +1,16 @@
"""Constants for Airthings BLE."""
from airthings_ble import AirthingsDeviceType
DOMAIN = "airthings_ble"
MFCT_ID = 820
VOLUME_BECQUEREL = "Bq/m³"
VOLUME_PICOCURIE = "pCi/L"
DEVICE_MODEL = "device_model"
DEFAULT_SCAN_INTERVAL = 300
DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800}
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.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__)
@@ -34,12 +39,18 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
self.airthings = AirthingsBluetoothDeviceData(
_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__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
update_interval=timedelta(seconds=interval),
)
async def _async_setup(self) -> None:
@@ -58,11 +69,29 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
)
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:
"""Get data from Airthings BLE."""
try:
data = await self.airthings.update_device(self.ble_device)
except Exception as err:
raise UpdateFailed(f"Unable to fetch data: {err}") from err
return data

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"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 typing import Any, Final
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.structures import AmazonDevice
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
from homeassistant.core import HomeAssistant

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
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 homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"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_pet_adjustment: bool
supports_car_adjustment: bool
supports_human_adjustment: bool
supports_wdr_adjustment: bool
supports_hdr_adjustment: bool
@@ -144,24 +145,32 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
if ret_sw == 0
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:
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:
is_pet_detection_on_val = False
if car_adjustment_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:
is_car_detection_on_val = False
is_human_detection_on_val = (
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False
)
if human_adjustment_val:
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(
dev_info=dev_info,
@@ -179,6 +188,7 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
supports_pet_adjustment=pet_adjustment_val,
supports_car_adjustment=car_adjustment_val,
supports_human_adjustment=human_adjustment_val,
supports_hdr_adjustment=supports_hdr_adjustment_val,
supports_wdr_adjustment=supports_wdr_adjustment_val,
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,
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
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
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
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
api_version = "v1"

View File

@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
OptionsFlow,
)
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.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
@@ -97,6 +97,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task[None] | 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]:
"""Shared translation placeholders."""
@@ -106,6 +112,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
if self._probed_firmware_info is not None
else "unknown"
),
"firmware_name": (
self.installing_firmware_name
if self.installing_firmware_name is not None
else "unknown"
),
"model": self._hardware_name,
}
@@ -182,22 +193,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return self.async_show_progress(
step_id=step_id,
progress_action="install_firmware",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
description_placeholders=self._get_translation_placeholders(),
progress_task=self.firmware_install_task,
)
try:
await self.firmware_install_task
except AbortFlow as err:
return self.async_show_progress_done(
next_step_id=err.reason,
)
self._progress_error = err
return self.async_show_progress_done(next_step_id="progress_failed")
except HomeAssistantError:
_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:
self.firmware_install_task = None
@@ -241,7 +252,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
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:
assert self._probed_firmware_info is not None
@@ -270,7 +284,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return
# 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(
hass=self.hass,
@@ -313,41 +330,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -511,16 +493,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Thread firmware."""
raise NotImplementedError
@progress_step(
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(
async def async_step_progress_failed(
self, user_input: dict[str, Any] | None = None
) -> 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_info = await self._async_get_addon_info(addon_manager)
@@ -538,18 +519,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
) from err
return await self.async_step_finish_thread_installation()
@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(
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> 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:
await self._configure_and_start_otbr_addon()
except AddonError as err:
@@ -562,7 +564,36 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
) 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(
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"
),
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(
GroupSelectOption(

View File

@@ -943,13 +943,19 @@ class MieleConsumptionSensor(MieleRestorableSensor):
"""Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device)
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 = (
float(cast(str, self._attr_native_value))
float(cast(str, restored_value))
if self._attr_native_value is not None
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 (
StateStatus.ON,
StateStatus.OFF,

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"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:
"""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)

View File

@@ -133,8 +133,11 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN):
if api_key_ok and web_login_ok:
data = {CONF_SITE_ID: site_id}
data.update(api_auth)
data.update(web_auth)
if api_key:
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 TYPE_CHECKING:

View File

@@ -43,5 +43,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"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.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.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
@@ -191,8 +191,14 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._hass = None # type: ignore[assignment]
self._radio_mgr = ZhaRadioManager()
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] = {}
# 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
def hass(self) -> HomeAssistant:
"""Return hass."""
@@ -224,6 +230,13 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
async def _async_create_radio_entry(self) -> ConfigFlowResult:
"""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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -464,7 +477,22 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._radio_mgr.chosen_backup = self._radio_mgr.backups[0]
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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -475,30 +503,36 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
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
config_entry = config_entries[0]
# Unload ZHA before connecting to the old adapter
with suppress(OperationNotAllowed):
await self.hass.config_entries.async_unload(config_entry.entry_id)
self._reset_old_radio_task = self.hass.async_create_task(
self._async_reset_old_radio(config_entry),
"Reset old radio",
)
# 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]]
if not self._reset_old_radio_task.done():
return self.async_show_progress(
step_id="maybe_reset_old_radio",
progress_action="maybe_reset_old_radio",
progress_task=self._reset_old_radio_task,
)
try:
await temp_radio_mgr.async_reset_adapter()
except HomeAssistantError:
# Old adapter not found or cannot connect, show prompt to plug back in
return await self.async_step_plug_in_old_radio()
try:
await self._reset_old_radio_task
except HomeAssistantError:
# Old adapter not found or cannot connect, show prompt to plug back in
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(
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
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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""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
await self._radio_mgr.async_load_network_settings()
return await self._async_create_radio_entry()
if not self._form_network_task.done():
return self.async_show_progress(
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(
self, uploaded_file_id: str
@@ -735,10 +788,11 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
# User unplugged the new adapter, allow retry
return self.async_show_progress_done(next_step_id="pre_plug_in_new_radio")
except CannotWriteNetworkSettings as exc:
return self.async_abort(
self._progress_error = AbortFlow(
reason="cannot_restore_backup",
description_placeholders={"error": str(exc)},
)
return self.async_show_progress_done(next_step_id="progress_failed")
finally:
self._restore_backup_task = None

View File

@@ -24,7 +24,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 11
PATCH_VERSION: Final = "0"
PATCH_VERSION: Final = "1"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
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
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251105.0
home-assistant-intents==2025.10.28
home-assistant-intents==2025.11.7
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.11.0"
version = "2025.11.1"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
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
# homeassistant.components.alexa_devices
aioamazondevices==6.5.6
aioamazondevices==8.0.1
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -1204,7 +1204,7 @@ holidays==0.84
home-assistant-frontend==20251105.0
# homeassistant.components.conversation
home-assistant-intents==2025.10.28
home-assistant-intents==2025.11.7
# homeassistant.components.homematicip_cloud
homematicip==2.3.1
@@ -1630,7 +1630,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.15
onedrive-personal-sdk==0.0.16
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -3051,7 +3051,7 @@ ttls==1.8.3
ttn_client==1.2.3
# homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.4
tuya-device-sharing-sdk==0.2.5
# homeassistant.components.twentemilieu
twentemilieu==2.2.1

View File

@@ -182,7 +182,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.2
# homeassistant.components.alexa_devices
aioamazondevices==6.5.6
aioamazondevices==8.0.1
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -1053,7 +1053,7 @@ holidays==0.84
home-assistant-frontend==20251105.0
# homeassistant.components.conversation
home-assistant-intents==2025.10.28
home-assistant-intents==2025.11.7
# homeassistant.components.homematicip_cloud
homematicip==2.3.1
@@ -1401,7 +1401,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.15
onedrive-personal-sdk==0.0.16
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -2528,7 +2528,7 @@ ttls==1.8.3
ttn_client==1.2.3
# homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.4
tuya-device-sharing-sdk==0.2.5
# homeassistant.components.twentemilieu
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 \
ha-ffmpeg==3.2.2 \
hassil==3.4.0 \
home-assistant-intents==2025.10.28 \
home-assistant-intents==2025.11.7 \
mutagen==1.47.0 \
pymicro-vad==1.0.1 \
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")
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:
"""Test login flow."""
data.add_auth("test-user", "test-pass")

View File

@@ -135,6 +135,27 @@ WAVE_ENHANCE_SERVICE_INFO = BluetoothServiceInfoBleak(
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(
name="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",
)
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(
unique_id="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
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.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
@@ -29,12 +29,13 @@ from tests.common import MockConfigEntry
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
"""Test discovery via bluetooth with a valid device."""
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
with (
patch_async_ble_device_from_address(WAVE_SERVICE_INFO),
patch_airthings_ble(
AirthingsDevice(
manufacturer="Airthings AS",
model=AirthingsDeviceType.WAVE_PLUS,
model=wave_plus_device,
name="Airthings Wave Plus",
identifier="123456",
)
@@ -60,6 +61,8 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Airthings Wave Plus (2930123456)"
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:
@@ -118,6 +121,7 @@ async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None:
async def test_user_setup(hass: HomeAssistant) -> None:
"""Test the user initiated form."""
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
with (
patch(
"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(
AirthingsDevice(
manufacturer="Airthings AS",
model=AirthingsDeviceType.WAVE_PLUS,
model=wave_plus_device,
name="Airthings Wave Plus",
identifier="123456",
)
@@ -158,6 +162,8 @@ async def test_user_setup(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Airthings Wave Plus (2930123456)"
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:
@@ -168,6 +174,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
source=SOURCE_IGNORE,
)
entry.add_to_hass(hass)
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
with (
patch(
"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(
AirthingsDevice(
manufacturer="Airthings AS",
model=AirthingsDeviceType.WAVE_PLUS,
model=wave_plus_device,
name="Airthings Wave Plus",
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["title"] == "Airthings Wave Plus (2930123456)"
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:

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."""
from datetime import timedelta
import logging
from freezegun.api import FrozenDateTimeFactory
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.core import HomeAssistant
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 (
CO2_V1,
CO2_V2,
CORENTIUM_HOME_2_DEVICE_INFO,
HUMIDITY_V2,
TEMPERATURE_V1,
VOC_V1,
@@ -21,6 +29,8 @@ from . import (
WAVE_ENHANCE_DEVICE_INFO,
WAVE_ENHANCE_SERVICE_INFO,
WAVE_SERVICE_INFO,
AirthingsDevice,
BluetoothServiceInfoBleak,
create_device,
create_entry,
patch_airthings_ble,
@@ -29,6 +39,7 @@ from . import (
patch_async_discovered_service_info,
)
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.bluetooth import inject_bluetooth_service_info
_LOGGER = logging.getLogger(__name__)
@@ -267,3 +278,102 @@ async def test_translation_keys(
expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_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 unittest.mock import AsyncMock, patch
from aioamazondevices.const import DEVICE_TYPE_TO_MODEL
from aioamazondevices.const.devices import DEVICE_TYPE_TO_MODEL
import pytest
from homeassistant.components.alexa_devices.const import (

View File

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

View File

@@ -14,11 +14,11 @@
'online': True,
'sensors': 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)",
}),
'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')",
}),
}),
@@ -44,11 +44,11 @@
'online': True,
'sensors': 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)",
}),
'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')",
}),
}),

View File

@@ -2,7 +2,7 @@
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
import pytest

View File

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

View File

@@ -9,8 +9,14 @@ import growattServer
import pytest
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.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
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 device2 is not None
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["progress_action"] == "install_firmware"
assert pick_result["step_id"] == "install_thread_firmware"
description_placeholders = pick_result["description_placeholders"]
assert description_placeholders is not None
assert description_placeholders["firmware_type"] == "ezsp"
assert description_placeholders["model"] == TEST_HARDWARE_NAME
assert pick_result["description_placeholders"] == {
"firmware_type": "ezsp",
"model": TEST_HARDWARE_NAME,
"firmware_name": "Thread",
}
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)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "pick_firmware"
description_placeholders = result["description_placeholders"]
assert description_placeholders is not None
assert description_placeholders["firmware_type"] == "ezsp"
assert description_placeholders["model"] == TEST_HARDWARE_NAME
assert result["description_placeholders"] == {
"firmware_type": "ezsp",
"model": TEST_HARDWARE_NAME,
"firmware_name": "unknown",
}
result = await hass.config_entries.options.async_configure(
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)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "pick_firmware"
description_placeholders = result["description_placeholders"]
assert description_placeholders is not None
assert description_placeholders["firmware_type"] == "spinel"
assert description_placeholders["model"] == TEST_HARDWARE_NAME
assert result["description_placeholders"] == {
"firmware_type": "spinel",
"model": TEST_HARDWARE_NAME,
"firmware_name": "unknown",
}
with mock_firmware_info(
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["reason"] == "not_hassio_thread"
assert result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "spinel",
"firmware_name": "Thread",
}
@pytest.mark.parametrize(
@@ -110,6 +115,12 @@ async def test_config_flow_thread_addon_info_fails(
# Cannot get addon info
assert result["type"] == FlowResultType.ABORT
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")
@@ -155,6 +166,12 @@ async def test_config_flow_thread_addon_install_fails(
# Cannot install addon
assert result["type"] == FlowResultType.ABORT
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")
@@ -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["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")
@@ -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["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")
@@ -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["reason"] == "fw_install_failed"
assert pick_thread_progress_result["description_placeholders"] == {
"firmware_name": "Thread",
"model": TEST_HARDWARE_NAME,
"firmware_type": "ezsp",
}
@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["reason"] == "fw_download_failed"
assert pick_result["description_placeholders"] == {
"firmware_name": "Zigbee",
"model": TEST_HARDWARE_NAME,
"firmware_type": "spinel",
}
@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["reason"] == "fw_download_failed"
assert pick_result["description_placeholders"] == {
"firmware_name": "Zigbee",
"model": TEST_HARDWARE_NAME,
"firmware_type": "spinel",
}
@pytest.mark.parametrize(
@@ -395,6 +439,11 @@ async def test_options_flow_zigbee_to_thread_zha_configured(
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "zha_still_using_stick"
assert result["description_placeholders"] == {
"model": TEST_HARDWARE_NAME,
"firmware_type": "ezsp",
"firmware_name": "unknown",
}
@pytest.mark.parametrize(
@@ -442,3 +491,8 @@ async def test_options_flow_thread_to_zigbee_otbr_configured(
assert result["type"] == FlowResultType.ABORT
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([
dict({
'main': 9,
'sub': 2,
'sub': 7,
}),
]),
'write': False,

View File

@@ -54,6 +54,10 @@ async def test_user_api_key(
CONF_NAME: NAME,
CONF_SITE_ID: SITE_ID,
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
@@ -85,6 +89,7 @@ async def test_user_web_login(
{
CONF_NAME: NAME,
CONF_SITE_ID: SITE_ID,
CONF_SECTION_API_AUTH: {CONF_API_KEY: ""},
CONF_SECTION_WEB_AUTH: {
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,

View File

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

View File

@@ -215,6 +215,15 @@ async def consume_progress_flow(
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(
("entry_name", "unique_id", "radio_type", "service_info"),
[
@@ -720,6 +729,7 @@ async def test_migration_strategy_recommended_cannot_write(
with patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=CannotWriteNetworkSettings("test error"),
) as mock_restore_backup:
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
) -> None:
"""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)
assert (
@@ -1773,7 +1783,7 @@ async def test_formation_strategy_form_initial_network(
) -> None:
"""Test forming a new network, with no previous settings on the radio."""
# 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
async def form_network_side_effect(*args, **kwargs):
@@ -1807,7 +1817,7 @@ async def test_onboarding_auto_formation_new_hardware(
) -> None:
"""Test auto network formation with new hardware during onboarding."""
# 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))
# 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(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=[
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
None,
@@ -1981,8 +1992,14 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
)
assert result_confirm["type"] is FlowResultType.CREATE_ENTRY
assert result_confirm["data"][CONF_RADIO_TYPE] == "ezsp"
result_final = await consume_progress_flow(
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.mock_calls[0].kwargs["overwrite_ieee"] is True
@@ -2014,6 +2031,7 @@ async def test_formation_strategy_restore_manual_backup_ezsp(
),
patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=[
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
None,
@@ -2316,6 +2334,7 @@ async def test_options_flow_defaults(
# ZHA gets unloaded
with patch(
"homeassistant.config_entries.ConfigEntries.async_unload",
new_callable=DelayedAsyncMock,
side_effect=[async_unload_effect],
) as mock_async_unload:
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.hassio.addon_manager.AddonManager.async_get_addon_info",
new_callable=DelayedAsyncMock,
side_effect=AddonError,
),
patch(
@@ -3075,6 +3095,7 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ
),
patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=[
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
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["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"],
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["reason"] == "cannot_restore_backup"
assert (
@@ -3191,6 +3218,7 @@ async def test_plug_in_new_radio_retry(
),
patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
new_callable=DelayedAsyncMock,
side_effect=[
HomeAssistantError(
"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,
):
result3 = await hass.config_entries.flow.async_configure(
upload_result = await hass.config_entries.flow.async_configure(
result2["flow_id"],
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
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "plug_in_new_radio"
assert result3["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"}
# 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"],
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
assert result4["type"] is FlowResultType.FORM
assert result4["step_id"] == "confirm_ezsp_ieee_overwrite"
# Confirm destructive rewrite, but adapter is unplugged again
result5 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
confirm_result = await hass.config_entries.flow.async_configure(
result4["flow_id"],
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
assert result5["type"] is FlowResultType.FORM
assert result5["step_id"] == "plug_in_new_radio"
assert result5["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"}
# User confirms they plugged in the adapter
result6 = await hass.config_entries.flow.async_configure(
result4["flow_id"],
final_retry_result = await hass.config_entries.flow.async_configure(
result5["flow_id"],
user_input={},
)
result6 = await consume_progress_flow(
hass,
flow_id=final_retry_result["flow_id"],
valid_step_ids=("restore_backup",),
)
# Entry created successfully
assert result6["type"] is FlowResultType.CREATE_ENTRY
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.async_reset_adapter = AsyncMock(
mock_temp_radio_mgr.async_reset_adapter = DelayedAsyncMock(
side_effect=HomeAssistantError(
"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"
result_recommended = await hass.config_entries.flow.async_configure(
recommended_result = await hass.config_entries.flow.async_configure(
result_confirm["flow_id"],
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
assert result_recommended["type"] is FlowResultType.MENU
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
result_retry = await hass.config_entries.flow.async_configure(
retry_result = await hass.config_entries.flow.async_configure(
result_recommended["flow_id"],
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
assert result_retry["type"] is FlowResultType.MENU
assert result_retry["step_id"] == "plug_in_old_radio"