Cancel call if user does not pick up (#136858)

This commit is contained in:
Michael Hansen 2025-01-29 12:52:32 -06:00 committed by GitHub
parent b500fde468
commit d206553a0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 90 additions and 20 deletions

View File

@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final
import wave import wave
from voip_utils import SIP_PORT, RtpDatagramProtocol from voip_utils import SIP_PORT, RtpDatagramProtocol
from voip_utils.sip import SipEndpoint, get_sip_endpoint from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint
from homeassistant.components import tts from homeassistant.components import tts
from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType
@ -43,6 +43,7 @@ _PIPELINE_TIMEOUT_SEC: Final = 30
_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 _ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5
_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 _ANNOUNCEMENT_AFTER_DELAY: Final = 1.0
_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 _ANNOUNCEMENT_HANGUP_SEC: Final = 0.5
_ANNOUNCEMENT_RING_TIMEOUT: Final = 30
class Tones(IntFlag): class Tones(IntFlag):
@ -116,7 +117,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
self._processing_tone_done = asyncio.Event() self._processing_tone_done = asyncio.Event()
self._announcement: AssistSatelliteAnnouncement | None = None self._announcement: AssistSatelliteAnnouncement | None = None
self._announcement_done = asyncio.Event() self._announcement_future: asyncio.Future[Any] = asyncio.Future()
self._announcment_start_time: float = 0.0
self._check_announcement_ended_task: asyncio.Task | None = None self._check_announcement_ended_task: asyncio.Task | None = None
self._last_chunk_time: float | None = None self._last_chunk_time: float | None = None
self._rtp_port: int | None = None self._rtp_port: int | None = None
@ -170,7 +172,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
Plays announcement in a loop, blocking until the caller hangs up. Plays announcement in a loop, blocking until the caller hangs up.
""" """
self._announcement_done.clear() self._announcement_future = asyncio.Future()
if self._rtp_port is None: if self._rtp_port is None:
# Choose random port for RTP # Choose random port for RTP
@ -194,16 +196,34 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
host=self.voip_device.voip_id, port=SIP_PORT host=self.voip_device.voip_id, port=SIP_PORT
) )
# Reset state so we can time out if needed
self._last_chunk_time = None
self._announcment_start_time = time.monotonic()
self._announcement = announcement self._announcement = announcement
# Make the call # Make the call
self.hass.data[DOMAIN].protocol.outgoing_call( sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol
call_info = sip_protocol.outgoing_call(
source=source_endpoint, source=source_endpoint,
destination=destination_endpoint, destination=destination_endpoint,
rtp_port=self._rtp_port, rtp_port=self._rtp_port,
) )
await self._announcement_done.wait() # Check if caller hung up or didn't pick up
self._check_announcement_ended_task = (
self.config_entry.async_create_background_task(
self.hass,
self._check_announcement_ended(),
"voip_announcement_ended",
)
)
try:
await self._announcement_future
except TimeoutError:
# Stop ringing
sip_protocol.cancel_call(call_info)
raise
async def _check_announcement_ended(self) -> None: async def _check_announcement_ended(self) -> None:
"""Continuously checks if an audio chunk was received within a time limit. """Continuously checks if an audio chunk was received within a time limit.
@ -211,12 +231,32 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
If not, the caller is presumed to have hung up and the announcement is ended. If not, the caller is presumed to have hung up and the announcement is ended.
""" """
while self._announcement is not None: while self._announcement is not None:
current_time = time.monotonic()
_LOGGER.debug(
"%s %s %s",
self._last_chunk_time,
current_time,
self._announcment_start_time,
)
if (self._last_chunk_time is None) and (
(current_time - self._announcment_start_time)
> _ANNOUNCEMENT_RING_TIMEOUT
):
# Ring timeout
self._announcement = None
self._check_announcement_ended_task = None
self._announcement_future.set_exception(
TimeoutError("User did not pick up in time")
)
_LOGGER.debug("Timed out waiting for the user to pick up the phone")
break
if (self._last_chunk_time is not None) and ( if (self._last_chunk_time is not None) and (
(time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC
): ):
# Caller hung up # Caller hung up
self._announcement = None self._announcement = None
self._announcement_done.set() self._announcement_future.set_result(None)
self._check_announcement_ended_task = None self._check_announcement_ended_task = None
_LOGGER.debug("Announcement ended") _LOGGER.debug("Announcement ended")
break break
@ -248,16 +288,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
self._audio_queue.put_nowait(audio_bytes) self._audio_queue.put_nowait(audio_bytes)
elif self._run_pipeline_task is None: elif self._run_pipeline_task is None:
# Announcement only # Announcement only
if self._check_announcement_ended_task is None:
# Check if caller hung up
self._check_announcement_ended_task = (
self.config_entry.async_create_background_task(
self.hass,
self._check_announcement_ended(),
"voip_announcement_ended",
)
)
# Play announcement (will repeat) # Play announcement (will repeat)
self._run_pipeline_task = self.config_entry.async_create_background_task( self._run_pipeline_task = self.config_entry.async_create_background_task(
self.hass, self.hass,

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/voip", "documentation": "https://www.home-assistant.io/integrations/voip",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["voip-utils==0.3.0"] "requirements": ["voip-utils==0.3.1"]
} }

2
requirements_all.txt generated
View File

@ -2991,7 +2991,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0 vilfo-api-client==0.5.0
# homeassistant.components.voip # homeassistant.components.voip
voip-utils==0.3.0 voip-utils==0.3.1
# homeassistant.components.volkszaehler # homeassistant.components.volkszaehler
volkszaehler==0.4.0 volkszaehler==0.4.0

View File

@ -2407,7 +2407,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0 vilfo-api-client==0.5.0
# homeassistant.components.voip # homeassistant.components.voip
voip-utils==0.3.0 voip-utils==0.3.1
# homeassistant.components.volvooncall # homeassistant.components.volvooncall
volvooncall==0.10.3 volvooncall==0.10.3

View File

@ -941,3 +941,43 @@ async def test_voip_id_is_ip_address(
await announce_task await announce_task
mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False)
@pytest.mark.usefixtures("socket_enabled")
async def test_announce_timeout(
hass: HomeAssistant,
voip_devices: VoIPDevices,
voip_device: VoIPDevice,
) -> None:
"""Test announcement when user does not pick up the phone in time."""
assert await async_setup_component(hass, "voip", {})
satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id)
assert isinstance(satellite, VoipAssistSatellite)
assert (
satellite.supported_features
& assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE
)
announcement = assist_satellite.AssistSatelliteAnnouncement(
message="test announcement",
media_id=_MEDIA_ID,
original_media_id=_MEDIA_ID,
media_id_source="tts",
)
# Protocol has already been mocked, but some methods are not async
mock_protocol: AsyncMock = hass.data[DOMAIN].protocol
mock_protocol.outgoing_call = Mock()
mock_protocol.cancel_call = Mock()
# Very short timeout which will trigger because we don't send any audio in
with (
patch(
"homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT",
0.01,
),
):
satellite.transport = Mock()
with pytest.raises(TimeoutError):
await satellite.async_announce(announcement)