From 3b7271d597d18da1b6add30515110bf0b8e2cfb4 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 8 Feb 2024 04:51:20 -0500 Subject: [PATCH 01/23] Catch APIRateLimit in Honeywell (#107806) --- homeassistant/components/honeywell/climate.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index efd06ba2905a1a..6bc6169c68c9ff 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 From dbfee24eb751ac826d14bd1faf3b0b3d4253e826 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 8 Feb 2024 14:09:53 -0500 Subject: [PATCH 02/23] Allow disabling home assistant watchdog (#109818) --- homeassistant/components/hassio/handler.py | 1 - tests/components/hassio/test_init.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index ddaebcbf2a7b92..8d78c878cfa7da 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/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index fe8eeb0b0f66fe..1c1197131c0646 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( From 44c9ea68eb681c3dae7a181dcda5e0ef829f565f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 7 Feb 2024 15:13:42 -0600 Subject: [PATCH 03/23] Assist fixes (#109889) * Don't pass entity ids in hassil slot lists * Use first completed response * Add more tests --- homeassistant/components/climate/intent.py | 34 ++++-- .../components/conversation/default_agent.py | 36 +++--- homeassistant/components/intent/__init__.py | 7 +- homeassistant/helpers/intent.py | 15 ++- tests/components/climate/test_intent.py | 70 ++++++++++- .../conversation/snapshots/test_init.ambr | 8 +- .../conversation/test_default_agent.py | 93 +++++++++++++- tests/components/conversation/test_trigger.py | 115 +++++++++++++++++- tests/components/intent/test_init.py | 25 ++++ 9 files changed, 354 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 4152fb5ee2d50f..db263451f0b784 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/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index fb33d87e107fa8..52925fbc24198e 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) @@ -724,7 +724,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 +745,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 +753,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, state.name, 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(): diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 5756b78b4de248..d032f535b06e2d 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,7 +156,7 @@ 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") + entity_name: str | None = slots.get("name", {}).get("value") # Look up area first to fail early area_name = slots.get("area", {}).get("value") @@ -186,7 +187,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,7 +198,7 @@ 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, diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index fe399659a562dd..8ca56b8eabea7a 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -403,11 +403,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", {}) @@ -436,7 +436,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,7 +447,7 @@ 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, @@ -455,6 +455,9 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: 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/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 6473eca1b883b4..e4f927597933ee 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 034bfafc1f5852..f44789414738ea 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': 'kitchen', }), }), 'intent': dict({ @@ -1422,7 +1422,7 @@ 'name': dict({ 'name': 'name', 'text': 'my cool light', - 'value': 'light.kitchen', + 'value': 'kitchen', }), }), '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 0cf343a3e209ef..d8a256608c8d25 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", @@ -692,7 +694,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 +715,82 @@ 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] diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 26626a04079c36..5853d98b760764 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/intent/test_init.py b/tests/components/intent/test_init.py index d80add2a4415f5..4c327a237c77e9 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, From e320d715c7a04115dfa1af7f559565955fca718d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 8 Feb 2024 08:59:57 +0100 Subject: [PATCH 04/23] Bump Python matter server to 5.5.0 (#109894) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/matter/test_api.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index d3d0568342e1ef..801704c25c5a19 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/requirements_all.txt b/requirements_all.txt index a61d360c4e04c2..506c913e11793e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 67c0775c2f726b..57d81e81f36d20 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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/matter/test_api.py b/tests/components/matter/test_api.py index 892f935ebab614..8e463800f986bd 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) From 19349e1779ce638db954800921969a4204aed108 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 8 Feb 2024 08:42:22 +0100 Subject: [PATCH 05/23] Bump aioelectricitymaps to 0.4.0 (#109895) --- homeassistant/components/co2signal/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 4f22ee6891025c..ff6d5bdb18b8c6 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/requirements_all.txt b/requirements_all.txt index 506c913e11793e..76fe94bc86522e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -233,7 +233,7 @@ aioeagle==1.1.0 aioecowitt==2024.2.0 # homeassistant.components.co2signal -aioelectricitymaps==0.3.1 +aioelectricitymaps==0.4.0 # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57d81e81f36d20..818387f42d07a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioeagle==1.1.0 aioecowitt==2024.2.0 # homeassistant.components.co2signal -aioelectricitymaps==0.3.1 +aioelectricitymaps==0.4.0 # homeassistant.components.emonitor aioemonitor==1.0.5 From a9b3c2e2b58f9cfb8300fc19ada7c49639563ab9 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 8 Feb 2024 09:01:48 +0100 Subject: [PATCH 06/23] Skip polling of unavailable Matter nodes (#109917) --- homeassistant/components/matter/entity.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 61535d990db227..5c3f65d903ca5b 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, From f48d70654b2b8dc0373c3f823055f3907dac72fb Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Fri, 9 Feb 2024 18:45:55 +1100 Subject: [PATCH 07/23] Bump aio-geojson-geonetnz-volcano to 0.9 (#109940) --- homeassistant/components/geonetnz_volcano/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json index 6e9503e0243aa2..421222bb810395 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/requirements_all.txt b/requirements_all.txt index 76fe94bc86522e..37845e0d0ea46b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 818387f42d07a3..ce78bc31eff569 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 From 7309c3c2900f2e918bb97c6e6afc8eacda6f9587 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 8 Feb 2024 13:14:10 +0100 Subject: [PATCH 08/23] Handle Matter nodes that become available after startup is done (#109956) --- homeassistant/components/matter/adapter.py | 21 +++++++++++++++++++++ tests/components/matter/test_adapter.py | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 5690996841d162..6d7d437a2068c6 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/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 35e6673114e406..0cc3e360ab61b0 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"] From a9e9ec2c3d2f5f23a617f34d68f09b291c940907 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 8 Feb 2024 12:40:32 +0100 Subject: [PATCH 09/23] Allow modbus "scale" to be negative. (#109965) --- homeassistant/components/modbus/__init__.py | 2 +- tests/components/modbus/test_sensor.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 0f674d4d0df49e..36e841f7859745 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( diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 97571041482e41..5ca38873c423c7 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -688,6 +688,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: From 95a800b6bc0b6b3401575908303dce5ac3670686 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 Feb 2024 15:39:01 +0100 Subject: [PATCH 10/23] Don't blow up if config entries have unhashable unique IDs (#109966) * Don't blow up if config entries have unhashable unique IDs * Add test * Add comment on when we remove the guard * Don't stringify hashable non string unique_id --- homeassistant/config_entries.py | 38 +++++++++++++++++--- tests/test_config_entries.py | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b0a8f952b1bcc0..d5259ef1fc5512 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/tests/test_config_entries.py b/tests/test_config_entries.py index 1c67534d5df55f..609f80e1a60de7 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 From de44af2948f0030f0acacb0dbc0f08979434d135 Mon Sep 17 00:00:00 2001 From: spycle <48740594+spycle@users.noreply.github.com> Date: Fri, 9 Feb 2024 07:33:52 +0000 Subject: [PATCH 11/23] Bump pyMicrobot to 0.0.12 (#109970) --- homeassistant/components/keymitt_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index ee07881a01ed12..05e06d819f154b 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/requirements_all.txt b/requirements_all.txt index 37845e0d0ea46b..326e885af9956e 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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce78bc31eff569..2ee08d1945dec9 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 From c665903f9d3a426d0fa68ac08bee8339cb4434b7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 8 Feb 2024 13:48:33 +0100 Subject: [PATCH 12/23] Allow modbus min/max temperature to be negative. (#109977) --- homeassistant/components/modbus/__init__.py | 4 ++-- tests/components/modbus/test_climate.py | 26 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 36e841f7859745..0ceb1a2523fc3c 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -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, diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 3ff9aa37bcfbd9..b885e6452d8652 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: From 49e5709826020abf6081370e68a0b6391ca9de5d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 8 Feb 2024 15:41:37 +0100 Subject: [PATCH 13/23] Bump deebot-client to 5.1.1 (#109994) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 34760ea6acabe6..3fcb2b3211e28f 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/requirements_all.txt b/requirements_all.txt index 326e885af9956e..601e59874f76f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ee08d1945dec9..d8713d7083a8d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 From a18918bb73d503eb85189ca5d085390d500bac8a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 8 Feb 2024 15:34:43 +0100 Subject: [PATCH 14/23] Allow modbus negative min/max value. (#109995) --- homeassistant/components/modbus/__init__.py | 4 ++-- tests/components/modbus/test_sensor.py | 22 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 0ceb1a2523fc3c..1151a5f1f013db 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -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/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 5ca38873c423c7..aa8b15585dc887 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: From 7ff2f376d4b06a634015dbcb982d3c0870c8e30c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 8 Feb 2024 15:41:19 +0100 Subject: [PATCH 15/23] Bump aioecowitt to 2024.2.1 (#109999) --- homeassistant/components/ecowitt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index d3dfe0331ef5a9..175960ab57d4f3 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/requirements_all.txt b/requirements_all.txt index 601e59874f76f3..38b4c0ec1fbdc4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -230,7 +230,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.0 +aioecowitt==2024.2.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8713d7083a8d5..998e36901982f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -209,7 +209,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.0 +aioecowitt==2024.2.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 From 4a18f592c647a8ef536453466cf23450acfb2ff3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 9 Feb 2024 08:39:08 +0100 Subject: [PATCH 16/23] Avoid key_error in modbus climate with non-defined fan_mode. (#110017) --- homeassistant/components/modbus/climate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 637478fffd4d0d..d31323a27e9c78 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 From 56ff767969ae5e1a0afd4fe6d83651016f0fa4a8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 8 Feb 2024 20:03:41 +0100 Subject: [PATCH 17/23] Update frontend to 20240207.1 (#110039) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d998871a60bdf8..21f4df7956800c 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/package_constraints.txt b/homeassistant/package_constraints.txt index e3a82474d8d450..6a9734ad1fafce 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/requirements_all.txt b/requirements_all.txt index 38b4c0ec1fbdc4..5dd3b81081511d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 998e36901982f4..5e1c38a92a2063 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 From e4382a494c20e56ba5b867d797daee221c774222 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 9 Feb 2024 08:35:12 +0100 Subject: [PATCH 18/23] Log error and continue on parsing issues of translated strings (#110046) --- homeassistant/helpers/translation.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index ab9d5f576feeaf..be3e04643612c0 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])) From f5884c627955b5a1f07349ba1eb48d3c01c0d4d6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 8 Feb 2024 19:38:03 -0600 Subject: [PATCH 19/23] Matching duplicate named entities is now an error in Assist (#110050) * Matching duplicate named entities is now an error * Update snapshot * Only use area id --- .../components/conversation/default_agent.py | 30 ++- homeassistant/components/intent/__init__.py | 21 +- homeassistant/helpers/intent.py | 24 +- .../conversation/snapshots/test_init.ambr | 4 +- .../conversation/test_default_agent.py | 218 ++++++++++++++++++ 5 files changed, 283 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 52925fbc24198e..cd371ff0630146 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -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. @@ -753,7 +767,7 @@ def _make_slot_lists(self) -> dict[str, SlotList]: if not alias.strip(): continue - entity_names.append((alias, state.name, context)) + entity_names.append((alias, alias, context)) # Default name entity_names.append((state.name, state.name, context)) @@ -992,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/intent/__init__.py b/homeassistant/components/intent/__init__.py index d032f535b06e2d..e960b5616cba2c 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -156,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 - entity_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}") @@ -205,6 +207,13 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse 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/helpers/intent.py b/homeassistant/helpers/intent.py index 8ca56b8eabea7a..295246b5e0aa6d 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: @@ -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}") @@ -453,6 +460,13 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: 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 diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index f44789414738ea..6af9d197e019ed 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': 'kitchen', + 'value': 'my cool light', }), }), 'intent': dict({ @@ -1422,7 +1422,7 @@ 'name': dict({ 'name': 'name', 'text': 'my cool light', - 'value': 'kitchen', + 'value': 'my cool light', }), }), 'intent': dict({ diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index d8a256608c8d25..4b4f9ade3eb464 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -614,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: @@ -794,3 +903,112 @@ async def test_same_named_entities_in_different_areas( 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 From 437a2a829f4eebeacd07006e4009e4906ba72031 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 9 Feb 2024 07:49:09 +0000 Subject: [PATCH 20/23] Bump evohome-async to 0.4.18 (#110056) --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 9d32ba98e92043..0c9bb44d06a27a 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/requirements_all.txt b/requirements_all.txt index 5dd3b81081511d..6509f2769beb92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 From 74ea9e24df9c10e5e1867a926dd0848a76279df7 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Fri, 9 Feb 2024 02:25:32 -0500 Subject: [PATCH 21/23] Bump py-aosmith to 1.0.8 (#110061) --- homeassistant/components/aosmith/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 436918ae772b65..21580b87286928 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/requirements_all.txt b/requirements_all.txt index 6509f2769beb92..0c084d0b6b97d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e1c38a92a2063..de460cbd0c42c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 From 58d46f6dec8ae2b4a8c3a3cbe695227325ac6617 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 9 Feb 2024 09:02:01 +0100 Subject: [PATCH 22/23] Bump version to 2024.2.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fb6e8ef896baae..a19ff18d8f3fa4 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/pyproject.toml b/pyproject.toml index 6a038aa1c5a569..895519889716a2 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" From 5f9cc2fec1751ef1db08595564c51ce31ce7217a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 8 Feb 2024 13:00:45 +0100 Subject: [PATCH 23/23] Prevent network access in emulated_hue tests (#109991) --- tests/components/emulated_hue/test_init.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 8f35997f176bbc..9a872d66946efe 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