Switch from pyVoIP to Linphone CLI - actually works! (v3.0.0)

This commit is contained in:
2026-02-08 15:57:21 +01:00
parent 8e79a3a576
commit f35a83761c
4 changed files with 121 additions and 119 deletions

View File

@@ -1,14 +1,12 @@
ARG BUILD_FROM ARG BUILD_FROM
FROM $BUILD_FROM FROM $BUILD_FROM
# Install system dependencies # Install system dependencies including Linphone
RUN apk add --no-cache \ RUN apk add --no-cache \
python3 \ python3 \
py3-pip \ py3-pip \
ffmpeg \ ffmpeg \
gcc \ linphone \
musl-dev \
python3-dev \
git git
# Set working directory # Set working directory
@@ -19,7 +17,7 @@ COPY requirements.txt .
RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt
# Remove git after installation to reduce image size # Remove git after installation to reduce image size
RUN apk del git gcc musl-dev python3-dev RUN apk del git
# Copy application files # Copy application files
COPY run.sh . COPY run.sh .

View File

@@ -1,5 +1,5 @@
name: "SIP Voice Notifier" name: "SIP Voice Notifier"
version: "2.1.2" version: "3.0.0"
slug: "sip-notifier" slug: "sip-notifier"
description: "Send voice notifications via SIP phone calls" description: "Send voice notifications via SIP phone calls"
arch: arch:

View File

@@ -2,4 +2,3 @@ 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
git+https://github.com/tayler6000/pyVoIP.git

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SIP Voice Notifier Add-on Service using pyVoIP.""" """SIP Voice Notifier Add-on Service using Linphone CLI."""
import json import json
import logging import logging
import os import os
import socket import subprocess
import tempfile import tempfile
import time import time
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -11,7 +11,6 @@ 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
from pyVoIP.VoIP import VoIPPhone, InvalidStateError, CallState
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -27,17 +26,52 @@ CONFIG = {}
DEFAULT_SAMPLE_RATE = 8000 DEFAULT_SAMPLE_RATE = 8000
def get_local_ip(): def setup_linphone_config():
"""Get local IP address of the container.""" """Create Linphone configuration file."""
try: sip_user = CONFIG['sip_user']
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sip_server = CONFIG['sip_server']
s.connect(("8.8.8.8", 80)) sip_password = CONFIG['sip_password']
local_ip = s.getsockname()[0]
s.close() config_dir = '/data/linphone'
return local_ip os.makedirs(config_dir, exist_ok=True)
except Exception as e:
_LOGGER.warning(f"Could not auto-detect IP: {e}, using 0.0.0.0") config_file = f'{config_dir}/linphonerc'
return "0.0.0.0"
# Create Linphone configuration
config_content = f"""[sip]
sip_port=5060
sip_tcp_port=-1
sip_tls_port=-1
default_proxy=0
[rtp]
audio_rtp_port=7078
audio_adaptive_jitt_comp_enabled=1
[sound]
playback_dev_id=ALSA: default device
capture_dev_id=ALSA: default device
[proxy_0]
reg_proxy=sip:{sip_server}
reg_identity=sip:{sip_user}@{sip_server}
reg_expires=3600
reg_sendregister=1
publish=0
dial_escape_plus=0
[auth_info_0]
username={sip_user}
userid={sip_user}
passwd={sip_password}
realm={sip_server}
"""
with open(config_file, 'w') as f:
f.write(config_content)
_LOGGER.info(f"Linphone config created: {config_file}")
return config_file
def download_and_convert_audio(url: str) -> str: def download_and_convert_audio(url: str) -> str:
@@ -106,111 +140,87 @@ def generate_tts_audio(message: str) -> str:
raise Exception("gTTS not available") raise Exception("gTTS not available")
def place_sip_call(destination: str, audio_file: str, duration: int): def place_sip_call_linphone(destination: str, audio_file: str, duration: int):
"""Place a SIP call using pyVoIP.""" """Place a SIP call using Linphone CLI."""
sip_user = CONFIG['sip_user'] config_file = setup_linphone_config()
sip_server = CONFIG['sip_server'] sip_server = CONFIG['sip_server']
sip_password = CONFIG['sip_password']
# Get local IP # Build destination URI
local_ip = get_local_ip() if not destination.startswith('sip:'):
dest_uri = f"sip:{destination}@{sip_server}"
else:
dest_uri = destination
_LOGGER.info(f"Local IP: {local_ip}") _LOGGER.info(f"Calling {dest_uri} with Linphone...")
_LOGGER.info(f"SIP Server: {sip_server}")
_LOGGER.info(f"SIP User: {sip_user}")
_LOGGER.info(f"Destination: {destination}")
phone = None
call = None
try: try:
# Create VoIP phone # Use linphonec (Linphone console) to make the call
_LOGGER.info("Creating SIP phone...") # linphonec command format:
phone = VoIPPhone( # linphonec -c config_file -a -C (console mode, auto-answer calls to us)
sip_server,
5060, cmd = [
sip_user, 'linphonec',
sip_password, '-c', config_file,
callCallback=None, '-C' # Console mode
myIP=local_ip, ]
sipPort=5060,
rtpPortLow=10000, _LOGGER.info(f"Starting Linphone: {' '.join(cmd)}")
rtpPortHigh=20000
# Start linphonec process
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
) )
_LOGGER.info("Starting SIP phone...") # Wait for registration
phone.start()
_LOGGER.info("Waiting for SIP registration...") _LOGGER.info("Waiting for SIP registration...")
time.sleep(3) time.sleep(5)
_LOGGER.info("Initiating call...") # Make the call
call = phone.call(destination) _LOGGER.info(f"Sending call command to: {dest_uri}")
process.stdin.write(f"call {dest_uri}\n")
process.stdin.flush()
_LOGGER.info(f"Call initiated, waiting for answer (state: {call.state})...") # Wait for call to establish
_LOGGER.info("Waiting for call to connect...")
time.sleep(5)
# Wait for call to be answered # Play audio
timeout = 20 _LOGGER.info(f"Playing audio file: {audio_file}")
elapsed = 0 process.stdin.write(f"play {audio_file}\n")
while call.state not in [CallState.ANSWERED, CallState.ENDED] and elapsed < timeout: process.stdin.flush()
time.sleep(0.5)
elapsed += 0.5
if elapsed % 2 == 0:
_LOGGER.info(f"Call state: {call.state} ({elapsed}s)")
if call.state == CallState.ANSWERED: # Keep call active
_LOGGER.info("✅ Call answered! Playing audio...") _LOGGER.info(f"Keeping call active for {duration}s...")
time.sleep(duration)
# Transmit audio file # Terminate call
call.write_audio(audio_file) _LOGGER.info("Terminating call...")
process.stdin.write("terminate\n")
process.stdin.flush()
time.sleep(2)
# Keep call active # Quit linphone
_LOGGER.info(f"Keeping call active for {duration}s...") process.stdin.write("quit\n")
time.sleep(duration) process.stdin.flush()
# Hangup # Wait for process to end
_LOGGER.info("Hanging up...") try:
call.hangup() process.wait(timeout=5)
_LOGGER.info("Call completed successfully") except subprocess.TimeoutExpired:
process.kill()
elif call.state == CallState.ENDED: _LOGGER.info("Call completed")
_LOGGER.warning("Call ended before being answered (rejected or failed)")
raise Exception("Call was rejected or failed to connect")
else:
_LOGGER.warning(f"Call not answered within {timeout}s")
_LOGGER.warning(f"Final state: {call.state}")
_LOGGER.warning("Possible issues:")
_LOGGER.warning("- SIP credentials may be incorrect")
_LOGGER.warning("- Destination number format may be wrong")
_LOGGER.warning("- SIP server may be rejecting the call")
_LOGGER.warning("- Network/firewall issues")
# Try to end the call gracefully
try:
call.deny()
except:
pass
raise Exception(f"Call timeout - state remained: {call.state}")
except InvalidStateError as e:
_LOGGER.error(f"Invalid call state: {e}")
raise Exception(f"Call state error: {e}")
except Exception as e: except Exception as e:
_LOGGER.error(f"Call error: {e}", exc_info=True) _LOGGER.error(f"Linphone call error: {e}", exc_info=True)
raise raise
finally:
# Cleanup
if phone:
try:
_LOGGER.info("Stopping SIP phone...")
phone.stop()
except Exception as e:
_LOGGER.warning(f"Error stopping phone: {e}")
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health(): def health():
@@ -247,8 +257,8 @@ def handle_send_notification():
audio_file = generate_tts_audio(message) audio_file = generate_tts_audio(message)
temp_files.append(audio_file) temp_files.append(audio_file)
# Place call # Place call using Linphone
place_sip_call(destination, audio_file, duration) place_sip_call_linphone(destination, audio_file, duration)
return jsonify({'status': 'success', 'message': 'Call completed'}), 200 return jsonify({'status': 'success', 'message': 'Call completed'}), 200
@@ -283,18 +293,13 @@ if __name__ == '__main__':
} }
_LOGGER.info("=" * 60) _LOGGER.info("=" * 60)
_LOGGER.info("SIP Voice Notifier Add-on Ready") _LOGGER.info("SIP Voice Notifier Add-on Ready (Using Linphone)")
_LOGGER.info("=" * 60) _LOGGER.info("=" * 60)
_LOGGER.info(f"SIP Server: {CONFIG.get('sip_server')}") _LOGGER.info(f"SIP Server: {CONFIG.get('sip_server')}")
_LOGGER.info(f"SIP User: {CONFIG.get('sip_user')}") _LOGGER.info(f"SIP User: {CONFIG.get('sip_user')}")
_LOGGER.info(f"Local IP: {get_local_ip()}")
_LOGGER.info("") _LOGGER.info("")
_LOGGER.info("⚠️ TROUBLESHOOTING:") _LOGGER.info("Using Linphone CLI for SIP calls")
_LOGGER.info("If calls stay in DIALING state, check:") _LOGGER.info("This should work better than pyVoIP!")
_LOGGER.info("1. SIP credentials are correct")
_LOGGER.info("2. Phone number format (try with/without country code)")
_LOGGER.info("3. SIP account has calling permissions")
_LOGGER.info("4. Firewall allows UDP traffic on ports 5060, 10000-20000")
_LOGGER.info("=" * 60) _LOGGER.info("=" * 60)
_LOGGER.info("Starting Flask service on port 8099") _LOGGER.info("Starting Flask service on port 8099")
app.run(host='0.0.0.0', port=8099, debug=False) app.run(host='0.0.0.0', port=8099, debug=False)