#!/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)