diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19cc8bd3af7147..f727d258d1e8cf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 3 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2025.7" + HA_SHORT_VERSION: "2025.8" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/CODEOWNERS b/CODEOWNERS index 419347d08a76fa..4e224f8802bcaf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1169,8 +1169,8 @@ build.json @home-assistant/supervisor /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan /tests/components/plaato/ @JohNan -/homeassistant/components/playstation_network/ @jackjpowell -/tests/components/playstation_network/ @jackjpowell +/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r +/tests/components/playstation_network/ @jackjpowell @tr4nt0r /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren /homeassistant/components/plugwise/ @CoMPaTech @bouwew diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 810c1f1e8d2ea2..afe8ea6f356af6 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -89,6 +89,7 @@ restore_state, template, translation, + trigger, ) from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.storage import get_internal_store_manager @@ -452,6 +453,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: create_eager_task(restore_state.async_load(hass)), create_eager_task(hass.config_entries.async_initialize()), create_eager_task(async_get_system_info(hass)), + create_eager_task(trigger.async_setup(hass)), ) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index a2bb423860b8da..e82cd471ac79d0 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.1.14"] + "requirements": ["aioamazondevices==3.1.19"] } diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 92901f8e857a59..5e2146a533c2ec 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( + Condition, ConditionCheckerType, trace_condition_function, ) @@ -51,20 +52,38 @@ async def async_get_conditions( """List conditions.""" -async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate device condition config.""" - return await async_validate_device_automation_config( - hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION - ) +class DeviceCondition(Condition): + """Device condition.""" + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + self._config = config + self._hass = hass -async def async_condition_from_config( - hass: HomeAssistant, config: ConfigType -) -> condition.ConditionCheckerType: - """Test a device condition.""" - platform = await async_get_device_automation_platform( - hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION - ) - return trace_condition_function(platform.async_condition_from_config(hass, config)) + @classmethod + async def async_validate_condition_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 + ) + + async def async_condition_from_config(self) -> condition.ConditionCheckerType: + """Test a device condition.""" + platform = await async_get_device_automation_platform( + self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION + ) + return trace_condition_function( + platform.async_condition_from_config(self._hass, self._config) + ) + + +CONDITIONS: dict[str, type[Condition]] = { + "device": DeviceCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the sun conditions.""" + return CONDITIONS diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d996963cb9c1b8..0028bda57be944 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==20250531.4"] + "requirements": ["home-assistant-frontend==20250625.0"] } diff --git a/homeassistant/components/mqtt/icons.json b/homeassistant/components/mqtt/icons.json index 73cbf22b629b62..46a588a56670b8 100644 --- a/homeassistant/components/mqtt/icons.json +++ b/homeassistant/components/mqtt/icons.json @@ -9,5 +9,10 @@ "reload": { "service": "mdi:reload" } + }, + "triggers": { + "mqtt": { + "trigger": "mdi:swap-horizontal" + } } } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index ed7da6fc1128a3..592ea8686e18bc 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -222,8 +222,8 @@ "unit_of_measurement": "Unit of measurement" }, "data_description": { - "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", - "entity_category": "Allow marking an entity as device configuration or diagnostics. An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect action calls to devices or areas. Sensor entities cannot be assigned a device configiuration class. [Learn more.](https://developers.home-assistant.io/docs/core/entity/#registry-properties)", + "device_class": "The device class of the {platform} entity. [Learn more.]({url}#device_class)", + "entity_category": "Allows marking an entity as device configuration or diagnostics. An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect action calls to devices or areas. Sensor entities cannot be assigned a device configuration class. [Learn more.](https://developers.home-assistant.io/docs/core/entity/#registry-properties)", "fan_feature_speed": "The fan supports multiple speeds.", "fan_feature_preset_modes": "The fan supports preset modes.", "fan_feature_oscillation": "The fan supports oscillation.", @@ -992,6 +992,23 @@ "description": "Reloads MQTT entities from the YAML-configuration." } }, + "triggers": { + "mqtt": { + "name": "MQTT", + "description": "When a specific message is received on a given MQTT topic.", + "description_configured": "When an MQTT message has been received", + "fields": { + "payload": { + "name": "Payload", + "description": "The payload to trigger on." + }, + "topic": { + "name": "Topic", + "description": "MQTT topic to listen to." + } + } + } + }, "exceptions": { "addon_start_failed": { "message": "Failed to correctly start {addon} add-on." diff --git a/homeassistant/components/mqtt/triggers.yaml b/homeassistant/components/mqtt/triggers.yaml new file mode 100644 index 00000000000000..d3998674d587bb --- /dev/null +++ b/homeassistant/components/mqtt/triggers.yaml @@ -0,0 +1,14 @@ +# Describes the format for MQTT triggers + +mqtt: + fields: + payload: + example: "on" + required: false + selector: + text: + topic: + example: "living_room/switch/ac" + required: true + selector: + text: diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index bdcb77f92c36be..bb7fc7c27ff6a7 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -1,7 +1,7 @@ { "domain": "playstation_network", "name": "PlayStation Network", - "codeowners": ["@jackjpowell"], + "codeowners": ["@jackjpowell", "@tr4nt0r"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 264afd79ed2e1f..0cf50326df1f91 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.4"], + "requirements": ["plugwise==1.7.6"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 35d81edbea04f9..fafa142273aee6 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN @@ -132,11 +133,23 @@ def get_contents( value = getattr(speaker, attrib) payload[attrib] = get_contents(value) + entity_registry = er.async_get(hass) payload["enabled_entities"] = sorted( - entity_id - for entity_id, s in config_entry.runtime_data.entity_id_mappings.items() - if s is speaker + registry_entry.entity_id + for registry_entry in entity_registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ) + if ( + ( + entity_speaker + := config_entry.runtime_data.unique_id_speaker_mappings.get( + registry_entry.unique_id + ) + ) + and speaker.uid == entity_speaker.uid + ) ) + payload["media"] = await async_generate_media_info(hass, speaker) payload["activity_stats"] = speaker.activity_stats.report() payload["event_stats"] = speaker.event_stats.report() diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 58108f9974ce76..5f7a2fb2d704b7 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -34,7 +34,10 @@ def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> Non async def async_added_to_hass(self) -> None: """Handle common setup when added to hass.""" - self.config_entry.runtime_data.entity_id_mappings[self.entity_id] = self.speaker + assert self.unique_id + self.config_entry.runtime_data.unique_id_speaker_mappings[self.unique_id] = ( + self.speaker + ) self.async_on_remove( async_dispatcher_connect( self.hass, @@ -52,7 +55,8 @@ async def async_added_to_hass(self) -> None: async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" - del self.config_entry.runtime_data.entity_id_mappings[self.entity_id] + assert self.unique_id + del self.config_entry.runtime_data.unique_id_speaker_mappings[self.unique_id] async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 3350df430f8102..1fb3bb3d5e7420 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -149,7 +149,8 @@ class SonosData: discovery_known: set[str] = field(default_factory=set) boot_counts: dict[str, int] = field(default_factory=dict) mdns_names: dict[str, str] = field(default_factory=dict) - entity_id_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) + # Maps the entity unique id to the associated SonosSpeaker instance. + unique_id_speaker_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) unjoin_data: dict[str, UnjoinData] = field(default_factory=dict) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 96e4d34ddc4fd1..6fb7bf005899c8 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -43,7 +43,12 @@ from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_platform, service +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, + service, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -880,13 +885,28 @@ async def async_browse_media( async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" speakers = [] + + entity_registry = er.async_get(self.hass) for entity_id in group_members: - if speaker := self.config_entry.runtime_data.entity_id_mappings.get( - entity_id + if not (entity_reg_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + if not ( + speaker + := self.config_entry.runtime_data.unique_id_speaker_mappings.get( + entity_reg_entry.unique_id + ) ): - speakers.append(speaker) - else: - raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="speaker_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + speakers.append(speaker) await SonosSpeaker.join_multi( self.hass, self.config_entry, self.speaker, speakers diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index c40f5ccd416490..4fb8037ab6431a 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -195,6 +195,12 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "entity_not_found": { + "message": "Entity {entity_id} not found." + }, + "speaker_not_found": { + "message": "{entity_id} is not a known Sonos speaker." + }, "timeout_join": { "message": "Timeout while waiting for Sonos player to join the group {group_description}" } diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 205f1bb8b5ca16..f48505b49932ee 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( + Condition, ConditionCheckerType, condition_trace_set_result, condition_trace_update_result, @@ -37,13 +38,6 @@ ) -async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate config.""" - return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] - - def sun( hass: HomeAssistant, before: str | None = None, @@ -128,16 +122,41 @@ def sun( return True -def async_condition_from_config(config: ConfigType) -> ConditionCheckerType: - """Wrap action method with sun based condition.""" - before = config.get("before") - after = config.get("after") - before_offset = config.get("before_offset") - after_offset = config.get("after_offset") +class SunCondition(Condition): + """Sun condition.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + self._config = config + self._hass = hass + + @classmethod + async def async_validate_condition_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + + async def async_condition_from_config(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") + + @trace_condition_function + def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Validate time based if-condition.""" + return sun(hass, before, after, before_offset, after_offset) + + return sun_if + + +CONDITIONS: dict[str, type[Condition]] = { + "sun": SunCondition, +} - @trace_condition_function - def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Validate time based if-condition.""" - return sun(hass, before, after, before_offset, after_offset) - return sun_if +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the sun conditions.""" + return CONDITIONS diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index c10a0036b1c072..acf37fe916b92b 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -93,6 +93,8 @@ SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR], SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR], SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR], + SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -119,6 +121,8 @@ SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier, SupportedModels.AIR_PURIFIER_TABLE.value: switchbot.SwitchbotAirPurifier, SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, + SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3, + SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, } diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 82e6e43130b1f2..b207440d796c39 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -367,8 +367,12 @@ async def async_step_init( ), ): int } - if self.config_entry.data.get(CONF_SENSOR_TYPE, "").startswith( - SupportedModels.LOCK + if CONF_SENSOR_TYPE in self.config_entry.data and self.config_entry.data[ + CONF_SENSOR_TYPE + ] in ( + SupportedModels.LOCK, + SupportedModels.LOCK_PRO, + SupportedModels.LOCK_ULTRA, ): options.update( { diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 981b7c75a28a3b..c57b8d467cc62f 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -49,6 +49,8 @@ class SupportedModels(StrEnum): AIR_PURIFIER = "air_purifier" AIR_PURIFIER_TABLE = "air_purifier_table" EVAPORATIVE_HUMIDIFIER = "evaporative_humidifier" + FLOOR_LAMP = "floor_lamp" + STRIP_LIGHT_3 = "strip_light_3" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -77,6 +79,8 @@ class SupportedModels(StrEnum): SwitchbotModel.AIR_PURIFIER: SupportedModels.AIR_PURIFIER, SwitchbotModel.AIR_PURIFIER_TABLE: SupportedModels.AIR_PURIFIER_TABLE, SwitchbotModel.EVAPORATIVE_HUMIDIFIER: SupportedModels.EVAPORATIVE_HUMIDIFIER, + SwitchbotModel.FLOOR_LAMP: SupportedModels.FLOOR_LAMP, + SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -106,6 +110,8 @@ class SupportedModels(StrEnum): SwitchbotModel.AIR_PURIFIER, SwitchbotModel.AIR_PURIFIER_TABLE, SwitchbotModel.EVAPORATIVE_HUMIDIFIER, + SwitchbotModel.FLOOR_LAMP, + SwitchbotModel.STRIP_LIGHT_3, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -120,6 +126,8 @@ class SupportedModels(StrEnum): SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier, SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier, SwitchbotModel.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, + SwitchbotModel.FLOOR_LAMP: switchbot.SwitchbotStripLight3, + SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 498a986e80628c..701a9a659b1f11 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -52,7 +52,13 @@ json_bytes, json_fragment, ) -from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.service import ( + async_get_all_descriptions as async_get_all_service_descriptions, +) +from homeassistant.helpers.trigger import ( + async_get_all_descriptions as async_get_all_trigger_descriptions, + async_subscribe_platform_events as async_subscribe_trigger_platform_events, +) from homeassistant.loader import ( IntegrationNotFound, async_get_integration, @@ -68,9 +74,10 @@ from . import const, decorators, messages from .connection import ActiveConnection -from .messages import construct_result_message +from .messages import construct_event_message, construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" +ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_trigger_descriptions_json" _LOGGER = logging.getLogger(__name__) @@ -96,6 +103,7 @@ def async_register_commands( async_reg(hass, handle_subscribe_bootstrap_integrations) async_reg(hass, handle_subscribe_events) async_reg(hass, handle_subscribe_trigger) + async_reg(hass, handle_subscribe_trigger_platforms) async_reg(hass, handle_test_condition) async_reg(hass, handle_unsubscribe_events) async_reg(hass, handle_validate_config) @@ -493,9 +501,9 @@ def _send_handle_entities_init_response( ) -async def _async_get_all_descriptions_json(hass: HomeAssistant) -> bytes: +async def _async_get_all_service_descriptions_json(hass: HomeAssistant) -> bytes: """Return JSON of descriptions (i.e. user documentation) for all service calls.""" - descriptions = await async_get_all_descriptions(hass) + descriptions = await async_get_all_service_descriptions(hass) if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data: cached_descriptions, cached_json_payload = hass.data[ ALL_SERVICE_DESCRIPTIONS_JSON_CACHE @@ -514,10 +522,57 @@ async def handle_get_services( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get services command.""" - payload = await _async_get_all_descriptions_json(hass) + payload = await _async_get_all_service_descriptions_json(hass) connection.send_message(construct_result_message(msg["id"], payload)) +async def _async_get_all_trigger_descriptions_json(hass: HomeAssistant) -> bytes: + """Return JSON of descriptions (i.e. user documentation) for all triggers.""" + descriptions = await async_get_all_trigger_descriptions(hass) + if ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE in hass.data: + cached_descriptions, cached_json_payload = hass.data[ + ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE + ] + # If the descriptions are the same, return the cached JSON payload + if cached_descriptions is descriptions: + return cast(bytes, cached_json_payload) + json_payload = json_bytes( + { + trigger: description + for trigger, description in descriptions.items() + if description is not None + } + ) + hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) + return json_payload + + +@decorators.websocket_command({vol.Required("type"): "trigger_platforms/subscribe"}) +@decorators.async_response +async def handle_subscribe_trigger_platforms( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe triggers command.""" + + async def on_new_triggers(new_triggers: set[str]) -> None: + """Forward new triggers to websocket.""" + descriptions = await async_get_all_trigger_descriptions(hass) + new_trigger_descriptions = {} + for trigger in new_triggers: + if (description := descriptions[trigger]) is not None: + new_trigger_descriptions[trigger] = description + if not new_trigger_descriptions: + return + connection.send_event(msg["id"], new_trigger_descriptions) + + connection.subscriptions[msg["id"]] = async_subscribe_trigger_platform_events( + hass, on_new_triggers + ) + connection.send_result(msg["id"]) + triggers_json = await _async_get_all_trigger_descriptions_json(hass) + connection.send_message(construct_event_message(msg["id"], triggers_json)) + + @callback @decorators.websocket_command({vol.Required("type"): "get_config"}) def handle_get_config( diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 6ae7de2c4b7e7d..88d29f243d533a 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -109,6 +109,19 @@ def event_message(iden: int, event: Any) -> dict[str, Any]: return {"id": iden, "type": "event", "event": event} +def construct_event_message(iden: int, event: bytes) -> bytes: + """Construct an event message JSON.""" + return b"".join( + ( + b'{"id":', + str(iden).encode(), + b',"type":"event","event":', + event, + b"}", + ) + ) + + def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes: """Return an event message. diff --git a/homeassistant/const.py b/homeassistant/const.py index 0abdcd59b77ab8..e6da8ba4a69734 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 7 +MINOR_VERSION: Final = 8 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index fbdf2dce7b158d..86b8a1002f1c91 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -2,6 +2,7 @@ from __future__ import annotations +import abc import asyncio from collections import deque from collections.abc import Callable, Container, Generator @@ -75,7 +76,7 @@ FROM_CONFIG_FORMAT = "{}_from_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config" -_PLATFORM_ALIASES = { +_PLATFORM_ALIASES: dict[str | None, str | None] = { "and": None, "device": "device_automation", "not": None, @@ -93,20 +94,33 @@ ) -class ConditionProtocol(Protocol): - """Define the format of condition modules.""" +class Condition(abc.ABC): + """Condition class.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + @classmethod + @abc.abstractmethod async def async_validate_condition_config( - self, hass: HomeAssistant, config: ConfigType + cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - def async_condition_from_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConditionCheckerType: + @abc.abstractmethod + async def async_condition_from_config(self) -> ConditionCheckerType: """Evaluate state based on configuration.""" +class ConditionProtocol(Protocol): + """Define the format of condition modules.""" + + async def async_get_conditions( + self, hass: HomeAssistant + ) -> dict[str, type[Condition]]: + """Return the conditions provided by this integration.""" + + type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] @@ -179,7 +193,9 @@ def wrapper(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool | N async def _async_get_condition_platform( hass: HomeAssistant, config: ConfigType ) -> ConditionProtocol | None: - platform = config[CONF_CONDITION] + condition_key: str = config[CONF_CONDITION] + platform_and_sub_type = condition_key.partition(".") + platform: str | None = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) if platform is None: return None @@ -187,7 +203,7 @@ async def _async_get_condition_platform( integration = await async_get_integration(hass, platform) except IntegrationNotFound: raise HomeAssistantError( - f'Invalid condition "{platform}" specified {config}' + f'Invalid condition "{condition_key}" specified {config}' ) from None try: return await integration.async_get_platform("condition") @@ -205,19 +221,6 @@ async def async_from_config( Should be run on the event loop. """ - factory: Any = None - platform = await _async_get_condition_platform(hass, config) - - if platform is None: - condition = config.get(CONF_CONDITION) - for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) - - if factory: - break - else: - factory = platform.async_condition_from_config - # Check if condition is not enabled if CONF_ENABLED in config: enabled = config[CONF_ENABLED] @@ -239,6 +242,21 @@ def disabled_condition( return disabled_condition + condition: str = config[CONF_CONDITION] + factory: Any = None + platform = await _async_get_condition_platform(hass, config) + + if platform is not None: + condition_descriptors = await platform.async_get_conditions(hass) + condition_instance = condition_descriptors[condition](hass, config) + return await condition_instance.async_condition_from_config() + + for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): + factory = getattr(sys.modules[__name__], fmt.format(condition), None) + + if factory: + break + # Check for partials to properly determine if coroutine function check_factory = factory while isinstance(check_factory, ft.partial): @@ -936,7 +954,7 @@ async def async_validate_condition_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - condition = config[CONF_CONDITION] + condition: str = config[CONF_CONDITION] if condition in ("and", "not", "or"): conditions = [] for sub_cond in config["conditions"]: @@ -947,7 +965,10 @@ async def async_validate_condition_config( platform = await _async_get_condition_platform(hass, config) if platform is not None: - return await platform.async_validate_condition_config(hass, config) + condition_descriptors = await platform.async_get_conditions(hass) + if not (condition_class := condition_descriptors.get(condition)): + raise vol.Invalid(f"Invalid condition '{condition}' specified") + return await condition_class.async_validate_condition_config(hass, config) if platform is None and condition in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 853b5aaf8129e7..66d1560ac70a2a 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -5,11 +5,11 @@ import abc import asyncio from collections import defaultdict -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field import functools import logging -from typing import Any, Protocol, TypedDict, cast +from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast import voluptuous as vol @@ -29,13 +29,24 @@ is_callback, ) from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_integration, + async_get_integrations, +) from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey +from homeassistant.util.yaml import load_yaml_dict +from homeassistant.util.yaml.loader import JSON_TYPE +from . import config_validation as cv +from .integration_platform import async_process_integration_platforms from .template import Template from .typing import ConfigType, TemplateVarsType +_LOGGER = logging.getLogger(__name__) + _PLATFORM_ALIASES = { "device": "device_automation", "event": "homeassistant", @@ -49,6 +60,99 @@ "pluggable_actions" ) +TRIGGER_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey( + "trigger_description_cache" +) +TRIGGER_PLATFORM_SUBSCRIPTIONS: HassKey[ + list[Callable[[set[str]], Coroutine[Any, Any, None]]] +] = HassKey("trigger_platform_subscriptions") +TRIGGERS: HassKey[dict[str, str]] = HassKey("triggers") + + +# Basic schemas to sanity check the trigger descriptions, +# full validation is done by hassfest.triggers +_FIELD_SCHEMA = vol.Schema( + {}, + extra=vol.ALLOW_EXTRA, +) + +_TRIGGER_SCHEMA = vol.Schema( + { + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + + +def starts_with_dot(key: str) -> str: + """Check if key starts with dot.""" + if not key.startswith("."): + raise vol.Invalid("Key does not start with .") + return key + + +_TRIGGERS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, starts_with_dot)): object, + cv.slug: vol.Any(None, _TRIGGER_SCHEMA), + } +) + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the trigger helper.""" + hass.data[TRIGGER_DESCRIPTION_CACHE] = {} + hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] = [] + hass.data[TRIGGERS] = {} + await async_process_integration_platforms( + hass, "trigger", _register_trigger_platform, wait_for_platforms=True + ) + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[set[str]], Coroutine[Any, Any, None]], +) -> Callable[[], None]: + """Subscribe to trigger platform events.""" + trigger_platform_event_subscriptions = hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] + + def remove_subscription() -> None: + trigger_platform_event_subscriptions.remove(on_event) + + trigger_platform_event_subscriptions.append(on_event) + return remove_subscription + + +async def _register_trigger_platform( + hass: HomeAssistant, integration_domain: str, platform: TriggerProtocol +) -> None: + """Register a trigger platform.""" + + new_triggers: set[str] = set() + + if hasattr(platform, "async_get_triggers"): + for trigger_key in await platform.async_get_triggers(hass): + hass.data[TRIGGERS][trigger_key] = integration_domain + new_triggers.add(trigger_key) + elif hasattr(platform, "async_validate_trigger_config") or hasattr( + platform, "TRIGGER_SCHEMA" + ): + hass.data[TRIGGERS][integration_domain] = integration_domain + new_triggers.add(integration_domain) + else: + _LOGGER.debug( + "Integration %s does not provide trigger support, skipping", + integration_domain, + ) + return + + tasks: list[asyncio.Task[None]] = [ + create_eager_task(listener(new_triggers)) + for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] + ] + await asyncio.gather(*tasks) + class Trigger(abc.ABC): """Trigger class.""" @@ -409,3 +513,107 @@ def remove_triggers() -> None: remove() return remove_triggers + + +def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: + """Load triggers file for an integration.""" + try: + return cast( + JSON_TYPE, + _TRIGGERS_SCHEMA( + load_yaml_dict(str(integration.file_path / "triggers.yaml")) + ), + ) + except FileNotFoundError: + _LOGGER.warning( + "Unable to find triggers.yaml for the %s integration", integration.domain + ) + return {} + except (HomeAssistantError, vol.Invalid) as ex: + _LOGGER.warning( + "Unable to parse triggers.yaml for the %s integration: %s", + integration.domain, + ex, + ) + return {} + + +def _load_triggers_files( + hass: HomeAssistant, integrations: Iterable[Integration] +) -> dict[str, JSON_TYPE]: + """Load trigger files for multiple integrations.""" + return { + integration.domain: _load_triggers_file(hass, integration) + for integration in integrations + } + + +async def async_get_all_descriptions( + hass: HomeAssistant, +) -> dict[str, dict[str, Any] | None]: + """Return descriptions (i.e. user documentation) for all triggers.""" + descriptions_cache = hass.data[TRIGGER_DESCRIPTION_CACHE] + + triggers = hass.data[TRIGGERS] + # See if there are new triggers not seen before. + # Any trigger that we saw before already has an entry in description_cache. + all_triggers = set(triggers) + previous_all_triggers = set(descriptions_cache) + # If the triggers are the same, we can return the cache + if previous_all_triggers == all_triggers: + return descriptions_cache + + # Files we loaded for missing descriptions + new_triggers_descriptions: dict[str, JSON_TYPE] = {} + # We try to avoid making a copy in the event the cache is good, + # but now we must make a copy in case new triggers get added + # while we are loading the missing ones so we do not + # add the new ones to the cache without their descriptions + triggers = triggers.copy() + + if missing_triggers := all_triggers.difference(descriptions_cache): + domains_with_missing_triggers = { + triggers[missing_trigger] for missing_trigger in missing_triggers + } + ints_or_excs = await async_get_integrations(hass, domains_with_missing_triggers) + integrations: list[Integration] = [] + for domain, int_or_exc in ints_or_excs.items(): + if type(int_or_exc) is Integration and int_or_exc.has_triggers: + integrations.append(int_or_exc) + continue + if TYPE_CHECKING: + assert isinstance(int_or_exc, Exception) + _LOGGER.debug( + "Failed to load triggers.yaml for integration: %s", + domain, + exc_info=int_or_exc, + ) + + if integrations: + new_triggers_descriptions = await hass.async_add_executor_job( + _load_triggers_files, hass, integrations + ) + + # Make a copy of the old cache and add missing descriptions to it + new_descriptions_cache = descriptions_cache.copy() + for missing_trigger in missing_triggers: + domain = triggers[missing_trigger] + + if ( + yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr] + missing_trigger + ) + ) is None: + _LOGGER.debug( + "No trigger descriptions found for trigger %s, skipping", + missing_trigger, + ) + new_descriptions_cache[missing_trigger] = None + continue + + description = {"fields": yaml_description.get("fields", {})} + + new_descriptions_cache[missing_trigger] = description + + hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache + return new_descriptions_cache diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6a3061b0d2a2d2..ae3709e383b75b 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -857,15 +857,20 @@ def import_executor(self) -> bool: # True. return self.manifest.get("import_executor", True) + @cached_property + def has_services(self) -> bool: + """Return if the integration has services.""" + return "services.yaml" in self._top_level_files + @cached_property def has_translations(self) -> bool: """Return if the integration has translations.""" return "translations" in self._top_level_files @cached_property - def has_services(self) -> bool: - """Return if the integration has services.""" - return "services.yaml" in self._top_level_files + def has_triggers(self) -> bool: + """Return if the integration has triggers.""" + return "triggers.yaml" in self._top_level_files @property def mqtt(self) -> list[str] | None: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 918b8a0f1fd2ed..725033f814edaa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.4 +home-assistant-frontend==20250625.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pyproject.toml b/pyproject.toml index 87dec7a8429392..d97bf3e18902a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0.dev0" +version = "2025.8.0.dev0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." @@ -450,7 +450,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ "error::sqlalchemy.exc.SAWarning", - "error:usefixtures\\(\\) in .* without arguments has no effect:UserWarning", # pytest + "error:usefixtures\\(\\) in .* without arguments has no effect:UserWarning", # pytest # -- HomeAssistant - aiohttp # Overwrite web.Application to pass a custom default argument to _make_request diff --git a/requirements_all.txt b/requirements_all.txt index 9f775b13102a49..76ef5d07d10454 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.14 +aioamazondevices==3.1.19 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -1171,7 +1171,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.4 +home-assistant-frontend==20250625.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 @@ -1692,7 +1692,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.4 +plugwise==1.7.6 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cbc154c9bb31e..9557e405e986f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.14 +aioamazondevices==3.1.19 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.4 +home-assistant-frontend==20250625.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 @@ -1427,7 +1427,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.4 +plugwise==1.7.6 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 277696c669bc33..05c0d455af6cbc 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -28,6 +28,7 @@ services, ssdp, translations, + triggers, usb, zeroconf, ) @@ -49,6 +50,7 @@ services, ssdp, translations, + triggers, usb, zeroconf, config_flow, # This needs to run last, after translations are processed diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 563fe0edb936ae..6abe338e45b7db 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -120,6 +120,16 @@ def ensure_range_is_sorted(value: dict) -> dict: ) +TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys( + vol.Schema( + { + vol.Optional("trigger"): icon_value_validator, + } + ), + slug_validator=translation_key_validator, +) + + def icon_schema( core_integration: bool, integration_type: str, no_entity_platform: bool ) -> vol.Schema: @@ -164,6 +174,7 @@ def icon_schema_slug(marker: type[vol.Marker]) -> dict[vol.Marker, Any]: vol.Optional("services"): CORE_SERVICE_ICONS_SCHEMA if core_integration else CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA, + vol.Optional("triggers"): TRIGGER_ICONS_SCHEMA, } ) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 0576a5b9b6a36c..a2115ae55915e4 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -81,6 +81,7 @@ # - reasonX should be the name of the invalid dependency "adax": {"adax": {"async-timeout"}, "adax-local": {"async-timeout"}}, "airthings": {"airthings-cloud": {"async-timeout"}}, + "alexa_devices": {"marisa-trie": {"setuptools"}}, "ampio": {"asmog": {"async-timeout"}}, "apache_kafka": {"aiokafka": {"async-timeout"}}, "apple_tv": {"pyatv": {"async-timeout"}}, diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 34c06abb4513f3..913f7df2e7a026 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -416,6 +416,22 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, slug_validator=translation_key_validator, ), + vol.Optional("triggers"): cv.schema_with_slug_keys( + { + vol.Required("name"): translation_value_validator, + vol.Required("description"): translation_value_validator, + vol.Required("description_configured"): translation_value_validator, + vol.Optional("fields"): cv.schema_with_slug_keys( + { + vol.Required("name"): str, + vol.Required("description"): translation_value_validator, + vol.Optional("example"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), + }, + slug_validator=translation_key_validator, + ), vol.Optional("conversation"): { vol.Required("agent"): { vol.Required("done"): translation_value_validator, diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py new file mode 100644 index 00000000000000..ff6654f2789834 --- /dev/null +++ b/script/hassfest/triggers.py @@ -0,0 +1,238 @@ +"""Validate triggers.""" + +from __future__ import annotations + +import contextlib +import json +import pathlib +import re +from typing import Any + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.const import CONF_SELECTOR +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, selector, trigger +from homeassistant.util.yaml import load_yaml_dict + +from .model import Config, Integration + + +def exists(value: Any) -> Any: + """Check if value exists.""" + if value is None: + raise vol.Invalid("Value cannot be None") + return value + + +FIELD_SCHEMA = vol.Schema( + { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, + } +) + +TRIGGER_SCHEMA = vol.Any( + vol.Schema( + { + vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), + } + ), + None, +) + +TRIGGERS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, trigger.starts_with_dot)): object, + cv.slug: TRIGGER_SCHEMA, + } +) + +NON_MIGRATED_INTEGRATIONS = { + "calendar", + "conversation", + "device_automation", + "geo_location", + "homeassistant", + "knx", + "lg_netcast", + "litejet", + "persistent_notification", + "samsungtv", + "sun", + "tag", + "template", + "webhook", + "webostv", + "zone", + "zwave_js", +} + + +def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool: + """Recursively go through a dir and it's children and find the regex.""" + pattern = re.compile(search_pattern) + + for fil in path.glob(glob_pattern): + if not fil.is_file(): + continue + + if pattern.search(fil.read_text()): + return True + + return False + + +def validate_triggers(config: Config, integration: Integration) -> None: # noqa: C901 + """Validate triggers.""" + try: + data = load_yaml_dict(str(integration.path / "triggers.yaml")) + except FileNotFoundError: + # Find if integration uses triggers + has_triggers = grep_dir( + integration.path, + "**/trigger.py", + r"async_attach_trigger|async_get_triggers", + ) + + if has_triggers and integration.domain not in NON_MIGRATED_INTEGRATIONS: + integration.add_error( + "triggers", "Registers triggers but has no triggers.yaml" + ) + return + except HomeAssistantError: + integration.add_error("triggers", "Invalid triggers.yaml") + return + + try: + triggers = TRIGGERS_SCHEMA(data) + except vol.Invalid as err: + integration.add_error( + "triggers", f"Invalid triggers.yaml: {humanize_error(data, err)}" + ) + return + + icons_file = integration.path / "icons.json" + icons = {} + if icons_file.is_file(): + with contextlib.suppress(ValueError): + icons = json.loads(icons_file.read_text()) + trigger_icons = icons.get("triggers", {}) + + # Try loading translation strings + if integration.core: + strings_file = integration.path / "strings.json" + else: + # For custom integrations, use the en.json file + strings_file = integration.path / "translations/en.json" + + strings = {} + if strings_file.is_file(): + with contextlib.suppress(ValueError): + strings = json.loads(strings_file.read_text()) + + error_msg_suffix = "in the translations file" + if not integration.core: + error_msg_suffix = f"and is not {error_msg_suffix}" + + # For each trigger in the integration: + # 1. Check if the trigger description is set, if not, + # check if it's in the strings file else add an error. + # 2. Check if the trigger has an icon set in icons.json. + # raise an error if not., + for trigger_name, trigger_schema in triggers.items(): + if integration.core and trigger_name not in trigger_icons: + # This is enforced for Core integrations only + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no icon in icons.json.", + ) + if trigger_schema is None: + continue + if "name" not in trigger_schema and integration.core: + try: + strings["triggers"][trigger_name]["name"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no name {error_msg_suffix}", + ) + + if "description" not in trigger_schema and integration.core: + try: + strings["triggers"][trigger_name]["description"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no description {error_msg_suffix}", + ) + + # The same check is done for the description in each of the fields of the + # trigger schema. + for field_name, field_schema in trigger_schema.get("fields", {}).items(): + if "fields" in field_schema: + # This is a section + continue + if "name" not in field_schema and integration.core: + try: + strings["triggers"][trigger_name]["fields"][field_name]["name"] + except KeyError: + integration.add_error( + "triggers", + ( + f"Trigger {trigger_name} has a field {field_name} with no " + f"name {error_msg_suffix}" + ), + ) + + if "description" not in field_schema and integration.core: + try: + strings["triggers"][trigger_name]["fields"][field_name][ + "description" + ] + except KeyError: + integration.add_error( + "triggers", + ( + f"Trigger {trigger_name} has a field {field_name} with no " + f"description {error_msg_suffix}" + ), + ) + + if "selector" in field_schema: + with contextlib.suppress(KeyError): + translation_key = field_schema["selector"]["select"][ + "translation_key" + ] + try: + strings["selector"][translation_key] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file", + ) + + # The same check is done for the description in each of the sections of the + # trigger schema. + for section_name, section_schema in trigger_schema.get("fields", {}).items(): + if "fields" not in section_schema: + # This is not a section + continue + if "name" not in section_schema and integration.core: + try: + strings["triggers"][trigger_name]["sections"][section_name]["name"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has a section {section_name} with no name {error_msg_suffix}", + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle dependencies for integrations.""" + # check triggers.yaml is valid + for integration in integrations.values(): + validate_triggers(config, integration) diff --git a/tests/common.py b/tests/common.py index 40d6e4d79d3510..ff64dcb33a7454 100644 --- a/tests/common.py +++ b/tests/common.py @@ -87,6 +87,7 @@ restore_state as rs, storage, translation, + trigger, ) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -295,6 +296,7 @@ def async_create_task_internal(coroutine, name=None, eager_start=True): # Load the registries entity.async_setup(hass) loader.async_setup(hass) + await trigger.async_setup(hass) # setup translation cache instead of calling translation.async_setup(hass) hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache( diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index 48e4cc139f3d14..a94a03b95a031b 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -15,6 +15,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import MockSoCo, group_speakers, ungroup_speakers @@ -85,6 +86,31 @@ async def test_media_player_join_bad_entity( assert "media_player.bad_entity" in str(excinfo.value) +async def test_media_player_join_entity_no_speaker( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + entity_registry: er.EntityRegistry, +) -> None: + """Test error handling of joining with no associated speaker.""" + + bad_media_player = entity_registry.async_get_or_create( + "media_player", "demo", "1234" + ) + + # Ensure an error is raised if the entity does not have a speaker + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_JOIN, + { + "entity_id": "media_player.living_room", + "group_members": bad_media_player.entity_id, + }, + blocking=True, + ) + assert bad_media_player.entity_id in str(excinfo.value) + + @asynccontextmanager async def instant_timeout(*args, **kwargs) -> None: """Mock a timeout error.""" diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 98e576e4fe56f4..d64ee2d7a73405 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -941,3 +941,61 @@ def make_advertisement( connectable=True, tx_power=-127, ) + + +STRIP_LIGHT_3_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Strip Light 3", + manufacturer_data={ + 2409: b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb1" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Strip Light 3", + manufacturer_data={ + 2409: b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb1" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Strip Light 3"), + time=0, + connectable=True, + tx_power=-127, +) + + +FLOOR_LAMP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Floor Lamp", + manufacturer_data={ + 2409: b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb0" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Floor Lamp", + manufacturer_data={ + 2409: b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb0" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Floor Lamp"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py index 6629de0150e2f3..718d7aecf960c8 100644 --- a/tests/components/switchbot/test_light.py +++ b/tests/components/switchbot/test_light.py @@ -7,6 +7,7 @@ import pytest from switchbot.devices.device import SwitchbotOperationError +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -20,7 +21,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import BULB_SERVICE_INFO, CEILING_LIGHT_SERVICE_INFO, WOSTRIP_SERVICE_INFO +from . import ( + BULB_SERVICE_INFO, + CEILING_LIGHT_SERVICE_INFO, + FLOOR_LAMP_SERVICE_INFO, + STRIP_LIGHT_3_SERVICE_INFO, + WOSTRIP_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -71,9 +78,9 @@ SET_COLOR_TEMP_PARAMETERS, ( SERVICE_TURN_ON, - {ATTR_EFFECT: "Breathing"}, + {ATTR_EFFECT: "breathing"}, "set_effect", - ("Breathing",), + ("breathing",), ), ], ) @@ -95,9 +102,25 @@ SET_RGB_PARAMETERS, ( SERVICE_TURN_ON, - {ATTR_EFFECT: "Halloween"}, + {ATTR_EFFECT: "halloween"}, "set_effect", - ("Halloween",), + ("halloween",), + ), + ], +) +FLOOR_LAMP_PARAMETERS = ( + COMMON_PARAMETERS, + [ + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_RGB_PARAMETERS, + SET_COLOR_TEMP_PARAMETERS, + ( + SERVICE_TURN_ON, + {ATTR_EFFECT: "halloween"}, + "set_effect", + ("halloween",), ), ], ) @@ -317,3 +340,91 @@ async def test_strip_light_services_exception( {**service_data, ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO), + ("floor_lamp", FLOOR_LAMP_SERVICE_INFO), + ], +) +@pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS) +async def test_floor_lamp_services( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot floor lamp services.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + entity_id = "light.test_name" + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotStripLight3", + **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO), + ("floor_lamp", FLOOR_LAMP_SERVICE_INFO), + ], +) +@pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS) +async def test_floor_lamp_services_exception( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot floor lamp services with exception.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + entity_id = "light.test_name" + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotStripLight3", + **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 6e4fa34ed26c13..bfb8c917f71063 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy +import io import logging from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -19,6 +20,7 @@ ) from homeassistant.components.websocket_api.commands import ( ALL_SERVICE_DESCRIPTIONS_JSON_CACHE, + ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE, ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.config_entries import ConfigEntryState @@ -28,9 +30,10 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.loader import async_get_integration +from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util.json import json_loads +from homeassistant.util.yaml.loader import parse_yaml from tests.common import ( MockConfigEntry, @@ -707,6 +710,91 @@ async def test_get_services( assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_triggers", return_value=True) +async def test_subscribe_triggers( + mock_has_triggers: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test trigger_platforms/subscribe command.""" + sun_trigger_descriptions = """ + sun: {} + """ + tag_trigger_descriptions = """ + tag: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/triggers.yaml"): + trigger_descriptions = sun_trigger_descriptions + elif fname.endswith("tag/triggers.yaml"): + trigger_descriptions = tag_trigger_descriptions + else: + raise FileNotFoundError + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + assert await async_setup_component(hass, "sun", {}) + assert await async_setup_component(hass, "system_health", {}) + await hass.async_block_till_done() + + assert ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE not in hass.data + + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + + # Test start subscription with initial event + msg = await websocket_client.receive_json() + assert msg == {"id": 1, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == {"event": {"sun": {"fields": {}}}, "id": 1, "type": "event"} + + old_cache = hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] + + # Test we receive an event when a new platform is loaded, if it has descriptions + assert await async_setup_component(hass, "calendar", {}) + assert await async_setup_component(hass, "tag", {}) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg == { + "event": {"tag": {"fields": {}}}, + "id": 1, + "type": "event", + } + + # Initiate a second subscription to check the cache is updated because of the new + # trigger + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 2, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"sun": {"fields": {}}, "tag": {"fields": {}}}, + "id": 2, + "type": "event", + } + + assert hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] is not old_cache + + # Initiate a third subscription to check the cache is not updated because no new + # trigger was added + old_cache = hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 3, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"sun": {"fields": {}}, "tag": {"fields": {}}}, + "id": 3, + "type": "event", + } + + assert hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] is old_cache + + async def test_get_config( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 7285301f12bf96..246afcb3022339 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2,7 +2,7 @@ from datetime import timedelta from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from freezegun import freeze_time import pytest @@ -26,9 +26,12 @@ trace, ) from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from tests.common import MockModule, mock_integration, mock_platform + def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. @@ -2251,15 +2254,78 @@ async def test_trigger(hass: HomeAssistant) -> None: assert test(hass, {"trigger": {"id": "123456"}}) -async def test_platform_async_validate_condition_config(hass: HomeAssistant) -> None: - """Test platform.async_validate_condition_config will be called if it exists.""" +async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: + """Test platform.async_get_conditions will be called if it exists.""" config = {CONF_DEVICE_ID: "test", CONF_DOMAIN: "test", CONF_CONDITION: "device"} with patch( - "homeassistant.components.device_automation.condition.async_validate_condition_config", - AsyncMock(), - ) as device_automation_validate_condition_mock: + "homeassistant.components.device_automation.condition.async_get_conditions", + AsyncMock(return_value={"device": AsyncMock()}), + ) as device_automation_async_get_conditions_mock: await condition.async_validate_condition_config(hass, config) - device_automation_validate_condition_mock.assert_awaited() + device_automation_async_get_conditions_mock.assert_awaited() + + +async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: + """Test a condition platform with multiple conditions.""" + + class MockCondition(condition.Condition): + """Mock condition.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + + @classmethod + async def async_validate_condition_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config + + class MockCondition1(MockCondition): + """Mock condition 1.""" + + async def async_condition_from_config(self) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + return lambda hass, vars: True + + class MockCondition2(MockCondition): + """Mock condition 2.""" + + async def async_condition_from_config(self) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + return lambda hass, vars: False + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[condition.Condition]]: + return { + "test": MockCondition1, + "test.cond_2": MockCondition2, + } + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=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 + with pytest.raises( + vol.Invalid, match="Invalid condition 'test.unknown_cond' specified" + ): + await condition.async_validate_condition_config(hass, config_3) + + cond_func = await condition.async_from_config(hass, config_1) + assert cond_func(hass, {}) is True + + cond_func = await condition.async_from_config(hass, config_2) + assert cond_func(hass, {}) is False + + with pytest.raises(KeyError): + await condition.async_from_config(hass, config_3) @pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index f5a2b549f892d0..27cde92d14ff24 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1,10 +1,15 @@ """The tests for the trigger helper.""" +import io from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest +from pytest_unordered import unordered import voluptuous as vol +from homeassistant.components.sun import DOMAIN as DOMAIN_SUN +from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH +from homeassistant.components.tag import DOMAIN as DOMAIN_TAG from homeassistant.core import ( CALLBACK_TYPE, Context, @@ -12,6 +17,8 @@ ServiceCall, callback, ) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import trigger from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, PluggableAction, @@ -23,9 +30,11 @@ async_validate_trigger_config, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util.yaml.loader import parse_yaml -from tests.common import MockModule, mock_integration, mock_platform +from tests.common import MockModule, MockPlatform, mock_integration, mock_platform async def test_bad_trigger_platform(hass: HomeAssistant) -> None: @@ -519,3 +528,213 @@ def cb_action(*args): with pytest.raises(KeyError): await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb) + + +@pytest.mark.parametrize( + "sun_trigger_descriptions", + [ + """ + sun: + fields: + event: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + offset: + selector: + time: null + """, + """ + .anchor: &anchor + - sunrise + - sunset + sun: + fields: + event: + example: sunrise + selector: + select: + options: *anchor + offset: + selector: + time: null + """, + ], +) +async def test_async_get_all_descriptions( + hass: HomeAssistant, sun_trigger_descriptions: str +) -> None: + """Test async_get_all_descriptions.""" + tag_trigger_descriptions = """ + tag: {} + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/triggers.yaml"): + trigger_descriptions = sun_trigger_descriptions + elif fname.endswith("tag/triggers.yaml"): + trigger_descriptions = tag_trigger_descriptions + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "homeassistant.helpers.trigger._load_triggers_files", + side_effect=trigger._load_triggers_files, + ) as proxy_load_triggers_files, + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + # Test we only load triggers.yaml for integrations with triggers, + # system_health has no triggers + assert proxy_load_triggers_files.mock_calls[0][1][1] == unordered( + [ + await async_get_integration(hass, DOMAIN_SUN), + ] + ) + + # system_health does not have triggers and should not be in descriptions + assert descriptions == { + DOMAIN_SUN: { + "fields": { + "event": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "offset": {"selector": {"time": None}}, + } + } + } + + # Verify the cache returns the same object + assert await trigger.async_get_all_descriptions(hass) is descriptions + + # Load the tag integration and check a new cache object is created + assert await async_setup_component(hass, DOMAIN_TAG, {}) + await hass.async_block_till_done() + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + new_descriptions = await trigger.async_get_all_descriptions(hass) + assert new_descriptions is not descriptions + assert new_descriptions == { + DOMAIN_SUN: { + "fields": { + "event": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "offset": {"selector": {"time": None}}, + } + }, + DOMAIN_TAG: { + "fields": {}, + }, + } + + # Verify the cache returns the same object + assert await trigger.async_get_all_descriptions(hass) is new_descriptions + + +@pytest.mark.parametrize( + ("yaml_error", "expected_message"), + [ + ( + FileNotFoundError("Blah"), + "Unable to find triggers.yaml for the sun integration", + ), + ( + HomeAssistantError("Test error"), + "Unable to parse triggers.yaml for the sun integration: Test error", + ), + ], +) +async def test_async_get_all_descriptions_with_yaml_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + yaml_error: Exception, + expected_message: str, +) -> None: + """Test async_get_all_descriptions.""" + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml_dict(fname, secrets=None): + raise yaml_error + + with ( + patch( + "homeassistant.helpers.trigger.load_yaml_dict", + side_effect=_load_yaml_dict, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert expected_message in caplog.text + + +async def test_async_get_all_descriptions_with_bad_description( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_get_all_descriptions.""" + sun_service_descriptions = """ + sun: + fields: not_a_dict + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + with io.StringIO(sun_service_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert ( + "Unable to parse triggers.yaml for the sun integration: " + "expected a dictionary for dictionary value @ data['sun']['fields']" + ) in caplog.text + + +async def test_invalid_trigger_platform( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid trigger platform.""" + mock_integration(hass, MockModule("test", async_setup=AsyncMock(return_value=True))) + mock_platform(hass, "test.trigger", MockPlatform()) + + await async_setup_component(hass, "test", {}) + + assert "Integration test does not provide trigger support, skipping" in caplog.text