Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Outgoing call #19

Merged
merged 20 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ tmp/
htmlcov

.projectile
.env
.venv/
venv/
.mypy_cache/
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# VoIP Utils

Voice over IP utilities for the [voip integration](https://www.home-assistant.io/integrations/voip/).

## Test outgoing call
Install dependencies from requirements_dev.txt

Set environment variables for source and destination endpoints in .env file
CALL_SRC_USER = "homeassistant"
CALL_SRC_IP = "192.168.1.1"
CALL_SRC_PORT = 5060
CALL_VIA_IP = "192.168.1.1"
CALL_DEST_IP = "192.168.1.2"
CALL_DEST_PORT = 5060
CALL_DEST_USER = "phone"

Run script
python call_example.py

174 changes: 174 additions & 0 deletions call_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import asyncio
import logging
import os
import socket
from functools import partial
from pathlib import Path
from typing import Any, Callable, Optional, Set

from dotenv import load_dotenv

from voip_utils.call_phone import VoipCallDatagramProtocol
from voip_utils.sip import (
CallInfo,
CallPhoneDatagramProtocol,
SdpInfo,
SipEndpoint,
get_sip_endpoint,
)
from voip_utils.voip import RtcpDatagramProtocol, RtcpState, RtpDatagramProtocol

_LOGGER = logging.getLogger(__name__)

load_dotenv()


def get_env_int(env_var: str, default_val: int) -> int:
value = os.getenv(env_var)
if value is None:
return default_val
try:
return int(value)
except ValueError:
return default_val


CALL_SRC_USER = os.getenv("CALL_SRC_USER")
CALL_SRC_IP = os.getenv("CALL_SRC_IP", "127.0.0.1")
CALL_SRC_PORT = get_env_int("CALL_SRC_PORT", 5060)
CALL_VIA_IP = os.getenv("CALL_VIA_IP")
CALL_DEST_IP = os.getenv("CALL_DEST_IP", "127.0.0.1")
CALL_DEST_PORT = get_env_int("CALL_DEST_PORT", 5060)
CALL_DEST_USER = os.getenv("CALL_DEST_USER")


RATE = 16000
WIDTH = 2
CHANNELS = 1
RTP_AUDIO_SETTINGS = {
"rate": RATE,
"width": WIDTH,
"channels": CHANNELS,
"sleep_ratio": 0.99,
}


class PreRecordMessageProtocol(RtpDatagramProtocol):
"""Plays a pre-recorded message on a loop."""

def __init__(
self,
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.loop = asyncio.get_running_loop()
self.file_name = file_name
self.message_delay = message_delay
self.loop_delay = loop_delay
self._audio_task: asyncio.Task | None = None
file_path = Path(__file__).parent / self.file_name
self._audio_bytes: bytes = file_path.read_bytes()
_LOGGER.debug("Created PreRecordMessageProtocol")

def on_chunk(self, audio_bytes: bytes) -> None:
"""Handle raw audio chunk."""
_LOGGER.debug("on_chunk")
if self.transport is None:
return

if self._audio_task is None:
self._audio_task = self.loop.create_task(
self._play_message(),
name="voip_not_connected",
)

async def _play_message(self) -> None:
_LOGGER.debug("_play_message")
self.send_audio(
self._audio_bytes,
self.rate,
self.width,
self.channels,
self.addr,
silence_before=self.message_delay,
)

await asyncio.sleep(self.loop_delay)

# Allow message to play again - Only play once for testing
# self._audio_task = None


async def main() -> None:
logging.basicConfig(level=logging.DEBUG)

loop = asyncio.get_event_loop()
source = get_sip_endpoint(
host=CALL_SRC_IP, port=CALL_SRC_PORT, username=CALL_SRC_USER, description=None
)
destination = get_sip_endpoint(
host=CALL_DEST_IP,
port=CALL_DEST_PORT,
username=CALL_DEST_USER,
description=None,
)

# Find free RTP/RTCP ports
rtp_port = 0

while True:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)

# Bind to a random UDP port
sock.bind(("", 0))
_, rtp_port = sock.getsockname()

# Close socket to free port for re-use
sock.close()

# Check that the next port up is available for RTCP
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.bind(("", rtp_port + 1))

# Will be opened again below
sock.close()

# Found our ports
break
except OSError:
# RTCP port is taken
pass

_, protocol = await loop.create_datagram_endpoint(
lambda: VoipCallDatagramProtocol(
None,
source,
destination,
rtp_port,
lambda call_info, rtcp_state: PreRecordMessageProtocol(
"problem.pcm",
call_info.opus_payload_type,
rtcp_state=rtcp_state,
),
),
local_addr=(CALL_SRC_IP, CALL_SRC_PORT),
)

await protocol.wait_closed()


if __name__ == "__main__":
asyncio.run(main())
Binary file added problem.pcm
Binary file not shown.
1 change: 1 addition & 0 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ isort==5.12.0
mypy==1.1.1
pylint==3.2.5
pytest==7.2.2
python-dotenv==1.0.1
46 changes: 29 additions & 17 deletions tests/test_sip.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
"""Test voip_utils SIP functionality."""

from voip_utils.sip import SipDatagramProtocol
from voip_utils.sip import SipEndpoint


def test_parse_header_for_uri():
endpoint, name = SipDatagramProtocol._parse_uri_header(
None, '"Test Name" <sip:[email protected]>'
endpoint = SipEndpoint(
'"Test Name" <sip:[email protected]>'
)
assert name == "Test Name"
assert endpoint == "sip:[email protected]"
assert endpoint.description == "Test Name"
assert endpoint.uri == "sip:[email protected]"
assert endpoint.username == "12345"
assert endpoint.host == "example.com"
assert endpoint.port == 5060


def test_parse_header_for_uri_no_name():
endpoint, name = SipDatagramProtocol._parse_uri_header(
None, "sip:[email protected]"
endpoint = SipEndpoint(
"sip:[email protected]"
)
assert name is None
assert endpoint == "sip:[email protected]"
assert endpoint.description is None
assert endpoint.uri == "sip:[email protected]"


def test_parse_header_for_uri_sips():
endpoint, name = SipDatagramProtocol._parse_uri_header(
None, '"Test Name" <sips:[email protected]>'
endpoint = SipEndpoint(
'"Test Name" <sips:[email protected]>'
)
assert name == "Test Name"
assert endpoint == "sips:[email protected]"
assert endpoint.description == "Test Name"
assert endpoint.uri == "sips:[email protected]"


def test_parse_header_for_uri_no_space_name():
endpoint, name = SipDatagramProtocol._parse_uri_header(
None, "Test <sip:[email protected]>"
endpoint = SipEndpoint(
"Test <sip:[email protected]>"
)
assert name == "Test"
assert endpoint == "sip:[email protected]"
assert endpoint.description == "Test"
assert endpoint.uri == "sip:[email protected]"


def test_parse_header_for_uri_no_username():
endpoint = SipEndpoint(
"Test <sip:example.com>"
)
assert endpoint.description == "Test"
assert endpoint.username is None
assert endpoint.uri == "sip:example.com"
89 changes: 89 additions & 0 deletions voip_utils/call_phone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import asyncio
import logging
from functools import partial
from typing import Any, Callable, Set

from .sip import CallInfo, CallPhoneDatagramProtocol, SdpInfo, SipEndpoint
from .voip import RtcpDatagramProtocol, RtcpState

_LOGGER = logging.getLogger(__name__)

RATE = 16000
WIDTH = 2
CHANNELS = 1
RTP_AUDIO_SETTINGS = {
"rate": RATE,
"width": WIDTH,
"channels": CHANNELS,
"sleep_ratio": 0.99,
}

CallProtocolFactory = Callable[[CallInfo, RtcpState], asyncio.DatagramProtocol]


class VoipCallDatagramProtocol(CallPhoneDatagramProtocol):
"""UDP server for Voice over IP (VoIP)."""

def __init__(
self,
sdp_info: SdpInfo | None,
source_endpoint: SipEndpoint,
dest_endpoint: SipEndpoint,
rtp_port: int,
call_protocol_factory: CallProtocolFactory,
) -> None:
"""Set up VoIP call handler."""
super().__init__(sdp_info, source_endpoint, dest_endpoint, rtp_port)
self.call_protocol_factory = call_protocol_factory
self._tasks: Set[asyncio.Future[Any]] = set()

def on_call(self, call_info: CallInfo):
"""Answer incoming calls and start RTP server on a random port."""

rtp_ip = self._source_endpoint.host

_LOGGER.debug(
"Starting RTP server on ip=%s, rtp_port=%s, rtcp_port=%s",
rtp_ip,
self._rtp_port,
self._rtp_port + 1,
)

# Handle RTP packets in RTP server
rtp_task = asyncio.create_task(
self._create_rtp_server(
self.call_protocol_factory, call_info, rtp_ip, self._rtp_port
)
)
self._tasks.add(rtp_task)
rtp_task.add_done_callback(self._tasks.remove)

_LOGGER.debug("RTP server started")

def end_call(self, task):
"""Callback for hanging up when call is ended."""
self.hang_up()

async def _create_rtp_server(
self,
protocol_factory: CallProtocolFactory,
call_info: CallInfo,
rtp_ip: str,
rtp_port: int,
):
# Shared state between RTP/RTCP servers
rtcp_state = RtcpState()

loop = asyncio.get_running_loop()

# RTCP server
await loop.create_datagram_endpoint(
lambda: RtcpDatagramProtocol(rtcp_state),
(rtp_ip, rtp_port + 1),
)

# RTP server
await loop.create_datagram_endpoint(
partial(protocol_factory, call_info, rtcp_state),
(rtp_ip, rtp_port),
)
Binary file added voip_utils/problem.pcm
Binary file not shown.
Loading