Files
ha-sip-notifier/sip-notifier/sip_service.py

306 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""SIP Voice Notifier Add-on Service using Linphone CLI."""
import json
import logging
import os
import subprocess
import tempfile
import time
from urllib.parse import urlparse
import requests
from flask import Flask, request, jsonify
from pydub import AudioSegment
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
_LOGGER = logging.getLogger(__name__)
app = Flask(__name__)
# Global config
CONFIG = {}
DEFAULT_SAMPLE_RATE = 8000
def setup_linphone_config():
"""Create Linphone configuration file."""
sip_user = CONFIG['sip_user']
sip_server = CONFIG['sip_server']
sip_password = CONFIG['sip_password']
config_dir = '/data/linphone'
os.makedirs(config_dir, exist_ok=True)
config_file = f'{config_dir}/linphonerc'
# 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:
"""Download and convert audio to WAV."""
parsed_url = urlparse(url)
extension = os.path.splitext(parsed_url.path)[1] or ".mp3"
with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as tmp:
download_path = tmp.name
response = requests.get(url, stream=True, timeout=30)
response.raise_for_status()
with open(download_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
_LOGGER.debug(f"Downloaded: {download_path}")
try:
audio = AudioSegment.from_file(download_path)
audio = audio.set_frame_rate(DEFAULT_SAMPLE_RATE)
audio = audio.set_channels(1)
audio = audio.set_sample_width(2)
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
wav_path = tmp.name
audio.export(wav_path, format="wav")
_LOGGER.debug(f"Converted: {wav_path}")
os.remove(download_path)
return wav_path
except Exception as e:
if os.path.exists(download_path):
os.remove(download_path)
raise Exception(f"Audio conversion failed: {e}")
def generate_tts_audio(message: str) -> str:
"""Generate TTS audio."""
try:
from gtts import gTTS
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp:
mp3_path = tmp.name
tts = gTTS(text=message, lang='en')
tts.save(mp3_path)
audio = AudioSegment.from_mp3(mp3_path)
audio = audio.set_frame_rate(DEFAULT_SAMPLE_RATE)
audio = audio.set_channels(1)
audio = audio.set_sample_width(2)
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
wav_path = tmp.name
audio.export(wav_path, format="wav")
os.remove(mp3_path)
return wav_path
except ImportError:
raise Exception("gTTS not available")
def place_sip_call_linphone(destination: str, audio_file: str, duration: int):
"""Place a SIP call using Linphone CLI."""
config_file = setup_linphone_config()
sip_server = CONFIG['sip_server']
# Build destination URI
if not destination.startswith('sip:'):
dest_uri = f"sip:{destination}@{sip_server}"
else:
dest_uri = destination
_LOGGER.info(f"Calling {dest_uri} with Linphone...")
try:
# Use linphonec (Linphone console) to make the call
# linphonec command format:
# linphonec -c config_file -a -C (console mode, auto-answer calls to us)
cmd = [
'linphonec',
'-c', config_file,
'-C' # Console mode
]
_LOGGER.info(f"Starting Linphone: {' '.join(cmd)}")
# Start linphonec process
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
# Wait for registration
_LOGGER.info("Waiting for SIP registration...")
time.sleep(5)
# Make the call
_LOGGER.info(f"Sending call command to: {dest_uri}")
process.stdin.write(f"call {dest_uri}\n")
process.stdin.flush()
# Wait for call to establish
_LOGGER.info("Waiting for call to connect...")
time.sleep(5)
# Play audio
_LOGGER.info(f"Playing audio file: {audio_file}")
process.stdin.write(f"play {audio_file}\n")
process.stdin.flush()
# Keep call active
_LOGGER.info(f"Keeping call active for {duration}s...")
time.sleep(duration)
# Terminate call
_LOGGER.info("Terminating call...")
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:
_LOGGER.error(f"Linphone call error: {e}", exc_info=True)
raise
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint."""
return jsonify({'status': 'ok'}), 200
@app.route('/send_notification', methods=['POST'])
def handle_send_notification():
"""Handle send_notification service call."""
try:
data = request.json
destination = data.get('destination')
audio_url = data.get('audio_url')
message = data.get('message')
duration = data.get('duration', CONFIG.get('default_duration', 30))
if not destination:
return jsonify({'error': 'destination required'}), 400
if not audio_url and not message:
return jsonify({'error': 'audio_url or message required'}), 400
temp_files = []
try:
# Prepare audio
if audio_url:
_LOGGER.info(f"Downloading: {audio_url}")
audio_file = download_and_convert_audio(audio_url)
temp_files.append(audio_file)
elif message:
_LOGGER.info(f"Generating TTS: {message}")
audio_file = generate_tts_audio(message)
temp_files.append(audio_file)
# Place call using Linphone
place_sip_call_linphone(destination, audio_file, duration)
return jsonify({'status': 'success', 'message': 'Call completed'}), 200
finally:
# Cleanup
for f in temp_files:
try:
if os.path.exists(f):
os.remove(f)
except Exception as e:
_LOGGER.warning(f"Cleanup failed: {e}")
except Exception as e:
_LOGGER.error(f"Request error: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
# Load config
options_file = '/data/options.json'
if os.path.exists(options_file):
with open(options_file, 'r') as f:
CONFIG = json.load(f)
_LOGGER.info("Config loaded from options.json")
else:
_LOGGER.warning("No options.json found")
CONFIG = {
'sip_server': os.getenv('SIP_SERVER', ''),
'sip_user': os.getenv('SIP_USER', ''),
'sip_password': os.getenv('SIP_PASSWORD', ''),
'default_duration': int(os.getenv('DEFAULT_DURATION', 30))
}
_LOGGER.info("=" * 60)
_LOGGER.info("SIP Voice Notifier Add-on Ready (Using Linphone)")
_LOGGER.info("=" * 60)
_LOGGER.info(f"SIP Server: {CONFIG.get('sip_server')}")
_LOGGER.info(f"SIP User: {CONFIG.get('sip_user')}")
_LOGGER.info("")
_LOGGER.info("Using Linphone CLI for SIP calls")
_LOGGER.info("This should work better than pyVoIP!")
_LOGGER.info("=" * 60)
_LOGGER.info("Starting Flask service on port 8099")
app.run(host='0.0.0.0', port=8099, debug=False)