diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index e6a74c7052b51d..f407c04cfc25a7 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -33,6 +33,7 @@ class AnovaSensorEntityDescription(SensorEntityDescription): key="cook_time", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, translation_key="cook_time", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.cook_time, @@ -62,6 +63,7 @@ class AnovaSensorEntityDescription(SensorEntityDescription): AnovaSensorEntityDescription( key="cook_time_remaining", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, translation_key="cook_time_remaining", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.cook_time_remaining, diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 24b27e363f3f6a..00e2d1582f2a2c 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -7,7 +7,6 @@ from dataclasses import dataclass, field from enum import StrEnum import logging -import time from typing import Any, Literal, final, override from hassil import Intents, recognize @@ -442,6 +441,8 @@ async def async_accept_pipeline_from_satellite( start_stage: PipelineStage = PipelineStage.STT, end_stage: PipelineStage = PipelineStage.TTS, wake_word_phrase: str | None = None, + *, + context: Context | None = None, ) -> None: """Triggers an Assist pipeline in Home Assistant from a satellite.""" await self._cancel_running_pipeline() @@ -485,15 +486,8 @@ async def async_accept_pipeline_from_satellite( device_id = self.registry_entry.device_id if self.registry_entry else None - # Refresh context if necessary - if ( - (self._context is None) - or (self._context_set is None) - or ((time.time() - self._context_set) > entity.CONTEXT_RECENT_TIME_SECONDS) - ): - self.async_set_context(Context()) - - assert self._context is not None + context = context or Context() + self.async_set_context(context) # Set entity state based on pipeline events self._run_has_tts = False @@ -511,7 +505,7 @@ async def async_accept_pipeline_from_satellite( self.hass, async_pipeline_from_audio_stream( self.hass, - context=self._context, + context=context, event_callback=self._internal_on_pipeline_event, stt_metadata=stt.SpeechMetadata( language="", # set in async_pipeline_from_audio_stream diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 31a5c58fd89722..f8b84ffeb9af7f 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -429,8 +429,6 @@ async def _run_pipeline(self) -> None: """Run a pipeline with STT input and TTS output.""" _LOGGER.debug("Starting pipeline") - self.async_set_context(Context(user_id=self.config_entry.data["user"])) - async def stt_stream(): retry: bool = True while True: @@ -455,6 +453,7 @@ async def stt_stream(): try: await self.async_accept_pipeline_from_satellite( audio_stream=stt_stream(), + context=Context(user_id=self.config_entry.data["user"]), ) if self._pipeline_had_error: diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index c1315d8619cc04..034fae1e015707 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -24,6 +24,7 @@ ) from homeassistant.config_entries import ( SOURCE_ESPHOME, + SOURCE_IGNORE, SOURCE_USB, SOURCE_ZEROCONF, ConfigEntry, @@ -235,6 +236,9 @@ def __init__(self) -> None: self._recommended_install = False self._rf_region: str | None = None self._entry_unloaded_by_flow = False + # Set if the flow unique id is a placeholder that must be replaced + # with the home ID before a config entry is created. + self._unique_id_is_placeholder = False async def async_step_install_addon( self, user_input: dict[str, Any] | None = None @@ -571,10 +575,13 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu await self.async_set_unique_id( f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" ) - # We don't need to check if the unique_id is already configured - # since we will update the unique_id before finishing the flow. - # The unique_id set above is just a temporary value to avoid - # duplicate discovery flows. + # The unique id set above is a placeholder that is replaced with the + # home ID before an entry is created, so only check ignored entries. + if any( + entry.source == SOURCE_IGNORE and entry.unique_id == self.unique_id + for entry in self._async_current_entries(include_ignore=True) + ): + return self.async_abort(reason="already_configured") dev_path = discovery_info.device self.usb_path = dev_path if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2": @@ -1025,7 +1032,11 @@ async def async_step_finish_addon_setup_user( discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id or self.source == SOURCE_USB: + if ( + not self.unique_id + or self.source == SOURCE_USB + or self._unique_id_is_placeholder + ): if not self.version_info: try: self.version_info = await async_get_version_info( @@ -1037,6 +1048,7 @@ async def async_step_finish_addon_setup_user( await self.async_set_unique_id( str(self.version_info.home_id), raise_on_progress=False ) + self._unique_id_is_placeholder = False if ( existing_entry := next( @@ -1659,6 +1671,11 @@ async def async_step_esphome( if not is_hassio(self.hass): return self.async_abort(reason="not_hassio") + # The adapter may first be discovered without a home ID and get the + # placeholder unique id below, then report a home ID on a later + # discovery. Track the placeholder id so such a discovery can be + # deduplicated against a pending prompt or an ignored entry. + placeholder_unique_id = f"esphome_{discovery_info.name}" if discovery_info.zwave_home_id: existing_entry: ConfigEntry | None = None if ( @@ -1706,6 +1723,11 @@ async def async_step_esphome( ) return self.async_abort(reason="already_configured") + if any( + flow["context"].get("unique_id") == placeholder_unique_id + for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") # We are not aborting if home ID configured # here, we just want to make sure that it's set # We will update a USB based config entry @@ -1714,6 +1736,19 @@ async def async_step_esphome( await self.async_set_unique_id( str(discovery_info.zwave_home_id), raise_on_progress=False ) + else: + # Set a placeholder unique id so the discovery can be ignored + # also when the adapter doesn't report a home ID yet. + # It is replaced with the home ID before an entry is created. + self._unique_id_is_placeholder = True + await self.async_set_unique_id(placeholder_unique_id) + + if any( + entry.source == SOURCE_IGNORE + and entry.unique_id in (self.unique_id, placeholder_unique_id) + for entry in self._async_current_entries(include_ignore=True) + ): + return self.async_abort(reason="already_configured") self.socket_path = discovery_info.socket_path home_id_display = format_home_id_for_display(discovery_info.zwave_home_id) diff --git a/tests/components/anova/test_sensor.py b/tests/components/anova/test_sensor.py index d1e083744ecf62..10005711befe33 100644 --- a/tests/components/anova/test_sensor.py +++ b/tests/components/anova/test_sensor.py @@ -18,9 +18,15 @@ async def test_sensors(hass: HomeAssistant, anova_api: AnovaApi) -> None: assert len(hass.states.async_all("sensor")) == 8 assert ( hass.states.get("sensor.anova_precision_cooker_cook_time_remaining").state - == "0" + == "0.0" + ) + assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0.0" + assert ( + hass.states.get("sensor.anova_precision_cooker_cook_time").attributes[ + "unit_of_measurement" + ] + == "h" ) - assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0" assert ( hass.states.get("sensor.anova_precision_cooker_heater_temperature").state == "22.37" diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index b8049b8ac96b24..07c0ffc2ab26fb 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -71,12 +71,10 @@ async def test_entity_state( context = Context() audio_stream = object() - entity.async_set_context(context) - with patch( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" ) as mock_start_pipeline: - await entity.async_accept_pipeline_from_satellite(audio_stream) + await entity.async_accept_pipeline_from_satellite(audio_stream, context=context) assert mock_start_pipeline.called kwargs = mock_start_pipeline.call_args[1] @@ -466,22 +464,28 @@ async def async_announce(announcement): ) -async def test_context_refresh( +async def test_context_not_inherited( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: - """Test that the context will be automatically refreshed.""" + """Test that audio from the satellite does not inherit an existing context.""" audio_stream = object() - # Remove context - entity._context = None + # A previous action targeting the entity, such as an announce service call + previous_context = Context(user_id="12345") + entity.async_set_context(previous_context) with patch( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" - ): + ) as mock_start_pipeline: await entity.async_accept_pipeline_from_satellite(audio_stream) - # Context should have been refreshed - assert entity._context is not None + # The speaker is unknown, so the pipeline must not run as the previous user + context = mock_start_pipeline.call_args[1]["context"] + assert context is not previous_context + assert context.user_id is None + + # The pipeline drives the entity state from here, so it owns the context + assert entity._context is context async def test_pipeline_entity( @@ -936,6 +940,8 @@ async def speech_to_text(self, *args, **kwargs): async def async_start_conversation(start_announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING + # The question is asked on behalf of the caller + assert hass.states.get(entity_id).context is context assert ( start_announcement.preannounce_media_id is not None ) is should_preannounce @@ -987,7 +993,6 @@ async def async_start_conversation(start_announcement): ) assert entity.state == AssistSatelliteState.IDLE assert response == asdict(expected_answer) - assert hass.states.get(entity_id).context is context async def test_ask_question_requires_entity_permission( diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 99d114455ece56..fe5ac5b6384b33 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1722,6 +1722,66 @@ async def test_esphome_discovery_no_home_id_configured_socket_no_migration( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_placeholder_then_home_id( + hass: HomeAssistant, +) -> None: + """Test a home ID discovery dedups against a pending placeholder prompt.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + # The same adapter now reports a home ID while its prompt is open. + home_id_info = ESPHomeServiceInfo( + name=ESPHOME_DISCOVERY_INFO_CLEAN.name, + zwave_home_id=1234, + ip_address=ESPHOME_DISCOVERY_INFO_CLEAN.ip_address, + port=ESPHOME_DISCOVERY_INFO_CLEAN.port, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=home_id_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_placeholder_ignored_then_home_id( + hass: HomeAssistant, +) -> None: + """Test a home ID discovery honors a placeholder-based ignore.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id="esphome_mock-name", + ) + entry.add_to_hass(hass) + + # The adapter that was ignored without a home ID now reports one. + home_id_info = ESPHomeServiceInfo( + name="mock-name", + zwave_home_id=1234, + ip_address="192.168.1.100", + port=6053, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=home_id_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_same_socket_no_reload( hass: HomeAssistant, @@ -1765,6 +1825,78 @@ async def test_esphome_discovery_same_socket_no_reload( } +@pytest.mark.usefixtures("supervisor", "addon_running") +@pytest.mark.parametrize( + ("esphome_discovery_info", "ignored_unique_id"), + [ + pytest.param(ESPHOME_DISCOVERY_INFO, "1234", id="home_id"), + pytest.param( + ESPHOME_DISCOVERY_INFO_CLEAN, "esphome_mock-name", id="no_home_id" + ), + ], +) +async def test_esphome_discovery_ignored( + hass: HomeAssistant, + esphome_discovery_info: ESPHomeServiceInfo, + ignored_unique_id: str, +) -> None: + """Test ESPHome discovery aborts when the discovery was ignored.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id=ignored_unique_id, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=esphome_discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_esphome_discovery_without_home_id_can_be_ignored( + hass: HomeAssistant, +) -> None: + """Test a discovery without a home ID gets a unique id for ignoring.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + + flows = hass.config_entries.flow.async_progress_by_handler( + DOMAIN, match_context={"source": config_entries.SOURCE_ESPHOME} + ) + assert len(flows) == 1 + assert flows[0]["context"]["unique_id"] == "esphome_mock-name" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": "esphome_mock-name", "title": "ZWA-2 proxy"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + # The discovery prompt is gone and rediscovery aborts. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO_CLEAN, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_running", "addon_info") async def test_esphome_discovery_already_configured_unmanaged_addon( hass: HomeAssistant, @@ -2420,6 +2552,29 @@ async def test_usb_discovery_leaves_manual_entry_alone( assert entry.data == {"url": "ws://external-server:3000"} +@pytest.mark.usefixtures("supervisor", "addon_info") +async def test_usb_discovery_ignored( + hass: HomeAssistant, + mock_usb_serial_by_id: MagicMock, +) -> None: + """Test USB discovery aborts when the discovery was ignored.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_IGNORE, + unique_id="AAAA:AAAA_1234_test_zwave radio", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("supervisor", "addon_info") async def test_abort_usb_discovery_addon_required(hass: HomeAssistant) -> None: """Test usb discovery aborted when existing entry not using add-on."""