mirror of
https://github.com/home-assistant/core.git
synced 2025-11-13 13:00:11 +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"])
|
user_hash = base64.b64decode(found["password"])
|
||||||
|
|
||||||
# bcrypt.checkpw is timing-safe
|
# bcrypt.checkpw is timing-safe
|
||||||
if not bcrypt.checkpw(password.encode(), user_hash):
|
# With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
|
||||||
|
# Previously the password was silently truncated.
|
||||||
|
# https://github.com/pyca/bcrypt/pull/1000
|
||||||
|
if not bcrypt.checkpw(password.encode()[:72], user_hash):
|
||||||
raise InvalidAuth
|
raise InvalidAuth
|
||||||
|
|
||||||
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
||||||
"""Encode a password."""
|
"""Encode a password."""
|
||||||
hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
|
# With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
|
||||||
|
# Previously the password was silently truncated.
|
||||||
|
# https://github.com/pyca/bcrypt/pull/1000
|
||||||
|
hashed: bytes = bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt(rounds=12))
|
||||||
|
|
||||||
if for_storage:
|
if for_storage:
|
||||||
hashed = base64.b64encode(hashed)
|
hashed = base64.b64encode(hashed)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from homeassistant.components.bluetooth import (
|
|||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_ADDRESS
|
from homeassistant.const import CONF_ADDRESS
|
||||||
|
|
||||||
from .const import DOMAIN, MFCT_ID
|
from .const import DEVICE_MODEL, DOMAIN, MFCT_ID
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -128,15 +128,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Confirm discovery."""
|
"""Confirm discovery."""
|
||||||
|
assert self._discovered_device is not None
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if (
|
if self._discovered_device.device.firmware.need_firmware_upgrade:
|
||||||
self._discovered_device is not None
|
|
||||||
and self._discovered_device.device.firmware.need_firmware_upgrade
|
|
||||||
):
|
|
||||||
return self.async_abort(reason="firmware_upgrade_required")
|
return self.async_abort(reason="firmware_upgrade_required")
|
||||||
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=self.context["title_placeholders"]["name"], data={}
|
title=self.context["title_placeholders"]["name"],
|
||||||
|
data={DEVICE_MODEL: self._discovered_device.device.model.value},
|
||||||
)
|
)
|
||||||
|
|
||||||
self._set_confirm_only()
|
self._set_confirm_only()
|
||||||
@@ -164,7 +164,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
self._discovered_device = discovery
|
self._discovered_device = discovery
|
||||||
|
|
||||||
return self.async_create_entry(title=discovery.name, data={})
|
return self.async_create_entry(
|
||||||
|
title=discovery.name,
|
||||||
|
data={DEVICE_MODEL: discovery.device.model.value},
|
||||||
|
)
|
||||||
|
|
||||||
current_addresses = self._async_current_ids(include_ignore=False)
|
current_addresses = self._async_current_ids(include_ignore=False)
|
||||||
devices: list[BluetoothServiceInfoBleak] = []
|
devices: list[BluetoothServiceInfoBleak] = []
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
"""Constants for Airthings BLE."""
|
"""Constants for Airthings BLE."""
|
||||||
|
|
||||||
|
from airthings_ble import AirthingsDeviceType
|
||||||
|
|
||||||
DOMAIN = "airthings_ble"
|
DOMAIN = "airthings_ble"
|
||||||
MFCT_ID = 820
|
MFCT_ID = 820
|
||||||
|
|
||||||
VOLUME_BECQUEREL = "Bq/m³"
|
VOLUME_BECQUEREL = "Bq/m³"
|
||||||
VOLUME_PICOCURIE = "pCi/L"
|
VOLUME_PICOCURIE = "pCi/L"
|
||||||
|
|
||||||
|
DEVICE_MODEL = "device_model"
|
||||||
|
|
||||||
DEFAULT_SCAN_INTERVAL = 300
|
DEFAULT_SCAN_INTERVAL = 300
|
||||||
|
DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800}
|
||||||
|
|
||||||
MAX_RETRIES_AFTER_STARTUP = 5
|
MAX_RETRIES_AFTER_STARTUP = 5
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||||
|
|
||||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
from .const import (
|
||||||
|
DEFAULT_SCAN_INTERVAL,
|
||||||
|
DEVICE_MODEL,
|
||||||
|
DEVICE_SPECIFIC_SCAN_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,12 +39,18 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
|||||||
self.airthings = AirthingsBluetoothDeviceData(
|
self.airthings = AirthingsBluetoothDeviceData(
|
||||||
_LOGGER, hass.config.units is METRIC_SYSTEM
|
_LOGGER, hass.config.units is METRIC_SYSTEM
|
||||||
)
|
)
|
||||||
|
|
||||||
|
device_model = entry.data.get(DEVICE_MODEL)
|
||||||
|
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
|
||||||
|
device_model, DEFAULT_SCAN_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
config_entry=entry,
|
config_entry=entry,
|
||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
update_interval=timedelta(seconds=interval),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_setup(self) -> None:
|
async def _async_setup(self) -> None:
|
||||||
@@ -58,11 +69,29 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
|||||||
)
|
)
|
||||||
self.ble_device = ble_device
|
self.ble_device = ble_device
|
||||||
|
|
||||||
|
if DEVICE_MODEL not in self.config_entry.data:
|
||||||
|
_LOGGER.debug("Fetching device info for migration")
|
||||||
|
try:
|
||||||
|
data = await self.airthings.update_device(self.ble_device)
|
||||||
|
except Exception as err:
|
||||||
|
raise UpdateFailed(
|
||||||
|
f"Unable to fetch data for migration: {err}"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.config_entry,
|
||||||
|
data={**self.config_entry.data, DEVICE_MODEL: data.model.value},
|
||||||
|
)
|
||||||
|
self.update_interval = timedelta(
|
||||||
|
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
|
||||||
|
data.model.value, DEFAULT_SCAN_INTERVAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_update_data(self) -> AirthingsDevice:
|
async def _async_update_data(self) -> AirthingsDevice:
|
||||||
"""Get data from Airthings BLE."""
|
"""Get data from Airthings BLE."""
|
||||||
try:
|
try:
|
||||||
data = await self.airthings.update_device(self.ble_device)
|
data = await self.airthings.update_device(self.ble_device)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.const.metadata import SENSOR_STATE_OFF
|
||||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
from aioamazondevices.api import AmazonEchoApi
|
||||||
from aioamazondevices.exceptions import (
|
from aioamazondevices.exceptions import (
|
||||||
CannotAuthenticate,
|
CannotAuthenticate,
|
||||||
CannotConnect,
|
CannotConnect,
|
||||||
CannotRetrieveData,
|
CannotRetrieveData,
|
||||||
)
|
)
|
||||||
|
from aioamazondevices.structures import AmazonDevice
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.diagnostics import async_redact_data
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Defines a base Alexa Devices entity."""
|
"""Defines a base Alexa Devices entity."""
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_MODEL
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity import EntityDescription
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioamazondevices"],
|
"loggers": ["aioamazondevices"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["aioamazondevices==6.5.6"]
|
"requirements": ["aioamazondevices==8.0.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
from aioamazondevices.api import AmazonEchoApi
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||||
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.const.schedules import (
|
||||||
from aioamazondevices.const import (
|
|
||||||
NOTIFICATION_ALARM,
|
NOTIFICATION_ALARM,
|
||||||
NOTIFICATION_REMINDER,
|
NOTIFICATION_REMINDER,
|
||||||
NOTIFICATION_TIMER,
|
NOTIFICATION_TIMER,
|
||||||
)
|
)
|
||||||
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Support for services."""
|
"""Support for services."""
|
||||||
|
|
||||||
from aioamazondevices.sounds import SOUNDS_LIST
|
from aioamazondevices.const.sounds import SOUNDS_LIST
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.switch import (
|
from homeassistant.components.switch import (
|
||||||
DOMAIN as SWITCH_DOMAIN,
|
DOMAIN as SWITCH_DOMAIN,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, Concatenate
|
from typing import Any, Concatenate
|
||||||
|
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||||
|
|
||||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||||
"integration_type": "entity",
|
"integration_type": "entity",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.10.28"]
|
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.7"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class FoscamDeviceInfo:
|
|||||||
supports_speak_volume_adjustment: bool
|
supports_speak_volume_adjustment: bool
|
||||||
supports_pet_adjustment: bool
|
supports_pet_adjustment: bool
|
||||||
supports_car_adjustment: bool
|
supports_car_adjustment: bool
|
||||||
|
supports_human_adjustment: bool
|
||||||
supports_wdr_adjustment: bool
|
supports_wdr_adjustment: bool
|
||||||
supports_hdr_adjustment: bool
|
supports_hdr_adjustment: bool
|
||||||
|
|
||||||
@@ -144,24 +145,32 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
|||||||
if ret_sw == 0
|
if ret_sw == 0
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
ret_md, mothion_config_val = self.session.get_motion_detect_config()
|
human_adjustment_val = (
|
||||||
|
bool(int(software_capabilities.get("swCapabilities2")) & 128)
|
||||||
|
if ret_sw == 0
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
ret_md, motion_config_val = self.session.get_motion_detect_config()
|
||||||
if pet_adjustment_val:
|
if pet_adjustment_val:
|
||||||
is_pet_detection_on_val = (
|
is_pet_detection_on_val = (
|
||||||
mothion_config_val["petEnable"] == "1" if ret_md == 0 else False
|
motion_config_val.get("petEnable") == "1" if ret_md == 0 else False
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
is_pet_detection_on_val = False
|
is_pet_detection_on_val = False
|
||||||
|
|
||||||
if car_adjustment_val:
|
if car_adjustment_val:
|
||||||
is_car_detection_on_val = (
|
is_car_detection_on_val = (
|
||||||
mothion_config_val["carEnable"] == "1" if ret_md == 0 else False
|
motion_config_val.get("carEnable") == "1" if ret_md == 0 else False
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
is_car_detection_on_val = False
|
is_car_detection_on_val = False
|
||||||
|
|
||||||
is_human_detection_on_val = (
|
if human_adjustment_val:
|
||||||
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False
|
is_human_detection_on_val = (
|
||||||
)
|
motion_config_val.get("humanEnable") == "1" if ret_md == 0 else False
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
is_human_detection_on_val = False
|
||||||
|
|
||||||
return FoscamDeviceInfo(
|
return FoscamDeviceInfo(
|
||||||
dev_info=dev_info,
|
dev_info=dev_info,
|
||||||
@@ -179,6 +188,7 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
|||||||
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
|
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
|
||||||
supports_pet_adjustment=pet_adjustment_val,
|
supports_pet_adjustment=pet_adjustment_val,
|
||||||
supports_car_adjustment=car_adjustment_val,
|
supports_car_adjustment=car_adjustment_val,
|
||||||
|
supports_human_adjustment=human_adjustment_val,
|
||||||
supports_hdr_adjustment=supports_hdr_adjustment_val,
|
supports_hdr_adjustment=supports_hdr_adjustment_val,
|
||||||
supports_wdr_adjustment=supports_wdr_adjustment_val,
|
supports_wdr_adjustment=supports_wdr_adjustment_val,
|
||||||
is_open_wdr=is_open_wdr,
|
is_open_wdr=is_open_wdr,
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
|
|||||||
native_value_fn=lambda data: data.is_human_detection_on,
|
native_value_fn=lambda data: data.is_human_detection_on,
|
||||||
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
|
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
|
||||||
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
|
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
|
||||||
|
exists_fn=lambda coordinator: coordinator.data.supports_human_adjustment,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,21 @@ async def async_setup_entry(
|
|||||||
new_data[CONF_URL] = url
|
new_data[CONF_URL] = url
|
||||||
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||||
|
|
||||||
|
# Migrate legacy config entries without auth_type field
|
||||||
|
if CONF_AUTH_TYPE not in config:
|
||||||
|
new_data = dict(config_entry.data)
|
||||||
|
# Detect auth type based on which fields are present
|
||||||
|
if CONF_TOKEN in config:
|
||||||
|
new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN
|
||||||
|
elif CONF_USERNAME in config:
|
||||||
|
new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD
|
||||||
|
else:
|
||||||
|
raise ConfigEntryError(
|
||||||
|
"Unable to determine authentication type from config entry."
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||||
|
config = config_entry.data
|
||||||
|
|
||||||
# Determine API version
|
# Determine API version
|
||||||
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
|
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
|
||||||
api_version = "v1"
|
api_version = "v1"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
|
|||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
)
|
)
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import AbortFlow, progress_step
|
from homeassistant.data_entry_flow import AbortFlow
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
@@ -97,6 +97,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
self.addon_uninstall_task: asyncio.Task | None = None
|
self.addon_uninstall_task: asyncio.Task | None = None
|
||||||
self.firmware_install_task: asyncio.Task[None] | None = None
|
self.firmware_install_task: asyncio.Task[None] | None = None
|
||||||
self.installing_firmware_name: str | None = None
|
self.installing_firmware_name: str | None = None
|
||||||
|
self._install_otbr_addon_task: asyncio.Task[None] | None = None
|
||||||
|
self._start_otbr_addon_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
# Progress flow steps cannot abort so we need to store the abort reason and then
|
||||||
|
# re-raise it in a dedicated step
|
||||||
|
self._progress_error: AbortFlow | None = None
|
||||||
|
|
||||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||||
"""Shared translation placeholders."""
|
"""Shared translation placeholders."""
|
||||||
@@ -106,6 +112,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
if self._probed_firmware_info is not None
|
if self._probed_firmware_info is not None
|
||||||
else "unknown"
|
else "unknown"
|
||||||
),
|
),
|
||||||
|
"firmware_name": (
|
||||||
|
self.installing_firmware_name
|
||||||
|
if self.installing_firmware_name is not None
|
||||||
|
else "unknown"
|
||||||
|
),
|
||||||
"model": self._hardware_name,
|
"model": self._hardware_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,22 +193,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
return self.async_show_progress(
|
return self.async_show_progress(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
progress_action="install_firmware",
|
progress_action="install_firmware",
|
||||||
description_placeholders={
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"firmware_name": firmware_name,
|
|
||||||
},
|
|
||||||
progress_task=self.firmware_install_task,
|
progress_task=self.firmware_install_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.firmware_install_task
|
await self.firmware_install_task
|
||||||
except AbortFlow as err:
|
except AbortFlow as err:
|
||||||
return self.async_show_progress_done(
|
self._progress_error = err
|
||||||
next_step_id=err.reason,
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
)
|
|
||||||
except HomeAssistantError:
|
except HomeAssistantError:
|
||||||
_LOGGER.exception("Failed to flash firmware")
|
_LOGGER.exception("Failed to flash firmware")
|
||||||
return self.async_show_progress_done(next_step_id="firmware_install_failed")
|
self._progress_error = AbortFlow(
|
||||||
|
reason="fw_install_failed",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
)
|
||||||
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
finally:
|
finally:
|
||||||
self.firmware_install_task = None
|
self.firmware_install_task = None
|
||||||
|
|
||||||
@@ -241,7 +252,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
|
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
|
||||||
return
|
return
|
||||||
|
|
||||||
raise AbortFlow(reason="firmware_download_failed") from err
|
raise AbortFlow(
|
||||||
|
reason="fw_download_failed",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
) from err
|
||||||
|
|
||||||
if not firmware_install_required:
|
if not firmware_install_required:
|
||||||
assert self._probed_firmware_info is not None
|
assert self._probed_firmware_info is not None
|
||||||
@@ -270,7 +284,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Otherwise, fail
|
# Otherwise, fail
|
||||||
raise AbortFlow(reason="firmware_download_failed") from err
|
raise AbortFlow(
|
||||||
|
reason="fw_download_failed",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
) from err
|
||||||
|
|
||||||
self._probed_firmware_info = await async_flash_silabs_firmware(
|
self._probed_firmware_info = await async_flash_silabs_firmware(
|
||||||
hass=self.hass,
|
hass=self.hass,
|
||||||
@@ -313,41 +330,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
|
|
||||||
await otbr_manager.async_start_addon_waiting()
|
await otbr_manager.async_start_addon_waiting()
|
||||||
|
|
||||||
async def async_step_firmware_download_failed(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Abort when firmware download failed."""
|
|
||||||
assert self.installing_firmware_name is not None
|
|
||||||
return self.async_abort(
|
|
||||||
reason="fw_download_failed",
|
|
||||||
description_placeholders={
|
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"firmware_name": self.installing_firmware_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_firmware_install_failed(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Abort when firmware install failed."""
|
|
||||||
assert self.installing_firmware_name is not None
|
|
||||||
return self.async_abort(
|
|
||||||
reason="fw_install_failed",
|
|
||||||
description_placeholders={
|
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"firmware_name": self.installing_firmware_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_unsupported_firmware(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Abort when unsupported firmware is detected."""
|
|
||||||
return self.async_abort(
|
|
||||||
reason="unsupported_firmware",
|
|
||||||
description_placeholders=self._get_translation_placeholders(),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_zigbee_installation_type(
|
async def async_step_zigbee_installation_type(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@@ -511,16 +493,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
"""Install Thread firmware."""
|
"""Install Thread firmware."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@progress_step(
|
async def async_step_progress_failed(
|
||||||
description_placeholders=lambda self: {
|
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
async def async_step_install_otbr_addon(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Show progress dialog for installing the OTBR addon."""
|
"""Abort when progress step failed."""
|
||||||
|
assert self._progress_error is not None
|
||||||
|
raise self._progress_error
|
||||||
|
|
||||||
|
async def _async_install_otbr_addon(self) -> None:
|
||||||
|
"""Do the work of installing the OTBR addon."""
|
||||||
addon_manager = get_otbr_addon_manager(self.hass)
|
addon_manager = get_otbr_addon_manager(self.hass)
|
||||||
addon_info = await self._async_get_addon_info(addon_manager)
|
addon_info = await self._async_get_addon_info(addon_manager)
|
||||||
|
|
||||||
@@ -538,18 +519,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
},
|
},
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
return await self.async_step_finish_thread_installation()
|
async def async_step_install_otbr_addon(
|
||||||
|
|
||||||
@progress_step(
|
|
||||||
description_placeholders=lambda self: {
|
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
async def async_step_start_otbr_addon(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
"""Show progress dialog for installing the OTBR addon."""
|
||||||
|
if self._install_otbr_addon_task is None:
|
||||||
|
self._install_otbr_addon_task = self.hass.async_create_task(
|
||||||
|
self._async_install_otbr_addon(),
|
||||||
|
"Install OTBR addon",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._install_otbr_addon_task.done():
|
||||||
|
return self.async_show_progress(
|
||||||
|
step_id="install_otbr_addon",
|
||||||
|
progress_action="install_otbr_addon",
|
||||||
|
description_placeholders={
|
||||||
|
**self._get_translation_placeholders(),
|
||||||
|
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||||
|
},
|
||||||
|
progress_task=self._install_otbr_addon_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._install_otbr_addon_task
|
||||||
|
except AbortFlow as err:
|
||||||
|
self._progress_error = err
|
||||||
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
|
finally:
|
||||||
|
self._install_otbr_addon_task = None
|
||||||
|
|
||||||
|
return self.async_show_progress_done(next_step_id="finish_thread_installation")
|
||||||
|
|
||||||
|
async def _async_start_otbr_addon(self) -> None:
|
||||||
|
"""Do the work of starting the OTBR addon."""
|
||||||
try:
|
try:
|
||||||
await self._configure_and_start_otbr_addon()
|
await self._configure_and_start_otbr_addon()
|
||||||
except AddonError as err:
|
except AddonError as err:
|
||||||
@@ -562,7 +564,36 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
},
|
},
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
return await self.async_step_pre_confirm_otbr()
|
async def async_step_start_otbr_addon(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
||||||
|
if self._start_otbr_addon_task is None:
|
||||||
|
self._start_otbr_addon_task = self.hass.async_create_task(
|
||||||
|
self._async_start_otbr_addon(),
|
||||||
|
"Start OTBR addon",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._start_otbr_addon_task.done():
|
||||||
|
return self.async_show_progress(
|
||||||
|
step_id="start_otbr_addon",
|
||||||
|
progress_action="start_otbr_addon",
|
||||||
|
description_placeholders={
|
||||||
|
**self._get_translation_placeholders(),
|
||||||
|
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||||
|
},
|
||||||
|
progress_task=self._start_otbr_addon_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._start_otbr_addon_task
|
||||||
|
except AbortFlow as err:
|
||||||
|
self._progress_error = err
|
||||||
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
|
finally:
|
||||||
|
self._start_otbr_addon_task = None
|
||||||
|
|
||||||
|
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
|
||||||
|
|
||||||
async def async_step_pre_confirm_otbr(
|
async def async_step_pre_confirm_otbr(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
|
|||||||
write=False, state_required=True, valid_dpt="9.001"
|
write=False, state_required=True, valid_dpt="9.001"
|
||||||
),
|
),
|
||||||
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
|
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
|
||||||
write=False, valid_dpt="9.002"
|
write=False, valid_dpt="9.007"
|
||||||
),
|
),
|
||||||
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
|
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
|
||||||
GroupSelectOption(
|
GroupSelectOption(
|
||||||
|
|||||||
@@ -943,13 +943,19 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
|||||||
"""Update the last value of the sensor."""
|
"""Update the last value of the sensor."""
|
||||||
current_value = self.entity_description.value_fn(self.device)
|
current_value = self.entity_description.value_fn(self.device)
|
||||||
current_status = StateStatus(self.device.state_status)
|
current_status = StateStatus(self.device.state_status)
|
||||||
|
# Guard for corrupt restored value
|
||||||
|
restored_value = (
|
||||||
|
self._attr_native_value
|
||||||
|
if isinstance(self._attr_native_value, (int, float))
|
||||||
|
else 0
|
||||||
|
)
|
||||||
last_value = (
|
last_value = (
|
||||||
float(cast(str, self._attr_native_value))
|
float(cast(str, restored_value))
|
||||||
if self._attr_native_value is not None
|
if self._attr_native_value is not None
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
# force unknown when appliance is not able to report consumption
|
# Force unknown when appliance is not able to report consumption
|
||||||
if current_status in (
|
if current_status in (
|
||||||
StateStatus.ON,
|
StateStatus.ON,
|
||||||
StateStatus.OFF,
|
StateStatus.OFF,
|
||||||
|
|||||||
@@ -10,5 +10,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["onedrive_personal_sdk"],
|
"loggers": ["onedrive_personal_sdk"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["onedrive-personal-sdk==0.0.15"]
|
"requirements": ["onedrive-personal-sdk==0.0.16"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,4 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) ->
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) -> bool:
|
||||||
"""Unload SolarEdge config entry."""
|
"""Unload SolarEdge config entry."""
|
||||||
|
if DATA_API_CLIENT not in entry.runtime_data:
|
||||||
|
return True # Nothing to unload
|
||||||
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|||||||
@@ -133,8 +133,11 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
if api_key_ok and web_login_ok:
|
if api_key_ok and web_login_ok:
|
||||||
data = {CONF_SITE_ID: site_id}
|
data = {CONF_SITE_ID: site_id}
|
||||||
data.update(api_auth)
|
if api_key:
|
||||||
data.update(web_auth)
|
data[CONF_API_KEY] = api_key
|
||||||
|
if username:
|
||||||
|
data[CONF_USERNAME] = username
|
||||||
|
data[CONF_PASSWORD] = web_auth[CONF_PASSWORD]
|
||||||
|
|
||||||
if self.source == SOURCE_RECONFIGURE:
|
if self.source == SOURCE_RECONFIGURE:
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|||||||
@@ -43,5 +43,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["tuya_sharing"],
|
"loggers": ["tuya_sharing"],
|
||||||
"requirements": ["tuya-device-sharing-sdk==0.2.4"]
|
"requirements": ["tuya-device-sharing-sdk==0.2.5"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from homeassistant.config_entries import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.data_entry_flow import AbortFlow, progress_step
|
from homeassistant.data_entry_flow import AbortFlow
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
|
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
|
||||||
@@ -191,8 +191,14 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
|||||||
self._hass = None # type: ignore[assignment]
|
self._hass = None # type: ignore[assignment]
|
||||||
self._radio_mgr = ZhaRadioManager()
|
self._radio_mgr = ZhaRadioManager()
|
||||||
self._restore_backup_task: asyncio.Task[None] | None = None
|
self._restore_backup_task: asyncio.Task[None] | None = None
|
||||||
|
self._reset_old_radio_task: asyncio.Task[None] | None = None
|
||||||
|
self._form_network_task: asyncio.Task[None] | None = None
|
||||||
self._extra_network_config: dict[str, Any] = {}
|
self._extra_network_config: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Progress flow steps cannot abort so we need to store the abort reason and then
|
||||||
|
# re-raise it in a dedicated step
|
||||||
|
self._progress_error: AbortFlow | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hass(self) -> HomeAssistant:
|
def hass(self) -> HomeAssistant:
|
||||||
"""Return hass."""
|
"""Return hass."""
|
||||||
@@ -224,6 +230,13 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
|||||||
async def _async_create_radio_entry(self) -> ConfigFlowResult:
|
async def _async_create_radio_entry(self) -> ConfigFlowResult:
|
||||||
"""Create a config entry with the current flow state."""
|
"""Create a config entry with the current flow state."""
|
||||||
|
|
||||||
|
async def async_step_progress_failed(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Abort when progress step failed."""
|
||||||
|
assert self._progress_error is not None
|
||||||
|
raise self._progress_error
|
||||||
|
|
||||||
async def async_step_choose_serial_port(
|
async def async_step_choose_serial_port(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@@ -464,7 +477,22 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
|||||||
self._radio_mgr.chosen_backup = self._radio_mgr.backups[0]
|
self._radio_mgr.chosen_backup = self._radio_mgr.backups[0]
|
||||||
return await self.async_step_maybe_reset_old_radio()
|
return await self.async_step_maybe_reset_old_radio()
|
||||||
|
|
||||||
@progress_step()
|
async def _async_reset_old_radio(self, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Do the work of resetting the old radio."""
|
||||||
|
|
||||||
|
# Unload ZHA before connecting to the old adapter
|
||||||
|
with suppress(OperationNotAllowed):
|
||||||
|
await self.hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
|
||||||
|
# Create a radio manager to connect to the old stick to reset it
|
||||||
|
temp_radio_mgr = ZhaRadioManager()
|
||||||
|
temp_radio_mgr.hass = self.hass
|
||||||
|
temp_radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||||
|
temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
|
||||||
|
temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
|
||||||
|
|
||||||
|
await temp_radio_mgr.async_reset_adapter()
|
||||||
|
|
||||||
async def async_step_maybe_reset_old_radio(
|
async def async_step_maybe_reset_old_radio(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@@ -475,30 +503,36 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
|||||||
DOMAIN, include_ignore=False
|
DOMAIN, include_ignore=False
|
||||||
)
|
)
|
||||||
|
|
||||||
if config_entries:
|
if not config_entries:
|
||||||
|
return await self.async_step_restore_backup()
|
||||||
|
|
||||||
|
if self._reset_old_radio_task is None:
|
||||||
|
# This will only ever be called during migration, so there must be an
|
||||||
|
# existing config entry
|
||||||
assert len(config_entries) == 1
|
assert len(config_entries) == 1
|
||||||
config_entry = config_entries[0]
|
config_entry = config_entries[0]
|
||||||
|
|
||||||
# Unload ZHA before connecting to the old adapter
|
self._reset_old_radio_task = self.hass.async_create_task(
|
||||||
with suppress(OperationNotAllowed):
|
self._async_reset_old_radio(config_entry),
|
||||||
await self.hass.config_entries.async_unload(config_entry.entry_id)
|
"Reset old radio",
|
||||||
|
)
|
||||||
|
|
||||||
# Create a radio manager to connect to the old stick to reset it
|
if not self._reset_old_radio_task.done():
|
||||||
temp_radio_mgr = ZhaRadioManager()
|
return self.async_show_progress(
|
||||||
temp_radio_mgr.hass = self.hass
|
step_id="maybe_reset_old_radio",
|
||||||
temp_radio_mgr.device_path = config_entry.data[CONF_DEVICE][
|
progress_action="maybe_reset_old_radio",
|
||||||
CONF_DEVICE_PATH
|
progress_task=self._reset_old_radio_task,
|
||||||
]
|
)
|
||||||
temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
|
|
||||||
temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await temp_radio_mgr.async_reset_adapter()
|
await self._reset_old_radio_task
|
||||||
except HomeAssistantError:
|
except HomeAssistantError:
|
||||||
# Old adapter not found or cannot connect, show prompt to plug back in
|
# Old adapter not found or cannot connect, show prompt to plug back in
|
||||||
return await self.async_step_plug_in_old_radio()
|
return self.async_show_progress_done(next_step_id="plug_in_old_radio")
|
||||||
|
finally:
|
||||||
|
self._reset_old_radio_task = None
|
||||||
|
|
||||||
return await self.async_step_restore_backup()
|
return self.async_show_progress_done(next_step_id="restore_backup")
|
||||||
|
|
||||||
async def async_step_plug_in_old_radio(
|
async def async_step_plug_in_old_radio(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@@ -618,16 +652,35 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
|||||||
# This step exists only for translations, it does nothing new
|
# This step exists only for translations, it does nothing new
|
||||||
return await self.async_step_form_new_network(user_input)
|
return await self.async_step_form_new_network(user_input)
|
||||||
|
|
||||||
@progress_step()
|
async def _async_form_new_network(self) -> None:
|
||||||
|
"""Do the work of forming a new network."""
|
||||||
|
await self._radio_mgr.async_form_network(config=self._extra_network_config)
|
||||||
|
# Load the newly formed network settings to get the network info
|
||||||
|
await self._radio_mgr.async_load_network_settings()
|
||||||
|
|
||||||
async def async_step_form_new_network(
|
async def async_step_form_new_network(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Form a brand-new network."""
|
"""Form a brand-new network."""
|
||||||
await self._radio_mgr.async_form_network(config=self._extra_network_config)
|
if self._form_network_task is None:
|
||||||
|
self._form_network_task = self.hass.async_create_task(
|
||||||
|
self._async_form_new_network(),
|
||||||
|
"Form new network",
|
||||||
|
)
|
||||||
|
|
||||||
# Load the newly formed network settings to get the network info
|
if not self._form_network_task.done():
|
||||||
await self._radio_mgr.async_load_network_settings()
|
return self.async_show_progress(
|
||||||
return await self._async_create_radio_entry()
|
step_id="form_new_network",
|
||||||
|
progress_action="form_new_network",
|
||||||
|
progress_task=self._form_network_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._form_network_task
|
||||||
|
finally:
|
||||||
|
self._form_network_task = None
|
||||||
|
|
||||||
|
return self.async_show_progress_done(next_step_id="create_entry")
|
||||||
|
|
||||||
def _parse_uploaded_backup(
|
def _parse_uploaded_backup(
|
||||||
self, uploaded_file_id: str
|
self, uploaded_file_id: str
|
||||||
@@ -735,10 +788,11 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
|||||||
# User unplugged the new adapter, allow retry
|
# User unplugged the new adapter, allow retry
|
||||||
return self.async_show_progress_done(next_step_id="pre_plug_in_new_radio")
|
return self.async_show_progress_done(next_step_id="pre_plug_in_new_radio")
|
||||||
except CannotWriteNetworkSettings as exc:
|
except CannotWriteNetworkSettings as exc:
|
||||||
return self.async_abort(
|
self._progress_error = AbortFlow(
|
||||||
reason="cannot_restore_backup",
|
reason="cannot_restore_backup",
|
||||||
description_placeholders={"error": str(exc)},
|
description_placeholders={"error": str(exc)},
|
||||||
)
|
)
|
||||||
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
finally:
|
finally:
|
||||||
self._restore_backup_task = None
|
self._restore_backup_task = None
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
|||||||
APPLICATION_NAME: Final = "HomeAssistant"
|
APPLICATION_NAME: Final = "HomeAssistant"
|
||||||
MAJOR_VERSION: Final = 2025
|
MAJOR_VERSION: Final = 2025
|
||||||
MINOR_VERSION: Final = 11
|
MINOR_VERSION: Final = 11
|
||||||
PATCH_VERSION: Final = "0"
|
PATCH_VERSION: Final = "1"
|
||||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ hass-nabucasa==1.5.1
|
|||||||
hassil==3.4.0
|
hassil==3.4.0
|
||||||
home-assistant-bluetooth==1.13.1
|
home-assistant-bluetooth==1.13.1
|
||||||
home-assistant-frontend==20251105.0
|
home-assistant-frontend==20251105.0
|
||||||
home-assistant-intents==2025.10.28
|
home-assistant-intents==2025.11.7
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
ifaddr==0.2.0
|
ifaddr==0.2.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "homeassistant"
|
name = "homeassistant"
|
||||||
version = "2025.11.0"
|
version = "2025.11.1"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||||
description = "Open-source home automation platform running on Python 3."
|
description = "Open-source home automation platform running on Python 3."
|
||||||
|
|||||||
8
requirements_all.txt
generated
8
requirements_all.txt
generated
@@ -194,7 +194,7 @@ aioairzone-cloud==0.7.2
|
|||||||
aioairzone==1.0.2
|
aioairzone==1.0.2
|
||||||
|
|
||||||
# homeassistant.components.alexa_devices
|
# homeassistant.components.alexa_devices
|
||||||
aioamazondevices==6.5.6
|
aioamazondevices==8.0.1
|
||||||
|
|
||||||
# homeassistant.components.ambient_network
|
# homeassistant.components.ambient_network
|
||||||
# homeassistant.components.ambient_station
|
# homeassistant.components.ambient_station
|
||||||
@@ -1204,7 +1204,7 @@ holidays==0.84
|
|||||||
home-assistant-frontend==20251105.0
|
home-assistant-frontend==20251105.0
|
||||||
|
|
||||||
# homeassistant.components.conversation
|
# homeassistant.components.conversation
|
||||||
home-assistant-intents==2025.10.28
|
home-assistant-intents==2025.11.7
|
||||||
|
|
||||||
# homeassistant.components.homematicip_cloud
|
# homeassistant.components.homematicip_cloud
|
||||||
homematicip==2.3.1
|
homematicip==2.3.1
|
||||||
@@ -1630,7 +1630,7 @@ omnilogic==0.4.5
|
|||||||
ondilo==0.5.0
|
ondilo==0.5.0
|
||||||
|
|
||||||
# homeassistant.components.onedrive
|
# homeassistant.components.onedrive
|
||||||
onedrive-personal-sdk==0.0.15
|
onedrive-personal-sdk==0.0.16
|
||||||
|
|
||||||
# homeassistant.components.onvif
|
# homeassistant.components.onvif
|
||||||
onvif-zeep-async==4.0.4
|
onvif-zeep-async==4.0.4
|
||||||
@@ -3051,7 +3051,7 @@ ttls==1.8.3
|
|||||||
ttn_client==1.2.3
|
ttn_client==1.2.3
|
||||||
|
|
||||||
# homeassistant.components.tuya
|
# homeassistant.components.tuya
|
||||||
tuya-device-sharing-sdk==0.2.4
|
tuya-device-sharing-sdk==0.2.5
|
||||||
|
|
||||||
# homeassistant.components.twentemilieu
|
# homeassistant.components.twentemilieu
|
||||||
twentemilieu==2.2.1
|
twentemilieu==2.2.1
|
||||||
|
|||||||
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
|
aioairzone==1.0.2
|
||||||
|
|
||||||
# homeassistant.components.alexa_devices
|
# homeassistant.components.alexa_devices
|
||||||
aioamazondevices==6.5.6
|
aioamazondevices==8.0.1
|
||||||
|
|
||||||
# homeassistant.components.ambient_network
|
# homeassistant.components.ambient_network
|
||||||
# homeassistant.components.ambient_station
|
# homeassistant.components.ambient_station
|
||||||
@@ -1053,7 +1053,7 @@ holidays==0.84
|
|||||||
home-assistant-frontend==20251105.0
|
home-assistant-frontend==20251105.0
|
||||||
|
|
||||||
# homeassistant.components.conversation
|
# homeassistant.components.conversation
|
||||||
home-assistant-intents==2025.10.28
|
home-assistant-intents==2025.11.7
|
||||||
|
|
||||||
# homeassistant.components.homematicip_cloud
|
# homeassistant.components.homematicip_cloud
|
||||||
homematicip==2.3.1
|
homematicip==2.3.1
|
||||||
@@ -1401,7 +1401,7 @@ omnilogic==0.4.5
|
|||||||
ondilo==0.5.0
|
ondilo==0.5.0
|
||||||
|
|
||||||
# homeassistant.components.onedrive
|
# homeassistant.components.onedrive
|
||||||
onedrive-personal-sdk==0.0.15
|
onedrive-personal-sdk==0.0.16
|
||||||
|
|
||||||
# homeassistant.components.onvif
|
# homeassistant.components.onvif
|
||||||
onvif-zeep-async==4.0.4
|
onvif-zeep-async==4.0.4
|
||||||
@@ -2528,7 +2528,7 @@ ttls==1.8.3
|
|||||||
ttn_client==1.2.3
|
ttn_client==1.2.3
|
||||||
|
|
||||||
# homeassistant.components.tuya
|
# homeassistant.components.tuya
|
||||||
tuya-device-sharing-sdk==0.2.4
|
tuya-device-sharing-sdk==0.2.5
|
||||||
|
|
||||||
# homeassistant.components.twentemilieu
|
# homeassistant.components.twentemilieu
|
||||||
twentemilieu==2.2.1
|
twentemilieu==2.2.1
|
||||||
|
|||||||
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 \
|
go2rtc-client==0.2.1 \
|
||||||
ha-ffmpeg==3.2.2 \
|
ha-ffmpeg==3.2.2 \
|
||||||
hassil==3.4.0 \
|
hassil==3.4.0 \
|
||||||
home-assistant-intents==2025.10.28 \
|
home-assistant-intents==2025.11.7 \
|
||||||
mutagen==1.47.0 \
|
mutagen==1.47.0 \
|
||||||
pymicro-vad==1.0.1 \
|
pymicro-vad==1.0.1 \
|
||||||
pyspeex-noise==1.0.2
|
pyspeex-noise==1.0.2
|
||||||
|
|||||||
@@ -133,6 +133,24 @@ async def test_changing_password(data: hass_auth.Data) -> None:
|
|||||||
data.validate_login("test-UsEr", "new-pass")
|
data.validate_login("test-UsEr", "new-pass")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_password_truncated(data: hass_auth.Data) -> None:
|
||||||
|
"""Test long passwords are truncated before they are send to bcrypt for hashing.
|
||||||
|
|
||||||
|
With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
|
||||||
|
Previously the password was silently truncated.
|
||||||
|
https://github.com/pyca/bcrypt/pull/1000
|
||||||
|
"""
|
||||||
|
pwd_truncated = "hWwjDpFiYtDTaaMbXdjzeuKAPI3G4Di2mC92" * 4 # 72 chars
|
||||||
|
long_pwd = pwd_truncated * 2 # 144 chars
|
||||||
|
data.add_auth("test-user", long_pwd)
|
||||||
|
data.validate_login("test-user", long_pwd)
|
||||||
|
|
||||||
|
# As pwd are truncated, login will technically work with only the first 72 bytes.
|
||||||
|
data.validate_login("test-user", pwd_truncated)
|
||||||
|
with pytest.raises(hass_auth.InvalidAuth):
|
||||||
|
data.validate_login("test-user", pwd_truncated[:71])
|
||||||
|
|
||||||
|
|
||||||
async def test_login_flow_validates(data: hass_auth.Data, hass: HomeAssistant) -> None:
|
async def test_login_flow_validates(data: hass_auth.Data, hass: HomeAssistant) -> None:
|
||||||
"""Test login flow."""
|
"""Test login flow."""
|
||||||
data.add_auth("test-user", "test-pass")
|
data.add_auth("test-user", "test-pass")
|
||||||
|
|||||||
@@ -135,6 +135,27 @@ WAVE_ENHANCE_SERVICE_INFO = BluetoothServiceInfoBleak(
|
|||||||
tx_power=0,
|
tx_power=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CORENTIUM_HOME_2_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||||
|
name="cc-cc-cc-cc-cc-cc",
|
||||||
|
address="cc:cc:cc:cc:cc:cc",
|
||||||
|
device=generate_ble_device(
|
||||||
|
address="cc:cc:cc:cc:cc:cc",
|
||||||
|
name="Airthings Corentium Home 2",
|
||||||
|
),
|
||||||
|
rssi=-61,
|
||||||
|
manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"},
|
||||||
|
service_data={},
|
||||||
|
service_uuids=[],
|
||||||
|
source="local",
|
||||||
|
advertisement=generate_advertisement_data(
|
||||||
|
manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"},
|
||||||
|
service_uuids=[],
|
||||||
|
),
|
||||||
|
connectable=True,
|
||||||
|
time=0,
|
||||||
|
tx_power=0,
|
||||||
|
)
|
||||||
|
|
||||||
VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak(
|
VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||||
name="cc-cc-cc-cc-cc-cc",
|
name="cc-cc-cc-cc-cc-cc",
|
||||||
address="cc:cc:cc:cc:cc:cc",
|
address="cc:cc:cc:cc:cc:cc",
|
||||||
@@ -265,6 +286,24 @@ WAVE_ENHANCE_DEVICE_INFO = AirthingsDevice(
|
|||||||
address="cc:cc:cc:cc:cc:cc",
|
address="cc:cc:cc:cc:cc:cc",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CORENTIUM_HOME_2_DEVICE_INFO = AirthingsDevice(
|
||||||
|
manufacturer="Airthings AS",
|
||||||
|
hw_version="REV X",
|
||||||
|
sw_version="R-SUB-1.3.4-master+0",
|
||||||
|
model=AirthingsDeviceType.CORENTIUM_HOME_2,
|
||||||
|
name="Airthings Corentium Home 2",
|
||||||
|
identifier="123456",
|
||||||
|
sensors={
|
||||||
|
"connectivity_mode": "Bluetooth",
|
||||||
|
"battery": 90,
|
||||||
|
"temperature": 20.0,
|
||||||
|
"humidity": 55.0,
|
||||||
|
"radon_1day_avg": 45,
|
||||||
|
"radon_1day_level": "low",
|
||||||
|
},
|
||||||
|
address="cc:cc:cc:cc:cc:cc",
|
||||||
|
)
|
||||||
|
|
||||||
TEMPERATURE_V1 = MockEntity(
|
TEMPERATURE_V1 = MockEntity(
|
||||||
unique_id="Airthings Wave Plus 123456_temperature",
|
unique_id="Airthings Wave Plus 123456_temperature",
|
||||||
name="Airthings Wave Plus 123456 Temperature",
|
name="Airthings Wave Plus 123456 Temperature",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from bleak import BleakError
|
|||||||
from home_assistant_bluetooth import BluetoothServiceInfoBleak
|
from home_assistant_bluetooth import BluetoothServiceInfoBleak
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.airthings_ble.const import DOMAIN
|
from homeassistant.components.airthings_ble.const import DEVICE_MODEL, DOMAIN
|
||||||
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER
|
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER
|
||||||
from homeassistant.const import CONF_ADDRESS
|
from homeassistant.const import CONF_ADDRESS
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -29,12 +29,13 @@ from tests.common import MockConfigEntry
|
|||||||
|
|
||||||
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
|
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
|
||||||
"""Test discovery via bluetooth with a valid device."""
|
"""Test discovery via bluetooth with a valid device."""
|
||||||
|
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
|
||||||
with (
|
with (
|
||||||
patch_async_ble_device_from_address(WAVE_SERVICE_INFO),
|
patch_async_ble_device_from_address(WAVE_SERVICE_INFO),
|
||||||
patch_airthings_ble(
|
patch_airthings_ble(
|
||||||
AirthingsDevice(
|
AirthingsDevice(
|
||||||
manufacturer="Airthings AS",
|
manufacturer="Airthings AS",
|
||||||
model=AirthingsDeviceType.WAVE_PLUS,
|
model=wave_plus_device,
|
||||||
name="Airthings Wave Plus",
|
name="Airthings Wave Plus",
|
||||||
identifier="123456",
|
identifier="123456",
|
||||||
)
|
)
|
||||||
@@ -60,6 +61,8 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
|
|||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result["title"] == "Airthings Wave Plus (2930123456)"
|
assert result["title"] == "Airthings Wave Plus (2930123456)"
|
||||||
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
|
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
|
||||||
|
assert result["data"] == {DEVICE_MODEL: wave_plus_device.value}
|
||||||
|
assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value}
|
||||||
|
|
||||||
|
|
||||||
async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant) -> None:
|
async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant) -> None:
|
||||||
@@ -118,6 +121,7 @@ async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
async def test_user_setup(hass: HomeAssistant) -> None:
|
async def test_user_setup(hass: HomeAssistant) -> None:
|
||||||
"""Test the user initiated form."""
|
"""Test the user initiated form."""
|
||||||
|
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.airthings_ble.config_flow.async_discovered_service_info",
|
"homeassistant.components.airthings_ble.config_flow.async_discovered_service_info",
|
||||||
@@ -127,7 +131,7 @@ async def test_user_setup(hass: HomeAssistant) -> None:
|
|||||||
patch_airthings_ble(
|
patch_airthings_ble(
|
||||||
AirthingsDevice(
|
AirthingsDevice(
|
||||||
manufacturer="Airthings AS",
|
manufacturer="Airthings AS",
|
||||||
model=AirthingsDeviceType.WAVE_PLUS,
|
model=wave_plus_device,
|
||||||
name="Airthings Wave Plus",
|
name="Airthings Wave Plus",
|
||||||
identifier="123456",
|
identifier="123456",
|
||||||
)
|
)
|
||||||
@@ -158,6 +162,8 @@ async def test_user_setup(hass: HomeAssistant) -> None:
|
|||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result["title"] == "Airthings Wave Plus (2930123456)"
|
assert result["title"] == "Airthings Wave Plus (2930123456)"
|
||||||
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
|
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
|
||||||
|
assert result["data"] == {DEVICE_MODEL: wave_plus_device.value}
|
||||||
|
assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value}
|
||||||
|
|
||||||
|
|
||||||
async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
|
async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
|
||||||
@@ -168,6 +174,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
|
|||||||
source=SOURCE_IGNORE,
|
source=SOURCE_IGNORE,
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
wave_plus_device = AirthingsDeviceType.WAVE_PLUS
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.airthings_ble.config_flow.async_discovered_service_info",
|
"homeassistant.components.airthings_ble.config_flow.async_discovered_service_info",
|
||||||
@@ -177,7 +184,7 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
|
|||||||
patch_airthings_ble(
|
patch_airthings_ble(
|
||||||
AirthingsDevice(
|
AirthingsDevice(
|
||||||
manufacturer="Airthings AS",
|
manufacturer="Airthings AS",
|
||||||
model=AirthingsDeviceType.WAVE_PLUS,
|
model=wave_plus_device,
|
||||||
name="Airthings Wave Plus",
|
name="Airthings Wave Plus",
|
||||||
identifier="123456",
|
identifier="123456",
|
||||||
)
|
)
|
||||||
@@ -208,6 +215,8 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None:
|
|||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result["title"] == "Airthings Wave Plus (2930123456)"
|
assert result["title"] == "Airthings Wave Plus (2930123456)"
|
||||||
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
|
assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc"
|
||||||
|
assert result["data"] == {DEVICE_MODEL: wave_plus_device.value}
|
||||||
|
assert result["result"].data == {DEVICE_MODEL: wave_plus_device.value}
|
||||||
|
|
||||||
|
|
||||||
async def test_user_setup_no_device(hass: HomeAssistant) -> None:
|
async def test_user_setup_no_device(hass: HomeAssistant) -> None:
|
||||||
|
|||||||
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."""
|
"""Test the Airthings Wave sensor."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.airthings_ble.const import DOMAIN
|
from homeassistant.components.airthings_ble.const import (
|
||||||
|
DEFAULT_SCAN_INTERVAL,
|
||||||
|
DEVICE_MODEL,
|
||||||
|
DEVICE_SPECIFIC_SCAN_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
@@ -12,6 +19,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
|||||||
from . import (
|
from . import (
|
||||||
CO2_V1,
|
CO2_V1,
|
||||||
CO2_V2,
|
CO2_V2,
|
||||||
|
CORENTIUM_HOME_2_DEVICE_INFO,
|
||||||
HUMIDITY_V2,
|
HUMIDITY_V2,
|
||||||
TEMPERATURE_V1,
|
TEMPERATURE_V1,
|
||||||
VOC_V1,
|
VOC_V1,
|
||||||
@@ -21,6 +29,8 @@ from . import (
|
|||||||
WAVE_ENHANCE_DEVICE_INFO,
|
WAVE_ENHANCE_DEVICE_INFO,
|
||||||
WAVE_ENHANCE_SERVICE_INFO,
|
WAVE_ENHANCE_SERVICE_INFO,
|
||||||
WAVE_SERVICE_INFO,
|
WAVE_SERVICE_INFO,
|
||||||
|
AirthingsDevice,
|
||||||
|
BluetoothServiceInfoBleak,
|
||||||
create_device,
|
create_device,
|
||||||
create_entry,
|
create_entry,
|
||||||
patch_airthings_ble,
|
patch_airthings_ble,
|
||||||
@@ -29,6 +39,7 @@ from . import (
|
|||||||
patch_async_discovered_service_info,
|
patch_async_discovered_service_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
from tests.components.bluetooth import inject_bluetooth_service_info
|
from tests.components.bluetooth import inject_bluetooth_service_info
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -267,3 +278,102 @@ async def test_translation_keys(
|
|||||||
|
|
||||||
expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_name}"
|
expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_name}"
|
||||||
assert state.attributes.get("friendly_name") == expected_name
|
assert state.attributes.get("friendly_name") == expected_name
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scan_interval_migration_corentium_home_2(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test that radon device migration uses 30-minute scan interval."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id=WAVE_SERVICE_INFO.address,
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
inject_bluetooth_service_info(hass, WAVE_SERVICE_INFO)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch_async_ble_device_from_address(WAVE_SERVICE_INFO.device),
|
||||||
|
patch_airthings_ble(CORENTIUM_HOME_2_DEVICE_INFO) as mock_update,
|
||||||
|
):
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Migration should have added device_model to entry data
|
||||||
|
assert DEVICE_MODEL in entry.data
|
||||||
|
assert entry.data[DEVICE_MODEL] == CORENTIUM_HOME_2_DEVICE_INFO.model.value
|
||||||
|
|
||||||
|
# Coordinator should have been configured with radon scan interval
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
assert coordinator.update_interval == timedelta(
|
||||||
|
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
|
||||||
|
CORENTIUM_HOME_2_DEVICE_INFO.model.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should have 2 calls: 1 for migration + 1 for initial refresh
|
||||||
|
assert mock_update.call_count == 2
|
||||||
|
|
||||||
|
# Fast forward by default interval (300s) - should NOT trigger update
|
||||||
|
freezer.tick(DEFAULT_SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_update.call_count == 2
|
||||||
|
|
||||||
|
# Fast forward to radon interval (1800s) - should trigger update
|
||||||
|
freezer.tick(
|
||||||
|
DEVICE_SPECIFIC_SCAN_INTERVAL.get(CORENTIUM_HOME_2_DEVICE_INFO.model.value)
|
||||||
|
)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_update.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("service_info", "device_info"),
|
||||||
|
[
|
||||||
|
(WAVE_SERVICE_INFO, WAVE_DEVICE_INFO),
|
||||||
|
(WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_default_scan_interval_migration(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
service_info: BluetoothServiceInfoBleak,
|
||||||
|
device_info: AirthingsDevice,
|
||||||
|
) -> None:
|
||||||
|
"""Test that non-radon device migration uses default 5-minute scan interval."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id=service_info.address,
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
inject_bluetooth_service_info(hass, service_info)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch_async_ble_device_from_address(service_info.device),
|
||||||
|
patch_airthings_ble(device_info) as mock_update,
|
||||||
|
):
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Migration should have added device_model to entry data
|
||||||
|
assert DEVICE_MODEL in entry.data
|
||||||
|
assert entry.data[DEVICE_MODEL] == device_info.model.value
|
||||||
|
|
||||||
|
# Coordinator should have been configured with default scan interval
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
assert coordinator.update_interval == timedelta(seconds=DEFAULT_SCAN_INTERVAL)
|
||||||
|
|
||||||
|
# Should have 2 calls: 1 for migration + 1 for initial refresh
|
||||||
|
assert mock_update.call_count == 2
|
||||||
|
|
||||||
|
# Fast forward by default interval (300s) - SHOULD trigger update
|
||||||
|
freezer.tick(DEFAULT_SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_update.call_count == 3
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from collections.abc import Generator
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from aioamazondevices.const import DEVICE_TYPE_TO_MODEL
|
from aioamazondevices.const.devices import DEVICE_TYPE_TO_MODEL
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.alexa_devices.const import (
|
from homeassistant.components.alexa_devices.const import (
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor, AmazonSchedule
|
from aioamazondevices.const.schedules import (
|
||||||
from aioamazondevices.const import (
|
|
||||||
NOTIFICATION_ALARM,
|
NOTIFICATION_ALARM,
|
||||||
NOTIFICATION_REMINDER,
|
NOTIFICATION_REMINDER,
|
||||||
NOTIFICATION_TIMER,
|
NOTIFICATION_TIMER,
|
||||||
)
|
)
|
||||||
|
from aioamazondevices.structures import AmazonDevice, AmazonDeviceSensor, AmazonSchedule
|
||||||
|
|
||||||
TEST_CODE = "023123"
|
TEST_CODE = "023123"
|
||||||
TEST_PASSWORD = "fake_password"
|
TEST_PASSWORD = "fake_password"
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
'online': True,
|
'online': True,
|
||||||
'sensors': dict({
|
'sensors': dict({
|
||||||
'dnd': dict({
|
'dnd': dict({
|
||||||
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
|
'__type': "<class 'aioamazondevices.structures.AmazonDeviceSensor'>",
|
||||||
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)",
|
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)",
|
||||||
}),
|
}),
|
||||||
'temperature': dict({
|
'temperature': dict({
|
||||||
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
|
'__type': "<class 'aioamazondevices.structures.AmazonDeviceSensor'>",
|
||||||
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')",
|
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
'online': True,
|
'online': True,
|
||||||
'sensors': dict({
|
'sensors': dict({
|
||||||
'dnd': dict({
|
'dnd': dict({
|
||||||
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
|
'__type': "<class 'aioamazondevices.structures.AmazonDeviceSensor'>",
|
||||||
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)",
|
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, error_type=None, error_msg=None, scale=None)",
|
||||||
}),
|
}),
|
||||||
'temperature': dict({
|
'temperature': dict({
|
||||||
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
|
'__type': "<class 'aioamazondevices.structures.AmazonDeviceSensor'>",
|
||||||
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')",
|
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, error_type=None, error_msg=None, scale='CELSIUS')",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY, SPEAKER_GROUP_MODEL
|
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY, SPEAKER_GROUP_MODEL
|
||||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def setup_mock_foscam_camera(mock_foscam_camera):
|
|||||||
0,
|
0,
|
||||||
{
|
{
|
||||||
"swCapabilities1": "100",
|
"swCapabilities1": "100",
|
||||||
"swCapabilities2": "768",
|
"swCapabilities2": "896",
|
||||||
"swCapabilities3": "100",
|
"swCapabilities3": "100",
|
||||||
"swCapabilities4": "100",
|
"swCapabilities4": "100",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ import growattServer
|
|||||||
import pytest
|
import pytest
|
||||||
from syrupy.assertion import SnapshotAssertion
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components.growatt_server.const import DOMAIN
|
from homeassistant.components.growatt_server.const import (
|
||||||
|
AUTH_API_TOKEN,
|
||||||
|
AUTH_PASSWORD,
|
||||||
|
CONF_AUTH_TYPE,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
@@ -174,3 +180,79 @@ async def test_multiple_devices_discovered(
|
|||||||
assert device1 == snapshot(name="device_min123456")
|
assert device1 == snapshot(name="device_min123456")
|
||||||
assert device2 is not None
|
assert device2 is not None
|
||||||
assert device2 == snapshot(name="device_min789012")
|
assert device2 == snapshot(name="device_min789012")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrate_legacy_api_token_config(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_growatt_v1_api,
|
||||||
|
) -> None:
|
||||||
|
"""Test migration of legacy config entry with API token but no auth_type."""
|
||||||
|
# Create a legacy config entry without CONF_AUTH_TYPE
|
||||||
|
legacy_config = {
|
||||||
|
CONF_TOKEN: "test_token_123",
|
||||||
|
CONF_URL: "https://openapi.growatt.com/",
|
||||||
|
"plant_id": "plant_123",
|
||||||
|
}
|
||||||
|
mock_config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=legacy_config,
|
||||||
|
unique_id="plant_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
# Verify migration occurred and auth_type was added
|
||||||
|
assert mock_config_entry.data[CONF_AUTH_TYPE] == AUTH_API_TOKEN
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrate_legacy_password_config(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_growatt_classic_api,
|
||||||
|
) -> None:
|
||||||
|
"""Test migration of legacy config entry with password auth but no auth_type."""
|
||||||
|
# Create a legacy config entry without CONF_AUTH_TYPE
|
||||||
|
legacy_config = {
|
||||||
|
CONF_USERNAME: "test_user",
|
||||||
|
CONF_PASSWORD: "test_password",
|
||||||
|
CONF_URL: "https://server.growatt.com/",
|
||||||
|
"plant_id": "plant_456",
|
||||||
|
}
|
||||||
|
mock_config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=legacy_config,
|
||||||
|
unique_id="plant_456",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Classic API doesn't support MIN devices - use TLX device instead
|
||||||
|
mock_growatt_classic_api.device_list.return_value = [
|
||||||
|
{"deviceSn": "TLX123456", "deviceType": "tlx"}
|
||||||
|
]
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
# Verify migration occurred and auth_type was added
|
||||||
|
assert mock_config_entry.data[CONF_AUTH_TYPE] == AUTH_PASSWORD
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrate_legacy_config_no_auth_fields(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> None:
|
||||||
|
"""Test that config entry with no recognizable auth fields raises error."""
|
||||||
|
# Create a config entry without any auth fields
|
||||||
|
invalid_config = {
|
||||||
|
CONF_URL: "https://openapi.growatt.com/",
|
||||||
|
"plant_id": "plant_789",
|
||||||
|
}
|
||||||
|
mock_config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=invalid_config,
|
||||||
|
unique_id="plant_789",
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
# The ConfigEntryError is caught by the config entry system
|
||||||
|
# and the entry state is set to SETUP_ERROR
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||||
|
|||||||
@@ -792,10 +792,11 @@ async def test_config_flow_thread(
|
|||||||
assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
|
assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
|
||||||
assert pick_result["progress_action"] == "install_firmware"
|
assert pick_result["progress_action"] == "install_firmware"
|
||||||
assert pick_result["step_id"] == "install_thread_firmware"
|
assert pick_result["step_id"] == "install_thread_firmware"
|
||||||
description_placeholders = pick_result["description_placeholders"]
|
assert pick_result["description_placeholders"] == {
|
||||||
assert description_placeholders is not None
|
"firmware_type": "ezsp",
|
||||||
assert description_placeholders["firmware_type"] == "ezsp"
|
"model": TEST_HARDWARE_NAME,
|
||||||
assert description_placeholders["model"] == TEST_HARDWARE_NAME
|
"firmware_name": "Thread",
|
||||||
|
}
|
||||||
|
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
@@ -919,10 +920,11 @@ async def test_options_flow_zigbee_to_thread(
|
|||||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||||
assert result["type"] is FlowResultType.MENU
|
assert result["type"] is FlowResultType.MENU
|
||||||
assert result["step_id"] == "pick_firmware"
|
assert result["step_id"] == "pick_firmware"
|
||||||
description_placeholders = result["description_placeholders"]
|
assert result["description_placeholders"] == {
|
||||||
assert description_placeholders is not None
|
"firmware_type": "ezsp",
|
||||||
assert description_placeholders["firmware_type"] == "ezsp"
|
"model": TEST_HARDWARE_NAME,
|
||||||
assert description_placeholders["model"] == TEST_HARDWARE_NAME
|
"firmware_name": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
result = await hass.config_entries.options.async_configure(
|
result = await hass.config_entries.options.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
@@ -995,10 +997,11 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None:
|
|||||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||||
assert result["type"] is FlowResultType.MENU
|
assert result["type"] is FlowResultType.MENU
|
||||||
assert result["step_id"] == "pick_firmware"
|
assert result["step_id"] == "pick_firmware"
|
||||||
description_placeholders = result["description_placeholders"]
|
assert result["description_placeholders"] == {
|
||||||
assert description_placeholders is not None
|
"firmware_type": "spinel",
|
||||||
assert description_placeholders["firmware_type"] == "spinel"
|
"model": TEST_HARDWARE_NAME,
|
||||||
assert description_placeholders["model"] == TEST_HARDWARE_NAME
|
"firmware_name": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
with mock_firmware_info(
|
with mock_firmware_info(
|
||||||
probe_app_type=ApplicationType.SPINEL,
|
probe_app_type=ApplicationType.SPINEL,
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
assert result["type"] is FlowResultType.ABORT
|
assert result["type"] is FlowResultType.ABORT
|
||||||
assert result["reason"] == "not_hassio_thread"
|
assert result["reason"] == "not_hassio_thread"
|
||||||
|
assert result["description_placeholders"] == {
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "spinel",
|
||||||
|
"firmware_name": "Thread",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -110,6 +115,12 @@ async def test_config_flow_thread_addon_info_fails(
|
|||||||
# Cannot get addon info
|
# Cannot get addon info
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT
|
||||||
assert result["reason"] == "addon_info_failed"
|
assert result["reason"] == "addon_info_failed"
|
||||||
|
assert result["description_placeholders"] == {
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "spinel",
|
||||||
|
"firmware_name": "Thread",
|
||||||
|
"addon_name": "OpenThread Border Router",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("addon_not_installed")
|
@pytest.mark.usefixtures("addon_not_installed")
|
||||||
@@ -155,6 +166,12 @@ async def test_config_flow_thread_addon_install_fails(
|
|||||||
# Cannot install addon
|
# Cannot install addon
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT
|
||||||
assert result["reason"] == "addon_install_failed"
|
assert result["reason"] == "addon_install_failed"
|
||||||
|
assert result["description_placeholders"] == {
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "ezsp",
|
||||||
|
"firmware_name": "Thread",
|
||||||
|
"addon_name": "OpenThread Border Router",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("addon_installed")
|
@pytest.mark.usefixtures("addon_installed")
|
||||||
@@ -196,6 +213,12 @@ async def test_config_flow_thread_addon_set_config_fails(
|
|||||||
|
|
||||||
assert pick_thread_progress_result["type"] == FlowResultType.ABORT
|
assert pick_thread_progress_result["type"] == FlowResultType.ABORT
|
||||||
assert pick_thread_progress_result["reason"] == "addon_set_config_failed"
|
assert pick_thread_progress_result["reason"] == "addon_set_config_failed"
|
||||||
|
assert pick_thread_progress_result["description_placeholders"] == {
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "ezsp",
|
||||||
|
"firmware_name": "Thread",
|
||||||
|
"addon_name": "OpenThread Border Router",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("addon_installed")
|
@pytest.mark.usefixtures("addon_installed")
|
||||||
@@ -236,6 +259,12 @@ async def test_config_flow_thread_flasher_run_fails(
|
|||||||
|
|
||||||
assert pick_thread_progress_result["type"] == FlowResultType.ABORT
|
assert pick_thread_progress_result["type"] == FlowResultType.ABORT
|
||||||
assert pick_thread_progress_result["reason"] == "addon_start_failed"
|
assert pick_thread_progress_result["reason"] == "addon_start_failed"
|
||||||
|
assert pick_thread_progress_result["description_placeholders"] == {
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "ezsp",
|
||||||
|
"firmware_name": "Thread",
|
||||||
|
"addon_name": "OpenThread Border Router",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("addon_running")
|
@pytest.mark.usefixtures("addon_running")
|
||||||
@@ -273,6 +302,11 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non
|
|||||||
|
|
||||||
assert pick_thread_progress_result["type"] is FlowResultType.ABORT
|
assert pick_thread_progress_result["type"] is FlowResultType.ABORT
|
||||||
assert pick_thread_progress_result["reason"] == "fw_install_failed"
|
assert pick_thread_progress_result["reason"] == "fw_install_failed"
|
||||||
|
assert pick_thread_progress_result["description_placeholders"] == {
|
||||||
|
"firmware_name": "Thread",
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "ezsp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -310,6 +344,11 @@ async def test_config_flow_firmware_index_download_fails_and_required(
|
|||||||
|
|
||||||
assert pick_result["type"] is FlowResultType.ABORT
|
assert pick_result["type"] is FlowResultType.ABORT
|
||||||
assert pick_result["reason"] == "fw_download_failed"
|
assert pick_result["reason"] == "fw_download_failed"
|
||||||
|
assert pick_result["description_placeholders"] == {
|
||||||
|
"firmware_name": "Zigbee",
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "spinel",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -347,6 +386,11 @@ async def test_config_flow_firmware_download_fails_and_required(
|
|||||||
|
|
||||||
assert pick_result["type"] is FlowResultType.ABORT
|
assert pick_result["type"] is FlowResultType.ABORT
|
||||||
assert pick_result["reason"] == "fw_download_failed"
|
assert pick_result["reason"] == "fw_download_failed"
|
||||||
|
assert pick_result["description_placeholders"] == {
|
||||||
|
"firmware_name": "Zigbee",
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "spinel",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -395,6 +439,11 @@ async def test_options_flow_zigbee_to_thread_zha_configured(
|
|||||||
|
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT
|
||||||
assert result["reason"] == "zha_still_using_stick"
|
assert result["reason"] == "zha_still_using_stick"
|
||||||
|
assert result["description_placeholders"] == {
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "ezsp",
|
||||||
|
"firmware_name": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -442,3 +491,8 @@ async def test_options_flow_thread_to_zigbee_otbr_configured(
|
|||||||
|
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT
|
||||||
assert result["reason"] == "otbr_still_using_stick"
|
assert result["reason"] == "otbr_still_using_stick"
|
||||||
|
assert result["description_placeholders"] == {
|
||||||
|
"model": TEST_HARDWARE_NAME,
|
||||||
|
"firmware_type": "spinel",
|
||||||
|
"firmware_name": "unknown",
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
'validDPTs': list([
|
'validDPTs': list([
|
||||||
dict({
|
dict({
|
||||||
'main': 9,
|
'main': 9,
|
||||||
'sub': 2,
|
'sub': 7,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
'write': False,
|
'write': False,
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ async def test_user_api_key(
|
|||||||
CONF_NAME: NAME,
|
CONF_NAME: NAME,
|
||||||
CONF_SITE_ID: SITE_ID,
|
CONF_SITE_ID: SITE_ID,
|
||||||
CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY},
|
CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY},
|
||||||
|
CONF_SECTION_WEB_AUTH: {
|
||||||
|
CONF_USERNAME: "",
|
||||||
|
CONF_PASSWORD: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert result.get("type") is FlowResultType.CREATE_ENTRY
|
assert result.get("type") is FlowResultType.CREATE_ENTRY
|
||||||
@@ -85,6 +89,7 @@ async def test_user_web_login(
|
|||||||
{
|
{
|
||||||
CONF_NAME: NAME,
|
CONF_NAME: NAME,
|
||||||
CONF_SITE_ID: SITE_ID,
|
CONF_SITE_ID: SITE_ID,
|
||||||
|
CONF_SECTION_API_AUTH: {CONF_API_KEY: ""},
|
||||||
CONF_SECTION_WEB_AUTH: {
|
CONF_SECTION_WEB_AUTH: {
|
||||||
CONF_USERNAME: USERNAME,
|
CONF_USERNAME: USERNAME,
|
||||||
CONF_PASSWORD: PASSWORD,
|
CONF_PASSWORD: PASSWORD,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for the SolarEdge integration."""
|
"""Tests for the SolarEdge integration."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientError
|
||||||
|
|
||||||
@@ -15,8 +15,15 @@ from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME
|
|||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
async def test_setup_unload_api_key(
|
async def test_setup_unload_api_key(
|
||||||
recorder_mock: Recorder, hass: HomeAssistant, solaredge_api: Mock
|
mock_unload_platforms: AsyncMock,
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
solaredge_api: Mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test successful setup and unload of a config entry with API key."""
|
"""Test successful setup and unload of a config entry with API key."""
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
@@ -33,11 +40,21 @@ async def test_setup_unload_api_key(
|
|||||||
|
|
||||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Unloading should be attempted because sensors were set up.
|
||||||
|
mock_unload_platforms.assert_awaited_once()
|
||||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
async def test_setup_unload_web_login(
|
async def test_setup_unload_web_login(
|
||||||
recorder_mock: Recorder, hass: HomeAssistant, solaredge_web_api: AsyncMock
|
mock_unload_platforms: AsyncMock,
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
solaredge_web_api: AsyncMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test successful setup and unload of a config entry with web login."""
|
"""Test successful setup and unload of a config entry with web login."""
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
@@ -59,10 +76,18 @@ async def test_setup_unload_web_login(
|
|||||||
|
|
||||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Unloading should NOT be attempted because sensors were not set up.
|
||||||
|
mock_unload_platforms.assert_not_called()
|
||||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
async def test_setup_unload_both(
|
async def test_setup_unload_both(
|
||||||
|
mock_unload_platforms: AsyncMock,
|
||||||
recorder_mock: Recorder,
|
recorder_mock: Recorder,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
solaredge_api: Mock,
|
solaredge_api: Mock,
|
||||||
@@ -90,6 +115,8 @@ async def test_setup_unload_both(
|
|||||||
|
|
||||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
mock_unload_platforms.assert_awaited_once()
|
||||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ async def consume_progress_flow(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class DelayedAsyncMock(AsyncMock):
|
||||||
|
"""AsyncMock that waits a moment before returning, useful for progress steps."""
|
||||||
|
|
||||||
|
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
"""Overridden `__call__` with an added delay."""
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return await super().__call__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("entry_name", "unique_id", "radio_type", "service_info"),
|
("entry_name", "unique_id", "radio_type", "service_info"),
|
||||||
[
|
[
|
||||||
@@ -720,6 +729,7 @@ async def test_migration_strategy_recommended_cannot_write(
|
|||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
||||||
|
new_callable=DelayedAsyncMock,
|
||||||
side_effect=CannotWriteNetworkSettings("test error"),
|
side_effect=CannotWriteNetworkSettings("test error"),
|
||||||
) as mock_restore_backup:
|
) as mock_restore_backup:
|
||||||
result_migrate = await hass.config_entries.flow.async_configure(
|
result_migrate = await hass.config_entries.flow.async_configure(
|
||||||
@@ -1735,7 +1745,7 @@ async def test_strategy_no_network_settings(
|
|||||||
advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant
|
advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test formation strategy when no network settings are present."""
|
"""Test formation strategy when no network settings are present."""
|
||||||
mock_app.load_network_info = MagicMock(side_effect=NetworkNotFormed())
|
mock_app.load_network_info = DelayedAsyncMock(side_effect=NetworkNotFormed())
|
||||||
|
|
||||||
result = await advanced_pick_radio(RadioType.ezsp)
|
result = await advanced_pick_radio(RadioType.ezsp)
|
||||||
assert (
|
assert (
|
||||||
@@ -1773,7 +1783,7 @@ async def test_formation_strategy_form_initial_network(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test forming a new network, with no previous settings on the radio."""
|
"""Test forming a new network, with no previous settings on the radio."""
|
||||||
# Initially, no network is formed
|
# Initially, no network is formed
|
||||||
mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed())
|
mock_app.load_network_info = DelayedAsyncMock(side_effect=NetworkNotFormed())
|
||||||
|
|
||||||
# After form_network is called, load_network_info should return the network settings
|
# After form_network is called, load_network_info should return the network settings
|
||||||
async def form_network_side_effect(*args, **kwargs):
|
async def form_network_side_effect(*args, **kwargs):
|
||||||
@@ -1807,7 +1817,7 @@ async def test_onboarding_auto_formation_new_hardware(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test auto network formation with new hardware during onboarding."""
|
"""Test auto network formation with new hardware during onboarding."""
|
||||||
# Initially, no network is formed
|
# Initially, no network is formed
|
||||||
mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed())
|
mock_app.load_network_info = DelayedAsyncMock(side_effect=NetworkNotFormed())
|
||||||
mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device))
|
mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device))
|
||||||
|
|
||||||
# After form_network is called, load_network_info should return the network settings
|
# After form_network is called, load_network_info should return the network settings
|
||||||
@@ -1951,6 +1961,7 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
|
|||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
||||||
|
new_callable=DelayedAsyncMock,
|
||||||
side_effect=[
|
side_effect=[
|
||||||
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
|
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
|
||||||
None,
|
None,
|
||||||
@@ -1981,8 +1992,14 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
|
|||||||
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
|
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result_confirm["type"] is FlowResultType.CREATE_ENTRY
|
result_final = await consume_progress_flow(
|
||||||
assert result_confirm["data"][CONF_RADIO_TYPE] == "ezsp"
|
hass,
|
||||||
|
flow_id=result_confirm["flow_id"],
|
||||||
|
valid_step_ids=("restore_backup",),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result_final["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result_final["data"][CONF_RADIO_TYPE] == "ezsp"
|
||||||
|
|
||||||
assert mock_restore_backup.call_count == 1
|
assert mock_restore_backup.call_count == 1
|
||||||
assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True
|
assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True
|
||||||
@@ -2014,6 +2031,7 @@ async def test_formation_strategy_restore_manual_backup_ezsp(
|
|||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
||||||
|
new_callable=DelayedAsyncMock,
|
||||||
side_effect=[
|
side_effect=[
|
||||||
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
|
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
|
||||||
None,
|
None,
|
||||||
@@ -2316,6 +2334,7 @@ async def test_options_flow_defaults(
|
|||||||
# ZHA gets unloaded
|
# ZHA gets unloaded
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.config_entries.ConfigEntries.async_unload",
|
"homeassistant.config_entries.ConfigEntries.async_unload",
|
||||||
|
new_callable=DelayedAsyncMock,
|
||||||
side_effect=[async_unload_effect],
|
side_effect=[async_unload_effect],
|
||||||
) as mock_async_unload:
|
) as mock_async_unload:
|
||||||
result1 = await hass.config_entries.options.async_configure(
|
result1 = await hass.config_entries.options.async_configure(
|
||||||
@@ -2853,6 +2872,7 @@ async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None:
|
|||||||
patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True),
|
patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True),
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info",
|
"homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info",
|
||||||
|
new_callable=DelayedAsyncMock,
|
||||||
side_effect=AddonError,
|
side_effect=AddonError,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
@@ -3075,6 +3095,7 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ
|
|||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
||||||
|
new_callable=DelayedAsyncMock,
|
||||||
side_effect=[
|
side_effect=[
|
||||||
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
|
DestructiveWriteNetworkSettings("Radio IEEE change is permanent"),
|
||||||
CannotWriteNetworkSettings("Failed to write settings"),
|
CannotWriteNetworkSettings("Failed to write settings"),
|
||||||
@@ -3100,11 +3121,17 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ
|
|||||||
assert confirm_restore_result["type"] is FlowResultType.FORM
|
assert confirm_restore_result["type"] is FlowResultType.FORM
|
||||||
assert confirm_restore_result["step_id"] == "confirm_ezsp_ieee_overwrite"
|
assert confirm_restore_result["step_id"] == "confirm_ezsp_ieee_overwrite"
|
||||||
|
|
||||||
final_result = await hass.config_entries.flow.async_configure(
|
confirm_result = await hass.config_entries.flow.async_configure(
|
||||||
confirm_restore_result["flow_id"],
|
confirm_restore_result["flow_id"],
|
||||||
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
|
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
final_result = await consume_progress_flow(
|
||||||
|
hass,
|
||||||
|
flow_id=confirm_result["flow_id"],
|
||||||
|
valid_step_ids=("restore_backup",),
|
||||||
|
)
|
||||||
|
|
||||||
assert final_result["type"] is FlowResultType.ABORT
|
assert final_result["type"] is FlowResultType.ABORT
|
||||||
assert final_result["reason"] == "cannot_restore_backup"
|
assert final_result["reason"] == "cannot_restore_backup"
|
||||||
assert (
|
assert (
|
||||||
@@ -3191,6 +3218,7 @@ async def test_plug_in_new_radio_retry(
|
|||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
"homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup",
|
||||||
|
new_callable=DelayedAsyncMock,
|
||||||
side_effect=[
|
side_effect=[
|
||||||
HomeAssistantError(
|
HomeAssistantError(
|
||||||
"Failed to connect to Zigbee adapter: [Errno 2] No such file or directory"
|
"Failed to connect to Zigbee adapter: [Errno 2] No such file or directory"
|
||||||
@@ -3203,43 +3231,67 @@ async def test_plug_in_new_radio_retry(
|
|||||||
],
|
],
|
||||||
) as mock_restore_backup,
|
) as mock_restore_backup,
|
||||||
):
|
):
|
||||||
result3 = await hass.config_entries.flow.async_configure(
|
upload_result = await hass.config_entries.flow.async_configure(
|
||||||
result2["flow_id"],
|
result2["flow_id"],
|
||||||
user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())},
|
user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result3 = await consume_progress_flow(
|
||||||
|
hass,
|
||||||
|
flow_id=upload_result["flow_id"],
|
||||||
|
valid_step_ids=("restore_backup",),
|
||||||
|
)
|
||||||
|
|
||||||
# Prompt user to plug old adapter back in when restore fails
|
# Prompt user to plug old adapter back in when restore fails
|
||||||
assert result3["type"] is FlowResultType.FORM
|
assert result3["type"] is FlowResultType.FORM
|
||||||
assert result3["step_id"] == "plug_in_new_radio"
|
assert result3["step_id"] == "plug_in_new_radio"
|
||||||
assert result3["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"}
|
assert result3["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"}
|
||||||
|
|
||||||
# Submit retry attempt with plugged in adapter
|
# Submit retry attempt with plugged in adapter
|
||||||
result4 = await hass.config_entries.flow.async_configure(
|
retry_result = await hass.config_entries.flow.async_configure(
|
||||||
result3["flow_id"],
|
result3["flow_id"],
|
||||||
user_input={},
|
user_input={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result4 = await consume_progress_flow(
|
||||||
|
hass,
|
||||||
|
flow_id=retry_result["flow_id"],
|
||||||
|
valid_step_ids=("restore_backup",),
|
||||||
|
)
|
||||||
|
|
||||||
# This adapter requires user confirmation for restore
|
# This adapter requires user confirmation for restore
|
||||||
assert result4["type"] is FlowResultType.FORM
|
assert result4["type"] is FlowResultType.FORM
|
||||||
assert result4["step_id"] == "confirm_ezsp_ieee_overwrite"
|
assert result4["step_id"] == "confirm_ezsp_ieee_overwrite"
|
||||||
|
|
||||||
# Confirm destructive rewrite, but adapter is unplugged again
|
# Confirm destructive rewrite, but adapter is unplugged again
|
||||||
result5 = await hass.config_entries.flow.async_configure(
|
confirm_result = await hass.config_entries.flow.async_configure(
|
||||||
result3["flow_id"],
|
result4["flow_id"],
|
||||||
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
|
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result5 = await consume_progress_flow(
|
||||||
|
hass,
|
||||||
|
flow_id=confirm_result["flow_id"],
|
||||||
|
valid_step_ids=("restore_backup",),
|
||||||
|
)
|
||||||
|
|
||||||
# Prompt user to plug old adapter back in again
|
# Prompt user to plug old adapter back in again
|
||||||
assert result5["type"] is FlowResultType.FORM
|
assert result5["type"] is FlowResultType.FORM
|
||||||
assert result5["step_id"] == "plug_in_new_radio"
|
assert result5["step_id"] == "plug_in_new_radio"
|
||||||
assert result5["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"}
|
assert result5["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"}
|
||||||
|
|
||||||
# User confirms they plugged in the adapter
|
# User confirms they plugged in the adapter
|
||||||
result6 = await hass.config_entries.flow.async_configure(
|
final_retry_result = await hass.config_entries.flow.async_configure(
|
||||||
result4["flow_id"],
|
result5["flow_id"],
|
||||||
user_input={},
|
user_input={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result6 = await consume_progress_flow(
|
||||||
|
hass,
|
||||||
|
flow_id=final_retry_result["flow_id"],
|
||||||
|
valid_step_ids=("restore_backup",),
|
||||||
|
)
|
||||||
|
|
||||||
# Entry created successfully
|
# Entry created successfully
|
||||||
assert result6["type"] is FlowResultType.CREATE_ENTRY
|
assert result6["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result6["data"][CONF_RADIO_TYPE] == "ezsp"
|
assert result6["data"][CONF_RADIO_TYPE] == "ezsp"
|
||||||
@@ -3277,7 +3329,7 @@ async def test_plug_in_old_radio_retry(hass: HomeAssistant, backup, mock_app) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
mock_temp_radio_mgr = AsyncMock()
|
mock_temp_radio_mgr = AsyncMock()
|
||||||
mock_temp_radio_mgr.async_reset_adapter = AsyncMock(
|
mock_temp_radio_mgr.async_reset_adapter = DelayedAsyncMock(
|
||||||
side_effect=HomeAssistantError(
|
side_effect=HomeAssistantError(
|
||||||
"Failed to connect to Zigbee adapter: [Errno 2] No such file or directory"
|
"Failed to connect to Zigbee adapter: [Errno 2] No such file or directory"
|
||||||
)
|
)
|
||||||
@@ -3303,11 +3355,17 @@ async def test_plug_in_old_radio_retry(hass: HomeAssistant, backup, mock_app) ->
|
|||||||
|
|
||||||
assert result_confirm["step_id"] == "choose_migration_strategy"
|
assert result_confirm["step_id"] == "choose_migration_strategy"
|
||||||
|
|
||||||
result_recommended = await hass.config_entries.flow.async_configure(
|
recommended_result = await hass.config_entries.flow.async_configure(
|
||||||
result_confirm["flow_id"],
|
result_confirm["flow_id"],
|
||||||
user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED},
|
user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result_recommended = await consume_progress_flow(
|
||||||
|
hass,
|
||||||
|
flow_id=recommended_result["flow_id"],
|
||||||
|
valid_step_ids=("maybe_reset_old_radio",),
|
||||||
|
)
|
||||||
|
|
||||||
# Prompt user to plug old adapter back in when reset fails
|
# Prompt user to plug old adapter back in when reset fails
|
||||||
assert result_recommended["type"] is FlowResultType.MENU
|
assert result_recommended["type"] is FlowResultType.MENU
|
||||||
assert result_recommended["step_id"] == "plug_in_old_radio"
|
assert result_recommended["step_id"] == "plug_in_old_radio"
|
||||||
@@ -3321,11 +3379,17 @@ async def test_plug_in_old_radio_retry(hass: HomeAssistant, backup, mock_app) ->
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Retry with unplugged adapter
|
# Retry with unplugged adapter
|
||||||
result_retry = await hass.config_entries.flow.async_configure(
|
retry_result = await hass.config_entries.flow.async_configure(
|
||||||
result_recommended["flow_id"],
|
result_recommended["flow_id"],
|
||||||
user_input={"next_step_id": "retry_old_radio"},
|
user_input={"next_step_id": "retry_old_radio"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result_retry = await consume_progress_flow(
|
||||||
|
hass,
|
||||||
|
flow_id=retry_result["flow_id"],
|
||||||
|
valid_step_ids=("maybe_reset_old_radio",),
|
||||||
|
)
|
||||||
|
|
||||||
# Prompt user again to plug old adapter back in
|
# Prompt user again to plug old adapter back in
|
||||||
assert result_retry["type"] is FlowResultType.MENU
|
assert result_retry["type"] is FlowResultType.MENU
|
||||||
assert result_retry["step_id"] == "plug_in_old_radio"
|
assert result_retry["step_id"] == "plug_in_old_radio"
|
||||||
|
|||||||
Reference in New Issue
Block a user