mirror of
https://github.com/home-assistant/core.git
synced 2025-11-12 20:40:18 +00:00
Compare commits
17 Commits
claude/tri
...
2025.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ddffb5ff | ||
|
|
9bdfa77fa0 | ||
|
|
c65003009f | ||
|
|
0f722109b7 | ||
|
|
f7d86dec3c | ||
|
|
6b49c8a70c | ||
|
|
ab9a8f3e53 | ||
|
|
4e12628266 | ||
|
|
e6d8d4de42 | ||
|
|
6620b90eb4 | ||
|
|
6fd3af8891 | ||
|
|
46979b8418 | ||
|
|
1718a11de2 | ||
|
|
2016b1d8c7 | ||
|
|
4b72e45fc2 | ||
|
|
ead5ce905b | ||
|
|
f233f2da3f |
@@ -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)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.5.6"]
|
||||
"requirements": ["aioamazondevices==8.0.1"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
8
requirements_all.txt
generated
@@ -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
|
||||
|
||||
8
requirements_test_all.txt
generated
8
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
2
script/hassfest/docker/Dockerfile
generated
2
script/hassfest/docker/Dockerfile
generated
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
192
tests/components/airthings_ble/test_init.py
Normal file
192
tests/components/airthings_ble/test_init.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')",
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def setup_mock_foscam_camera(mock_foscam_camera):
|
||||
0,
|
||||
{
|
||||
"swCapabilities1": "100",
|
||||
"swCapabilities2": "768",
|
||||
"swCapabilities2": "896",
|
||||
"swCapabilities3": "100",
|
||||
"swCapabilities4": "100",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
'validDPTs': list([
|
||||
dict({
|
||||
'main': 9,
|
||||
'sub': 2,
|
||||
'sub': 7,
|
||||
}),
|
||||
]),
|
||||
'write': False,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user