diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 436918ae772b6..21580b8728692 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.6"] + "requirements": ["py-aosmith==1.0.8"] } diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 4152fb5ee2d50..db263451f0b78 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -1,4 +1,5 @@ """Intents for the client integration.""" + from __future__ import annotations import voluptuous as vol @@ -36,24 +37,34 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse if not entities: raise intent.IntentHandleError("No climate entities") - if "area" in slots: - # Filter by area - area_name = slots["area"]["value"] + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + entity_text: str | None = name_slot.get("text") + + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + if area_id: + # Filter by area and optionally name + area_name = area_slot.get("text") for maybe_climate in intent.async_match_states( - hass, area_name=area_name, domains=[DOMAIN] + hass, name=entity_name, area_name=area_id, domains=[DOMAIN] ): climate_state = maybe_climate break if climate_state is None: - raise intent.IntentHandleError(f"No climate entity in area {area_name}") + raise intent.NoStatesMatchedError( + name=entity_text or entity_name, + area=area_name or area_id, + domains={DOMAIN}, + device_classes=None, + ) climate_entity = component.get_entity(climate_state.entity_id) - elif "name" in slots: + elif entity_name: # Filter by name - entity_name = slots["name"]["value"] - for maybe_climate in intent.async_match_states( hass, name=entity_name, domains=[DOMAIN] ): @@ -61,7 +72,12 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse break if climate_state is None: - raise intent.IntentHandleError(f"No climate entity named {entity_name}") + raise intent.NoStatesMatchedError( + name=entity_name, + area=None, + domains={DOMAIN}, + device_classes=None, + ) climate_entity = component.get_entity(climate_state.entity_id) else: diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 4f22ee6891025..ff6d5bdb18b8c 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioelectricitymaps"], - "requirements": ["aioelectricitymaps==0.3.1"] + "requirements": ["aioelectricitymaps==0.4.0"] } diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index fb33d87e107fa..cd371ff063014 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -223,22 +223,22 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu # Check if a trigger matched if isinstance(result, SentenceTriggerResult): # Gather callback responses in parallel - trigger_responses = await asyncio.gather( - *( - self._trigger_sentences[trigger_id].callback( - result.sentence, trigger_result - ) - for trigger_id, trigger_result in result.matched_triggers.items() + trigger_callbacks = [ + self._trigger_sentences[trigger_id].callback( + result.sentence, trigger_result ) - ) + for trigger_id, trigger_result in result.matched_triggers.items() + ] # Use last non-empty result as response. # # There may be multiple copies of a trigger running when editing in # the UI, so it's critical that we filter out empty responses here. response_text: str | None = None - for trigger_response in trigger_responses: - response_text = response_text or trigger_response + for trigger_future in asyncio.as_completed(trigger_callbacks): + if trigger_response := await trigger_future: + response_text = trigger_response + break # Convert to conversation result response = intent.IntentResponse(language=language) @@ -316,6 +316,20 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu ), conversation_id, ) + except intent.DuplicateNamesMatchedError as duplicate_names_error: + # Intent was valid, but two or more entities with the same name matched. + ( + error_response_type, + error_response_args, + ) = _get_duplicate_names_matched_response(duplicate_names_error) + return _make_error_result( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), + conversation_id, + ) except intent.IntentHandleError: # Intent was valid and entities matched constraints, but an error # occurred during handling. @@ -724,7 +738,12 @@ def _make_slot_lists(self) -> dict[str, SlotList]: if async_should_expose(self.hass, DOMAIN, state.entity_id) ] - # Gather exposed entity names + # Gather exposed entity names. + # + # NOTE: We do not pass entity ids in here because multiple entities may + # have the same name. The intent matcher doesn't gather all matching + # values for a list, just the first. So we will need to match by name no + # matter what. entity_names = [] for state in states: # Checked against "requires_context" and "excludes_context" in hassil @@ -740,7 +759,7 @@ def _make_slot_lists(self) -> dict[str, SlotList]: if not entity: # Default name - entity_names.append((state.name, state.entity_id, context)) + entity_names.append((state.name, state.name, context)) continue if entity.aliases: @@ -748,12 +767,15 @@ def _make_slot_lists(self) -> dict[str, SlotList]: if not alias.strip(): continue - entity_names.append((alias, state.entity_id, context)) + entity_names.append((alias, alias, context)) # Default name - entity_names.append((state.name, state.entity_id, context)) + entity_names.append((state.name, state.name, context)) - # Expose all areas + # Expose all areas. + # + # We pass in area id here with the expectation that no two areas will + # share the same name or alias. areas = ar.async_get(self.hass) area_names = [] for area in areas.async_list_areas(): @@ -984,6 +1006,20 @@ def _get_no_states_matched_response( return ErrorKey.NO_INTENT, {} +def _get_duplicate_names_matched_response( + duplicate_names_error: intent.DuplicateNamesMatchedError, +) -> tuple[ErrorKey, dict[str, Any]]: + """Return key and template arguments for error when intent returns duplicate matches.""" + + if duplicate_names_error.area: + return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, { + "entity": duplicate_names_error.name, + "area": duplicate_names_error.area, + } + + return ErrorKey.DUPLICATE_ENTITIES, {"entity": duplicate_names_error.name} + + def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Sequence): diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 34760ea6acabe..3fcb2b3211e28 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.8", "deebot-client==5.1.0"] + "requirements": ["py-sucks==0.9.8", "deebot-client==5.1.1"] } diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index d3dfe0331ef5a..175960ab57d4f 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2024.2.0"] + "requirements": ["aioecowitt==2024.2.1"] } diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 9d32ba98e9204..0c9bb44d06a27 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.17"] + "requirements": ["evohome-async==0.4.18"] } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d998871a60bdf..21f4df7956800 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==20240207.0"] + "requirements": ["home-assistant-frontend==20240207.1"] } diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json index 6e9503e0243aa..421222bb81039 100644 --- a/homeassistant/components/geonetnz_volcano/manifest.json +++ b/homeassistant/components/geonetnz_volcano/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_volcano"], - "requirements": ["aio-geojson-geonetnz-volcano==0.8"] + "requirements": ["aio-geojson-geonetnz-volcano==0.9"] } diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index ddaebcbf2a7b9..8d78c878cfa7d 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -506,7 +506,6 @@ async def update_hass_api( options = { "ssl": CONF_SSL_CERTIFICATE in http_config, "port": port, - "watchdog": True, "refresh_token": refresh_token.token, } diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index efd06ba2905a1..6bc6169c68c9f 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -7,6 +7,7 @@ from aiohttp import ClientConnectionError from aiosomecomfort import ( + APIRateLimited, AuthError, ConnectionError as AscConnectionError, SomeComfortError, @@ -505,10 +506,11 @@ async def _login() -> None: await self._device.refresh() except ( + asyncio.TimeoutError, + AscConnectionError, + APIRateLimited, AuthError, ClientConnectionError, - AscConnectionError, - asyncio.TimeoutError, ): self._retry += 1 self._attr_available = self._retry <= RETRY @@ -524,7 +526,12 @@ async def _login() -> None: await _login() return - except (AscConnectionError, ClientConnectionError, asyncio.TimeoutError): + except ( + asyncio.TimeoutError, + AscConnectionError, + APIRateLimited, + ClientConnectionError, + ): self._retry += 1 self._attr_available = self._retry <= RETRY return diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 5756b78b4de24..e960b5616cba2 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -1,4 +1,5 @@ """The Intent integration.""" + from __future__ import annotations import logging @@ -155,16 +156,18 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse slots = self.async_validate_slots(intent_obj.slots) # Entity name to match - name: str | None = slots.get("name", {}).get("value") + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + entity_text: str | None = name_slot.get("text") # Look up area first to fail early - area_name = slots.get("area", {}).get("value") + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + area_name = area_slot.get("text") area: ar.AreaEntry | None = None - if area_name is not None: + if area_id is not None: areas = ar.async_get(hass) - area = areas.async_get_area(area_name) or areas.async_get_area_by_name( - area_name - ) + area = areas.async_get_area(area_id) if area is None: raise intent.IntentHandleError(f"No area named {area_name}") @@ -186,7 +189,7 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse states = list( intent.async_match_states( hass, - name=name, + name=entity_name, area=area, domains=domains, device_classes=device_classes, @@ -197,13 +200,20 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse _LOGGER.debug( "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s", len(states), - name, + entity_name, area, domains, device_classes, intent_obj.assistant, ) + if entity_name and (len(states) > 1): + # Multiple entities matched for the same name + raise intent.DuplicateNamesMatchedError( + name=entity_text or entity_name, + area=area_name or area_id, + ) + # Create response response = intent_obj.create_response() response.response_type = intent.IntentResponseType.QUERY_ANSWER diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index ee07881a01ed1..05e06d819f154 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -16,5 +16,5 @@ "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.10"] + "requirements": ["PyMicroBot==0.0.12"] } diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 5690996841d16..6d7d437a2068c 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -52,11 +52,27 @@ def register_platform_handler( async def setup_nodes(self) -> None: """Set up all existing nodes and subscribe to new nodes.""" + initialized_nodes: set[int] = set() for node in self.matter_client.get_nodes(): + if not node.available: + # ignore un-initialized nodes at startup + # catch them later when they become available. + continue + initialized_nodes.add(node.node_id) self._setup_node(node) def node_added_callback(event: EventType, node: MatterNode) -> None: """Handle node added event.""" + initialized_nodes.add(node.node_id) + self._setup_node(node) + + def node_updated_callback(event: EventType, node: MatterNode) -> None: + """Handle node updated event.""" + if node.node_id in initialized_nodes: + return + if not node.available: + return + initialized_nodes.add(node.node_id) self._setup_node(node) def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None: @@ -116,6 +132,11 @@ def node_removed_callback(event: EventType, node_id: int) -> None: callback=node_added_callback, event_filter=EventType.NODE_ADDED ) ) + self.config_entry.async_on_unload( + self.matter_client.subscribe_events( + callback=node_updated_callback, event_filter=EventType.NODE_UPDATED + ) + ) def _setup_node(self, node: MatterNode) -> None: """Set up an node.""" diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 61535d990db22..5c3f65d903ca5 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -129,6 +129,9 @@ async def async_will_remove_from_hass(self) -> None: async def async_update(self) -> None: """Call when the entity needs to be updated.""" + if not self._endpoint.node.available: + # skip poll when the node is not (yet) available + return # manually poll/refresh the primary value await self.matter_client.refresh_attribute( self._endpoint.node.node_id, diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index d3d0568342e1e..801704c25c5a1 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.4.1"] + "requirements": ["python-matter-server==5.5.0"] } diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 0f674d4d0df49..1151a5f1f013d 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -186,7 +186,7 @@ ] ), vol.Optional(CONF_STRUCTURE): cv.string, - vol.Optional(CONF_SCALE, default=1): cv.positive_float, + vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), vol.Optional(CONF_PRECISION): cv.positive_int, vol.Optional( @@ -241,8 +241,8 @@ { vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, - vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_float, - vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_float, + vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float), + vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float), vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, @@ -342,8 +342,8 @@ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, - vol.Optional(CONF_MIN_VALUE): cv.positive_float, - vol.Optional(CONF_MAX_VALUE): cv.positive_float, + vol.Optional(CONF_MIN_VALUE): vol.Coerce(float), + vol.Optional(CONF_MAX_VALUE): vol.Coerce(float), vol.Optional(CONF_NAN_VALUE): nan_validator, vol.Optional(CONF_ZERO_SUPPRESS): cv.positive_float, } diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 637478fffd4d0..d31323a27e9c7 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -364,7 +364,9 @@ async def async_update(self, now: datetime | None = None) -> None: # Translate the value received if fan_mode is not None: - self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] + self._attr_fan_mode = self._fan_mode_mapping_from_modbus.get( + int(fan_mode), self._attr_fan_mode + ) # Read the on/off register if defined. If the value in this # register is "OFF", it will take precedence over the value diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b0a8f952b1bcc..d5259ef1fc551 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -7,6 +7,7 @@ Callable, Coroutine, Generator, + Hashable, Iterable, Mapping, ValuesView, @@ -49,6 +50,7 @@ ) from .helpers.frame import report from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType +from .loader import async_suggest_report_issue from .setup import DATA_SETUP_DONE, async_process_deps_reqs, async_setup_component from .util import uuid as uuid_util from .util.decorator import Registry @@ -1124,9 +1126,10 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): - domain -> unique_id -> ConfigEntry """ - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the container.""" super().__init__() + self._hass = hass self._domain_index: dict[str, list[ConfigEntry]] = {} self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {} @@ -1145,8 +1148,27 @@ def __setitem__(self, entry_id: str, entry: ConfigEntry) -> None: data[entry_id] = entry self._domain_index.setdefault(entry.domain, []).append(entry) if entry.unique_id is not None: + unique_id_hash = entry.unique_id + # Guard against integrations using unhashable unique_id + # In HA Core 2024.9, we should remove the guard and instead fail + if not isinstance(entry.unique_id, Hashable): + unique_id_hash = str(entry.unique_id) # type: ignore[unreachable] + report_issue = async_suggest_report_issue( + self._hass, integration_domain=entry.domain + ) + _LOGGER.error( + ( + "Config entry '%s' from integration %s has an invalid unique_id" + " '%s', please %s" + ), + entry.title, + entry.domain, + entry.unique_id, + report_issue, + ) + self._domain_unique_id_index.setdefault(entry.domain, {})[ - entry.unique_id + unique_id_hash ] = entry def _unindex_entry(self, entry_id: str) -> None: @@ -1157,6 +1179,9 @@ def _unindex_entry(self, entry_id: str) -> None: if not self._domain_index[domain]: del self._domain_index[domain] if (unique_id := entry.unique_id) is not None: + # Check type first to avoid expensive isinstance call + if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 + unique_id = str(entry.unique_id) # type: ignore[unreachable] del self._domain_unique_id_index[domain][unique_id] if not self._domain_unique_id_index[domain]: del self._domain_unique_id_index[domain] @@ -1174,6 +1199,9 @@ def get_entry_by_domain_and_unique_id( self, domain: str, unique_id: str ) -> ConfigEntry | None: """Get entry by domain and unique id.""" + # Check type first to avoid expensive isinstance call + if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 + unique_id = str(unique_id) # type: ignore[unreachable] return self._domain_unique_id_index.get(domain, {}).get(unique_id) @@ -1189,7 +1217,7 @@ def __init__(self, hass: HomeAssistant, hass_config: ConfigType) -> None: self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.options = OptionsFlowManager(hass) self._hass_config = hass_config - self._entries = ConfigEntryItems() + self._entries = ConfigEntryItems(hass) self._store = storage.Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) @@ -1314,10 +1342,10 @@ async def async_initialize(self) -> None: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) if config is None: - self._entries = ConfigEntryItems() + self._entries = ConfigEntryItems(self.hass) return - entries: ConfigEntryItems = ConfigEntryItems() + entries: ConfigEntryItems = ConfigEntryItems(self.hass) for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") diff --git a/homeassistant/const.py b/homeassistant/const.py index fb6e8ef896baa..a19ff18d8f3fa 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index fe399659a562d..295246b5e0aa6 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -155,6 +155,17 @@ def __init__( self.device_classes = device_classes +class DuplicateNamesMatchedError(IntentError): + """Error when two or more entities with the same name matched.""" + + def __init__(self, name: str, area: str | None) -> None: + """Initialize error.""" + super().__init__() + + self.name = name + self.area = area + + def _is_device_class( state: State, entity: entity_registry.RegistryEntry | None, @@ -318,8 +329,6 @@ def async_match_states( for state, entity in states_and_entities: if _has_name(state, entity, name): yield state - break - else: # Not filtered by name for state, _entity in states_and_entities: @@ -403,11 +412,11 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: slots = self.async_validate_slots(intent_obj.slots) name_slot = slots.get("name", {}) - entity_id: str | None = name_slot.get("value") - entity_name: str | None = name_slot.get("text") - if entity_id == "all": + entity_name: str | None = name_slot.get("value") + entity_text: str | None = name_slot.get("text") + if entity_name == "all": # Don't match on name if targeting all entities - entity_id = None + entity_name = None # Look up area first to fail early area_slot = slots.get("area", {}) @@ -416,9 +425,7 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: area: area_registry.AreaEntry | None = None if area_id is not None: areas = area_registry.async_get(hass) - area = areas.async_get_area(area_id) or areas.async_get_area_by_name( - area_name - ) + area = areas.async_get_area(area_id) if area is None: raise IntentHandleError(f"No area named {area_name}") @@ -436,7 +443,7 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: states = list( async_match_states( hass, - name=entity_id, + name=entity_name, area=area, domains=domains, device_classes=device_classes, @@ -447,14 +454,24 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: if not states: # No states matched constraints raise NoStatesMatchedError( - name=entity_name or entity_id, + name=entity_text or entity_name, area=area_name or area_id, domains=domains, device_classes=device_classes, ) + if entity_name and (len(states) > 1): + # Multiple entities matched for the same name + raise DuplicateNamesMatchedError( + name=entity_text or entity_name, + area=area_name or area_id, + ) + response = await self.async_handle_states(intent_obj, states, area) + # Make the matched states available in the response + response.async_set_states(matched_states=states, unmatched_states=[]) + return response async def async_handle_states( diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index ab9d5f576feea..be3e04643612c 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -273,7 +273,13 @@ def _validate_placeholders( for key, value in updated_resources.items(): if key not in cached_resources: continue - tuples = list(string.Formatter().parse(value)) + try: + tuples = list(string.Formatter().parse(value)) + except ValueError: + _LOGGER.error( + ("Error while parsing localized (%s) string %s"), language, key + ) + continue updated_placeholders = {tup[1] for tup in tuples if tup[1] is not None} tuples = list(string.Formatter().parse(cached_resources[key])) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e3a82474d8d45..6a9734ad1fafc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.4.0 hass-nabucasa==0.76.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240207.0 +home-assistant-frontend==20240207.1 home-assistant-intents==2024.2.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/pyproject.toml b/pyproject.toml index 6a038aa1c5a56..895519889716a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0" +version = "2024.2.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index a61d360c4e04c..0c084d0b6b97d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -76,7 +76,7 @@ PyMetEireann==2021.8.0 PyMetno==0.11.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.10 +PyMicroBot==0.0.12 # homeassistant.components.nina PyNINA==0.3.3 @@ -173,7 +173,7 @@ aio-geojson-generic-client==0.4 aio-geojson-geonetnz-quakes==0.16 # homeassistant.components.geonetnz_volcano -aio-geojson-geonetnz-volcano==0.8 +aio-geojson-geonetnz-volcano==0.9 # homeassistant.components.nsw_rural_fire_service_feed aio-geojson-nsw-rfs-incidents==0.7 @@ -230,10 +230,10 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.0 +aioecowitt==2024.2.1 # homeassistant.components.co2signal -aioelectricitymaps==0.3.1 +aioelectricitymaps==0.4.0 # homeassistant.components.emonitor aioemonitor==1.0.5 @@ -684,7 +684,7 @@ debugpy==1.8.0 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==5.1.0 +deebot-client==5.1.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -818,7 +818,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.17 +evohome-async==0.4.18 # homeassistant.components.faa_delays faadelays==2023.9.1 @@ -1059,7 +1059,7 @@ hole==0.8.0 holidays==0.42 # homeassistant.components.frontend -home-assistant-frontend==20240207.0 +home-assistant-frontend==20240207.1 # homeassistant.components.conversation home-assistant-intents==2024.2.2 @@ -1579,7 +1579,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.6 +py-aosmith==1.0.8 # homeassistant.components.canary py-canary==0.5.3 @@ -2238,7 +2238,7 @@ python-kasa[speedups]==0.6.2.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.4.1 +python-matter-server==5.5.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67c0775c2f726..de460cbd0c42c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -64,7 +64,7 @@ PyMetEireann==2021.8.0 PyMetno==0.11.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.10 +PyMicroBot==0.0.12 # homeassistant.components.nina PyNINA==0.3.3 @@ -152,7 +152,7 @@ aio-geojson-generic-client==0.4 aio-geojson-geonetnz-quakes==0.16 # homeassistant.components.geonetnz_volcano -aio-geojson-geonetnz-volcano==0.8 +aio-geojson-geonetnz-volcano==0.9 # homeassistant.components.nsw_rural_fire_service_feed aio-geojson-nsw-rfs-incidents==0.7 @@ -209,10 +209,10 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.0 +aioecowitt==2024.2.1 # homeassistant.components.co2signal -aioelectricitymaps==0.3.1 +aioelectricitymaps==0.4.0 # homeassistant.components.emonitor aioemonitor==1.0.5 @@ -559,7 +559,7 @@ dbus-fast==2.21.1 debugpy==1.8.0 # homeassistant.components.ecovacs -deebot-client==5.1.0 +deebot-client==5.1.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -855,7 +855,7 @@ hole==0.8.0 holidays==0.42 # homeassistant.components.frontend -home-assistant-frontend==20240207.0 +home-assistant-frontend==20240207.1 # homeassistant.components.conversation home-assistant-intents==2024.2.2 @@ -1232,7 +1232,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.6 +py-aosmith==1.0.8 # homeassistant.components.canary py-canary==0.5.3 @@ -1711,7 +1711,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter -python-matter-server==5.4.1 +python-matter-server==5.5.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 6473eca1b883b..e4f927597933e 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -1,4 +1,5 @@ """Test climate intents.""" + from collections.abc import Generator from unittest.mock import patch @@ -135,8 +136,10 @@ async def test_get_temperature( # Add climate entities to different areas: # climate_1 => living room # climate_2 => bedroom + # nothing in office living_room_area = area_registry.async_create(name="Living Room") bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") entity_registry.async_update_entity( climate_1.entity_id, area_id=living_room_area.id @@ -158,7 +161,7 @@ async def test_get_temperature( hass, "test", climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": "Bedroom"}}, + {"area": {"value": bedroom_area.name}}, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 @@ -179,6 +182,52 @@ async def test_get_temperature( state = response.matched_states[0] assert state.attributes["current_temperature"] == 22.0 + # Check area with no climate entities + with pytest.raises(intent.NoStatesMatchedError) as error: + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": office_area.name}}, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.NoStatesMatchedError) + assert error.value.name is None + assert error.value.area == office_area.name + assert error.value.domains == {DOMAIN} + assert error.value.device_classes is None + + # Check wrong name + with pytest.raises(intent.NoStatesMatchedError) as error: + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Does not exist"}}, + ) + + assert isinstance(error.value, intent.NoStatesMatchedError) + assert error.value.name == "Does not exist" + assert error.value.area is None + assert error.value.domains == {DOMAIN} + assert error.value.device_classes is None + + # Check wrong name with area + with pytest.raises(intent.NoStatesMatchedError) as error: + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, + ) + + assert isinstance(error.value, intent.NoStatesMatchedError) + assert error.value.name == "Climate 1" + assert error.value.area == bedroom_area.name + assert error.value.domains == {DOMAIN} + assert error.value.device_classes is None + async def test_get_temperature_no_entities( hass: HomeAssistant, @@ -216,19 +265,28 @@ async def test_get_temperature_no_state( climate_1.entity_id, area_id=living_room_area.id ) - with patch("homeassistant.core.StateMachine.get", return_value=None), pytest.raises( - intent.IntentHandleError + with ( + patch("homeassistant.core.StateMachine.get", return_value=None), + pytest.raises(intent.IntentHandleError), ): await intent.async_handle( hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} ) - with patch( - "homeassistant.core.StateMachine.async_all", return_value=[] - ), pytest.raises(intent.IntentHandleError): + with ( + patch("homeassistant.core.StateMachine.async_all", return_value=[]), + pytest.raises(intent.NoStatesMatchedError) as error, + ): await intent.async_handle( hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {"area": {"value": "Living Room"}}, ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.NoStatesMatchedError) + assert error.value.name is None + assert error.value.area == "Living Room" + assert error.value.domains == {DOMAIN} + assert error.value.device_classes is None diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 034bfafc1f585..6af9d197e019e 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1397,7 +1397,7 @@ 'name': dict({ 'name': 'name', 'text': 'my cool light', - 'value': 'light.kitchen', + 'value': 'my cool light', }), }), 'intent': dict({ @@ -1422,7 +1422,7 @@ 'name': dict({ 'name': 'name', 'text': 'my cool light', - 'value': 'light.kitchen', + 'value': 'my cool light', }), }), 'intent': dict({ @@ -1572,7 +1572,7 @@ 'name': dict({ 'name': 'name', 'text': 'test light', - 'value': 'light.demo_1234', + 'value': 'test light', }), }), 'intent': dict({ @@ -1604,7 +1604,7 @@ 'name': dict({ 'name': 'name', 'text': 'test light', - 'value': 'light.demo_1234', + 'value': 'test light', }), }), 'intent': dict({ diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 0cf343a3e209e..4b4f9ade3eb46 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -101,7 +101,7 @@ async def test_exposed_areas( device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") - entity_registry.async_update_entity( + kitchen_light = entity_registry.async_update_entity( kitchen_light.entity_id, device_id=kitchen_device.id ) hass.states.async_set( @@ -109,7 +109,7 @@ async def test_exposed_areas( ) bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") - entity_registry.async_update_entity( + bedroom_light = entity_registry.async_update_entity( bedroom_light.entity_id, area_id=area_bedroom.id ) hass.states.async_set( @@ -206,14 +206,14 @@ async def test_unexposed_entities_skipped( # Both lights are in the kitchen exposed_light = entity_registry.async_get_or_create("light", "demo", "1234") - entity_registry.async_update_entity( + exposed_light = entity_registry.async_update_entity( exposed_light.entity_id, area_id=area_kitchen.id, ) hass.states.async_set(exposed_light.entity_id, "off") unexposed_light = entity_registry.async_get_or_create("light", "demo", "5678") - entity_registry.async_update_entity( + unexposed_light = entity_registry.async_update_entity( unexposed_light.entity_id, area_id=area_kitchen.id, ) @@ -336,7 +336,9 @@ async def test_device_area_context( light_entity = entity_registry.async_get_or_create( "light", "demo", f"{area.name}-light-{i}" ) - entity_registry.async_update_entity(light_entity.entity_id, area_id=area.id) + light_entity = entity_registry.async_update_entity( + light_entity.entity_id, area_id=area.id + ) hass.states.async_set( light_entity.entity_id, "off", @@ -612,6 +614,115 @@ async def test_error_no_intent(hass: HomeAssistant, init_components) -> None: ) +async def test_error_duplicate_names( + hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry +) -> None: + """Test error message when multiple devices have the same name (or alias).""" + kitchen_light_1 = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light_2 = entity_registry.async_get_or_create("light", "demo", "5678") + + # Same name and alias + for light in (kitchen_light_1, kitchen_light_2): + light = entity_registry.async_update_entity( + light.entity_id, + name="kitchen light", + aliases={"overhead light"}, + ) + hass.states.async_set( + light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: light.name}, + ) + + # Check name and alias + for name in ("kitchen light", "overhead light"): + # command + result = await conversation.async_converse( + hass, f"turn on {name}", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == f"Sorry, there are multiple devices called {name}" + ) + + # question + result = await conversation.async_converse( + hass, f"is {name} on?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == f"Sorry, there are multiple devices called {name}" + ) + + +async def test_error_duplicate_names_in_area( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test error message when multiple devices have the same name (or alias).""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + + kitchen_light_1 = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light_2 = entity_registry.async_get_or_create("light", "demo", "5678") + + # Same name and alias + for light in (kitchen_light_1, kitchen_light_2): + light = entity_registry.async_update_entity( + light.entity_id, + name="kitchen light", + area_id=area_kitchen.id, + aliases={"overhead light"}, + ) + hass.states.async_set( + light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: light.name}, + ) + + # Check name and alias + for name in ("kitchen light", "overhead light"): + # command + result = await conversation.async_converse( + hass, f"turn on {name} in {area_kitchen.name}", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == f"Sorry, there are multiple devices called {name} in the {area_kitchen.name} area" + ) + + # question + result = await conversation.async_converse( + hass, f"is {name} on in the {area_kitchen.name}?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == f"Sorry, there are multiple devices called {name} in the {area_kitchen.name} area" + ) + + async def test_no_states_matched_default_error( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: @@ -692,7 +803,7 @@ async def test_empty_aliases( names = slot_lists["name"] assert len(names.values) == 1 - assert names.values[0].value_out == kitchen_light.entity_id + assert names.values[0].value_out == kitchen_light.name assert names.values[0].text_in.text == kitchen_light.name @@ -713,3 +824,191 @@ async def test_all_domains_loaded(hass: HomeAssistant, init_components) -> None: result.response.speech["plain"]["speech"] == "Sorry, I am not aware of any device called test light" ) + + +async def test_same_named_entities_in_different_areas( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that entities with the same name in different areas can be targeted.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") + + # Both lights have the same name, but are in different areas + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, + area_id=area_kitchen.id, + name="overhead light", + ) + hass.states.async_set( + kitchen_light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: kitchen_light.name}, + ) + + bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") + bedroom_light = entity_registry.async_update_entity( + bedroom_light.entity_id, + area_id=area_bedroom.id, + name="overhead light", + ) + hass.states.async_set( + bedroom_light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: bedroom_light.name}, + ) + + # Target kitchen light + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on overhead light in the kitchen", None, Context(), None + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert ( + result.response.intent.slots.get("name", {}).get("value") == kitchen_light.name + ) + assert ( + result.response.intent.slots.get("name", {}).get("text") == kitchen_light.name + ) + assert len(result.response.matched_states) == 1 + assert result.response.matched_states[0].entity_id == kitchen_light.entity_id + assert calls[0].data.get("entity_id") == [kitchen_light.entity_id] + + # Target bedroom light + calls.clear() + result = await conversation.async_converse( + hass, "turn on overhead light in the bedroom", None, Context(), None + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert ( + result.response.intent.slots.get("name", {}).get("value") == bedroom_light.name + ) + assert ( + result.response.intent.slots.get("name", {}).get("text") == bedroom_light.name + ) + assert len(result.response.matched_states) == 1 + assert result.response.matched_states[0].entity_id == bedroom_light.entity_id + assert calls[0].data.get("entity_id") == [bedroom_light.entity_id] + + # Targeting a duplicate name should fail + result = await conversation.async_converse( + hass, "turn on overhead light", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + + # Querying a duplicate name should also fail + result = await conversation.async_converse( + hass, "is the overhead light on?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + + # But we can still ask questions that don't rely on the name + result = await conversation.async_converse( + hass, "how many lights are on?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER + + +async def test_same_aliased_entities_in_different_areas( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that entities with the same alias (but different names) in different areas can be targeted.""" + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") + + # Both lights have the same alias, but are in different areas + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, + area_id=area_kitchen.id, + name="kitchen overhead light", + aliases={"overhead light"}, + ) + hass.states.async_set( + kitchen_light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: kitchen_light.name}, + ) + + bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") + bedroom_light = entity_registry.async_update_entity( + bedroom_light.entity_id, + area_id=area_bedroom.id, + name="bedroom overhead light", + aliases={"overhead light"}, + ) + hass.states.async_set( + bedroom_light.entity_id, + "off", + attributes={ATTR_FRIENDLY_NAME: bedroom_light.name}, + ) + + # Target kitchen light + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on overhead light in the kitchen", None, Context(), None + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots.get("name", {}).get("value") == "overhead light" + assert result.response.intent.slots.get("name", {}).get("text") == "overhead light" + assert len(result.response.matched_states) == 1 + assert result.response.matched_states[0].entity_id == kitchen_light.entity_id + assert calls[0].data.get("entity_id") == [kitchen_light.entity_id] + + # Target bedroom light + calls.clear() + result = await conversation.async_converse( + hass, "turn on overhead light in the bedroom", None, Context(), None + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots.get("name", {}).get("value") == "overhead light" + assert result.response.intent.slots.get("name", {}).get("text") == "overhead light" + assert len(result.response.matched_states) == 1 + assert result.response.matched_states[0].entity_id == bedroom_light.entity_id + assert calls[0].data.get("entity_id") == [bedroom_light.entity_id] + + # Targeting a duplicate alias should fail + result = await conversation.async_converse( + hass, "turn on overhead light", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + + # Querying a duplicate alias should also fail + result = await conversation.async_converse( + hass, "is the overhead light on?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + + # But we can still ask questions that don't rely on the alias + result = await conversation.async_converse( + hass, "how many lights are on?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 26626a04079c3..5853d98b76076 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -1,4 +1,7 @@ """Test conversation triggers.""" + +import logging + import pytest import voluptuous as vol @@ -70,7 +73,7 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None async def test_response(hass: HomeAssistant, setup_comp) -> None: - """Test the firing of events.""" + """Test the conversation response action.""" response = "I'm sorry, Dave. I'm afraid I can't do that" assert await async_setup_component( hass, @@ -100,6 +103,116 @@ async def test_response(hass: HomeAssistant, setup_comp) -> None: assert service_response["response"]["speech"]["plain"]["speech"] == response +async def test_response_same_sentence(hass: HomeAssistant, calls, setup_comp) -> None: + """Test the conversation response action with multiple triggers using the same sentence.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + { + "trigger": { + "id": "trigger1", + "platform": "conversation", + "command": ["test sentence"], + }, + "action": [ + # Add delay so this response will not be the first + {"delay": "0:0:0.100"}, + { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + {"set_conversation_response": "response 2"}, + ], + }, + { + "trigger": { + "id": "trigger2", + "platform": "conversation", + "command": ["test sentence"], + }, + "action": {"set_conversation_response": "response 1"}, + }, + ] + }, + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + {"text": "test sentence"}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + # Should only get first response + assert service_response["response"]["speech"]["plain"]["speech"] == "response 1" + + # Service should still have been called + assert len(calls) == 1 + assert calls[0].data["data"] == { + "alias": None, + "id": "trigger1", + "idx": "0", + "platform": "conversation", + "sentence": "test sentence", + "slots": {}, + "details": {}, + } + + +async def test_response_same_sentence_with_error( + hass: HomeAssistant, calls, setup_comp, caplog: pytest.LogCaptureFixture +) -> None: + """Test the conversation response action with multiple triggers using the same sentence and an error.""" + caplog.set_level(logging.ERROR) + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + { + "trigger": { + "id": "trigger1", + "platform": "conversation", + "command": ["test sentence"], + }, + "action": [ + # Add delay so this will not finish first + {"delay": "0:0:0.100"}, + {"service": "fake_domain.fake_service"}, + ], + }, + { + "trigger": { + "id": "trigger2", + "platform": "conversation", + "command": ["test sentence"], + }, + "action": {"set_conversation_response": "response 1"}, + }, + ] + }, + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + {"text": "test sentence"}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + # Should still get first response + assert service_response["response"]["speech"]["plain"]["speech"] == "response 1" + + # Error should have been logged + assert "Error executing script" in caplog.text + + async def test_subscribe_trigger_does_not_interfere_with_responses( hass: HomeAssistant, setup_comp, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 8f35997f176bb..9a872d66946ef 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -1,7 +1,9 @@ """Test the Emulated Hue component.""" from datetime import timedelta from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch + +from aiohttp import web from homeassistant.components.emulated_hue.config import ( DATA_KEY, @@ -135,6 +137,9 @@ async def test_setup_works(hass: HomeAssistant) -> None: AsyncMock(), ) as mock_create_upnp_datagram_endpoint, patch( "homeassistant.components.emulated_hue.async_get_source_ip" + ), patch( + "homeassistant.components.emulated_hue.web.TCPSite", + return_value=Mock(spec_set=web.TCPSite), ): mock_create_upnp_datagram_endpoint.return_value = AsyncMock( spec=UPNPResponderProtocol diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index fe8eeb0b0f66f..1c1197131c064 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -293,7 +293,7 @@ async def test_setup_api_push_api_data( assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 - assert aioclient_mock.mock_calls[1][2]["watchdog"] + assert "watchdog" not in aioclient_mock.mock_calls[1][2] async def test_setup_api_push_api_data_server_host( diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index d80add2a4415f..4c327a237c77e 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -1,4 +1,5 @@ """Tests for Intent component.""" + import pytest from homeassistant.components.cover import SERVICE_OPEN_COVER @@ -225,6 +226,30 @@ async def test_turn_on_multiple_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": ["light.test_lights_2"]} +async def test_turn_on_all(hass: HomeAssistant) -> None: + """Test HassTurnOn intent with "all" name.""" + result = await async_setup_component(hass, "homeassistant", {}) + result = await async_setup_component(hass, "intent", {}) + assert result + + hass.states.async_set("light.test_light", "off") + hass.states.async_set("light.test_light_2", "off") + calls = async_mock_service(hass, "light", SERVICE_TURN_ON) + + await intent.async_handle(hass, "test", "HassTurnOn", {"name": {"value": "all"}}) + await hass.async_block_till_done() + + # All lights should be on now + assert len(calls) == 2 + entity_ids = set() + for call in calls: + assert call.domain == "light" + assert call.service == "turn_on" + entity_ids.update(call.data.get("entity_id", [])) + + assert entity_ids == {"light.test_light", "light.test_light_2"} + + async def test_get_state_intent( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 35e6673114e40..0cc3e360ab61b 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -144,10 +144,10 @@ async def test_node_added_subscription( integration: MagicMock, ) -> None: """Test subscription to new devices work.""" - assert matter_client.subscribe_events.call_count == 4 + assert matter_client.subscribe_events.call_count == 5 assert ( matter_client.subscribe_events.call_args.kwargs["event_filter"] - == EventType.NODE_ADDED + == EventType.NODE_UPDATED ) node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 892f935ebab61..8e463800f986b 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -229,6 +229,7 @@ async def test_node_diagnostics( mac_address="00:11:22:33:44:55", available=True, active_fabrics=[MatterFabricData(2, 4939, 1, vendor_name="Nabu Casa")], + active_fabric_index=0, ) matter_client.node_diagnostics = AsyncMock(return_value=mock_diagnostics) diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 3ff9aa37bcfbd..b885e6452d865 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -42,6 +42,8 @@ CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, CONF_HVAC_ONOFF_REGISTER, + CONF_MAX_TEMP, + CONF_MIN_TEMP, CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, @@ -170,6 +172,30 @@ } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_MIN_TEMP: 23, + CONF_MAX_TEMP: 57, + } + ], + }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_MIN_TEMP: -57, + CONF_MAX_TEMP: -23, + } + ], + }, ], ) async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 97571041482e4..aa8b15585dc88 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -185,6 +185,28 @@ } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DATA_TYPE: DataType.INT16, + CONF_MIN_VALUE: 1, + CONF_MAX_VALUE: 3, + } + ] + }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DATA_TYPE: DataType.INT16, + CONF_MIN_VALUE: -3, + CONF_MAX_VALUE: -1, + } + ] + }, ], ) async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: @@ -688,6 +710,16 @@ async def test_config_wrong_struct_sensor( False, "112594", ), + ( + { + CONF_DATA_TYPE: DataType.INT16, + CONF_SCALE: -1, + CONF_OFFSET: 0, + }, + [0x000A], + False, + "-10", + ), ], ) async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 1c67534d5df55..609f80e1a60de 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4257,3 +4257,64 @@ async def async_step_reauth(self, data): assert entry.state == config_entries.ConfigEntryState.LOADED assert task["type"] == FlowResultType.ABORT assert task["reason"] == "reauth_successful" + + +@pytest.mark.parametrize("unique_id", [["blah", "bleh"], {"key": "value"}]) +async def test_unhashable_unique_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any +) -> None: + """Test the ConfigEntryItems user dict handles unhashable unique_id.""" + entries = config_entries.ConfigEntryItems(hass) + entry = config_entries.ConfigEntry( + version=1, + minor_version=1, + domain="test", + entry_id="mock_id", + title="title", + data={}, + source="test", + unique_id=unique_id, + ) + + entries[entry.entry_id] = entry + assert ( + "Config entry 'title' from integration test has an invalid unique_id " + f"'{str(unique_id)}'" + ) in caplog.text + + assert entry.entry_id in entries + assert entries[entry.entry_id] is entry + assert entries.get_entry_by_domain_and_unique_id("test", unique_id) == entry + del entries[entry.entry_id] + assert not entries + assert entries.get_entry_by_domain_and_unique_id("test", unique_id) is None + + +@pytest.mark.parametrize("unique_id", [123]) +async def test_hashable_non_string_unique_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any +) -> None: + """Test the ConfigEntryItems user dict handles hashable non string unique_id.""" + entries = config_entries.ConfigEntryItems(hass) + entry = config_entries.ConfigEntry( + version=1, + minor_version=1, + domain="test", + entry_id="mock_id", + title="title", + data={}, + source="test", + unique_id=unique_id, + ) + + entries[entry.entry_id] = entry + assert ( + "Config entry 'title' from integration test has an invalid unique_id" + ) not in caplog.text + + assert entry.entry_id in entries + assert entries[entry.entry_id] is entry + assert entries.get_entry_by_domain_and_unique_id("test", unique_id) == entry + del entries[entry.entry_id] + assert not entries + assert entries.get_entry_by_domain_and_unique_id("test", unique_id) is None