From 76136b55662674e30bf9bcce76e99a80f5976c56 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sun, 8 Feb 2026 14:34:46 +0100 Subject: [PATCH] Replace PJSIP with pyVoIP - simpler, pure Python SIP library (v2.0.3) --- sip-notifier/Dockerfile | 56 +--------- sip-notifier/config.yaml | 2 +- sip-notifier/requirements.txt | 1 + sip-notifier/sip_service.py | 188 ++++++++-------------------------- 4 files changed, 51 insertions(+), 196 deletions(-) diff --git a/sip-notifier/Dockerfile b/sip-notifier/Dockerfile index 35f0cce..bfd1481 100644 --- a/sip-notifier/Dockerfile +++ b/sip-notifier/Dockerfile @@ -1,62 +1,14 @@ 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 -# Install runtime dependencies only +# Install system dependencies RUN apk add --no-cache \ python3 \ py3-pip \ ffmpeg \ - alsa-lib \ - openssl \ - opus \ - 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 + gcc \ + musl-dev \ + python3-dev # Set working directory WORKDIR /app diff --git a/sip-notifier/config.yaml b/sip-notifier/config.yaml index 9c12d39..94caddf 100644 --- a/sip-notifier/config.yaml +++ b/sip-notifier/config.yaml @@ -1,5 +1,5 @@ name: "SIP Voice Notifier" -version: "2.0.2" +version: "2.0.3" slug: "sip-notifier" description: "Send voice notifications via SIP phone calls (includes integration)" arch: diff --git a/sip-notifier/requirements.txt b/sip-notifier/requirements.txt index 32da09f..f6aacec 100644 --- a/sip-notifier/requirements.txt +++ b/sip-notifier/requirements.txt @@ -2,3 +2,4 @@ flask==3.0.0 requests==2.31.0 pydub==0.25.1 gtts==2.5.0 +pyVoIP==1.6.6 diff --git a/sip-notifier/sip_service.py b/sip-notifier/sip_service.py index 5efff9c..4a56e15 100644 --- a/sip-notifier/sip_service.py +++ b/sip-notifier/sip_service.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""SIP Voice Notifier Add-on Service.""" +"""SIP Voice Notifier Add-on Service using pyVoIP.""" import json import logging import os @@ -10,7 +10,7 @@ from urllib.parse import urlparse import requests from flask import Flask, request, jsonify from pydub import AudioSegment -import pjsua2 as pj +from pyVoIP.VoIP import VoIPPhone, InvalidStateError, CallState # Configure logging logging.basicConfig( @@ -26,80 +26,6 @@ CONFIG = {} 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: """Download and convert audio to WAV.""" parsed_url = urlparse(url) @@ -167,88 +93,67 @@ def generate_tts_audio(message: str) -> str: def place_sip_call(destination: str, audio_file: str, duration: int): - """Place a SIP call.""" - ep = pj.Endpoint() - ep.libCreate() + """Place a SIP call using pyVoIP.""" + sip_user = CONFIG['sip_user'] + sip_server = CONFIG['sip_server'] + sip_password = CONFIG['sip_password'] - 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") + _LOGGER.info(f"Connecting to SIP server: {sip_server}") try: - acc = SIPAccount() - acc_cfg = pj.AccountConfig() + # Create VoIP phone + phone = VoIPPhone( + sip_server, + 5060, + sip_user, + sip_password, + callCallback=None, + myIP=None, + sipPort=5060, + rtpPortLow=10000, + rtpPortHigh=20000 + ) - sip_user = CONFIG['sip_user'] - sip_server = CONFIG['sip_server'] - sip_password = CONFIG['sip_password'] + phone.start() + time.sleep(2) # Wait for registration - if not sip_user.startswith('sip:'): - sip_uri = f"sip:{sip_user}@{sip_server}" - else: - sip_uri = sip_user - - acc_cfg.idUri = sip_uri - acc_cfg.regConfig.registrarUri = f"sip:{sip_server}" + _LOGGER.info(f"Calling: {destination}") - cred = pj.AuthCredInfo("digest", "*", sip_user, 0, sip_password) - acc_cfg.sipConfig.authCreds.append(cred) + # Make call + call = phone.call(destination) - acc.create(acc_cfg) - _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: + # Wait for call to be answered + timeout = 10 + elapsed = 0 + while call.state != CallState.ANSWERED and elapsed < timeout: time.sleep(0.5) - wait_time += 0.5 + elapsed += 0.5 - if not call.connected: - _LOGGER.warning("Call did not connect") + if call.state != CallState.ANSWERED: + _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") time.sleep(duration) + # Hangup _LOGGER.info("Hanging up...") - hangup_param = pj.CallOpParam() - call.hangup(hangup_param) + call.hangup() - time.sleep(1) + # Cleanup + phone.stop() except Exception as e: _LOGGER.error(f"Call error: {e}", exc_info=True) raise - - finally: - try: - ep.libDestroy() - except Exception as e: - _LOGGER.warning(f"Cleanup warning: {e}") @app.route('/health', methods=['GET']) @@ -259,10 +164,7 @@ def health(): @app.route('/send_notification', methods=['POST']) def handle_send_notification(): - """ - Handle send_notification service call. - This endpoint is called by Home Assistant when the service is invoked. - """ + """Handle send_notification service call.""" try: data = request.json destination = data.get('destination')