Skip to content

Commit

Permalink
Cancel call if user does not pick up (#136858)
Browse files Browse the repository at this point in the history
  • Loading branch information
synesthesiam authored Jan 29, 2025
1 parent b500fde commit d206553
Show file tree
Hide file tree
Showing 5 changed files with 90 additions and 20 deletions.
64 changes: 47 additions & 17 deletions homeassistant/components/voip/assist_satellite.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import wave

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.assist_pipeline import PipelineEvent, PipelineEventType
Expand Down Expand Up @@ -43,6 +43,7 @@
_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5
_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0
_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5
_ANNOUNCEMENT_RING_TIMEOUT: Final = 30


class Tones(IntFlag):
Expand Down Expand Up @@ -116,7 +117,8 @@ def __init__(
self._processing_tone_done = asyncio.Event()

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._last_chunk_time: float | None = None
self._rtp_port: int | None = None
Expand Down Expand Up @@ -170,7 +172,7 @@ async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> Non
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:
# Choose random port for RTP
Expand All @@ -194,29 +196,67 @@ async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> Non
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

# 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,
destination=destination_endpoint,
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:
"""Continuously checks if an audio chunk was received within a time limit.
If not, the caller is presumed to have hung up and the announcement is ended.
"""
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 (
(time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC
(current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC
):
# Caller hung up
self._announcement = None
self._announcement_done.set()
self._announcement_future.set_result(None)
self._check_announcement_ended_task = None
_LOGGER.debug("Announcement ended")
break
Expand Down Expand Up @@ -248,16 +288,6 @@ def on_chunk(self, audio_bytes: bytes) -> None:
self._audio_queue.put_nowait(audio_bytes)
elif self._run_pipeline_task is None:
# 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)
self._run_pipeline_task = self.config_entry.async_create_background_task(
self.hass,
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/voip/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/voip",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["voip-utils==0.3.0"]
"requirements": ["voip-utils==0.3.1"]
}
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions tests/components/voip/test_voip.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,3 +941,43 @@ async def test_voip_id_is_ip_address(
await announce_task

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)

0 comments on commit d206553

Please sign in to comment.