diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 0625054869d885..1912c20e8d8de8 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==1.1.1"], + "requirements": ["hass-nabucasa==1.1.2"], "single_config_entry": true } diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 63be9641aeb9f8..a37a72cdcf4d4c 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -6,12 +6,13 @@ import voluptuous as vol -from homeassistant.const import CONF_DOMAIN +from homeassistant.const import CONF_DOMAIN, CONF_OPTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( Condition, ConditionCheckerType, + ConditionConfig, trace_condition_function, ) from homeassistant.helpers.typing import ConfigType @@ -55,19 +56,40 @@ async def async_get_conditions( class DeviceCondition(Condition): """Device condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - self._config = config - self._hass = hass + _hass: HomeAssistant + _config: ConfigType + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = await async_validate_device_automation_config( + hass, + complete_config, + cv.DEVICE_CONDITION_SCHEMA, + DeviceAutomationType.CONDITION, + ) + # Since we don't want to migrate device conditions to a new format + # we just pass the entire config as options. + complete_config[CONF_OPTIONS] = complete_config.copy() + return complete_config @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: - """Validate device condition config.""" - return await async_validate_device_automation_config( - hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION - ) + """Validate config. + + This is here just to satisfy the abstract class interface. It is never called. + """ + raise NotImplementedError + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + self._hass = hass + assert config.options is not None + self._config = config.options async def async_get_checker(self) -> condition.ConditionCheckerType: """Test a device condition.""" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 11e703cd73e42e..bf7c9642c131e1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250924.0"] + "requirements": ["home-assistant-frontend==20250925.0"] } diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json index 2c4e6a7e735ad8..8058c602dc4da0 100644 --- a/homeassistant/components/mvglive/manifest.json +++ b/homeassistant/components/mvglive/manifest.json @@ -2,10 +2,8 @@ "domain": "mvglive", "name": "MVG", "codeowners": [], - "disabled": "This integration is disabled because it uses non-open source code to operate.", "documentation": "https://www.home-assistant.io/integrations/mvglive", "iot_class": "cloud_polling", - "loggers": ["MVGLive"], - "quality_scale": "legacy", - "requirements": ["PyMVGLive==1.1.4"] + "loggers": ["MVG"], + "requirements": ["mvg==1.4.0"] } diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index d8b435177118b1..031ec164ecd771 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -1,13 +1,14 @@ """Support for departure information for public transport in Munich.""" -# mypy: ignore-errors from __future__ import annotations +from collections.abc import Mapping from copy import deepcopy from datetime import timedelta import logging +from typing import Any -import MVGLive +from mvg import MvgApi, MvgApiError, TransportType import voluptuous as vol from homeassistant.components.sensor import ( @@ -19,6 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -44,53 +46,55 @@ "SEV": "mdi:checkbox-blank-circle-outline", "-": "mdi:clock", } -ATTRIBUTION = "Data provided by MVG-live.de" + +ATTRIBUTION = "Data provided by mvg.de" SCAN_INTERVAL = timedelta(seconds=30) -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NEXT_DEPARTURE): [ - { - vol.Required(CONF_STATION): cv.string, - vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv, - vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv, - vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv, - vol.Optional( - CONF_PRODUCTS, default=DEFAULT_PRODUCT - ): cv.ensure_list_csv, - vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int, - vol.Optional(CONF_NUMBER, default=1): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - } - ] - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_DIRECTIONS), + SENSOR_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NEXT_DEPARTURE): [ + { + vol.Required(CONF_STATION): cv.string, + vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv, + vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv, + vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv, + vol.Optional( + CONF_PRODUCTS, default=DEFAULT_PRODUCT + ): cv.ensure_list_csv, + vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int, + vol.Optional(CONF_NUMBER, default=1): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + } + ] + } + ), ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the MVGLive sensor.""" - add_entities( - ( - MVGLiveSensor( - nextdeparture.get(CONF_STATION), - nextdeparture.get(CONF_DESTINATIONS), - nextdeparture.get(CONF_DIRECTIONS), - nextdeparture.get(CONF_LINES), - nextdeparture.get(CONF_PRODUCTS), - nextdeparture.get(CONF_TIMEOFFSET), - nextdeparture.get(CONF_NUMBER), - nextdeparture.get(CONF_NAME), - ) - for nextdeparture in config[CONF_NEXT_DEPARTURE] - ), - True, - ) + sensors = [ + MVGLiveSensor( + hass, + nextdeparture.get(CONF_STATION), + nextdeparture.get(CONF_DESTINATIONS), + nextdeparture.get(CONF_LINES), + nextdeparture.get(CONF_PRODUCTS), + nextdeparture.get(CONF_TIMEOFFSET), + nextdeparture.get(CONF_NUMBER), + nextdeparture.get(CONF_NAME), + ) + for nextdeparture in config[CONF_NEXT_DEPARTURE] + ] + add_entities(sensors, True) class MVGLiveSensor(SensorEntity): @@ -100,38 +104,38 @@ class MVGLiveSensor(SensorEntity): def __init__( self, - station, + hass: HomeAssistant, + station_name, destinations, - directions, lines, products, timeoffset, number, name, - ): + ) -> None: """Initialize the sensor.""" - self._station = station self._name = name + self._station_name = station_name self.data = MVGLiveData( - station, destinations, directions, lines, products, timeoffset, number + hass, station_name, destinations, lines, products, timeoffset, number ) self._state = None self._icon = ICONS["-"] @property - def name(self): + def name(self) -> str | None: """Return the name of the sensor.""" if self._name: return self._name - return self._station + return self._station_name @property - def native_value(self): + def native_value(self) -> str | None: """Return the next departure time.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" if not (dep := self.data.departures): return None @@ -140,88 +144,114 @@ def extra_state_attributes(self): return attr @property - def icon(self): + def icon(self) -> str | None: """Icon to use in the frontend, if any.""" return self._icon @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and update the state.""" - self.data.update() + await self.data.update() if not self.data.departures: - self._state = "-" + self._state = None self._icon = ICONS["-"] else: - self._state = self.data.departures[0].get("time", "-") - self._icon = ICONS[self.data.departures[0].get("product", "-")] + self._state = self.data.departures[0].get("time_in_mins", "-") + self._icon = self.data.departures[0].get("icon", ICONS["-"]) + + +def _get_minutes_until_departure(departure_time: int) -> int: + """Calculate the time difference in minutes between the current time and a given departure time. + + Args: + departure_time: Unix timestamp of the departure time, in seconds. + + Returns: + The time difference in minutes, as an integer. + + """ + current_time = dt_util.utcnow() + departure_datetime = dt_util.utc_from_timestamp(departure_time) + time_difference = (departure_datetime - current_time).total_seconds() + return int(time_difference / 60.0) class MVGLiveData: - """Pull data from the mvg-live.de web page.""" + """Pull data from the mvg.de web page.""" def __init__( - self, station, destinations, directions, lines, products, timeoffset, number - ): + self, + hass: HomeAssistant, + station_name, + destinations, + lines, + products, + timeoffset, + number, + ) -> None: """Initialize the sensor.""" - self._station = station + self._hass = hass + self._station_name = station_name + self._station_id = None self._destinations = destinations - self._directions = directions self._lines = lines self._products = products self._timeoffset = timeoffset self._number = number - self._include_ubahn = "U-Bahn" in self._products - self._include_tram = "Tram" in self._products - self._include_bus = "Bus" in self._products - self._include_sbahn = "S-Bahn" in self._products - self.mvg = MVGLive.MVGLive() - self.departures = [] + self.departures: list[dict[str, Any]] = [] - def update(self): + async def update(self): """Update the connection data.""" + if self._station_id is None: + try: + station = await MvgApi.station_async(self._station_name) + self._station_id = station["id"] + except MvgApiError as err: + _LOGGER.error( + "Failed to resolve station %s: %s", self._station_name, err + ) + self.departures = [] + return + try: - _departures = self.mvg.getlivedata( - station=self._station, - timeoffset=self._timeoffset, - ubahn=self._include_ubahn, - tram=self._include_tram, - bus=self._include_bus, - sbahn=self._include_sbahn, + _departures = await MvgApi.departures_async( + station_id=self._station_id, + offset=self._timeoffset, + limit=self._number, + transport_types=[ + transport_type + for transport_type in TransportType + if transport_type.value[0] in self._products + ] + if self._products + else None, ) except ValueError: self.departures = [] _LOGGER.warning("Returned data not understood") return self.departures = [] - for i, _departure in enumerate(_departures): - # find the first departure meeting the criteria + for _departure in _departures: if ( "" not in self._destinations[:1] and _departure["destination"] not in self._destinations ): continue - if ( - "" not in self._directions[:1] - and _departure["direction"] not in self._directions - ): + if "" not in self._lines[:1] and _departure["line"] not in self._lines: continue - if "" not in self._lines[:1] and _departure["linename"] not in self._lines: - continue + time_to_departure = _get_minutes_until_departure(_departure["time"]) - if _departure["time"] < self._timeoffset: + if time_to_departure < self._timeoffset: continue - # now select the relevant data _nextdep = {} - for k in ("destination", "linename", "time", "direction", "product"): + for k in ("destination", "line", "type", "cancelled", "icon"): _nextdep[k] = _departure.get(k, "") - _nextdep["time"] = int(_nextdep["time"]) + _nextdep["time_in_mins"] = time_to_departure self.departures.append(_nextdep) - if i == self._number - 1: - break diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index a21aca70d2ecab..a2719ec6ba93bb 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -610,7 +610,7 @@ def _play_media( def _play_media_queue( self, soco: SoCo, item: MusicServiceItem, enqueue: MediaPlayerEnqueue - ): + ) -> None: """Manage adding, replacing, playing items onto the sonos queue.""" _LOGGER.debug( "_play_media_queue item_id [%s] title [%s] enqueue [%s]", @@ -639,7 +639,7 @@ def _play_media_directory( media_type: MediaType | str, media_id: str, enqueue: MediaPlayerEnqueue, - ): + ) -> None: """Play a directory from a music library share.""" item = media_browser.get_media(self.media.library, media_id, media_type) if not item: @@ -660,6 +660,7 @@ def _play_media_sharelink( enqueue: MediaPlayerEnqueue, title: str, ) -> None: + """Play a sharelink.""" share_link = self.coordinator.share_link kwargs = {} if title: diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 2bd845923fc0ca..c7411e935dfd47 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -1,5 +1,6 @@ """The Squeezebox integration.""" +import asyncio from asyncio import timeout from dataclasses import dataclass, field from datetime import datetime @@ -31,11 +32,11 @@ ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.util.hass_dict import HassKey from .const import ( CONF_HTTPS, DISCOVERY_INTERVAL, - DISCOVERY_TASK, DOMAIN, SERVER_MANUFACTURER, SERVER_MODEL, @@ -64,6 +65,8 @@ Platform.UPDATE, ] +SQUEEZEBOX_HASS_DATA: HassKey[asyncio.Task] = HassKey(DOMAIN) + @dataclass class SqueezeboxData: @@ -240,7 +243,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) current_entries = hass.config_entries.async_entries(DOMAIN) if len(current_entries) == 1 and current_entries[0] == entry: _LOGGER.debug("Stopping server discovery task") - hass.data[DOMAIN][DISCOVERY_TASK].cancel() - hass.data[DOMAIN].pop(DISCOVERY_TASK) + hass.data[SQUEEZEBOX_HASS_DATA].cancel() + hass.data.pop(SQUEEZEBOX_HASS_DATA) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 091ef4d1bbda53..b61d28943cfa57 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -1,7 +1,6 @@ """Constants for the Squeezebox component.""" CONF_HTTPS = "https" -DISCOVERY_TASK = "discovery_task" DOMAIN = "squeezebox" DEFAULT_PORT = 9000 PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index a5f5288807f324..d1313eccc37bda 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -44,6 +44,7 @@ from homeassistant.helpers.start import async_at_start from homeassistant.util.dt import utcnow +from . import SQUEEZEBOX_HASS_DATA from .browse_media import ( BrowseData, build_item_response, @@ -58,7 +59,6 @@ CONF_VOLUME_STEP, DEFAULT_BROWSE_LIMIT, DEFAULT_VOLUME_STEP, - DISCOVERY_TASK, DOMAIN, SERVER_MANUFACTURER, SERVER_MODEL, @@ -110,12 +110,10 @@ def _discovered_server(server: Server) -> None: }, ) - hass.data.setdefault(DOMAIN, {}) - if DISCOVERY_TASK not in hass.data[DOMAIN]: + if not hass.data.get(SQUEEZEBOX_HASS_DATA): _LOGGER.debug("Adding server discovery task for squeezebox") - hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_background_task( - async_discover(_discovered_server), - name="squeezebox server discovery", + hass.data[SQUEEZEBOX_HASS_DATA] = hass.async_create_background_task( + async_discover(_discovered_server), name="squeezebox server discovery" ) diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 415d0a04e7ce54..f748a6da8bc556 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -3,16 +3,18 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import cast +from typing import Any, cast import voluptuous as vol -from homeassistant.const import CONF_CONDITION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET +from homeassistant.const import CONF_OPTIONS, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, ConditionCheckerType, + ConditionConfig, condition_trace_set_result, condition_trace_update_result, trace_condition_function, @@ -21,20 +23,22 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.util import dt as dt_util -_CONDITION_SCHEMA = vol.All( - vol.Schema( - { - **cv.CONDITION_BASE_SCHEMA, - vol.Required(CONF_CONDITION): "sun", - vol.Optional("before"): cv.sun_event, - vol.Optional("before_offset"): cv.time_period, - vol.Optional("after"): vol.All( - vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) - ), - vol.Optional("after_offset"): cv.time_period, - } +_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { + vol.Optional("before"): cv.sun_event, + vol.Optional("before_offset"): cv.time_period, + vol.Optional("after"): vol.All( + vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) ), - cv.has_at_least_one_key("before", "after"), + vol.Optional("after_offset"): cv.time_period, +} + +_CONDITION_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS): vol.All( + _OPTIONS_SCHEMA_DICT, + cv.has_at_least_one_key("before", "after"), + ) + } ) @@ -125,24 +129,36 @@ def sun( class SunCondition(Condition): """Sun condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - self._config = config - self._hass = hass + _options: dict[str, Any] + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, _OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, complete_config) @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + return cast(ConfigType, _CONDITION_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + assert config.options is not None + self._options = config.options async def async_get_checker(self) -> ConditionCheckerType: """Wrap action method with sun based condition.""" - before = self._config.get("before") - after = self._config.get("after") - before_offset = self._config.get("before_offset") - after_offset = self._config.get("after_offset") + before = self._options.get("before") + after = self._options.get("after") + before_offset = self._options.get("before_offset") + after_offset = self._options.get("after_offset") @trace_condition_function def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index cc2429ed3a4289..caa75b4e0be1dc 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -2,14 +2,16 @@ from __future__ import annotations +from typing import Any, cast + import voluptuous as vol from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, - CONF_CONDITION, CONF_ENTITY_ID, + CONF_OPTIONS, CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -17,26 +19,25 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, ConditionCheckerType, + ConditionConfig, trace_condition_function, ) from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import in_zone -_CONDITION_SCHEMA = vol.Schema( - { - **cv.CONDITION_BASE_SCHEMA, - vol.Required(CONF_CONDITION): "zone", - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - vol.Required("zone"): cv.entity_ids, - # To support use_trigger_value in automation - # Deprecated 2016/04/25 - vol.Optional("event"): vol.Any("enter", "leave"), - } -) +_OPTIONS_SCHEMA_DICT = { + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required("zone"): cv.entity_ids, + # To support use_trigger_value in automation + # Deprecated 2016/04/25 + vol.Optional("event"): vol.Any("enter", "leave"), +} +_CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT}) def zone( @@ -95,21 +96,34 @@ def zone( class ZoneCondition(Condition): """Zone condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - self._config = config + _options: dict[str, Any] + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, _OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, complete_config) @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + return cast(ConfigType, _CONDITION_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + assert config.options is not None + self._options = config.options async def async_get_checker(self) -> ConditionCheckerType: """Wrap action method with zone based condition.""" - entity_ids = self._config.get(CONF_ENTITY_ID, []) - zone_entity_ids = self._config.get(CONF_ZONE, []) + entity_ids = self._options.get(CONF_ENTITY_ID, []) + zone_entity_ids = self._options.get(CONF_ZONE, []) @trace_condition_function def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 6565e6983733c7..4273bf653c2712 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -21,6 +21,7 @@ ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import ( Trigger, @@ -28,7 +29,6 @@ TriggerConfig, TriggerData, TriggerInfo, - move_top_level_schema_fields_to_options, ) from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 14ab09961894f1..7ea565299d64d5 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -20,13 +20,13 @@ ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import ( Trigger, TriggerActionType, TriggerConfig, TriggerInfo, - move_top_level_schema_fields_to_options, ) from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index 52a0fc13255250..85f03d8e13fccc 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -1,5 +1,13 @@ """Helpers for automation.""" +from typing import Any + +import voluptuous as vol + +from homeassistant.const import CONF_OPTIONS + +from .typing import ConfigType + def get_absolute_description_key(domain: str, key: str) -> str: """Return the absolute description key.""" @@ -19,3 +27,26 @@ def get_relative_description_key(domain: str, key: str) -> str: if not subtype: return "_" return subtype[0] + + +def move_top_level_schema_fields_to_options( + config: ConfigType, options_schema_dict: dict[vol.Marker, Any] +) -> ConfigType: + """Move top-level fields to options. + + This function is used to help migrating old-style configs to new-style configs. + If options is already present, the config is returned as-is. + """ + if CONF_OPTIONS in config: + return config + + config = config.copy() + options = config.setdefault(CONF_OPTIONS, {}) + + # Move top-level fields to options + for key_marked in options_schema_dict: + key = key_marked.schema + if key in config: + options[key] = config.pop(key) + + return config diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 67c99eb70b478f..7e162b15d8f0ad 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -6,6 +6,7 @@ from collections import deque from collections.abc import Callable, Container, Coroutine, Generator, Iterable from contextlib import contextmanager +from dataclasses import dataclass from datetime import datetime, time as dt_time, timedelta import functools as ft import inspect @@ -30,8 +31,10 @@ CONF_FOR, CONF_ID, CONF_MATCH, + CONF_OPTIONS, CONF_SELECTOR, CONF_STATE, + CONF_TARGET, CONF_VALUE_TEMPLATE, CONF_WEEKDAY, ENTITY_MATCH_ALL, @@ -111,17 +114,17 @@ # Basic schemas to sanity check the condition descriptions, # full validation is done by hassfest.conditions -_FIELD_SCHEMA = vol.Schema( +_FIELD_DESCRIPTION_SCHEMA = vol.Schema( { vol.Optional(CONF_SELECTOR): selector.validate_selector, }, extra=vol.ALLOW_EXTRA, ) -_CONDITION_SCHEMA = vol.Schema( +_CONDITION_DESCRIPTION_SCHEMA = vol.Schema( { vol.Optional("target"): TargetSelector.CONFIG_SCHEMA, - vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + vol.Optional("fields"): vol.Schema({str: _FIELD_DESCRIPTION_SCHEMA}), }, extra=vol.ALLOW_EXTRA, ) @@ -134,10 +137,10 @@ def starts_with_dot(key: str) -> str: return key -_CONDITIONS_SCHEMA = vol.Schema( +_CONDITIONS_DESCRIPTION_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.underscore_slug: vol.Any(None, _CONDITION_SCHEMA), + cv.underscore_slug: vol.Any(None, _CONDITION_DESCRIPTION_SCHEMA), } ) @@ -199,11 +202,43 @@ async def _register_condition_platform( _LOGGER.exception("Error while notifying condition platform listener") +_CONDITION_SCHEMA = vol.Schema( + { + **cv.CONDITION_BASE_SCHEMA, + vol.Required(CONF_CONDITION): str, + vol.Optional(CONF_OPTIONS): object, + vol.Optional(CONF_TARGET): cv.TARGET_FIELDS, + } +) + + class Condition(abc.ABC): """Condition class.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config. + + The complete config includes fields that are generic to all conditions, + such as the alias. + This method should be overridden by conditions that need to migrate + from the old-style config. + """ + complete_config = _CONDITION_SCHEMA(complete_config) + + specific_config: ConfigType = {} + for key in (CONF_OPTIONS, CONF_TARGET): + if key in complete_config: + specific_config[key] = complete_config.pop(key) + specific_config = await cls.async_validate_config(hass, specific_config) + + for key in (CONF_OPTIONS, CONF_TARGET): + if key in specific_config: + complete_config[key] = specific_config[key] + + return complete_config @classmethod @abc.abstractmethod @@ -212,6 +247,9 @@ async def async_validate_config( ) -> ConfigType: """Validate config.""" + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + @abc.abstractmethod async def async_get_checker(self) -> ConditionCheckerType: """Get the condition checker.""" @@ -226,6 +264,14 @@ async def async_get_conditions( """Return the conditions provided by this integration.""" +@dataclass(slots=True) +class ConditionConfig: + """Condition config.""" + + options: dict[str, Any] | None = None + target: dict[str, Any] | None = None + + type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] @@ -355,8 +401,15 @@ def disabled_condition( relative_condition_key = get_relative_description_key( platform_domain, condition_key ) - condition_instance = condition_descriptors[relative_condition_key](hass, config) - return await condition_instance.async_get_checker() + condition_cls = condition_descriptors[relative_condition_key] + condition = condition_cls( + hass, + ConditionConfig( + options=config.get(CONF_OPTIONS), + target=config.get(CONF_TARGET), + ), + ) + return await condition.async_get_checker() for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) @@ -989,9 +1042,9 @@ async def async_validate_condition_config( ) if not (condition_class := condition_descriptors.get(relative_condition_key)): raise vol.Invalid(f"Invalid condition '{condition_key}' specified") - return await condition_class.async_validate_config(hass, config) + return await condition_class.async_validate_complete_config(hass, config) - if platform is None and condition_key in ("numeric_state", "state"): + if condition_key in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], getattr( @@ -1111,7 +1164,7 @@ def _load_conditions_file(integration: Integration) -> dict[str, Any]: try: return cast( dict[str, Any], - _CONDITIONS_SCHEMA( + _CONDITIONS_DESCRIPTION_SCHEMA( load_yaml_dict(str(integration.file_path / "conditions.yaml")) ), ) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 9ebd33678468bb..5c844c81cf432c 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -401,29 +401,6 @@ async def async_run( await task -def move_top_level_schema_fields_to_options( - config: ConfigType, options_schema_dict: dict[vol.Marker, Any] -) -> ConfigType: - """Move top-level fields to options. - - This function is used to help migrating old-style configs to new-style configs. - If options is already present, the config is returned as-is. - """ - if CONF_OPTIONS in config: - return config - - config = config.copy() - options = config.setdefault(CONF_OPTIONS, {}) - - # Move top-level fields to options - for key_marked in options_schema_dict: - key = key_marked.schema - if key in config: - options[key] = config.pop(key) - - return config - - async def _async_get_trigger_platform( hass: HomeAssistant, trigger_key: str ) -> tuple[str, TriggerProtocol]: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 36f01d11b69583..f9d165d5b3b4de 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,10 +36,10 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.6.4 -hass-nabucasa==1.1.1 +hass-nabucasa==1.1.2 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pyproject.toml b/pyproject.toml index ae1d8fa5c10d3a..4b84c63d951b7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==1.1.1", + "hass-nabucasa==1.1.2", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 0f161b69c20268..237ecebb6614dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==1.1.1 +hass-nabucasa==1.1.2 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3830c097adc31f..e7eb58bbb20541 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1145,7 +1145,7 @@ habiticalib==0.4.5 habluetooth==5.6.4 # homeassistant.components.cloud -hass-nabucasa==1.1.1 +hass-nabucasa==1.1.2 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 @@ -1499,6 +1499,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.mvglive +mvg==1.4.0 + # homeassistant.components.permobil mypermobil==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb508dd12fb236..3c6183a4199e27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1006,7 +1006,7 @@ habiticalib==0.4.5 habluetooth==5.6.4 # homeassistant.components.cloud -hass-nabucasa==1.1.1 +hass-nabucasa==1.1.2 # homeassistant.components.assist_satellite # homeassistant.components.conversation @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 7da19e9418cbca..315ab14bdb5042 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -152,7 +152,7 @@ async def test_no_maps( return_value=prop, ), patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_multi_maps_list", + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", return_value=MultiMapsList( max_multi_map=1, max_bak_map=1, multi_map_count=0, map_info=[] ), diff --git a/tests/components/sun/test_condition.py b/tests/components/sun/test_condition.py index 52c0d885461518..0375525268d455 100644 --- a/tests/components/sun/test_condition.py +++ b/tests/components/sun/test_condition.py @@ -83,7 +83,10 @@ async def test_if_action_before_sunrise_no_offset( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -156,7 +159,10 @@ async def test_if_action_after_sunrise_no_offset( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -231,8 +237,10 @@ async def test_if_action_before_sunrise_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "before_offset": "+1:00:00", + "options": { + "before": SUN_EVENT_SUNRISE, + "before_offset": "+1:00:00", + }, }, "action": {"service": "test.automation"}, } @@ -356,8 +364,7 @@ async def test_if_action_before_sunset_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": "sunset", - "before_offset": "+1:00:00", + "options": {"before": "sunset", "before_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -481,8 +488,7 @@ async def test_if_action_after_sunrise_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "after_offset": "+1:00:00", + "options": {"after": SUN_EVENT_SUNRISE, "after_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -630,8 +636,7 @@ async def test_if_action_after_sunset_with_offset( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": "sunset", - "after_offset": "+1:00:00", + "options": {"after": "sunset", "after_offset": "+1:00:00"}, }, "action": {"service": "test.automation"}, } @@ -707,8 +712,7 @@ async def test_if_action_after_and_before_during( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "before": SUN_EVENT_SUNSET, + "options": {"after": SUN_EVENT_SUNRISE, "before": SUN_EVENT_SUNSET}, }, "action": {"service": "test.automation"}, } @@ -812,8 +816,7 @@ async def test_if_action_before_or_after_during( "trigger": {"platform": "event", "event_type": "test_event"}, "condition": { "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "after": SUN_EVENT_SUNSET, + "options": {"before": SUN_EVENT_SUNRISE, "after": SUN_EVENT_SUNSET}, }, "action": {"service": "test.automation"}, } @@ -941,7 +944,10 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -1020,7 +1026,10 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNRISE}, + }, "action": {"service": "test.automation"}, } }, @@ -1099,7 +1108,10 @@ async def test_if_action_before_sunset_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNSET}, + "condition": { + "condition": "sun", + "options": {"before": SUN_EVENT_SUNSET}, + }, "action": {"service": "test.automation"}, } }, @@ -1178,7 +1190,10 @@ async def test_if_action_after_sunset_no_offset_kotzebue( automation.DOMAIN: { "id": "sun", "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNSET}, + "condition": { + "condition": "sun", + "options": {"after": SUN_EVENT_SUNSET}, + }, "action": {"service": "test.automation"}, } }, diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index ab78fc90baede3..dae76186702ed3 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -12,8 +12,7 @@ async def test_zone_raises(hass: HomeAssistant) -> None: """Test that zone raises ConditionError on errors.""" config = { "condition": "zone", - "entity_id": "device_tracker.cat", - "zone": "zone.home", + "options": {"entity_id": "device_tracker.cat", "zone": "zone.home"}, } config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) @@ -66,8 +65,10 @@ async def test_zone_raises(hass: HomeAssistant) -> None: config = { "condition": "zone", - "entity_id": ["device_tracker.cat", "device_tracker.dog"], - "zone": ["zone.home", "zone.work"], + "options": { + "entity_id": ["device_tracker.cat", "device_tracker.dog"], + "zone": ["zone.home", "zone.work"], + }, } config = cv.CONDITION_SCHEMA(config) config = await condition.async_validate_condition_config(hass, config) @@ -102,8 +103,10 @@ async def test_zone_multiple_entities(hass: HomeAssistant) -> None: { "alias": "Zone Condition", "condition": "zone", - "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], - "zone": "zone.home", + "options": { + "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], + "zone": "zone.home", + }, }, ], } @@ -161,8 +164,10 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: "conditions": [ { "condition": "zone", - "entity_id": "device_tracker.person", - "zone": ["zone.home", "zone.work"], + "options": { + "entity_id": "device_tracker.person", + "zone": ["zone.home", "zone.work"], + }, }, ], } diff --git a/tests/helpers/test_automation.py b/tests/helpers/test_automation.py index 1cd9944aecf81e..6e0a76a28ce92e 100644 --- a/tests/helpers/test_automation.py +++ b/tests/helpers/test_automation.py @@ -1,10 +1,12 @@ """Test automation helpers.""" import pytest +import voluptuous as vol from homeassistant.helpers.automation import ( get_absolute_description_key, get_relative_description_key, + move_top_level_schema_fields_to_options, ) @@ -34,3 +36,73 @@ def test_relative_description_key(relative_key: str, absolute_key: str) -> None: """Test relative description key.""" DOMAIN = "homeassistant" assert get_relative_description_key(DOMAIN, absolute_key) == relative_key + + +@pytest.mark.parametrize( + ("config", "schema_dict", "expected_config"), + [ + ( + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + }, + {}, + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + "options": {}, + }, + ), + ( + { + "platform": "test", + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + "extra_field": "extra_value", + }, + { + vol.Required("entity"): str, + vol.Optional("from"): str, + vol.Optional("to"): str, + vol.Optional("for"): dict, + vol.Optional("attribute"): str, + vol.Optional("value_template"): str, + }, + { + "platform": "test", + "extra_field": "extra_value", + "options": { + "entity": "sensor.test", + "from": "open", + "to": "closed", + "for": {"hours": 1}, + "attribute": "state", + "value_template": "{{ value_json.val }}", + }, + }, + ), + ], +) +async def test_move_schema_fields_to_options( + config, schema_dict, expected_config +) -> None: + """Test moving schema fields to options.""" + assert ( + move_top_level_schema_fields_to_options(config, schema_dict) == expected_config + ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 260ef86023d744..e8e334d2ab68cb 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -32,6 +32,13 @@ entity_registry as er, trace, ) +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options +from homeassistant.helpers.condition import ( + Condition, + ConditionCheckerType, + ConditionConfig, + async_validate_condition_config, +) from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration @@ -2105,12 +2112,9 @@ async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: """Test a condition platform with multiple conditions.""" - class MockCondition(condition.Condition): + class MockCondition(Condition): """Mock condition.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize condition.""" - @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType @@ -2118,23 +2122,24 @@ async def async_validate_config( """Validate config.""" return config + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize condition.""" + class MockCondition1(MockCondition): """Mock condition 1.""" - async def async_get_checker(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: True class MockCondition2(MockCondition): """Mock condition 2.""" - async def async_get_checker(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: False - async def async_get_conditions( - hass: HomeAssistant, - ) -> dict[str, type[condition.Condition]]: + async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: return { "_": MockCondition1, "cond_2": MockCondition2, @@ -2148,12 +2153,12 @@ async def async_get_conditions( config_1 = {CONF_CONDITION: "test"} config_2 = {CONF_CONDITION: "test.cond_2"} config_3 = {CONF_CONDITION: "test.unknown_cond"} - assert await condition.async_validate_condition_config(hass, config_1) == config_1 - assert await condition.async_validate_condition_config(hass, config_2) == config_2 + assert await async_validate_condition_config(hass, config_1) == config_1 + assert await async_validate_condition_config(hass, config_2) == config_2 with pytest.raises( vol.Invalid, match="Invalid condition 'test.unknown_cond' specified" ): - await condition.async_validate_condition_config(hass, config_3) + await async_validate_condition_config(hass, config_3) cond_func = await condition.async_from_config(hass, config_1) assert cond_func(hass, {}) is True @@ -2165,6 +2170,74 @@ async def async_get_conditions( await condition.async_from_config(hass, config_3) +async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: + """Test a condition platform with a migration.""" + + OPTIONS_SCHEMA_DICT = { + vol.Required("option_1"): str, + vol.Optional("option_2"): int, + } + + class MockCondition(Condition): + """Mock condition.""" + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, complete_config) + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config + + async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + return { + "_": MockCondition, + } + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + config_1 = { + "condition": "test", + "option_1": "value_1", + "option_2": 2, + } + config_2 = { + "condition": "test", + "option_1": "value_1", + } + config_1_migrated = { + "condition": "test", + "options": {"option_1": "value_1", "option_2": 2}, + } + config_2_migrated = { + "condition": "test", + "options": {"option_1": "value_1"}, + } + + assert await async_validate_condition_config(hass, config_1) == config_1_migrated + assert await async_validate_condition_config(hass, config_2) == config_2_migrated + assert ( + await async_validate_condition_config(hass, config_1_migrated) + == config_1_migrated + ) + assert ( + await async_validate_condition_config(hass, config_2_migrated) + == config_2_migrated + ) + + @pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) async def test_enabled_condition( hass: HomeAssistant, enabled_value: bool | str diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index d28d0bc1a1c96d..0a271057ad5fd6 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -19,6 +19,7 @@ ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import trigger +from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, PluggableAction, @@ -29,7 +30,6 @@ _async_get_trigger_platform, async_initialize_triggers, async_validate_trigger_config, - move_top_level_schema_fields_to_options, ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration @@ -449,76 +449,6 @@ async def test_pluggable_action( assert not plug_2 -@pytest.mark.parametrize( - ("config", "schema_dict", "expected_config"), - [ - ( - { - "platform": "test", - "entity": "sensor.test", - "from": "open", - "to": "closed", - "for": {"hours": 1}, - "attribute": "state", - "value_template": "{{ value_json.val }}", - "extra_field": "extra_value", - }, - {}, - { - "platform": "test", - "entity": "sensor.test", - "from": "open", - "to": "closed", - "for": {"hours": 1}, - "attribute": "state", - "value_template": "{{ value_json.val }}", - "extra_field": "extra_value", - "options": {}, - }, - ), - ( - { - "platform": "test", - "entity": "sensor.test", - "from": "open", - "to": "closed", - "for": {"hours": 1}, - "attribute": "state", - "value_template": "{{ value_json.val }}", - "extra_field": "extra_value", - }, - { - vol.Required("entity"): str, - vol.Optional("from"): str, - vol.Optional("to"): str, - vol.Optional("for"): dict, - vol.Optional("attribute"): str, - vol.Optional("value_template"): str, - }, - { - "platform": "test", - "extra_field": "extra_value", - "options": { - "entity": "sensor.test", - "from": "open", - "to": "closed", - "for": {"hours": 1}, - "attribute": "state", - "value_template": "{{ value_json.val }}", - }, - }, - ), - ], -) -async def test_move_schema_fields_to_options( - config, schema_dict, expected_config -) -> None: - """Test moving schema fields to options.""" - assert ( - move_top_level_schema_fields_to_options(config, schema_dict) == expected_config - ) - - async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: """Test a trigger platform with multiple trigger."""