diff --git a/CODEOWNERS b/CODEOWNERS index 511ed96461be5f..421e7a22dd753f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1533,8 +1533,8 @@ build.json @home-assistant/supervisor /tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang -/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur -/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur +/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git +/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git /homeassistant/components/switcher_kis/ @thecode @YogevBokobza /tests/components/switcher_kis/ @thecode @YogevBokobza /homeassistant/components/switchmate/ @danielhiversen @qiz-li diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index b11bd44489bafb..2a247a8507f70b 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==9.1.0", "yalexs-ble==3.1.2"] + "requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index dec26dd3215186..f189c367cf28b8 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -50,14 +50,13 @@ ATTR_LANGUAGE, ATTR_TEXT, DATA_COMPONENT, - DATA_DEFAULT_ENTITY, DOMAIN, HOME_ASSISTANT_AGENT, SERVICE_PROCESS, SERVICE_RELOAD, ConversationEntityFeature, ) -from .default_agent import DefaultAgent, async_setup_default_agent +from .default_agent import async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult @@ -142,7 +141,7 @@ def async_unset_agent( hass: HomeAssistant, config_entry: ConfigEntry, ) -> None: - """Set the agent to handle the conversations.""" + """Unset the agent to handle the conversations.""" get_agent_manager(hass).async_unset_agent(config_entry.entry_id) @@ -241,10 +240,10 @@ async def async_handle_sentence_triggers( Returns None if no match occurred. """ - default_agent = async_get_agent(hass) - assert isinstance(default_agent, DefaultAgent) + agent = get_agent_manager(hass).default_agent + assert agent is not None - return await default_agent.async_handle_sentence_triggers(user_input) + return await agent.async_handle_sentence_triggers(user_input) async def async_handle_intents( @@ -257,12 +256,10 @@ async def async_handle_intents( Returns None if no match occurred. """ - default_agent = async_get_agent(hass) - assert isinstance(default_agent, DefaultAgent) + agent = get_agent_manager(hass).default_agent + assert agent is not None - return await default_agent.async_handle_intents( - user_input, intent_filter=intent_filter - ) + return await agent.async_handle_intents(user_input, intent_filter=intent_filter) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -298,9 +295,9 @@ async def handle_process(service: ServiceCall) -> ServiceResponse: async def handle_reload(service: ServiceCall) -> None: """Reload intents.""" - await hass.data[DATA_DEFAULT_ENTITY].async_reload( - language=service.data.get(ATTR_LANGUAGE) - ) + agent = get_agent_manager(hass).default_agent + if agent is not None: + await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index fb050397061008..7cd70bb768f93e 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -4,7 +4,7 @@ import dataclasses import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent, singleton -from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT +from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT from .entity import ConversationEntity from .models import ( AbstractConversationAgent, @@ -28,6 +28,9 @@ _LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from .default_agent import DefaultAgent + @singleton.singleton("conversation_agent") @callback @@ -49,8 +52,10 @@ def async_get_agent( hass: HomeAssistant, agent_id: str | None = None ) -> AbstractConversationAgent | ConversationEntity | None: """Get specified agent.""" + manager = get_agent_manager(hass) + if agent_id is None or agent_id == HOME_ASSISTANT_AGENT: - return hass.data[DATA_DEFAULT_ENTITY] + return manager.default_agent if "." in agent_id: return hass.data[DATA_COMPONENT].get_entity(agent_id) @@ -134,6 +139,7 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize the conversation agents.""" self.hass = hass self._agents: dict[str, AbstractConversationAgent] = {} + self.default_agent: DefaultAgent | None = None @callback def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None: @@ -182,3 +188,7 @@ def async_set_agent(self, agent_id: str, agent: AbstractConversationAgent) -> No def async_unset_agent(self, agent_id: str) -> None: """Unset the agent.""" self._agents.pop(agent_id, None) + + async def async_setup_default_agent(self, agent: DefaultAgent) -> None: + """Set up the default agent.""" + self.default_agent = agent diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 266a9f15b8316a..e1029de99180c9 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -10,11 +10,9 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_component import EntityComponent - from .default_agent import DefaultAgent from .entity import ConversationEntity DOMAIN = "conversation" -DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "conversation.home_assistant" ATTR_TEXT = "text" @@ -26,7 +24,6 @@ SERVICE_RELOAD = "reload" DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN) -DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity") class ConversationEntityFeature(IntFlag): diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index f7b3562fe81328..68029190439c98 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -68,13 +68,9 @@ from homeassistant.util import language as language_util from homeassistant.util.json import JsonObjectType, json_loads_object +from .agent_manager import get_agent_manager from .chat_log import AssistantContent, ChatLog -from .const import ( - DATA_DEFAULT_ENTITY, - DEFAULT_EXPOSED_ATTRIBUTES, - DOMAIN, - ConversationEntityFeature, -) +from .const import DOMAIN, ConversationEntityFeature from .entity import ConversationEntity from .models import ConversationInput, ConversationResult from .trace import ConversationTraceEventType, async_conversation_trace_append @@ -83,6 +79,8 @@ _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] +_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} + REGEX_TYPE = type(re.compile("")) TRIGGER_CALLBACK_TYPE = Callable[ [ConversationInput, RecognizeResult], Awaitable[str | None] @@ -209,9 +207,9 @@ async def async_setup_default_agent( config_intents: dict[str, Any], ) -> None: """Set up entity registry listener for the default agent.""" - entity = DefaultAgent(hass, config_intents) - await entity_component.async_add_entities([entity]) - hass.data[DATA_DEFAULT_ENTITY] = entity + agent = DefaultAgent(hass, config_intents) + await entity_component.async_add_entities([agent]) + await get_agent_manager(hass).async_setup_default_agent(agent) @core.callback def async_entity_state_listener( @@ -846,7 +844,7 @@ def _get_entity_name_tuples( context = {"domain": state.domain} if state.attributes: # Include some attributes - for attr in DEFAULT_EXPOSED_ATTRIBUTES: + for attr in _DEFAULT_EXPOSED_ATTRIBUTES: if attr not in state.attributes: continue context[attr] = state.attributes[attr] diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 077201fca6eaf4..ac7816daf8cb3c 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -25,7 +25,7 @@ async_get_agent, get_agent_manager, ) -from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY +from .const import DATA_COMPONENT from .default_agent import ( METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, @@ -169,7 +169,8 @@ async def websocket_list_sentences( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """List custom registered sentences.""" - agent = hass.data[DATA_DEFAULT_ENTITY] + agent = get_agent_manager(hass).default_agent + assert agent is not None sentences = [] for trigger_data in agent.trigger_sentences: @@ -191,7 +192,8 @@ async def websocket_hass_agent_debug( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Return intents that would be matched by the default agent for a list of sentences.""" - agent = hass.data[DATA_DEFAULT_ENTITY] + agent = get_agent_manager(hass).default_agent + assert agent is not None # Return results for each sentence in the same order as the input. result_dicts: list[dict[str, Any] | None] = [] diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index ccf3868c212ab3..36f8b2246776c4 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -20,7 +20,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import UNDEFINED, ConfigType -from .const import DATA_DEFAULT_ENTITY, DOMAIN +from .agent_manager import get_agent_manager +from .const import DOMAIN from .models import ConversationInput @@ -123,4 +124,6 @@ async def call_action( # two trigger copies for who will provide a response. return None - return hass.data[DATA_DEFAULT_ENTITY].register_trigger(sentences, call_action) + agent = get_agent_manager(hass).default_agent + assert agent is not None + return agent.register_trigger(sentences, call_action) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index fb5e04fbff0a35..b4f45f8f872272 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -338,7 +338,7 @@ class MieleActions(IntEnum): } -class StateProgramType(MieleEnum): +class StateProgramType(MieleEnum, missing_to_none=True): """Defines program types.""" normal_operation_mode = 0 @@ -346,10 +346,9 @@ class StateProgramType(MieleEnum): automatic_program = 2 cleaning_care_program = 3 maintenance_program = 4 - missing2none = -9999 -class StateDryingStep(MieleEnum): +class StateDryingStep(MieleEnum, missing_to_none=True): """Defines drying steps.""" extra_dry = 0 @@ -360,7 +359,6 @@ class StateDryingStep(MieleEnum): hand_iron_2 = 5 machine_iron = 6 smoothing = 7 - missing2none = -9999 WASHING_MACHINE_PROGRAM_ID: dict[int, str] = { @@ -1314,7 +1312,7 @@ class StateDryingStep(MieleEnum): } -class PlatePowerStep(MieleEnum): +class PlatePowerStep(MieleEnum, missing_to_none=True): """Plate power settings.""" plate_step_0 = 0 @@ -1339,4 +1337,3 @@ class PlatePowerStep(MieleEnum): plate_step_18 = 18 plate_step_boost = 117, 118, 218 plate_step_boost_2 = 217 - missing2none = -9999 diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index b5948c4cd18e82..2ed00c564d10af 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -8,7 +8,7 @@ "iot_class": "cloud_push", "loggers": ["pymiele"], "quality_scale": "platinum", - "requirements": ["pymiele==0.5.4"], + "requirements": ["pymiele==0.5.5"], "single_config_entry": true, "zeroconf": ["_mieleathome._tcp.local."] } diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py index 999ceac5cce9eb..8ca2713f59fdc1 100644 --- a/homeassistant/components/miele/vacuum.py +++ b/homeassistant/components/miele/vacuum.py @@ -64,7 +64,7 @@ class FanProgram(IntEnum): } -class MieleVacuumStateCode(MieleEnum): +class MieleVacuumStateCode(MieleEnum, missing_to_none=True): """Define vacuum state codes.""" idle = 0 @@ -82,7 +82,6 @@ class MieleVacuumStateCode(MieleEnum): blocked_front_wheel = 5900 docked = 5903, 5904 remote_controlled = 5910 - missing2none = -9999 SUPPORTED_FEATURES = ( diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index ba18dcb4f50a6c..6809f9aafd4e9c 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["aionfty"], "quality_scale": "bronze", - "requirements": ["aiontfy==0.5.5"] + "requirements": ["aiontfy==0.6.0"] } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 6bcdac284763ca..b30c9425b0a27b 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -371,7 +371,11 @@ def native_unit_of_measurement(self) -> str | None: @final @property def __native_unit_of_measurement_compat(self) -> str | None: - """Process ambiguous units.""" + """Handle wrong character coding in unit provided by integrations. + + NumberEntity should read the number's native unit through this property instead + of through native_unit_of_measurement. + """ native_unit_of_measurement = self.native_unit_of_measurement return AMBIGUOUS_UNITS.get( native_unit_of_measurement, native_unit_of_measurement diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 419c4df4f84dd0..9997c992cd7c48 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -366,7 +366,7 @@ def _is_valid_suggested_unit(self, suggested_unit_of_measurement: str) -> bool: because a unit converter supports both. """ # No need to check the unit converter if the units are the same - if self.native_unit_of_measurement == suggested_unit_of_measurement: + if self.__native_unit_of_measurement_compat == suggested_unit_of_measurement: return True # Make sure there is a unit converter and it supports both units @@ -478,7 +478,11 @@ def native_unit_of_measurement(self) -> str | None: @final @property def __native_unit_of_measurement_compat(self) -> str | None: - """Process ambiguous units.""" + """Handle wrong character coding in unit provided by integrations. + + SensorEntity should read the sensor's native unit through this property instead + of through native_unit_of_measurement. + """ native_unit_of_measurement = self.native_unit_of_measurement return AMBIGUOUS_UNITS.get( native_unit_of_measurement, diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index b07bae88072f09..2e5813182ffaf9 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -1,12 +1,17 @@ { "domain": "switchbot_cloud", "name": "SwitchBot Cloud", - "codeowners": ["@SeraphicRav", "@laurence-presland", "@Gigatrappeur"], + "codeowners": [ + "@SeraphicRav", + "@laurence-presland", + "@Gigatrappeur", + "@XiaoLing-git" + ], "config_flow": true, "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot_api"], - "requirements": ["switchbot-api==2.7.0"] + "requirements": ["switchbot-api==2.8.0"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 3ae222960ef291..4533e6fb49d682 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==9.1.0", "yalexs-ble==3.1.2"] + "requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 1358c3aca966ca..a9775873f0c245 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -16,6 +16,7 @@ CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, + CONF_OPTIONS, CONF_PLATFORM, CONF_TYPE, ) @@ -434,12 +435,13 @@ async def async_attach_trigger( if trigger_platform == VALUE_UPDATED_PLATFORM_TYPE: zwave_js_config = { - state.CONF_PLATFORM: trigger_platform, - CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_OPTIONS: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + }, } copy_available_params( config, - zwave_js_config, + zwave_js_config[CONF_OPTIONS], [ ATTR_COMMAND_CLASS, ATTR_PROPERTY, @@ -453,7 +455,7 @@ async def async_attach_trigger( hass, zwave_js_config ) return await attach_value_updated_trigger( - hass, zwave_js_config, action, trigger_info + hass, zwave_js_config[CONF_OPTIONS], action, trigger_info ) raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 1414582bc0da99..f7b76fa9a81ccb 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -15,6 +15,7 @@ ATTR_CONFIG_ENTRY_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, + CONF_OPTIONS, CONF_PLATFORM, ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback @@ -25,6 +26,7 @@ TriggerActionType, TriggerData, TriggerInfo, + move_top_level_schema_fields_to_options, ) from homeassistant.helpers.typing import ConfigType @@ -95,55 +97,37 @@ def validate_event_data(obj: dict) -> dict: return obj -TRIGGER_SCHEMA = vol.All( - cv.TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_PLATFORM): PLATFORM_TYPE, - vol.Optional(ATTR_CONFIG_ENTRY_ID): str, - vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_EVENT_SOURCE): vol.In(["controller", "driver", "node"]), - vol.Required(ATTR_EVENT): cv.string, - vol.Optional(ATTR_EVENT_DATA): dict, - vol.Optional(ATTR_PARTIAL_DICT_MATCH, default=False): bool, - }, - ), - validate_event_name, - validate_event_data, - vol.Any( - validate_non_node_event_source, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), - ), -) - - -async def async_validate_trigger_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate config.""" - config = TRIGGER_SCHEMA(config) - - if ATTR_CONFIG_ENTRY_ID in config: - entry_id = config[ATTR_CONFIG_ENTRY_ID] - if hass.config_entries.async_get_entry(entry_id) is None: - raise vol.Invalid(f"Config entry '{entry_id}' not found") - - if async_bypass_dynamic_config_validation(hass, config): - return config - - if config[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets( - hass, config - ): - raise vol.Invalid( - f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." +_OPTIONS_SCHEMA_DICT = { + vol.Optional(ATTR_CONFIG_ENTRY_ID): str, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_EVENT_SOURCE): vol.In(["controller", "driver", "node"]), + vol.Required(ATTR_EVENT): cv.string, + vol.Optional(ATTR_EVENT_DATA): dict, + vol.Optional(ATTR_PARTIAL_DICT_MATCH, default=False): bool, +} + +_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS): vol.All( + _OPTIONS_SCHEMA_DICT, + validate_event_name, + validate_event_data, + vol.Any( + validate_non_node_event_source, + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + ), ) - - return config + } +) class EventTrigger(Trigger): """Z-Wave JS event trigger.""" + _hass: HomeAssistant + _options: ConfigType + _event_source: str _event_name: str _event_data_filter: dict @@ -153,17 +137,43 @@ class EventTrigger(Trigger): _platform_type = PLATFORM_TYPE - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize trigger.""" - self._config = config - self._hass = hass + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + config = move_top_level_schema_fields_to_options(config, _OPTIONS_SCHEMA_DICT) + return await super().async_validate_complete_config(hass, config) @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - return await async_validate_trigger_config(hass, config) + config = _CONFIG_SCHEMA(config) + options = config[CONF_OPTIONS] + + if ATTR_CONFIG_ENTRY_ID in options: + entry_id = options[ATTR_CONFIG_ENTRY_ID] + if hass.config_entries.async_get_entry(entry_id) is None: + raise vol.Invalid(f"Config entry '{entry_id}' not found") + + if async_bypass_dynamic_config_validation(hass, options): + return config + + if options[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets( + hass, options + ): + raise vol.Invalid( + f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." + ) + + return config + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize trigger.""" + self._hass = hass + self._options = config[CONF_OPTIONS] async def async_attach( self, @@ -172,17 +182,17 @@ async def async_attach( ) -> CALLBACK_TYPE: """Attach a trigger.""" dev_reg = dr.async_get(self._hass) - config = self._config - if config[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets( - self._hass, config, dev_reg=dev_reg + options = self._options + if options[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets( + self._hass, options, dev_reg=dev_reg ): raise ValueError( f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." ) - self._event_source = config[ATTR_EVENT_SOURCE] - self._event_name = config[ATTR_EVENT] - self._event_data_filter = config.get(ATTR_EVENT_DATA, {}) + self._event_source = options[ATTR_EVENT_SOURCE] + self._event_name = options[ATTR_EVENT] + self._event_data_filter = options.get(ATTR_EVENT_DATA, {}) self._job = HassJob(action) self._trigger_data = trigger_info["trigger_data"] self._unsubs: list[Callable] = [] @@ -199,7 +209,7 @@ def _async_on_event( if key not in event_data: return if ( - self._config[ATTR_PARTIAL_DICT_MATCH] + self._options[ATTR_PARTIAL_DICT_MATCH] and isinstance(event_data[key], dict) and isinstance(val, dict) ): @@ -255,10 +265,10 @@ def _create_zwave_listeners(self) -> None: dev_reg = dr.async_get(self._hass) if not ( nodes := async_get_nodes_from_targets( - self._hass, self._config, dev_reg=dev_reg + self._hass, self._options, dev_reg=dev_reg ) ): - entry_id = self._config[ATTR_CONFIG_ENTRY_ID] + entry_id = self._options[ATTR_CONFIG_ENTRY_ID] entry = self._hass.config_entries.async_get_entry(entry_id) assert entry client = entry.runtime_data.client diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index f46592769cbe2a..4a61cbba72324a 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -10,11 +10,22 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value, get_value_id_str -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM, MATCH_ALL +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_OPTIONS, + CONF_PLATFORM, + MATCH_ALL, +) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.trigger import Trigger, TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + Trigger, + TriggerActionType, + TriggerInfo, + move_top_level_schema_fields_to_options, +) from homeassistant.helpers.typing import ConfigType from ..config_validation import VALUE_SCHEMA @@ -46,27 +57,26 @@ ATTR_FROM = "from" ATTR_TO = "to" -TRIGGER_SCHEMA = vol.All( - cv.TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_PLATFORM): PLATFORM_TYPE, - vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_COMMAND_CLASS): vol.In( - {cc.value: cc.name for cc in CommandClass} - ), - vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), - vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), - vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), - vol.Optional(ATTR_FROM, default=MATCH_ALL): vol.Any( - VALUE_SCHEMA, [VALUE_SCHEMA] - ), - vol.Optional(ATTR_TO, default=MATCH_ALL): vol.Any( - VALUE_SCHEMA, [VALUE_SCHEMA] - ), - }, +_OPTIONS_SCHEMA_DICT = { + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_COMMAND_CLASS): vol.In( + {cc.value: cc.name for cc in CommandClass} ), - cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), + vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_FROM, default=MATCH_ALL): vol.Any(VALUE_SCHEMA, [VALUE_SCHEMA]), + vol.Optional(ATTR_TO, default=MATCH_ALL): vol.Any(VALUE_SCHEMA, [VALUE_SCHEMA]), +} + +_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS): vol.All( + _OPTIONS_SCHEMA_DICT, + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), + ), + }, ) @@ -74,12 +84,13 @@ async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - config = TRIGGER_SCHEMA(config) + config = _CONFIG_SCHEMA(config) + options = config[CONF_OPTIONS] - if async_bypass_dynamic_config_validation(hass, config): + if async_bypass_dynamic_config_validation(hass, options): return config - if not async_get_nodes_from_targets(hass, config): + if not async_get_nodes_from_targets(hass, options): raise vol.Invalid( f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." ) @@ -88,7 +99,7 @@ async def async_validate_trigger_config( async def async_attach_trigger( hass: HomeAssistant, - config: ConfigType, + options: ConfigType, action: TriggerActionType, trigger_info: TriggerInfo, *, @@ -96,17 +107,17 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" dev_reg = dr.async_get(hass) - if not async_get_nodes_from_targets(hass, config, dev_reg=dev_reg): + if not async_get_nodes_from_targets(hass, options, dev_reg=dev_reg): raise ValueError( f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." ) - from_value = config[ATTR_FROM] - to_value = config[ATTR_TO] - command_class = config[ATTR_COMMAND_CLASS] - property_ = config[ATTR_PROPERTY] - endpoint = config.get(ATTR_ENDPOINT) - property_key = config.get(ATTR_PROPERTY_KEY) + from_value = options[ATTR_FROM] + to_value = options[ATTR_TO] + command_class = options[ATTR_COMMAND_CLASS] + property_ = options[ATTR_PROPERTY] + endpoint = options.get(ATTR_ENDPOINT) + property_key = options.get(ATTR_PROPERTY_KEY) unsubs: list[Callable] = [] job = HassJob(action) @@ -174,7 +185,7 @@ def _create_zwave_listeners() -> None: # Nodes list can come from different drivers and we will need to listen to # server connections for all of them. drivers: set[Driver] = set() - for node in async_get_nodes_from_targets(hass, config, dev_reg=dev_reg): + for node in async_get_nodes_from_targets(hass, options, dev_reg=dev_reg): driver = node.client.driver assert driver is not None # The node comes from the driver. drivers.add(driver) @@ -210,10 +221,16 @@ def _create_zwave_listeners() -> None: class ValueUpdatedTrigger(Trigger): """Z-Wave JS value updated trigger.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize trigger.""" - self._config = config - self._hass = hass + _hass: HomeAssistant + _options: ConfigType + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + config = move_top_level_schema_fields_to_options(config, _OPTIONS_SCHEMA_DICT) + return await super().async_validate_complete_config(hass, config) @classmethod async def async_validate_config( @@ -222,6 +239,11 @@ async def async_validate_config( """Validate config.""" return await async_validate_trigger_config(hass, config) + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize trigger.""" + self._hass = hass + self._options = config[CONF_OPTIONS] + async def async_attach( self, action: TriggerActionType, @@ -229,5 +251,5 @@ async def async_attach( ) -> CALLBACK_TYPE: """Attach a trigger.""" return await async_attach_trigger( - self._hass, self._config, action, trigger_info + self._hass, self._options, action, trigger_info ) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3934b810db5794..fdea434b8cb7bb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -186,6 +186,7 @@ CONF_NAME: Final = "name" CONF_OFFSET: Final = "offset" CONF_OPTIMISTIC: Final = "optimistic" +CONF_OPTIONS: Final = "options" CONF_PACKAGES: Final = "packages" CONF_PARALLEL: Final = "parallel" CONF_PARAMS: Final = "params" diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 2351ab9468bd26..d949c9fdecb801 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -18,8 +18,10 @@ CONF_ALIAS, CONF_ENABLED, CONF_ID, + CONF_OPTIONS, CONF_PLATFORM, CONF_SELECTOR, + CONF_TARGET, CONF_VARIABLES, ) from homeassistant.core import ( @@ -74,17 +76,17 @@ # Basic schemas to sanity check the trigger descriptions, # full validation is done by hassfest.triggers -_FIELD_SCHEMA = vol.Schema( +_FIELD_DESCRIPTION_SCHEMA = vol.Schema( { vol.Optional(CONF_SELECTOR): selector.validate_selector, }, extra=vol.ALLOW_EXTRA, ) -_TRIGGER_SCHEMA = vol.Schema( +_TRIGGER_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, ) @@ -97,10 +99,10 @@ def starts_with_dot(key: str) -> str: return key -_TRIGGERS_SCHEMA = vol.Schema( +_TRIGGERS_DESCRIPTION_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.underscore_slug: vol.Any(None, _TRIGGER_SCHEMA), + cv.underscore_slug: vol.Any(None, _TRIGGER_DESCRIPTION_SCHEMA), } ) @@ -165,11 +167,41 @@ async def _register_trigger_platform( _LOGGER.exception("Error while notifying trigger platform listener") +_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Optional(CONF_OPTIONS): object, + vol.Optional(CONF_TARGET): cv.TARGET_FIELDS, + } +) + + class Trigger(abc.ABC): """Trigger class.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize trigger.""" + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate complete config. + + The complete config includes fields that are generic to all triggers, + such as the alias or the ID. + This method should be overridden by triggers that need to migrate + from the old-style config. + """ + config = _TRIGGER_SCHEMA(config) + + specific_config: ConfigType = {} + for key in (CONF_OPTIONS, CONF_TARGET): + if key in config: + specific_config[key] = 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: + config[key] = specific_config[key] + + return config @classmethod @abc.abstractmethod @@ -178,6 +210,9 @@ async def async_validate_config( ) -> ConfigType: """Validate config.""" + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize trigger.""" + @abc.abstractmethod async def async_attach( self, @@ -357,6 +392,29 @@ 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]: @@ -390,7 +448,7 @@ async def async_validate_trigger_config( ) if not (trigger := trigger_descriptors.get(relative_trigger_key)): raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") - conf = await trigger.async_validate_config(hass, conf) + conf = await trigger.async_validate_complete_config(hass, conf) elif hasattr(platform, "async_validate_trigger_config"): conf = await platform.async_validate_trigger_config(hass, conf) else: @@ -537,7 +595,7 @@ def _load_triggers_file(integration: Integration) -> dict[str, Any]: try: return cast( dict[str, Any], - _TRIGGERS_SCHEMA( + _TRIGGERS_DESCRIPTION_SCHEMA( load_yaml_dict(str(integration.file_path / "triggers.yaml")) ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 463e15d041060e..f10af188d66065 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -325,7 +325,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.5 +aiontfy==0.6.0 # homeassistant.components.nut aionut==4.3.4 @@ -2156,7 +2156,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.4 +pymiele==0.5.5 # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -2889,7 +2889,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.7.0 +switchbot-api==2.8.0 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -3183,7 +3183,7 @@ yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale -yalexs==9.1.0 +yalexs==9.2.0 # homeassistant.components.yeelight yeelight==0.7.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76a9b13c782be6..fb3aa461fb5063 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -307,7 +307,7 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.5.5 +aiontfy==0.6.0 # homeassistant.components.nut aionut==4.3.4 @@ -1801,7 +1801,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.4 +pymiele==0.5.5 # homeassistant.components.mochad pymochad==0.2.0 @@ -2396,7 +2396,7 @@ subarulink==0.7.13 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.7.0 +switchbot-api==2.8.0 # homeassistant.components.system_bridge systembridgeconnector==4.1.10 @@ -2639,7 +2639,7 @@ yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale -yalexs==9.1.0 +yalexs==9.2.0 # homeassistant.components.yeelight yeelight==0.7.16 diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 681f6e7759ddf2..19be971f36c2a1 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -298,7 +298,7 @@ async def async_setup_entry_wake_word_platform( assert await async_setup_component(hass, "conversation", {"conversation": {}}) # Disable fuzzy matching by default for tests - agent = hass.data[conversation.DATA_DEFAULT_ENTITY] + agent = conversation.async_get_agent(hass) agent.fuzzy_matching = False config_entry = MockConfigEntry(domain="test") diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 8fefcdf7f01d58..f7d674769eb1d9 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components import conversation +from homeassistant.components.conversation import async_get_agent, default_agent from homeassistant.components.shopping_list import intent as sl_intent from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant @@ -77,5 +78,6 @@ async def init_components(hass: HomeAssistant): assert await async_setup_component(hass, "conversation", {conversation.DOMAIN: {}}) # Disable fuzzy matching by default for tests - agent = hass.data[conversation.DATA_DEFAULT_ENTITY] + agent = async_get_agent(hass) + assert isinstance(agent, default_agent.DefaultAgent) agent.fuzzy_matching = False diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 64457300dad2e2..6dcb032c0d3dd4 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -12,8 +12,7 @@ import yaml from homeassistant.components import conversation, cover, media_player, weather -from homeassistant.components.conversation import default_agent -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation import async_get_agent, default_agent from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE from homeassistant.components.conversation.models import ConversationInput from homeassistant.components.cover import SERVICE_OPEN_COVER @@ -87,7 +86,8 @@ async def init_components(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "intent", {}) # Disable fuzzy matching by default for tests - agent = hass.data[DATA_DEFAULT_ENTITY] + agent = async_get_agent(hass) + assert isinstance(agent, default_agent.DefaultAgent) agent.fuzzy_matching = False @@ -215,7 +215,7 @@ async def test_exposed_areas( @pytest.mark.usefixtures("init_components") async def test_conversation_agent(hass: HomeAssistant) -> None: """Test DefaultAgent.""" - agent = hass.data[DATA_DEFAULT_ENTITY] + agent = async_get_agent(hass) with patch( "homeassistant.components.conversation.default_agent.get_languages", return_value=["dwarvish", "elvish", "entish"], @@ -415,8 +415,7 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: trigger_sentences = ["It's party time", "It is time to party"] trigger_response = "Cowabunga!" - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) callback = AsyncMock(return_value=trigger_response) unregister = agent.register_trigger(trigger_sentences, callback) @@ -462,8 +461,7 @@ async def test_trigger_sentence_response_translation( """Test translation of default response 'done'.""" hass.config.language = language - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) translations = { "en": {"component.conversation.conversation.agent.done": "English done"}, @@ -2525,8 +2523,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non hass.states.async_set("cover.front_door", "closed") calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) result = await agent.async_process( ConversationInput( @@ -2872,8 +2869,7 @@ async def test_query_same_name_different_areas( @pytest.mark.usefixtures("init_components") async def test_intent_cache_exposed(hass: HomeAssistant) -> None: """Test that intent recognition results are cached for exposed entities.""" - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) entity_id = "light.test_light" hass.states.async_set(entity_id, "off") @@ -2912,8 +2908,7 @@ async def test_intent_cache_exposed(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("init_components") async def test_intent_cache_all_entities(hass: HomeAssistant) -> None: """Test that intent recognition results are cached for all entities.""" - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) entity_id = "light.test_light" hass.states.async_set(entity_id, "off") @@ -2952,8 +2947,7 @@ async def test_intent_cache_all_entities(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("init_components") async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None: """Test that intent recognition results are cached for fuzzy matches.""" - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) # There is no entity named test light user_input = ConversationInput( @@ -2982,8 +2976,7 @@ async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("init_components") async def test_entities_filtered_by_input(hass: HomeAssistant) -> None: """Test that entities are filtered by the input text before intent matching.""" - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) # Only the switch is exposed hass.states.async_set("light.test_light", "off") @@ -3165,7 +3158,7 @@ async def test_handle_intents_with_response_errors( assert await async_setup_component(hass, "climate", {}) area_registry.async_create("living room") - agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY] + agent = async_get_agent(hass) user_input = ConversationInput( text="What is the temperature in the living room?", @@ -3203,7 +3196,7 @@ async def test_handle_intents_filters_results( assert await async_setup_component(hass, "climate", {}) area_registry.async_create("living room") - agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY] + agent = async_get_agent(hass) user_input = ConversationInput( text="What is the temperature in the living room?", @@ -3363,7 +3356,7 @@ async def test_fuzzy_matching( assert await async_setup_component(hass, "intent", {}) await light_intent.async_setup_intents(hass) - agent = hass.data[DATA_DEFAULT_ENTITY] + agent = async_get_agent(hass) agent.fuzzy_matching = fuzzy_matching area_office = area_registry.async_get_or_create("office_id") diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 29cd567e904116..24fc4d1b135e87 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -7,11 +7,8 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.conversation import default_agent -from homeassistant.components.conversation.const import ( - DATA_DEFAULT_ENTITY, - HOME_ASSISTANT_AGENT, -) +from homeassistant.components.conversation import async_get_agent +from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -216,8 +213,7 @@ async def test_ws_prepare( hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator, agent_id ) -> None: """Test the Websocket prepare conversation API.""" - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) # No intents should be loaded yet assert not agent._lang_intents.get(hass.config.language) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 7cec3543fabd9b..6c1f7703287eb7 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -10,14 +10,12 @@ from homeassistant.components import conversation from homeassistant.components.conversation import ( ConversationInput, + async_get_agent, async_handle_intents, async_handle_sentence_triggers, default_agent, ) -from homeassistant.components.conversation.const import ( - DATA_DEFAULT_ENTITY, - HOME_ASSISTANT_AGENT, -) +from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -145,13 +143,13 @@ async def test_custom_agent( ) -async def test_prepare_reload(hass: HomeAssistant, init_components) -> None: +@pytest.mark.usefixtures("init_components") +async def test_prepare_reload(hass: HomeAssistant) -> None: """Test calling the reload service.""" language = hass.config.language + agent = async_get_agent(hass) # Load intents - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) await agent.async_prepare(language) # Confirm intents are loaded @@ -172,14 +170,12 @@ async def test_prepare_reload(hass: HomeAssistant, init_components) -> None: assert not agent._lang_intents.get(language) +@pytest.mark.usefixtures("init_components") async def test_prepare_fail(hass: HomeAssistant) -> None: """Test calling prepare with a non-existent language.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + agent = async_get_agent(hass) # Load intents - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) await agent.async_prepare("not-a-language") # Confirm no intents were loaded diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 4531e5857e2351..b0af8a59dc22e6 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -5,8 +5,7 @@ import pytest import voluptuous as vol -from homeassistant.components.conversation import HOME_ASSISTANT_AGENT, default_agent -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation import HOME_ASSISTANT_AGENT, async_get_agent from homeassistant.components.conversation.models import ConversationInput from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import trigger @@ -16,10 +15,8 @@ @pytest.fixture(autouse=True) -async def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant, init_components) -> None: """Initialize components.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) async def test_if_fires_on_event( @@ -680,8 +677,7 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None: }, ) - agent = hass.data[DATA_DEFAULT_ENTITY] - assert isinstance(agent, default_agent.DefaultAgent) + agent = async_get_agent(hass) result = await agent.async_process( ConversationInput( diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 7b00a9d0eef68b..17e48ab6572ad1 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -65,9 +65,11 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -77,10 +79,12 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "device_id": device.id, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": "ajar", + "options": { + "device_id": device.id, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": "ajar", + }, }, "action": { "event": "single_from_value_filter", @@ -90,10 +94,12 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": ["closed", "opened"], + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + }, }, "action": { "event": "multiple_from_value_filters", @@ -103,11 +109,13 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": ["closed", "opened"], - "to": ["opened"], + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": ["closed", "opened"], + "to": ["opened"], + }, }, "action": { "event": "from_and_to_value_filters", @@ -117,9 +125,11 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "boltStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "boltStatus", + }, }, "action": { "event": "different_value", @@ -299,9 +309,11 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -357,9 +369,11 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation_no_nodes( { "trigger": { "platform": trigger_type, - "entity_id": "sensor.test", - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": "sensor.test", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -413,9 +427,11 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation_no_driver( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -506,9 +522,11 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, }, "action": { "event": "node_no_event_data_filter", @@ -518,10 +536,12 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "device_id": device.id, - "event_source": "node", - "event": "interview stage completed", - "event_data": {"stageName": "ProtocolInfo"}, + "options": { + "device_id": device.id, + "event_source": "node", + "event": "interview stage completed", + "event_data": {"stageName": "ProtocolInfo"}, + }, }, "action": { "event": "node_event_data_filter", @@ -531,9 +551,11 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "config_entry_id": integration.entry_id, - "event_source": "controller", - "event": "inclusion started", + "options": { + "config_entry_id": integration.entry_id, + "event_source": "controller", + "event": "inclusion started", + }, }, "action": { "event": "controller_no_event_data_filter", @@ -543,10 +565,12 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "config_entry_id": integration.entry_id, - "event_source": "controller", - "event": "inclusion started", - "event_data": {"strategy": 0}, + "options": { + "config_entry_id": integration.entry_id, + "event_source": "controller", + "event": "inclusion started", + "event_data": {"strategy": 0}, + }, }, "action": { "event": "controller_event_data_filter", @@ -556,9 +580,11 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "config_entry_id": integration.entry_id, - "event_source": "driver", - "event": "logging", + "options": { + "config_entry_id": integration.entry_id, + "event_source": "driver", + "event": "logging", + }, }, "action": { "event": "driver_no_event_data_filter", @@ -568,10 +594,12 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "config_entry_id": integration.entry_id, - "event_source": "driver", - "event": "logging", - "event_data": {"message": "test"}, + "options": { + "config_entry_id": integration.entry_id, + "event_source": "driver", + "event": "logging", + "event_data": {"message": "test"}, + }, }, "action": { "event": "driver_event_data_filter", @@ -581,10 +609,12 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "value updated", - "event_data": {"args": {"commandClassName": "Door Lock"}}, + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "value updated", + "event_data": {"args": {"commandClassName": "Door Lock"}}, + }, }, "action": { "event": "node_event_data_no_partial_dict_match_filter", @@ -594,11 +624,13 @@ def clear_events(): { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "value updated", - "event_data": {"args": {"commandClassName": "Door Lock"}}, - "partial_dict_match": True, + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "value updated", + "event_data": {"args": {"commandClassName": "Door Lock"}}, + "partial_dict_match": True, + }, }, "action": { "event": "node_event_data_partial_dict_match_filter", @@ -864,9 +896,11 @@ async def test_zwave_js_event_bypass_dynamic_validation( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, }, "action": { "event": "node_no_event_data_filter", @@ -915,9 +949,11 @@ async def test_zwave_js_event_bypass_dynamic_validation_no_nodes( { "trigger": { "platform": trigger_type, - "entity_id": "sensor.fake", - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": "sensor.fake", + "event_source": "node", + "event": "interview stage completed", + }, }, "action": { "event": "node_no_event_data_filter", @@ -958,9 +994,11 @@ async def test_zwave_js_event_invalid_config_entry_id( { "trigger": { "platform": trigger_type, - "config_entry_id": "not_real_entry_id", - "event_source": "controller", - "event": "inclusion started", + "options": { + "config_entry_id": "not_real_entry_id", + "event_source": "controller", + "event": "inclusion started", + }, }, "action": { "event": "node_no_event_data_filter", @@ -977,24 +1015,28 @@ async def test_zwave_js_event_invalid_config_entry_id( async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: """Test invalid trigger configs.""" with pytest.raises(vol.Invalid): - await TRIGGERS["event"].async_validate_config( + await TRIGGERS["event"].async_validate_complete_config( hass, { "platform": f"{DOMAIN}.event", - "entity_id": "fake.entity", - "event_source": "node", - "event": "value updated", + "options": { + "entity_id": "fake.entity", + "event_source": "node", + "event": "value updated", + }, }, ) with pytest.raises(vol.Invalid): - await TRIGGERS["value_updated"].async_validate_config( + await TRIGGERS["value_updated"].async_validate_complete_config( hass, { "platform": f"{DOMAIN}.value_updated", - "entity_id": "fake.entity", - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": "fake.entity", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, ) @@ -1017,32 +1059,38 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.value_updated", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, ) await hass.config_entries.async_unload(integration.entry_id) # Test full validation for both events - assert await TRIGGERS["value_updated"].async_validate_config( + assert await TRIGGERS["value_updated"].async_validate_complete_config( hass, { "platform": f"{DOMAIN}.value_updated", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, ) - assert await TRIGGERS["event"].async_validate_config( + assert await TRIGGERS["event"].async_validate_complete_config( hass, { "platform": f"{DOMAIN}.event", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, }, ) @@ -1051,9 +1099,11 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.value_updated", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, ) @@ -1061,10 +1111,12 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.value_updated", - "device_id": device.id, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", - "from": "ajar", + "options": { + "device_id": device.id, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + "from": "ajar", + }, }, ) @@ -1072,9 +1124,11 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.event", - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": "interview stage completed", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, }, ) @@ -1082,10 +1136,12 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.event", - "device_id": device.id, - "event_source": "node", - "event": "interview stage completed", - "event_data": {"stageName": "ProtocolInfo"}, + "options": { + "device_id": device.id, + "event_source": "node", + "event": "interview stage completed", + "event_data": {"stageName": "ProtocolInfo"}, + }, }, ) @@ -1093,9 +1149,11 @@ async def test_zwave_js_trigger_config_entry_unloaded( hass, { "platform": f"{DOMAIN}.event", - "config_entry_id": integration.entry_id, - "event_source": "controller", - "event": "nvm convert progress", + "options": { + "config_entry_id": integration.entry_id, + "event_source": "controller", + "event": "nvm convert progress", + }, }, ) @@ -1125,9 +1183,11 @@ async def test_server_reconnect_event( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "event_source": "node", - "event": event_name, + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": event_name, + }, }, "action": { "event": "blah", @@ -1205,9 +1265,11 @@ async def test_server_reconnect_value_updated( { "trigger": { "platform": trigger_type, - "entity_id": SCHLAGE_BE469_LOCK_ENTITY, - "command_class": CommandClass.DOOR_LOCK.value, - "property": "latchStatus", + "options": { + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, }, "action": { "event": "no_value_filter", @@ -1258,3 +1320,78 @@ async def test_server_reconnect_value_updated( # Make sure the old listener is no longer referenced assert old_listener not in new_node._listeners.get(event_name, []) + + +async def test_zwave_js_old_syntax( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: + """Test zwave_js triggers work with the old syntax.""" + node: Node = lock_schlage_be469 + + zwavejs_event = async_capture_events(hass, "zwavejs_event") + zwavejs_value_updated = async_capture_events(hass, "zwavejs_value_updated") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": f"{DOMAIN}.value_updated", + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "zwavejs_event", + }, + }, + { + "trigger": { + "platform": f"{DOMAIN}.event", + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, + "action": { + "event": "zwavejs_value_updated", + }, + }, + ] + }, + ) + + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(zwavejs_event) == 1 + + event = Event( + type="interview stage completed", + data={ + "source": "node", + "event": "interview stage completed", + "stageName": "NodeInfo", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(zwavejs_value_updated) == 1 diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index d5621a1ae616b2..876ba62396f53c 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -28,6 +28,7 @@ _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 @@ -451,15 +452,82 @@ 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.""" class MockTrigger(Trigger): """Mock trigger.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Initialize trigger.""" - @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType @@ -467,6 +535,9 @@ async def async_validate_config( """Validate config.""" return config + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize trigger.""" + class MockTrigger1(MockTrigger): """Mock trigger 1.""" @@ -489,9 +560,7 @@ async def async_attach( """Attach a trigger.""" action({"trigger": "test_trigger_2"}) - async def async_get_triggers( - hass: HomeAssistant, - ) -> dict[str, type[Trigger]]: + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "_": MockTrigger1, "trig_2": MockTrigger2, @@ -501,7 +570,7 @@ async def async_get_triggers( mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) config_1 = [{"platform": "test"}] - config_2 = [{"platform": "test.trig_2"}] + config_2 = [{"platform": "test.trig_2", "options": {"x": 1}}] config_3 = [{"platform": "test.unknown_trig"}] assert await async_validate_trigger_config(hass, config_1) == config_1 assert await async_validate_trigger_config(hass, config_2) == config_2 @@ -530,6 +599,53 @@ def cb_action(*args): await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb) +async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: + """Test a trigger platform with a migration.""" + + OPTIONS_SCHEMA_DICT = { + vol.Required("option_1"): str, + vol.Optional("option_2"): int, + } + + class MockTrigger(Trigger): + """Mock trigger.""" + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate complete config.""" + config = move_top_level_schema_fields_to_options( + config, OPTIONS_SCHEMA_DICT + ) + return await super().async_validate_complete_config(hass, config) + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return { + "_": MockTrigger, + } + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + config_1 = [{"platform": "test", "option_1": "value_1", "option_2": 2}] + config_2 = [{"platform": "test", "option_1": "value_1"}] + config_3 = [{"platform": "test", "options": {"option_1": "value_1", "option_2": 2}}] + config_4 = [{"platform": "test", "options": {"option_1": "value_1"}}] + + assert await async_validate_trigger_config(hass, config_1) == config_3 + assert await async_validate_trigger_config(hass, config_2) == config_4 + assert await async_validate_trigger_config(hass, config_3) == config_3 + assert await async_validate_trigger_config(hass, config_4) == config_4 + + @pytest.mark.parametrize( "sun_trigger_descriptions", [