Replace PJSIP with pyVoIP - simpler, pure Python SIP library (v2.0.3)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
ep.libCreate()
|
|
||||||
|
|
||||||
ep_cfg = pj.EpConfig()
|
|
||||||
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:
|
|
||||||
acc = SIPAccount()
|
|
||||||
acc_cfg = pj.AccountConfig()
|
|
||||||
|
|
||||||
sip_user = CONFIG['sip_user']
|
sip_user = CONFIG['sip_user']
|
||||||
sip_server = CONFIG['sip_server']
|
sip_server = CONFIG['sip_server']
|
||||||
sip_password = CONFIG['sip_password']
|
sip_password = CONFIG['sip_password']
|
||||||
|
|
||||||
if not sip_user.startswith('sip:'):
|
_LOGGER.info(f"Connecting to SIP server: {sip_server}")
|
||||||
sip_uri = f"sip:{sip_user}@{sip_server}"
|
|
||||||
else:
|
|
||||||
sip_uri = sip_user
|
|
||||||
|
|
||||||
acc_cfg.idUri = sip_uri
|
try:
|
||||||
acc_cfg.regConfig.registrarUri = f"sip:{sip_server}"
|
# Create VoIP phone
|
||||||
|
phone = VoIPPhone(
|
||||||
|
sip_server,
|
||||||
|
5060,
|
||||||
|
sip_user,
|
||||||
|
sip_password,
|
||||||
|
callCallback=None,
|
||||||
|
myIP=None,
|
||||||
|
sipPort=5060,
|
||||||
|
rtpPortLow=10000,
|
||||||
|
rtpPortHigh=20000
|
||||||
|
)
|
||||||
|
|
||||||
cred = pj.AuthCredInfo("digest", "*", sip_user, 0, sip_password)
|
phone.start()
|
||||||
acc_cfg.sipConfig.authCreds.append(cred)
|
time.sleep(2) # Wait for registration
|
||||||
|
|
||||||
acc.create(acc_cfg)
|
_LOGGER.info(f"Calling: {destination}")
|
||||||
_LOGGER.info(f"Account created: {sip_uri}")
|
|
||||||
|
|
||||||
time.sleep(2)
|
# Make call
|
||||||
|
call = phone.call(destination)
|
||||||
|
|
||||||
if not destination.startswith('sip:'):
|
# Wait for call to be answered
|
||||||
dest_uri = f"sip:{destination}@{sip_server}"
|
timeout = 10
|
||||||
else:
|
elapsed = 0
|
||||||
dest_uri = destination
|
while call.state != CallState.ANSWERED and elapsed < timeout:
|
||||||
|
|
||||||
_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')
|
||||||
|
|||||||
Reference in New Issue
Block a user