Switch from pyVoIP to Linphone CLI - actually works! (v3.0.0)
This commit is contained in:
@@ -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 .
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,110 +140,86 @@ 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
|
|
||||||
call.write_audio(audio_file)
|
|
||||||
|
|
||||||
# Keep call active
|
|
||||||
_LOGGER.info(f"Keeping call active for {duration}s...")
|
|
||||||
time.sleep(duration)
|
|
||||||
|
|
||||||
# Hangup
|
|
||||||
_LOGGER.info("Hanging up...")
|
|
||||||
call.hangup()
|
|
||||||
_LOGGER.info("Call completed successfully")
|
|
||||||
|
|
||||||
elif call.state == CallState.ENDED:
|
|
||||||
_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:
|
# Terminate call
|
||||||
_LOGGER.error(f"Invalid call state: {e}")
|
_LOGGER.info("Terminating call...")
|
||||||
raise Exception(f"Call state error: {e}")
|
process.stdin.write("terminate\n")
|
||||||
|
process.stdin.flush()
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Quit linphone
|
||||||
|
process.stdin.write("quit\n")
|
||||||
|
process.stdin.flush()
|
||||||
|
|
||||||
|
# Wait for process to end
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
_LOGGER.info("Call completed")
|
||||||
|
|
||||||
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'])
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user