diff --git a/CODEOWNERS b/CODEOWNERS index 6c01218ecb1aab..e57a00aa5e553c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1373,8 +1373,6 @@ CLAUDE.md @home-assistant/core /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 /tests/components/pegel_online/ @mib1185 -/homeassistant/components/permobil/ @IsakNyberg -/tests/components/permobil/ @IsakNyberg /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/pglab/ @pglab-electronics diff --git a/homeassistant/components/analytics_insights/quality_scale.yaml b/homeassistant/components/analytics_insights/quality_scale.yaml index e842dc6f3b1fa8..3455dc26750bd8 100644 --- a/homeassistant/components/analytics_insights/quality_scale.yaml +++ b/homeassistant/components/analytics_insights/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: done comment: | The coordinator handles this. - parallel-updates: todo + parallel-updates: done reauthentication-flow: status: exempt comment: | diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index 05136dc90611fe..f68a697decae9a 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import AnalyticsData, HomeassistantAnalyticsDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class AnalyticsSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 0000fbf09112b8..6966beb7d3e401 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -257,10 +257,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo # Perform first refresh of fast coordinator (required for entities) await fast_coordinator.async_config_entry_first_refresh() - # Refresh slow coordinator - don't fail if DHW is not available - # This allows the integration to work even if the device doesn't support DHW - await slow_coordinator.async_refresh() - entry.runtime_data = BSBLanData( client=bsblan, fast_coordinator=fast_coordinator, @@ -271,6 +267,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo available_circuits=circuits, ) + # Fetch slow data in the background so it does not block startup. + entry.async_create_background_task( + hass, + slow_coordinator.async_refresh(), + name=f"{DOMAIN}_slow_data_fetch_{entry.entry_id}", + ) + # Register main device before forwarding platforms, so sub-devices # (heating circuits, water heater) can reference it via via_device device_registry = dr.async_get(hass) diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index 518b6e4dcc4572..8c0f8b37cdf11b 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -80,32 +80,49 @@ def __init__(self, data: BSBLanData) -> None: # Initialize available attribute to resolve multiple inheritance conflict self._attr_available = True - # Set temperature limits based on device capabilities from slow coordinator + @property + @override + def min_temp(self) -> float: + """Return the minimum temperature. + + Derived from the slow-coordinator DHW config, which may still be + pending when the platform is set up. Falls back to the default until + the config becomes available. + """ dhw_config = ( - data.slow_coordinator.data.dhw_config - if data.slow_coordinator.data + self.slow_coordinator.data.dhw_config + if self.slow_coordinator.data else None ) - - # For min_temp: Use reduced_setpoint from config data (slow polling) if ( dhw_config is not None and dhw_config.reduced_setpoint is not None and dhw_config.reduced_setpoint.value is not None ): - self._attr_min_temp = dhw_config.reduced_setpoint.value - else: - self._attr_min_temp = 10.0 # Default minimum + return dhw_config.reduced_setpoint.value + return 10.0 # Default minimum + + @property + @override + def max_temp(self) -> float: + """Return the maximum temperature. - # For max_temp: Use nominal_setpoint_max from config data (slow polling) + Derived from the slow-coordinator DHW config, which may still be + pending when the platform is set up. Falls back to the default until + the config becomes available. + """ + dhw_config = ( + self.slow_coordinator.data.dhw_config + if self.slow_coordinator.data + else None + ) if ( dhw_config is not None and dhw_config.nominal_setpoint_max is not None and dhw_config.nominal_setpoint_max.value is not None ): - self._attr_max_temp = dhw_config.nominal_setpoint_max.value - else: - self._attr_max_temp = 65.0 # Default maximum + return dhw_config.nominal_setpoint_max.value + return 65.0 # Default maximum @property def _dhw(self) -> HotWaterState: diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 72941c5a55234c..bd5f79524ac469 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==2.2.0", "openai==2.21.0"], + "requirements": ["hass-nabucasa==2.2.0", "openai==2.45.0"], "single_config_entry": true } diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 3a85a01c3fc8d9..a072b6edeff3eb 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -7,6 +7,7 @@ from aiohttp import ClientSession from pylamarzocco import LaMarzoccoCloudClient +from pylamarzocco.const import DeviceType from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import Thing from pylamarzocco.util import InstallationKey, generate_installation_key @@ -105,7 +106,11 @@ async def async_step_user( _LOGGER.error("Error connecting to server: %s", exc) errors["base"] = "cannot_connect" else: - self._things = {thing.serial_number: thing for thing in things} + self._things = { + thing.serial_number: thing + for thing in things + if thing.type is DeviceType.MACHINE + } if not self._things: errors["base"] = "no_machines" diff --git a/homeassistant/components/llama_cpp/manifest.json b/homeassistant/components/llama_cpp/manifest.json index 1285be7afabf6f..a610d902720824 100644 --- a/homeassistant/components/llama_cpp/manifest.json +++ b/homeassistant/components/llama_cpp/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["openai==2.21.0"] + "requirements": ["openai==2.45.0"] } diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 121273348a5b47..9baa8ea1735f38 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.3.6"], + "requirements": ["music-assistant-client==1.4.3"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 5be81a48a75fe6..1631b3f9df5b54 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.21.0", "python-open-router==0.3.3"] + "requirements": ["openai==2.45.0", "python-open-router==0.3.3"] } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 7460bf938a7fb4..95fb8fc7d211ac 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.21.0"] + "requirements": ["openai==2.45.0"] } diff --git a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json index f2393ec1ade1b0..93feba80481141 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json +++ b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["openai==2.21.0"] + "requirements": ["openai==2.45.0"] } diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index ff3127d75a8e59..898f43277400a6 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -1,59 +1,37 @@ """The MyPermobil integration.""" -import logging - -from mypermobil import MyPermobil, MyPermobilClientException - -from homeassistant.const import ( - CONF_CODE, - CONF_EMAIL, - CONF_REGION, - CONF_TOKEN, - CONF_TTL, - Platform, -) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import APPLICATION -from .coordinator import MyPermobilCoordinator, PermobilConfigEntry - -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool: - """Set up MyPermobil from a config entry.""" - - # create the API object from the config and save it in hass - session = async_get_clientsession(hass) - p_api = MyPermobil( - application=APPLICATION, - session=session, - email=entry.data[CONF_EMAIL], - region=entry.data[CONF_REGION], - code=entry.data[CONF_CODE], - token=entry.data[CONF_TOKEN], - expiration_date=entry.data[CONF_TTL], +from homeassistant.helpers import issue_registry as ir + +DOMAIN = "permobil" + + +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: + """Set up config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/permobil", + }, ) - try: - p_api.self_authenticate() - except MyPermobilClientException as err: - _LOGGER.error("Error authenticating %s", err) - raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err - - # create the coordinator with the API object - coordinator = MyPermobilCoordinator(hass, entry, p_api) - await coordinator.async_config_entry_first_refresh() + return True - entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" return True -async def async_unload_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py deleted file mode 100644 index 2f9f042510474b..00000000000000 --- a/homeassistant/components/permobil/binary_sensor.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Platform for binary sensor integration.""" - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any, override - -from mypermobil import BATTERY_CHARGING - -from homeassistant.components.binary_sensor import ( - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import PermobilConfigEntry -from .entity import PermobilEntity - - -@dataclass(frozen=True, kw_only=True) -class PermobilBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describes Permobil binary sensor entity.""" - - is_on_fn: Callable[[Any], bool] - available_fn: Callable[[Any], bool] - - -BINARY_SENSOR_DESCRIPTIONS: tuple[PermobilBinarySensorEntityDescription, ...] = ( - PermobilBinarySensorEntityDescription( - is_on_fn=lambda data: data.battery[BATTERY_CHARGING[0]], - available_fn=lambda data: BATTERY_CHARGING[0] in data.battery, - key="is_charging", - translation_key="is_charging", - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: PermobilConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Create and setup the binary sensor.""" - - coordinator = config_entry.runtime_data - - async_add_entities( - PermobilbinarySensor(coordinator=coordinator, description=description) - for description in BINARY_SENSOR_DESCRIPTIONS - ) - - -class PermobilbinarySensor(PermobilEntity, BinarySensorEntity): - """Representation of a Binary Sensor.""" - - entity_description: PermobilBinarySensorEntityDescription - - @property - @override - def is_on(self) -> bool: - """Return True if the wheelchair is charging.""" - return self.entity_description.is_on_fn(self.coordinator.data) - - @property - @override - def available(self) -> bool: - """Return True if the sensor has value.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data - ) diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 5b05a8eabbe2ef..b1711b89469796 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -1,184 +1,11 @@ -"""Config flow for MyPermobil integration.""" +"""Config flow to configure Permobil integration.""" -from collections.abc import Mapping -import logging -from typing import Any, override +from homeassistant.config_entries import ConfigFlow -from mypermobil import ( - MyPermobil, - MyPermobilAPIException, - MyPermobilClientException, - MyPermobilEulaException, -) -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL -from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import ( - TextSelector, - TextSelectorConfig, - TextSelectorType, -) - -from .const import APPLICATION, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -GET_EMAIL_SCHEMA = vol.Schema( - { - vol.Required(CONF_EMAIL): TextSelector( - TextSelectorConfig(type=TextSelectorType.EMAIL) - ), - } -) - -GET_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_CODE): cv.string}) +from . import DOMAIN class PermobilConfigFlow(ConfigFlow, domain=DOMAIN): - """Permobil config flow.""" + """Permobil integration config flow.""" VERSION = 1 - region_names: dict[str, str] = {} - data: dict[str, str] = {} - - def __init__(self) -> None: - """Initialize flow.""" - hass: HomeAssistant = async_get_hass() - session = async_get_clientsession(hass) - self.p_api = MyPermobil(APPLICATION, session=session) - - @override - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Invoke when a user initiates a flow via the user interface.""" - errors: dict[str, str] = {} - - if user_input: - try: - self.p_api.set_email(user_input[CONF_EMAIL]) - except MyPermobilClientException: - _LOGGER.exception("Error validating email") - errors["base"] = "invalid_email" - - self.data.update(user_input) - - await self.async_set_unique_id(self.data[CONF_EMAIL]) - self._abort_if_unique_id_configured() - - if errors or not user_input: - return self.async_show_form( - step_id="user", data_schema=GET_EMAIL_SCHEMA, errors=errors - ) - return await self.async_step_region() - - async def async_step_region( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Invoke when a user initiates a flow via the user interface.""" - errors: dict[str, str] = {} - if not user_input: - # fetch the list of regions names and urls from the api - # for the user to select from. - try: - self.region_names = await self.p_api.request_region_names() - _LOGGER.debug( - "region names %s", - ",".join(list(self.region_names.keys())), - ) - except MyPermobilAPIException: - _LOGGER.exception("Error requesting regions") - errors["base"] = "region_fetch_error" - - else: - region_url = self.region_names[user_input[CONF_REGION]] - - self.data[CONF_REGION] = region_url - self.p_api.set_region(region_url) - _LOGGER.debug("region %s", self.p_api.region) - try: - # tell backend to send code to the users email - await self.p_api.request_application_code() - except MyPermobilAPIException: - _LOGGER.exception("Error requesting code") - errors["base"] = "code_request_error" - - if errors or not user_input: - # the error could either be that the fetch region did not pass - # or that the request application code failed - schema = vol.Schema( - { - vol.Required(CONF_REGION): selector.SelectSelector( - selector.SelectSelectorConfig( - options=list(self.region_names.keys()), - mode=selector.SelectSelectorMode.DROPDOWN, - ) - ), - } - ) - return self.async_show_form( - step_id="region", data_schema=schema, errors=errors - ) - - return await self.async_step_email_code() - - async def async_step_email_code( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Second step in config flow to enter the email code.""" - errors: dict[str, str] = {} - - if user_input: - try: - self.p_api.set_code(user_input[CONF_CODE]) - self.data.update(user_input) - token, ttl = await self.p_api.request_application_token() - self.data[CONF_TOKEN] = token - self.data[CONF_TTL] = ttl - except MyPermobilAPIException, MyPermobilClientException: - # the code did not pass validation by the api client - # or the backend returned an error when trying to validate the code - _LOGGER.exception("Error verifying code") - errors["base"] = "invalid_code" - except MyPermobilEulaException: - # The user has not accepted the EULA - errors["base"] = "unsigned_eula" - - if errors or not user_input: - return self.async_show_form( - step_id="email_code", - data_schema=GET_TOKEN_SCHEMA, - errors=errors, - description_placeholders={"app_name": "MyPermobil"}, - ) - - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), title=self.data[CONF_EMAIL], data=self.data - ) - - return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - try: - email: str = entry_data[CONF_EMAIL] - region: str = entry_data[CONF_REGION] - self.p_api.set_email(email) - self.p_api.set_region(region) - self.data = { - CONF_EMAIL: email, - CONF_REGION: region, - } - await self.p_api.request_application_code() - except MyPermobilAPIException: - _LOGGER.exception("Error requesting code for reauth") - return self.async_abort(reason="unknown") - - return await self.async_step_email_code() diff --git a/homeassistant/components/permobil/const.py b/homeassistant/components/permobil/const.py deleted file mode 100644 index fd5fe673f2a72e..00000000000000 --- a/homeassistant/components/permobil/const.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Constants for the MyPermobil integration.""" - -DOMAIN = "permobil" - -APPLICATION = "Home Assistant" - - -BATTERY_ASSUMED_VOLTAGE = 25.0 # This is the average voltage over all states of charge -REGIONS = "regions" -KM = "kilometers" -MILES = "miles" diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py deleted file mode 100644 index 13273949e964b1..00000000000000 --- a/homeassistant/components/permobil/coordinator.py +++ /dev/null @@ -1,66 +0,0 @@ -"""DataUpdateCoordinator for permobil integration.""" - -import asyncio -from dataclasses import dataclass -from datetime import timedelta -import logging -from typing import override - -from mypermobil import MyPermobil, MyPermobilAPIException - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -_LOGGER = logging.getLogger(__name__) - -type PermobilConfigEntry = ConfigEntry[MyPermobilCoordinator] - - -@dataclass -class MyPermobilData: - """MyPermobil data stored in the DataUpdateCoordinator.""" - - battery: dict[str, str | float | int | bool | list | dict] - daily_usage: dict[str, str | float | int | list | dict] - records: dict[str, str | float | int | list | dict] - - -class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): - """MyPermobil coordinator.""" - - config_entry: PermobilConfigEntry - - def __init__( - self, hass: HomeAssistant, config_entry: PermobilConfigEntry, p_api: MyPermobil - ) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name="permobil", - update_interval=timedelta(minutes=5), - ) - self.p_api = p_api - - @override - async def _async_update_data(self) -> MyPermobilData: - """Fetch data from the 3 API endpoints.""" - try: - async with asyncio.timeout(10): - battery = await self.p_api.get_battery_info() - daily_usage = await self.p_api.get_daily_usage() - records = await self.p_api.get_usage_records() - return MyPermobilData( - battery=battery, - daily_usage=daily_usage, - records=records, - ) - - except MyPermobilAPIException as err: - _LOGGER.exception( - "Error fetching data from MyPermobil API for account %s", - self.p_api.email, - ) - raise UpdateFailed from err diff --git a/homeassistant/components/permobil/entity.py b/homeassistant/components/permobil/entity.py deleted file mode 100644 index 702781aa361e64..00000000000000 --- a/homeassistant/components/permobil/entity.py +++ /dev/null @@ -1,29 +0,0 @@ -"""PermobilEntity class.""" - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import MyPermobilCoordinator - - -class PermobilEntity(CoordinatorEntity[MyPermobilCoordinator]): - """Representation of a permobil Entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MyPermobilCoordinator, - description: EntityDescription, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.p_api.email}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.p_api.email)}, - manufacturer="Permobil", - name="Permobil Wheelchair", - ) diff --git a/homeassistant/components/permobil/icons.json b/homeassistant/components/permobil/icons.json deleted file mode 100644 index 53bddcc00a97dd..00000000000000 --- a/homeassistant/components/permobil/icons.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "entity": { - "sensor": { - "charge_time_left": { - "default": "mdi:battery-clock" - }, - "distance_left": { - "default": "mdi:map-marker-distance" - }, - "max_distance_left": { - "default": "mdi:map-marker-distance" - }, - "max_watt_hours": { - "default": "mdi:lightning-bolt" - }, - "record_adjustments": { - "default": "mdi:seat-recline-extra" - }, - "record_distance": { - "default": "mdi:map-marker-distance" - }, - "state_of_health": { - "default": "mdi:battery-heart-variant" - }, - "usage_adjustments": { - "default": "mdi:seat-recline-extra" - }, - "usage_distance": { - "default": "mdi:map-marker-distance" - }, - "watt_hours_left": { - "default": "mdi:lightning-bolt" - } - } - } -} diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json index 7bba8182c04ad5..ab37dd5d0ed648 100644 --- a/homeassistant/components/permobil/manifest.json +++ b/homeassistant/components/permobil/manifest.json @@ -1,10 +1,10 @@ { "domain": "permobil", "name": "MyPermobil", - "codeowners": ["@IsakNyberg"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/permobil", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["mypermobil==0.1.8"] + "quality_scale": "legacy", + "requirements": [] } diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py deleted file mode 100644 index a1c35e0340232c..00000000000000 --- a/homeassistant/components/permobil/sensor.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Platform for sensor integration.""" - -from collections.abc import Callable -from dataclasses import dataclass -import logging -from typing import Any, override - -from mypermobil import ( - BATTERY_AMPERE_HOURS_LEFT, - BATTERY_CHARGE_TIME_LEFT, - BATTERY_DISTANCE_LEFT, - BATTERY_INDOOR_DRIVE_TIME, - BATTERY_MAX_AMPERE_HOURS, - BATTERY_MAX_DISTANCE_LEFT, - BATTERY_STATE_OF_CHARGE, - BATTERY_STATE_OF_HEALTH, - RECORDS_DISTANCE, - RECORDS_DISTANCE_UNIT, - RECORDS_SEATING, - USAGE_ADJUSTMENTS, - USAGE_DISTANCE, -) - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import BATTERY_ASSUMED_VOLTAGE, KM, MILES -from .coordinator import PermobilConfigEntry -from .entity import PermobilEntity - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class PermobilSensorEntityDescription(SensorEntityDescription): - """Describes Permobil sensor entity.""" - - value_fn: Callable[[Any], float | int] - available_fn: Callable[[Any], bool] - - -SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( - PermobilSensorEntityDescription( - # Current battery as a percentage - value_fn=lambda data: data.battery[BATTERY_STATE_OF_CHARGE[0]], - available_fn=lambda data: BATTERY_STATE_OF_CHARGE[0] in data.battery, - key="state_of_charge", - translation_key="state_of_charge", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Current battery health as a percentage of original capacity - value_fn=lambda data: data.battery[BATTERY_STATE_OF_HEALTH[0]], - available_fn=lambda data: BATTERY_STATE_OF_HEALTH[0] in data.battery, - key="state_of_health", - translation_key="state_of_health", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Time until fully charged (displays 0 if not charging) - value_fn=lambda data: data.battery[BATTERY_CHARGE_TIME_LEFT[0]], - available_fn=lambda data: BATTERY_CHARGE_TIME_LEFT[0] in data.battery, - key="charge_time_left", - translation_key="charge_time_left", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - ), - PermobilSensorEntityDescription( - # Distance possible on current change (km) - value_fn=lambda data: data.battery[BATTERY_DISTANCE_LEFT[0]], - available_fn=lambda data: BATTERY_DISTANCE_LEFT[0] in data.battery, - key="distance_left", - translation_key="distance_left", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - ), - PermobilSensorEntityDescription( - # Drive time possible on current charge - value_fn=lambda data: data.battery[BATTERY_INDOOR_DRIVE_TIME[0]], - available_fn=lambda data: BATTERY_INDOOR_DRIVE_TIME[0] in data.battery, - key="indoor_drive_time", - translation_key="indoor_drive_time", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - ), - PermobilSensorEntityDescription( - # Watt hours the battery can store given battery health - value_fn=lambda data: ( - data.battery[BATTERY_MAX_AMPERE_HOURS[0]] * BATTERY_ASSUMED_VOLTAGE - ), - available_fn=lambda data: BATTERY_MAX_AMPERE_HOURS[0] in data.battery, - key="max_watt_hours", - translation_key="max_watt_hours", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY_STORAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Current amount of watt hours in battery - value_fn=lambda data: ( - data.battery[BATTERY_AMPERE_HOURS_LEFT[0]] * BATTERY_ASSUMED_VOLTAGE - ), - available_fn=lambda data: BATTERY_AMPERE_HOURS_LEFT[0] in data.battery, - key="watt_hours_left", - translation_key="watt_hours_left", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY_STORAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Distance that can be traveled with full charge given battery health (km) - value_fn=lambda data: data.battery[BATTERY_MAX_DISTANCE_LEFT[0]], - available_fn=lambda data: BATTERY_MAX_DISTANCE_LEFT[0] in data.battery, - key="max_distance_left", - translation_key="max_distance_left", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - ), - PermobilSensorEntityDescription( - # Distance traveled today monotonically increasing, resets every 24h (km) - value_fn=lambda data: data.daily_usage[USAGE_DISTANCE[0]], - available_fn=lambda data: USAGE_DISTANCE[0] in data.daily_usage, - key="usage_distance", - translation_key="usage_distance", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Number of adjustments monotonically increasing, resets every 24h - value_fn=lambda data: data.daily_usage[USAGE_ADJUSTMENTS[0]], - available_fn=lambda data: USAGE_ADJUSTMENTS[0] in data.daily_usage, - key="usage_adjustments", - translation_key="usage_adjustments", - native_unit_of_measurement="adjustments", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Largest number of adjustments in a single 24h period, - # monotonically increasing, never resets - value_fn=lambda data: data.records[RECORDS_SEATING[0]], - available_fn=lambda data: RECORDS_SEATING[0] in data.records, - key="record_adjustments", - translation_key="record_adjustments", - native_unit_of_measurement="adjustments", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Record of largest distance travelled in a day, - # monotonically increasing, never resets - value_fn=lambda data: data.records[RECORDS_DISTANCE[0]], - available_fn=lambda data: RECORDS_DISTANCE[0] in data.records, - key="record_distance", - translation_key="record_distance", - device_class=SensorDeviceClass.DISTANCE, - state_class=SensorStateClass.TOTAL_INCREASING, - ), -) - -DISTANCE_UNITS: dict[Any, UnitOfLength] = { - KM: UnitOfLength.KILOMETERS, - MILES: UnitOfLength.MILES, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: PermobilConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Create sensors from a config entry created in the integrations UI.""" - - coordinator = config_entry.runtime_data - - async_add_entities( - PermobilSensor(coordinator=coordinator, description=description) - for description in SENSOR_DESCRIPTIONS - ) - - -class PermobilSensor(PermobilEntity, SensorEntity): - """Representation of a Sensor. - - This implements the common functions of all sensors. - """ - - _attr_suggested_display_precision = 0 - entity_description: PermobilSensorEntityDescription - - @property - @override - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor.""" - if self.entity_description.key == "record_distance": - return DISTANCE_UNITS.get( - self.coordinator.data.records[RECORDS_DISTANCE_UNIT[0]] - ) - return self.entity_description.native_unit_of_measurement - - @property - @override - def available(self) -> bool: - """Return True if the sensor has value.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data - ) - - @property - @override - def native_value(self) -> float | int: - """Return the value of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json index 12adf6dff5a72c..5bbfe044bf5ed2 100644 --- a/homeassistant/components/permobil/strings.json +++ b/homeassistant/components/permobil/strings.json @@ -1,81 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "error": { - "code_request_error": "Error requesting application code", - "invalid_code": "The code you gave is incorrect", - "invalid_email": "Invalid email", - "region_fetch_error": "Error fetching regions", - "unknown": "Unexpected error, more information in the logs", - "unsigned_eula": "Please sign the EULA in the {app_name} app" - }, - "step": { - "email_code": { - "data": { - "code": "Email code" - }, - "description": "Enter the code that was sent to your email." - }, - "region": { - "data": { - "region": "Region" - }, - "description": "Select the region of your account." - }, - "user": { - "data": { - "email": "Enter your permobil email" - } - } - } - }, - "entity": { - "binary_sensor": { - "is_charging": { - "name": "Is charging" - } - }, - "sensor": { - "charge_time_left": { - "name": "Charge time left" - }, - "distance_left": { - "name": "Distance left" - }, - "indoor_drive_time": { - "name": "Indoor drive time" - }, - "max_distance_left": { - "name": "Full charge distance" - }, - "max_watt_hours": { - "name": "Battery max watt hours" - }, - "record_adjustments": { - "name": "Record number of adjustments" - }, - "record_distance": { - "name": "Record distance" - }, - "state_of_charge": { - "name": "Battery charge" - }, - "state_of_health": { - "name": "Battery health" - }, - "usage_adjustments": { - "name": "Number of adjustments" - }, - "usage_distance": { - "name": "Distance traveled" - }, - "watt_hours_left": { - "name": "Watt hours left" - } + "issues": { + "integration_removed": { + "description": "The Permobil integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Permobil integration entries]({entries}).", + "title": "The Permobil integration has been removed" } } } diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index e80a925094ce73..0d5bfe9953977c 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.2.4"], + "requirements": ["ha-philipsjs==3.2.5"], "zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."] } diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index b3831daecbb325..8929dc0be85b55 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.6.3"] + "requirements": ["tesla-fleet-api==1.7.1"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 6579ef22abfabb..e8ac86c3076da2 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.6.3", "teslemetry-stream==0.9.1"] + "requirements": ["tesla-fleet-api==1.7.1", "teslemetry-stream==0.9.1"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 66b5bf3d71f8f1..0a37f0856c2eec 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.6.3"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.7.1"] } diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ecb6f202d85d4b..9434e93105ffc2 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.9.0"] + "requirements": ["uiprotect==15.10.0"] } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 3d10be4c647f6c..f803fd121c0b4a 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -163,15 +163,15 @@ def percentage(self) -> int | None: """Return the currently set speed.""" current_level = self.device.state.fan_level - if ( - self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL) - and current_level is not None - ): + if self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL): if current_level == 0: return 0 - return ordered_list_item_to_percentage( - self.device.fan_levels, current_level - ) + # The device can report an out-of-range level (e.g. -1) when the + # speed is not applicable; treat it as unknown instead of crashing. + if current_level in self.device.fan_levels: + return ordered_list_item_to_percentage( + self.device.fan_levels, current_level + ) return None @property diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 103a914f62ea44..0e8963704c87db 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -581,7 +581,6 @@ "peblar", "peco", "pegel_online", - "permobil", "pglab", "philips_js", "pi_hole", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 46b56b722cd753..5d311722c09130 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5326,7 +5326,7 @@ "permobil": { "name": "MyPermobil", "integration_type": "device", - "config_flow": true, + "config_flow": false, "iot_class": "cloud_polling" }, "pge": { diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 971cb9a961e7ab..4896cf59b49817 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -48,6 +48,7 @@ CONF_TARGET, CONF_VALUE_TEMPLATE, CONF_WEEKDAY, + CONF_ZONE, ENTITY_MATCH_ALL, ENTITY_MATCH_ANY, STATE_UNAVAILABLE, @@ -2148,6 +2149,20 @@ def async_extract_entities(config: ConfigType | Template) -> set[str]: referenced.add(value) continue + if condition == "zone": + options = config.get(CONF_OPTIONS, {}) + referenced.update(options.get(CONF_ENTITY_ID, [])) + referenced.update(options.get(CONF_ZONE, [])) + + elif condition in ( + "zone.in_zone", + "zone.not_in_zone", + "zone.occupancy_is_detected", + "zone.occupancy_is_not_detected", + ): + if zone_entity_id := config.get(CONF_OPTIONS, {}).get(CONF_ZONE): + referenced.add(zone_entity_id) + entity_ids = config.get(CONF_ENTITY_ID) if isinstance(entity_ids, str): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2308d37fb0dced..cd2b9ffc2b6b17 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 -openai==2.21.0 +openai==2.45.0 orjson==3.11.9 packaging>=23.1 paho-mqtt==2.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index f6a2f25560f125..6abecac0a27b51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1204,7 +1204,7 @@ ha-ffmpeg==3.2.2 ha-iotawattpy==0.2.1 # homeassistant.components.philips_js -ha-philipsjs==3.2.4 +ha-philipsjs==3.2.5 # homeassistant.components.homeassistant_hardware ha-silabs-firmware-client==0.3.0 @@ -1625,7 +1625,7 @@ mozart-api==6.2.0.44.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.6 +music-assistant-client==1.4.3 # homeassistant.components.tts mutagen==1.48.1 @@ -1636,9 +1636,6 @@ mutesync==0.0.1 # homeassistant.components.mvglive mvg==1.4.0 -# homeassistant.components.permobil -mypermobil==0.1.8 - # homeassistant.components.myuplink myuplink==0.7.0 @@ -1771,7 +1768,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation # homeassistant.components.ovhcloud_ai_endpoints -openai==2.21.0 +openai==2.45.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -3165,7 +3162,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.6.3 +tesla-fleet-api==1.7.1 # homeassistant.components.powerwall tesla-powerwall==0.5.3 @@ -3255,7 +3252,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.9.0 +uiprotect==15.10.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index fc43a5b2d0c4ad..4dc6b019bbc86e 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1650,7 +1650,6 @@ class Rule: "panel_iframe", "peco", "pencom", - "permobil", "persistent_notification", "person", "philips_js", diff --git a/script/licenses.py b/script/licenses.py index 01fdf114e5b861..0d4dac8d84b75f 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -205,9 +205,6 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: # fmt: off TODO = { "TravisPy": AwesomeVersion("0.3.5"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)'] - "aiocache": AwesomeVersion( - "0.12.3" - ), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved? } # fmt: on diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json index 9adc76144d5f3c..bc8f5cdc29677b 100644 --- a/tests/components/analytics_insights/fixtures/current_data.json +++ b/tests/components/analytics_insights/fixtures/current_data.json @@ -1199,7 +1199,6 @@ "fritzbox_netmonitor": 4, "apprise": 2, "drop_connect": 1, - "permobil": 3, "norway_air": 3, "push": 2, "upc_connect": 2, diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index 9ec11356c59664..2b1fac2eef8303 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -1,5 +1,6 @@ """Tests for the BSBLan integration.""" +import asyncio from datetime import timedelta from unittest.mock import MagicMock @@ -291,6 +292,37 @@ async def test_coordinator_dhw_config_update_error( assert mock_bsblan.hot_water_schedule.called +async def test_setup_does_not_block_on_slow_fetch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test setup does not wait for the background slow-data fetch.""" + release = asyncio.Event() + config_value = mock_bsblan.hot_water_config.return_value + + async def _blocking_config(*args: object, **kwargs: object) -> object: + await release.wait() + return config_value + + mock_bsblan.hot_water_config.side_effect = _blocking_config + + mock_config_entry.add_to_hass(hass) + try: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Setup finished even though the slow-data fetch is still pending. + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not mock_bsblan.hot_water_schedule.called + finally: + # Release the fetch so it can complete and clean up. + release.set() + await hass.async_block_till_done() + + assert mock_bsblan.hot_water_schedule.called + + async def test_coordinator_slow_first_fetch_failure( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index b730ddad688ff7..d7ef7f0718f3e0 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -1,5 +1,6 @@ """Tests for the BSB-LAN water heater platform.""" +import asyncio from datetime import timedelta from unittest.mock import AsyncMock, MagicMock @@ -392,6 +393,41 @@ async def test_water_heater_custom_temperature_limits_from_config( ) # Custom maximum from nominal_setpoint_max +async def test_water_heater_temperature_limits_update_after_slow_fetch( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test temperature limits update when the background fetch completes.""" + release = asyncio.Event() + config = mock_bsblan.hot_water_config.return_value + config.reduced_setpoint.value = 15.0 + config.nominal_setpoint_max.value = 75.0 + + async def _blocking_config(*args: object, **kwargs: object) -> object: + await release.wait() + return config + + mock_bsblan.hot_water_config.side_effect = _blocking_config + + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 10.0 + assert state.attributes["max_temp"] == 65.0 + + release.set() + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 15.0 + assert state.attributes["max_temp"] == 75.0 + + async def test_turn_on( hass: HomeAssistant, mock_bsblan: AsyncMock, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 5106b6db6e9911..5af778f31d3aec 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -4,8 +4,9 @@ from copy import deepcopy from unittest.mock import AsyncMock, MagicMock, patch -from pylamarzocco.const import ModelName +from pylamarzocco.const import DeviceType, ModelName from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import Thing import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE @@ -35,7 +36,7 @@ get_bluetooth_service_info, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture(autouse=True) @@ -197,6 +198,30 @@ async def test_form_no_machines( await __do_sucessful_machine_selection_step(hass, result) +async def test_grinders_not_configurable( + hass: HomeAssistant, + mock_cloud_client: MagicMock, +) -> None: + """Test that grinders are filtered out so only machines can be configured.""" + grinder = await async_load_json_object_fixture(hass, "thing.json", DOMAIN) + grinder["type"] = DeviceType.GRINDER + grinder["serialNumber"] = "GR012345" + grinder["name"] = "GR012345" + + mock_cloud_client.list_things.return_value = [ + *mock_cloud_client.list_things.return_value, + Thing.from_dict(grinder), + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await __do_successful_user_step(hass, result, mock_cloud_client) + + options = result["data_schema"].schema[CONF_MACHINE].config["options"] + assert [option["value"] for option in options] == ["GS012345"] + + async def test_reauth_flow( hass: HomeAssistant, mock_cloud_client: MagicMock, diff --git a/tests/components/permobil/__init__.py b/tests/components/permobil/__init__.py index 56e779eef4d77e..1e170cf8edd2d6 100644 --- a/tests/components/permobil/__init__.py +++ b/tests/components/permobil/__init__.py @@ -1 +1 @@ -"""Tests for the MyPermobil integration.""" +"""Tests for the Permobil integration.""" diff --git a/tests/components/permobil/conftest.py b/tests/components/permobil/conftest.py deleted file mode 100644 index d3630d3f36658c..00000000000000 --- a/tests/components/permobil/conftest.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Common fixtures for the MyPermobil tests.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch - -from mypermobil import MyPermobil -import pytest - -from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.permobil.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def my_permobil() -> Mock: - """Mock spec for MyPermobilApi.""" - mock = Mock(spec=MyPermobil) - mock.request_region_names.return_value = {MOCK_REGION_NAME: MOCK_URL} - mock.request_application_token.return_value = MOCK_TOKEN - mock.region = "" - return mock diff --git a/tests/components/permobil/const.py b/tests/components/permobil/const.py deleted file mode 100644 index cb8a0c32f17c3d..00000000000000 --- a/tests/components/permobil/const.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Test constants for Permobil.""" - -MOCK_URL = "https://example.com" -MOCK_REGION_NAME = "region_name" -MOCK_TOKEN = ("a" * 256, "date") diff --git a/tests/components/permobil/test_config_flow.py b/tests/components/permobil/test_config_flow.py deleted file mode 100644 index 9b591e2b991913..00000000000000 --- a/tests/components/permobil/test_config_flow.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Test the MyPermobil config flow.""" - -from unittest.mock import Mock, patch - -from mypermobil import ( - MyPermobilAPIException, - MyPermobilClientException, - MyPermobilEulaException, -) -import pytest - -from homeassistant import config_entries -from homeassistant.components.permobil import config_flow -from homeassistant.components.permobil.const import DOMAIN -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL - -from tests.common import MockConfigEntry - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - -MOCK_CODE = "012345" -MOCK_EMAIL = "valid@email.com" -INVALID_EMAIL = "this is not a valid email" -VALID_DATA = { - CONF_EMAIL: MOCK_EMAIL, - CONF_REGION: MOCK_URL, - CONF_CODE: MOCK_CODE, - CONF_TOKEN: MOCK_TOKEN[0], - CONF_TTL: MOCK_TOKEN[1], -} - - -async def test_sucessful_config_flow(hass: HomeAssistant, my_permobil: Mock) -> None: - """Test the config flow from start to finish with no errors.""" - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - # request region code - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == VALID_DATA - - -async def test_config_flow_incorrect_code( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test email code verification with API error. - - Test the config flow from start to until email code verification - and have the API return API error. - """ - my_permobil.request_application_token.side_effect = MyPermobilAPIException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request region code - # here the request_application_token raises a MyPermobilAPIException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "invalid_code" - - -async def test_config_flow_unsigned_eula( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test email code verification with unsigned eula error. - - Test the config flow from start to until email code verification - and have the API return that the eula is unsigned. - """ - my_permobil.request_application_token.side_effect = MyPermobilEulaException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request region code - # here the request_application_token raises a MyPermobilEulaException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "unsigned_eula" - - # Retry to submit the code again, but this time the user has signed the EULA - with patch.object( - my_permobil, - "request_application_token", - return_value=MOCK_TOKEN, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - - # Now the method should not raise an exception, and you can - # proceed with your assertions - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == VALID_DATA - - -async def test_config_flow_incorrect_region( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test when the user does not exist in the selected region. - - Test the config flow from start to until the request for email - code and have the API return error because there is not user for - that email. - """ - my_permobil.request_application_code.side_effect = MyPermobilAPIException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - # here the request_application_code raises a MyPermobilAPIException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"]["base"] == "code_request_error" - - -async def test_config_flow_region_request_error( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test region request error. - - Test the config flow from start to until the request for regions - and have the API return an error. - """ - my_permobil.request_region_names.side_effect = MyPermobilAPIException - # init flow - # here the request_region_names raises a MyPermobilAPIException - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"]["base"] == "region_fetch_error" - - -async def test_config_flow_invalid_email( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test an incorrectly formatted email. - - Test that the email must be formatted correctly. The schema for the - input should already check for this, but since the API does a - separate check that might not overlap 100% with the schema, - this test is still needed. - """ - my_permobil.set_email.side_effect = MyPermobilClientException() - # init flow - # here the set_email raises a MyPermobilClientException - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: INVALID_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"]["base"] == "invalid_email" - - -async def test_config_flow_reauth_success( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth make sure that the values are replaced.""" - # new token and code - reauth_token = ("b" * 256, "reauth_date") - reauth_code = "567890" - my_permobil.request_application_token.return_value = reauth_token - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request new token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: reauth_code}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_entry.data == { - CONF_EMAIL: MOCK_EMAIL, - CONF_REGION: MOCK_URL, - CONF_CODE: reauth_code, - CONF_TOKEN: reauth_token[0], - CONF_TTL: reauth_token[1], - } - - -async def test_config_flow_reauth_fail_invalid_code( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth when the email code fails.""" - # new code - reauth_invalid_code = "567890" # pretend this code is invalid/incorrect - my_permobil.request_application_token.side_effect = MyPermobilAPIException - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request request new token but have the API return error - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: reauth_invalid_code}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "invalid_code" - - -async def test_config_flow_reauth_fail_code_request( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth.""" - my_permobil.request_application_code.side_effect = MyPermobilAPIException - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - # test the reauth and have request_application_code fail leading to an abort - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/permobil/test_init.py b/tests/components/permobil/test_init.py new file mode 100644 index 00000000000000..57b2e69b4f3c29 --- /dev/null +++ b/tests/components/permobil/test_init.py @@ -0,0 +1,79 @@ +"""Tests for the Permobil integration.""" + +from homeassistant.components.permobil import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_permobil_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Permobil configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 07076e1dd8cb81..017b2a20354391 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -99,9 +99,15 @@ def mock_devices_response( - aioclient_mock: AiohttpClientMocker, device_name: str + aioclient_mock: AiohttpClientMocker, + device_name: str, + details_override: dict[str, Any] | None = None, ) -> None: - """Build a response for the Helpers.call_api method.""" + """Build a response for the Helpers.call_api method. + + ``details_override`` is merged into the nested ``result`` payload of the + device detail response, allowing tests to simulate specific device states. + """ device_list = [ device for device in ALL_DEVICES["result"]["list"] @@ -126,9 +132,16 @@ def mock_devices_response( ) for fixture in DEVICE_FIXTURES[device_name]: + detail = load_json_object_fixture(fixture[2], DOMAIN) + if details_override: + assert "result" in detail.get("result", {}), ( + f"Fixture {fixture[2]} does not have the expected " + "result.result payload to apply details_override" + ) + detail["result"]["result"].update(details_override) getattr(aioclient_mock, fixture[0])( f"https://smartapi.vesync.com{fixture[1]}", - json=load_json_object_fixture(fixture[2], DOMAIN), + json=detail, ) mock_firmware(aioclient_mock) diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 03f862088fc476..436ab852f52efc 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -6,8 +6,17 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fan import ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -188,6 +197,26 @@ async def test_set_preset_mode( update_mock.assert_called_once() +async def test_out_of_range_fan_level( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that an out-of-range fan level produces an unknown percentage.""" + + mock_devices_response( + aioclient_mock, "CoreBreeze 432S", details_override={"fanSpeedLevel": -1} + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_PEDESTAL_FAN) + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.attributes[ATTR_PERCENTAGE] is None + + @pytest.mark.parametrize( ("action", "api_response", "expectation"), [ diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index a98c47c3084639..449864e0bf60bc 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2134,6 +2134,25 @@ async def test_extract_entities(hass: HomeAssistant) -> None: "entity_id": ["sensor.temperature_9", "sensor.temperature_10"], "below": 110, }, + { + "condition": "zone", + "options": { + "entity_id": [ + "device_tracker.paulus", + "device_tracker.anne_therese", + ], + "zone": ["zone.home"], + }, + }, + { + "condition": "zone.in_zone", + "target": {"entity_id": "person.paulus"}, + "options": {"zone": "zone.work", "behavior": "any"}, + }, + { + "condition": "zone.occupancy_is_detected", + "options": {"zone": "zone.school"}, + }, { "condition": "time", "after": "input_datetime.start", @@ -2147,7 +2166,10 @@ async def test_extract_entities(hass: HomeAssistant) -> None: ], } ) == { + "device_tracker.anne_therese", + "device_tracker.paulus", "input_datetime.start", + "person.paulus", "sensor.end", "sensor.temperature", "sensor.temperature_2", @@ -2159,6 +2181,29 @@ async def test_extract_entities(hass: HomeAssistant) -> None: "sensor.temperature_8", "sensor.temperature_9", "sensor.temperature_10", + "zone.home", + "zone.school", + "zone.work", + } + + +async def test_extract_entities_zone_condition_validated(hass: HomeAssistant) -> None: + """Test extracting entities from a validated legacy zone condition. + + Validation moves the top level entity_id and zone fields into options. + """ + assert await async_setup_component(hass, "zone", {}) + config = await condition.async_validate_condition_config( + hass, + { + "condition": "zone", + "entity_id": "device_tracker.paulus", + "zone": "zone.home", + }, + ) + assert condition.async_extract_entities(config) == { + "device_tracker.paulus", + "zone.home", }