From ef319c966d6767a98ec8398a6ed65d1f9e9d7a48 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 17 Jun 2025 14:11:55 +0200 Subject: [PATCH 1/6] Bump nextcord to 3.1.0 (#147020) --- homeassistant/components/discord/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 5f1ba2a13efb4..c795c7ed2ed2c 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["discord"], - "requirements": ["nextcord==2.6.0"] + "requirements": ["nextcord==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 44499fb9d33b5..92790f35cb080 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1505,7 +1505,7 @@ nexia==2.10.0 nextcloudmonitor==1.5.1 # homeassistant.components.discord -nextcord==2.6.0 +nextcord==3.1.0 # homeassistant.components.nextdns nextdns==4.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82238467816c6..fdd8e785e7933 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1285,7 +1285,7 @@ nexia==2.10.0 nextcloudmonitor==1.5.1 # homeassistant.components.discord -nextcord==2.6.0 +nextcord==3.1.0 # homeassistant.components.nextdns nextdns==4.0.0 From 058f860be756dca882cfdb18472963016aab3d33 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Jun 2025 14:24:31 +0200 Subject: [PATCH 2/6] Fix incorrect use of zip in service.async_get_all_descriptions (#147013) * Fix incorrect use of zip in service.async_get_all_descriptions * Fix lint errors in test --- homeassistant/helpers/service.py | 10 +++--- tests/helpers/test_service.py | 54 ++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index f157e82bc530b..6e1988fe4cd8f 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -682,9 +682,12 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T def _load_services_files( hass: HomeAssistant, integrations: Iterable[Integration] -) -> list[JSON_TYPE]: +) -> dict[str, JSON_TYPE]: """Load service files for multiple integrations.""" - return [_load_services_file(hass, integration) for integration in integrations] + return { + integration.domain: _load_services_file(hass, integration) + for integration in integrations + } @callback @@ -744,10 +747,9 @@ async def async_get_all_descriptions( _LOGGER.error("Failed to load integration: %s", domain, exc_info=int_or_exc) if integrations: - contents = await hass.async_add_executor_job( + loaded = await hass.async_add_executor_job( _load_services_files, hass, integrations ) - loaded = dict(zip(domains_with_missing_services, contents, strict=False)) # Load translations for all service domains translations = await translation.async_get_translations( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 38e7e1ae4521d..6b464faa1106d 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -16,6 +16,7 @@ from homeassistant.auth.permissions import PolicyPermissions import homeassistant.components # noqa: F401 from homeassistant.components.group import DOMAIN as DOMAIN_GROUP, Group +from homeassistant.components.input_button import DOMAIN as DOMAIN_INPUT_BUTTON from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER from homeassistant.components.shell_command import DOMAIN as DOMAIN_SHELL_COMMAND from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH @@ -42,7 +43,12 @@ entity_registry as er, service, ) -from homeassistant.loader import async_get_integration +from homeassistant.helpers.translation import async_get_translations +from homeassistant.loader import ( + Integration, + async_get_integration, + async_get_integrations, +) from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml @@ -1092,38 +1098,66 @@ async def test_async_get_all_descriptions_failing_integration( """Test async_get_all_descriptions when async_get_integrations returns an exception.""" group_config = {DOMAIN_GROUP: {}} await async_setup_component(hass, DOMAIN_GROUP, group_config) - descriptions = await service.async_get_all_descriptions(hass) - - assert len(descriptions) == 1 - - assert "description" in descriptions["group"]["reload"] - assert "fields" in descriptions["group"]["reload"] logger_config = {DOMAIN_LOGGER: {}} await async_setup_component(hass, DOMAIN_LOGGER, logger_config) + + input_button_config = {DOMAIN_INPUT_BUTTON: {}} + await async_setup_component(hass, DOMAIN_INPUT_BUTTON, input_button_config) + + async def wrap_get_integrations( + hass: HomeAssistant, domains: Iterable[str] + ) -> dict[str, Integration | Exception]: + integrations = await async_get_integrations(hass, domains) + integrations[DOMAIN_LOGGER] = ImportError("Failed to load services.yaml") + return integrations + + async def wrap_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, str]: + translations = await async_get_translations( + hass, language, category, integrations, config_flow + ) + return { + key: value + for key, value in translations.items() + if not key.startswith("component.logger.services.") + } + with ( patch( "homeassistant.helpers.service.async_get_integrations", - return_value={"logger": ImportError}, + wraps=wrap_get_integrations, ), patch( "homeassistant.helpers.service.translation.async_get_translations", - return_value={}, + wrap_get_translations, ), ): descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 2 + assert len(descriptions) == 3 assert "Failed to load integration: logger" in caplog.text # Services are empty defaults if the load fails but should # not raise + assert descriptions[DOMAIN_GROUP]["remove"]["description"] + assert descriptions[DOMAIN_GROUP]["remove"]["fields"] + assert descriptions[DOMAIN_LOGGER]["set_level"] == { "description": "", "fields": {}, "name": "", } + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["description"] + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["fields"] == {} + assert "target" in descriptions[DOMAIN_INPUT_BUTTON]["press"] + hass.services.async_register(DOMAIN_LOGGER, "new_service", lambda x: None, None) service.async_set_service_schema( hass, DOMAIN_LOGGER, "new_service", {"description": "new service"} From 5c455304a57b9b79c93d0b7bdf37dfc585c07625 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 17 Jun 2025 14:39:22 +0200 Subject: [PATCH 3/6] Disable Z-Wave indidator CC entities by default (#147018) * Update discovery tests * Disable Z-Wave indidator CC entities by default --- .../components/zwave_js/discovery.py | 4 + tests/components/zwave_js/test_discovery.py | 117 ++++++++++-------- 2 files changed, 69 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index b46735e4040a7..924778a9e5b2e 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -896,6 +896,7 @@ class ZWaveDiscoverySchema: writeable=False, ), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # generic text sensors ZWaveDiscoverySchema( @@ -932,6 +933,7 @@ class ZWaveDiscoverySchema: ), data_template=NumericSensorDataTemplate(), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # Meter sensors for Meter CC ZWaveDiscoverySchema( @@ -957,6 +959,7 @@ class ZWaveDiscoverySchema: writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # button for Indicator CC ZWaveDiscoverySchema( @@ -980,6 +983,7 @@ class ZWaveDiscoverySchema: writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # binary switch # barrier operator signaling states diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 7ef5f0e480fb3..02296262d1f16 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -1,10 +1,12 @@ """Test entity discovery for device-specific schemas for the Z-Wave JS integration.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.components.number import ( @@ -12,7 +14,6 @@ DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -26,12 +27,13 @@ from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) -from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_aeon_smart_switch_6_state( @@ -222,17 +224,24 @@ async def test_merten_507801_disabled_enitites( async def test_zooz_zen72( hass: HomeAssistant, entity_registry: er.EntityRegistry, - client, - switch_zooz_zen72, - integration, + client: MagicMock, + switch_zooz_zen72: Node, + integration: MockConfigEntry, ) -> None: """Test that Zooz ZEN72 Indicators are discovered as number entities.""" - assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 # includes ping entity_id = "number.z_wave_plus_700_series_dimmer_switch_indicator_value" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + client.async_send_command.reset_mock() state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN @@ -246,7 +255,7 @@ async def test_zooz_zen72( }, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -260,16 +269,18 @@ async def test_zooz_zen72( client.async_send_command.reset_mock() entity_id = "button.z_wave_plus_700_series_dimmer_switch_identify" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is None + assert hass.states.get(entity_id) is not None await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -285,53 +296,55 @@ async def test_indicator_test( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - indicator_test, - integration, + client: MagicMock, + indicator_test: Node, + integration: MockConfigEntry, ) -> None: """Test that Indicators are discovered properly. This test covers indicators that we don't already have device fixtures for. """ - device = device_registry.async_get_device( - identifiers={get_device_id(client.driver, indicator_test)} + binary_sensor_entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" + sensor_entity_id = "sensor.this_is_a_fake_device_sensor" + switch_entity_id = "switch.this_is_a_fake_device_switch" + + for entity_id in ( + binary_sensor_entity_id, + sensor_entity_id, + ): + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + entity_id = switch_entity_id + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - assert device - entities = er.async_entries_for_device(entity_registry, device.id) - - def len_domain(domain): - return len([entity for entity in entities if entity.domain == domain]) - - assert len_domain(NUMBER_DOMAIN) == 0 - assert len_domain(BUTTON_DOMAIN) == 1 # only ping - assert len_domain(BINARY_SENSOR_DOMAIN) == 1 - assert len_domain(SENSOR_DOMAIN) == 3 # include node status + last seen - assert len_domain(SWITCH_DOMAIN) == 1 - - entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + await hass.async_block_till_done() + client.async_send_command.reset_mock() + + entity_id = binary_sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - client.async_send_command.reset_mock() - - entity_id = "sensor.this_is_a_fake_device_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == "0.0" - client.async_send_command.reset_mock() - - entity_id = "switch.this_is_a_fake_device_switch" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_id = switch_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -342,7 +355,7 @@ def len_domain(domain): {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id @@ -362,7 +375,7 @@ def len_domain(domain): {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id From 79cc3bffc680fc1a95a48b6d5c7f22a51d71a6ec Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 17 Jun 2025 07:40:56 -0500 Subject: [PATCH 4/6] Bump aiorussound to 4.6.0 (#147023) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index e16e589e6487d..30b9205f439ef 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.5.2"], + "requirements": ["aiorussound==4.6.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 92790f35cb080..65a4b441cb4ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.2 +aiorussound==4.6.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdd8e785e7933..60c7ae6082977 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -351,7 +351,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.2 +aiorussound==4.6.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 3b611b9b03758398e5c7574b9cdaa1ec0655a208 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 17 Jun 2025 08:39:18 -0500 Subject: [PATCH 5/6] Add TTS response timeout for idle state (#146984) * Add TTS response timeout for idle state * Consider time spent sending TTS audio in timeout --- .../components/wyoming/assist_satellite.py | 92 ++++++++++----- tests/components/wyoming/test_satellite.py | 107 ++++++++++++++++++ 2 files changed, 170 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 88939f0ba77b0..75c227f8537b3 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator import io import logging +import time from typing import Any, Final import wave @@ -36,6 +37,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.ulid import ulid_now from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_WIDTH from .data import WyomingService @@ -53,6 +55,7 @@ _PIPELINE_FINISH_TIMEOUT: Final = 1 _TTS_SAMPLE_RATE: Final = 22050 _ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples +_TTS_TIMEOUT_EXTRA: Final = 1.0 # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -125,6 +128,10 @@ def __init__( self._ffmpeg_manager: ffmpeg.FFmpegManager | None = None self._played_event_received: asyncio.Event | None = None + # Randomly set on each pipeline loop run. + # Used to ensure TTS timeout is acted on correctly. + self._run_loop_id: str | None = None + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -511,6 +518,7 @@ async def _run_pipeline_loop(self) -> None: wake_word_phrase: str | None = None run_pipeline: RunPipeline | None = None send_ping = True + self._run_loop_id = ulid_now() # Read events and check for pipeline end in parallel pipeline_ended_task = self.config_entry.async_create_background_task( @@ -698,38 +706,52 @@ async def _stream_tts(self, tts_result: tts.ResultStream) -> None: f"Cannot stream audio format to satellite: {tts_result.extension}" ) - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) - - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - sample_channels = wav_file.getnchannels() - _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) - - timestamp = 0 - await self._client.write_event( - AudioStart( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - timestamp=timestamp, - ).event() - ) + # Track the total duration of TTS audio for response timeout + total_seconds = 0.0 + start_time = time.monotonic() - # Stream audio chunks - while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): - chunk = AudioChunk( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - audio=audio_bytes, - timestamp=timestamp, + try: + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() ) - await self._client.write_event(chunk.event()) - timestamp += chunk.seconds - await self._client.write_event(AudioStop(timestamp=timestamp).event()) - _LOGGER.debug("TTS streaming complete") + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + total_seconds += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + finally: + send_duration = time.monotonic() - start_time + timeout_seconds = max(0, total_seconds - send_duration + _TTS_TIMEOUT_EXTRA) + self.config_entry.async_create_background_task( + self.hass, + self._tts_timeout(timeout_seconds, self._run_loop_id), + name="wyoming TTS timeout", + ) async def _stt_stream(self) -> AsyncGenerator[bytes]: """Yield audio chunks from a queue.""" @@ -744,6 +766,18 @@ async def _stt_stream(self) -> AsyncGenerator[bytes]: yield chunk + async def _tts_timeout( + self, timeout_seconds: float, run_loop_id: str | None + ) -> None: + """Force state change to IDLE in case TTS played event isn't received.""" + await asyncio.sleep(timeout_seconds + _TTS_TIMEOUT_EXTRA) + + if run_loop_id != self._run_loop_id: + # On a different pipeline run now + return + + self.tts_response_finished() + @callback def _handle_timer( self, event_type: intent.TimerEventType, timer: intent.TimerInfo diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 800870f46040c..dec5d6cbebde3 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -1365,3 +1365,110 @@ def async_process_play_media_url(hass: HomeAssistant, media_id: str) -> str: # Stop the satellite await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_tts_timeout( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity state goes back to IDLE on a timeout.""" + events = [ + Info(satellite=SATELLITE_INFO.satellite).event(), + RunPipeline(start_stage=PipelineStage.TTS, end_stage=PipelineStage.TTS).event(), + ] + + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + + response_finished = asyncio.Event() + + def tts_response_finished(self): + response_finished.set() + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), + patch( + "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite.tts_response_finished", + tts_response_finished, + ), + patch( + "homeassistant.components.wyoming.assist_satellite._TTS_TIMEOUT_EXTRA", + 0, + ), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][entry.entry_id].device + assert device is not None + + satellite_entry = next( + ( + maybe_entry + for maybe_entry in er.async_entries_for_device( + entity_registry, device.device_id + ) + if maybe_entry.domain == assist_satellite.DOMAIN + ), + None, + ) + assert satellite_entry is not None + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Reset so we can check the pipeline is automatically restarted below + run_pipeline_called.clear() + + assert pipeline_event_callback is not None + assert pipeline_kwargs.get("device_id") == device.device_id + + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"token": mock_tts_result_stream.token}}, + ) + ) + async with asyncio.timeout(1): + # tts_response_finished should be called on timeout + await response_finished.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 22a06a6c2e64acfa75077701c82d721dc8bc7c7b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 17 Jun 2025 07:06:51 -0700 Subject: [PATCH 6/6] Bump ical to 10.0.4 (#147005) * Bump ical to 10.0.4 * Bump ical to 10.0.4 in google --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index fecd245869aaf..1acfa3a2ad121 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"] + "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index e0b08313d63ff..3bf00f3062408 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index c8e80e4f91b05..134cea5293bd9 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 052b409dfe773..6ba1dea55edc3 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 65a4b441cb4ea..7eddc5762a290 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1203,7 +1203,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60c7ae6082977..4e9155baeec87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0