178 lines
5.3 KiB
Python
178 lines
5.3 KiB
Python
"""Voice over IP (VoIP) implementation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from functools import partial
|
|
import logging
|
|
from pathlib import Path
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from voip_utils import (
|
|
CallInfo,
|
|
RtcpState,
|
|
RtpDatagramProtocol,
|
|
SdpInfo,
|
|
VoipDatagramProtocol,
|
|
)
|
|
|
|
from homeassistant.components.assist_pipeline import (
|
|
Pipeline,
|
|
PipelineNotFound,
|
|
async_get_pipeline,
|
|
select as pipeline_select,
|
|
)
|
|
from homeassistant.const import __version__
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH
|
|
|
|
if TYPE_CHECKING:
|
|
from .devices import VoIPDevices
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def make_protocol(
|
|
hass: HomeAssistant,
|
|
devices: VoIPDevices,
|
|
call_info: CallInfo,
|
|
rtcp_state: RtcpState | None = None,
|
|
) -> VoipDatagramProtocol:
|
|
"""Plays a pre-recorded message if pipeline is misconfigured."""
|
|
voip_device = devices.async_get_or_create(call_info)
|
|
|
|
pipeline_id = pipeline_select.get_chosen_pipeline(hass, DOMAIN, voip_device.voip_id)
|
|
try:
|
|
pipeline: Pipeline | None = async_get_pipeline(hass, pipeline_id)
|
|
except PipelineNotFound:
|
|
pipeline = None
|
|
|
|
if (
|
|
(pipeline is None)
|
|
or (pipeline.stt_engine is None)
|
|
or (pipeline.tts_engine is None)
|
|
):
|
|
# Play pre-recorded message instead of failing
|
|
return PreRecordMessageProtocol(
|
|
hass,
|
|
"problem.pcm",
|
|
opus_payload_type=call_info.opus_payload_type,
|
|
rtcp_state=rtcp_state,
|
|
)
|
|
|
|
if (protocol := voip_device.protocol) is None:
|
|
raise ValueError("VoIP satellite not found")
|
|
|
|
protocol._rtp_input.opus_payload_type = call_info.opus_payload_type # noqa: SLF001
|
|
protocol._rtp_output.opus_payload_type = call_info.opus_payload_type # noqa: SLF001
|
|
|
|
protocol.rtcp_state = rtcp_state
|
|
if protocol.rtcp_state is not None:
|
|
# Automatically disconnect when BYE is received over RTCP
|
|
protocol.rtcp_state.bye_callback = protocol.disconnect
|
|
|
|
return protocol
|
|
|
|
|
|
class HassVoipDatagramProtocol(VoipDatagramProtocol):
|
|
"""HA UDP server for Voice over IP (VoIP)."""
|
|
|
|
def __init__(self, hass: HomeAssistant, devices: VoIPDevices) -> None:
|
|
"""Set up VoIP call handler."""
|
|
super().__init__(
|
|
sdp_info=SdpInfo(
|
|
username="homeassistant",
|
|
id=time.monotonic_ns(),
|
|
session_name="voip_hass",
|
|
version=__version__,
|
|
),
|
|
valid_protocol_factory=lambda call_info, rtcp_state: make_protocol(
|
|
hass, devices, call_info, rtcp_state
|
|
),
|
|
invalid_protocol_factory=(
|
|
lambda call_info, rtcp_state: PreRecordMessageProtocol(
|
|
hass,
|
|
"not_configured.pcm",
|
|
opus_payload_type=call_info.opus_payload_type,
|
|
rtcp_state=rtcp_state,
|
|
)
|
|
),
|
|
)
|
|
self.hass = hass
|
|
self.devices = devices
|
|
self._closed_event = asyncio.Event()
|
|
|
|
def is_valid_call(self, call_info: CallInfo) -> bool:
|
|
"""Filter calls."""
|
|
device = self.devices.async_get_or_create(call_info)
|
|
return device.async_allow_call(self.hass)
|
|
|
|
def connection_lost(self, exc):
|
|
"""Signal wait_closed when transport is completely closed."""
|
|
self.hass.loop.call_soon_threadsafe(self._closed_event.set)
|
|
|
|
async def wait_closed(self) -> None:
|
|
"""Wait for connection_lost to be called."""
|
|
await self._closed_event.wait()
|
|
|
|
|
|
class PreRecordMessageProtocol(RtpDatagramProtocol):
|
|
"""Plays a pre-recorded message on a loop."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
file_name: str,
|
|
opus_payload_type: int,
|
|
message_delay: float = 1.0,
|
|
loop_delay: float = 2.0,
|
|
rtcp_state: RtcpState | None = None,
|
|
) -> None:
|
|
"""Set up RTP server."""
|
|
super().__init__(
|
|
rate=RATE,
|
|
width=WIDTH,
|
|
channels=CHANNELS,
|
|
opus_payload_type=opus_payload_type,
|
|
rtcp_state=rtcp_state,
|
|
)
|
|
self.hass = hass
|
|
self.file_name = file_name
|
|
self.message_delay = message_delay
|
|
self.loop_delay = loop_delay
|
|
self._audio_task: asyncio.Task | None = None
|
|
self._audio_bytes: bytes | None = None
|
|
|
|
def on_chunk(self, audio_bytes: bytes) -> None:
|
|
"""Handle raw audio chunk."""
|
|
if self.transport is None:
|
|
return
|
|
|
|
if self._audio_bytes is None:
|
|
# 16Khz, 16-bit mono audio message
|
|
file_path = Path(__file__).parent / self.file_name
|
|
self._audio_bytes = file_path.read_bytes()
|
|
|
|
if self._audio_task is None:
|
|
self._audio_task = self.hass.async_create_background_task(
|
|
self._play_message(),
|
|
"voip_not_connected",
|
|
)
|
|
|
|
async def _play_message(self) -> None:
|
|
await self.hass.async_add_executor_job(
|
|
partial(
|
|
self.send_audio,
|
|
self._audio_bytes,
|
|
silence_before=self.message_delay,
|
|
**RTP_AUDIO_SETTINGS,
|
|
)
|
|
)
|
|
|
|
await asyncio.sleep(self.loop_delay)
|
|
|
|
# Allow message to play again
|
|
self._audio_task = None
|