Home Assistant Unofficial Reference 2024.12.1
voip.py
Go to the documentation of this file.
1 """Voice over IP (VoIP) implementation."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from functools import partial
7 import logging
8 from pathlib import Path
9 import time
10 from typing import TYPE_CHECKING
11 
12 from voip_utils import (
13  CallInfo,
14  RtcpState,
15  RtpDatagramProtocol,
16  SdpInfo,
17  VoipDatagramProtocol,
18 )
19 
21  Pipeline,
22  PipelineNotFound,
23  async_get_pipeline,
24  select as pipeline_select,
25 )
26 from homeassistant.const import __version__
27 from homeassistant.core import HomeAssistant
28 
29 from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH
30 
31 if TYPE_CHECKING:
32  from .devices import VoIPDevices
33 
34 _LOGGER = logging.getLogger(__name__)
35 
36 
38  hass: HomeAssistant,
39  devices: VoIPDevices,
40  call_info: CallInfo,
41  rtcp_state: RtcpState | None = None,
42 ) -> VoipDatagramProtocol:
43  """Plays a pre-recorded message if pipeline is misconfigured."""
44  voip_device = devices.async_get_or_create(call_info)
45 
46  pipeline_id = pipeline_select.get_chosen_pipeline(hass, DOMAIN, voip_device.voip_id)
47  try:
48  pipeline: Pipeline | None = async_get_pipeline(hass, pipeline_id)
49  except PipelineNotFound:
50  pipeline = None
51 
52  if (
53  (pipeline is None)
54  or (pipeline.stt_engine is None)
55  or (pipeline.tts_engine is None)
56  ):
57  # Play pre-recorded message instead of failing
59  hass,
60  "problem.pcm",
61  opus_payload_type=call_info.opus_payload_type,
62  rtcp_state=rtcp_state,
63  )
64 
65  if (protocol := voip_device.protocol) is None:
66  raise ValueError("VoIP satellite not found")
67 
68  protocol._rtp_input.opus_payload_type = call_info.opus_payload_type # noqa: SLF001
69  protocol._rtp_output.opus_payload_type = call_info.opus_payload_type # noqa: SLF001
70 
71  protocol.rtcp_state = rtcp_state
72  if protocol.rtcp_state is not None:
73  # Automatically disconnect when BYE is received over RTCP
74  protocol.rtcp_state.bye_callback = protocol.disconnect
75 
76  return protocol
77 
78 
79 class HassVoipDatagramProtocol(VoipDatagramProtocol):
80  """HA UDP server for Voice over IP (VoIP)."""
81 
82  def __init__(self, hass: HomeAssistant, devices: VoIPDevices) -> None:
83  """Set up VoIP call handler."""
84  super().__init__(
85  sdp_info=SdpInfo(
86  username="homeassistant",
87  id=time.monotonic_ns(),
88  session_name="voip_hass",
89  version=__version__,
90  ),
91  valid_protocol_factory=lambda call_info, rtcp_state: make_protocol(
92  hass, devices, call_info, rtcp_state
93  ),
94  invalid_protocol_factory=(
95  lambda call_info, rtcp_state: PreRecordMessageProtocol(
96  hass,
97  "not_configured.pcm",
98  opus_payload_type=call_info.opus_payload_type,
99  rtcp_state=rtcp_state,
100  )
101  ),
102  )
103  self.hasshass = hass
104  self.devicesdevices = devices
105  self._closed_event_closed_event = asyncio.Event()
106 
107  def is_valid_call(self, call_info: CallInfo) -> bool:
108  """Filter calls."""
109  device = self.devicesdevices.async_get_or_create(call_info)
110  return device.async_allow_call(self.hasshass)
111 
112  def connection_lost(self, exc):
113  """Signal wait_closed when transport is completely closed."""
114  self.hasshass.loop.call_soon_threadsafe(self._closed_event_closed_event.set)
115 
116  async def wait_closed(self) -> None:
117  """Wait for connection_lost to be called."""
118  await self._closed_event_closed_event.wait()
119 
120 
121 class PreRecordMessageProtocol(RtpDatagramProtocol):
122  """Plays a pre-recorded message on a loop."""
123 
124  def __init__(
125  self,
126  hass: HomeAssistant,
127  file_name: str,
128  opus_payload_type: int,
129  message_delay: float = 1.0,
130  loop_delay: float = 2.0,
131  rtcp_state: RtcpState | None = None,
132  ) -> None:
133  """Set up RTP server."""
134  super().__init__(
135  rate=RATE,
136  width=WIDTH,
137  channels=CHANNELS,
138  opus_payload_type=opus_payload_type,
139  rtcp_state=rtcp_state,
140  )
141  self.hasshass = hass
142  self.file_namefile_name = file_name
143  self.message_delaymessage_delay = message_delay
144  self.loop_delayloop_delay = loop_delay
145  self._audio_task_audio_task: asyncio.Task | None = None
146  self._audio_bytes_audio_bytes: bytes | None = None
147 
148  def on_chunk(self, audio_bytes: bytes) -> None:
149  """Handle raw audio chunk."""
150  if self.transport is None:
151  return
152 
153  if self._audio_bytes_audio_bytes is None:
154  # 16Khz, 16-bit mono audio message
155  file_path = Path(__file__).parent / self.file_namefile_name
156  self._audio_bytes_audio_bytes = file_path.read_bytes()
157 
158  if self._audio_task_audio_task is None:
159  self._audio_task_audio_task = self.hasshass.async_create_background_task(
160  self._play_message_play_message(),
161  "voip_not_connected",
162  )
163 
164  async def _play_message(self) -> None:
165  await self.hasshass.async_add_executor_job(
166  partial(
167  self.send_audio,
168  self._audio_bytes_audio_bytes,
169  silence_before=self.message_delaymessage_delay,
170  **RTP_AUDIO_SETTINGS,
171  )
172  )
173 
174  await asyncio.sleep(self.loop_delayloop_delay)
175 
176  # Allow message to play again
177  self._audio_task_audio_task = None
None __init__(self, HomeAssistant hass, VoIPDevices devices)
Definition: voip.py:82
bool is_valid_call(self, CallInfo call_info)
Definition: voip.py:107
None __init__(self, HomeAssistant hass, str file_name, int opus_payload_type, float message_delay=1.0, float loop_delay=2.0, RtcpState|None rtcp_state=None)
Definition: voip.py:132
Pipeline async_get_pipeline(HomeAssistant hass, str|None pipeline_id=None)
Definition: pipeline.py:282
VoipDatagramProtocol make_protocol(HomeAssistant hass, VoIPDevices devices, CallInfo call_info, RtcpState|None rtcp_state=None)
Definition: voip.py:42