Replace PJSIP with pyVoIP - simpler, pure Python SIP library (v2.0.3)

This commit is contained in:
2026-02-08 14:34:46 +01:00
parent 8e54da29fe
commit 76136b5566
4 changed files with 51 additions and 196 deletions

View File

@@ -1,62 +1,14 @@
ARG BUILD_FROM ARG BUILD_FROM
FROM $BUILD_FROM as builder
# Install build dependencies
RUN apk add --no-cache \
git \
build-base \
python3-dev \
linux-headers \
openssl-dev \
alsa-lib-dev \
opus-dev \
speex-dev \
speexdsp-dev \
py3-pip
# Build PJSIP
WORKDIR /tmp
RUN git clone --depth 1 --branch 2.14.1 https://github.com/pjsip/pjproject.git && \
cd pjproject && \
./configure \
--prefix=/opt/pjsip \
--enable-shared \
--disable-video \
--disable-opencore-amr \
--disable-silk \
--disable-opus \
--disable-resample \
--disable-speex-aec \
--disable-g711-codec \
--disable-l16-codec \
--disable-g722-codec && \
make dep && \
make && \
make install && \
cd pjsip-apps/src/python && \
python3 setup.py build && \
python3 setup.py install --prefix=/opt/pjsip
# Final stage
FROM $BUILD_FROM FROM $BUILD_FROM
# Install runtime dependencies only # Install system dependencies
RUN apk add --no-cache \ RUN apk add --no-cache \
python3 \ python3 \
py3-pip \ py3-pip \
ffmpeg \ ffmpeg \
alsa-lib \ gcc \
openssl \ musl-dev \
opus \ python3-dev
speex \
speexdsp
# Copy PJSIP from builder
COPY --from=builder /opt/pjsip /usr/local
COPY --from=builder /usr/lib/python3.*/site-packages/pjsua2* /usr/lib/python3.11/site-packages/
# Update library cache
RUN ldconfig /usr/local/lib || true
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app

View File

@@ -1,5 +1,5 @@
name: "SIP Voice Notifier" name: "SIP Voice Notifier"
version: "2.0.2" version: "2.0.3"
slug: "sip-notifier" slug: "sip-notifier"
description: "Send voice notifications via SIP phone calls (includes integration)" description: "Send voice notifications via SIP phone calls (includes integration)"
arch: arch:

View File

@@ -2,3 +2,4 @@ flask==3.0.0
requests==2.31.0 requests==2.31.0
pydub==0.25.1 pydub==0.25.1
gtts==2.5.0 gtts==2.5.0
pyVoIP==1.6.6

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SIP Voice Notifier Add-on Service.""" """SIP Voice Notifier Add-on Service using pyVoIP."""
import json import json
import logging import logging
import os import os
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
import requests import requests
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from pydub import AudioSegment from pydub import AudioSegment
import pjsua2 as pj from pyVoIP.VoIP import VoIPPhone, InvalidStateError, CallState
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -26,80 +26,6 @@ CONFIG = {}
DEFAULT_SAMPLE_RATE = 8000 DEFAULT_SAMPLE_RATE = 8000
class CallHandler(pj.Call):
"""Handle SIP call events."""
def __init__(self, account, call_id=pj.PJSUA_INVALID_ID):
pj.Call.__init__(self, account, call_id)
self.player = None
self.audio_file = None
self.connected = False
def onCallState(self, prm):
"""Called when call state changes."""
ci = self.getInfo()
_LOGGER.info(f"Call state: {ci.stateText}")
if ci.state == pj.PJSIP_INV_STATE_CONFIRMED:
_LOGGER.info("Call connected! Playing audio...")
self.connected = True
self.play_audio()
elif ci.state == pj.PJSIP_INV_STATE_DISCONNECTED:
_LOGGER.info("Call disconnected")
def onCallMediaState(self, prm):
"""Called when media state changes."""
ci = self.getInfo()
for mi in ci.media:
if mi.type == pj.PJMEDIA_TYPE_AUDIO and mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE:
aud_med = self.getAudioMedia(mi.index)
try:
pj.Endpoint.instance().audDevManager().getPlaybackDevMedia().startTransmit(aud_med)
aud_med.startTransmit(pj.Endpoint.instance().audDevManager().getPlaybackDevMedia())
except Exception as e:
_LOGGER.warning(f"Audio routing warning: {e}")
def play_audio(self):
"""Play the audio file into the call."""
if not self.audio_file:
_LOGGER.error("No audio file specified")
return
try:
self.player = pj.AudioMediaPlayer()
self.player.createPlayer(self.audio_file, pj.PJMEDIA_FILE_NO_LOOP)
ci = self.getInfo()
for mi in ci.media:
if mi.type == pj.PJMEDIA_TYPE_AUDIO and mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE:
aud_med = self.getAudioMedia(mi.index)
self.player.startTransmit(aud_med)
_LOGGER.info(f"Playing: {self.audio_file}")
break
except Exception as e:
_LOGGER.error(f"Error playing audio: {e}", exc_info=True)
def set_audio_file(self, audio_file: str):
"""Set the audio file to play."""
self.audio_file = audio_file
class SIPAccount(pj.Account):
"""SIP Account handler."""
def __init__(self):
pj.Account.__init__(self)
def onIncomingCall(self, prm):
"""Reject incoming calls."""
call = CallHandler(self, prm.callId)
call_param = pj.CallOpParam()
call_param.statusCode = 486
call.answer(call_param)
def download_and_convert_audio(url: str) -> str: def download_and_convert_audio(url: str) -> str:
"""Download and convert audio to WAV.""" """Download and convert audio to WAV."""
parsed_url = urlparse(url) parsed_url = urlparse(url)
@@ -167,89 +93,68 @@ def generate_tts_audio(message: str) -> str:
def place_sip_call(destination: str, audio_file: str, duration: int): def place_sip_call(destination: str, audio_file: str, duration: int):
"""Place a SIP call.""" """Place a SIP call using pyVoIP."""
ep = pj.Endpoint() sip_user = CONFIG['sip_user']
ep.libCreate() sip_server = CONFIG['sip_server']
sip_password = CONFIG['sip_password']
ep_cfg = pj.EpConfig() _LOGGER.info(f"Connecting to SIP server: {sip_server}")
ep_cfg.logConfig.level = 3
ep_cfg.logConfig.consoleLevel = 0
ep.libInit(ep_cfg)
transport_cfg = pj.TransportConfig()
transport_cfg.port = 0
ep.transportCreate(pj.PJSIP_TRANSPORT_UDP, transport_cfg)
ep.libStart()
_LOGGER.info("PJSIP started")
try: try:
acc = SIPAccount() # Create VoIP phone
acc_cfg = pj.AccountConfig() phone = VoIPPhone(
sip_server,
5060,
sip_user,
sip_password,
callCallback=None,
myIP=None,
sipPort=5060,
rtpPortLow=10000,
rtpPortHigh=20000
)
sip_user = CONFIG['sip_user'] phone.start()
sip_server = CONFIG['sip_server'] time.sleep(2) # Wait for registration
sip_password = CONFIG['sip_password']
if not sip_user.startswith('sip:'): _LOGGER.info(f"Calling: {destination}")
sip_uri = f"sip:{sip_user}@{sip_server}"
else:
sip_uri = sip_user
acc_cfg.idUri = sip_uri # Make call
acc_cfg.regConfig.registrarUri = f"sip:{sip_server}" call = phone.call(destination)
cred = pj.AuthCredInfo("digest", "*", sip_user, 0, sip_password) # Wait for call to be answered
acc_cfg.sipConfig.authCreds.append(cred) timeout = 10
elapsed = 0
acc.create(acc_cfg) while call.state != CallState.ANSWERED and elapsed < timeout:
_LOGGER.info(f"Account created: {sip_uri}")
time.sleep(2)
if not destination.startswith('sip:'):
dest_uri = f"sip:{destination}@{sip_server}"
else:
dest_uri = destination
_LOGGER.info(f"Calling: {dest_uri}")
call = CallHandler(acc)
call.set_audio_file(audio_file)
call_param = pj.CallOpParam()
call_param.opt.audioCount = 1
call_param.opt.videoCount = 0
call.makeCall(dest_uri, call_param)
wait_time = 0
while not call.connected and wait_time < 10:
time.sleep(0.5) time.sleep(0.5)
wait_time += 0.5 elapsed += 0.5
if not call.connected: if call.state != CallState.ANSWERED:
_LOGGER.warning("Call did not connect") _LOGGER.warning("Call not answered within timeout")
call.hangup()
phone.stop()
return
_LOGGER.info("Call answered! Playing audio...")
# Transmit audio file
call.write_audio(audio_file)
# Keep call active
_LOGGER.info(f"Call active for {duration}s") _LOGGER.info(f"Call active for {duration}s")
time.sleep(duration) time.sleep(duration)
# Hangup
_LOGGER.info("Hanging up...") _LOGGER.info("Hanging up...")
hangup_param = pj.CallOpParam() call.hangup()
call.hangup(hangup_param)
time.sleep(1) # Cleanup
phone.stop()
except Exception as e: except Exception as e:
_LOGGER.error(f"Call error: {e}", exc_info=True) _LOGGER.error(f"Call error: {e}", exc_info=True)
raise raise
finally:
try:
ep.libDestroy()
except Exception as e:
_LOGGER.warning(f"Cleanup warning: {e}")
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health(): def health():
@@ -259,10 +164,7 @@ def health():
@app.route('/send_notification', methods=['POST']) @app.route('/send_notification', methods=['POST'])
def handle_send_notification(): def handle_send_notification():
""" """Handle send_notification service call."""
Handle send_notification service call.
This endpoint is called by Home Assistant when the service is invoked.
"""
try: try:
data = request.json data = request.json
destination = data.get('destination') destination = data.get('destination')