#!/usr/bin/env python3 """SIP Voice Notifier Add-on Service using pyVoIP.""" import json import logging import os import socket import tempfile import time from urllib.parse import urlparse import requests from flask import Flask, request, jsonify from pydub import AudioSegment from pyVoIP.VoIP import VoIPPhone, InvalidStateError, CallState # 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 get_local_ip(): """Get local IP address of the container.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) local_ip = s.getsockname()[0] s.close() return local_ip except Exception as e: _LOGGER.warning(f"Could not auto-detect IP: {e}, using 0.0.0.0") return "0.0.0.0" 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(destination: str, audio_file: str, duration: int): """Place a SIP call using pyVoIP.""" sip_user = CONFIG['sip_user'] sip_server = CONFIG['sip_server'] sip_password = CONFIG['sip_password'] # Get local IP local_ip = get_local_ip() _LOGGER.info(f"Local IP: {local_ip}") _LOGGER.info(f"SIP Server: {sip_server}") _LOGGER.info(f"SIP User: {sip_user}") _LOGGER.info(f"Destination: {destination}") phone = None call = None try: # Create VoIP phone _LOGGER.info("Creating SIP phone...") phone = VoIPPhone( sip_server, 5060, sip_user, sip_password, callCallback=None, myIP=local_ip, sipPort=5060, rtpPortLow=10000, rtpPortHigh=20000 ) _LOGGER.info("Starting SIP phone...") phone.start() _LOGGER.info("Waiting for SIP registration...") time.sleep(3) _LOGGER.info("Initiating call...") call = phone.call(destination) _LOGGER.info(f"Call initiated, waiting for answer (state: {call.state})...") # Wait for call to be answered timeout = 20 elapsed = 0 while call.state not in [CallState.ANSWERED, CallState.ENDED] and elapsed < timeout: 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: _LOGGER.info("✅ Call answered! Playing audio...") # 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: _LOGGER.error(f"Invalid call state: {e}") raise Exception(f"Call state error: {e}") except Exception as e: _LOGGER.error(f"Call error: {e}", exc_info=True) 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']) 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 place_sip_call(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") _LOGGER.info("=" * 60) _LOGGER.info(f"SIP Server: {CONFIG.get('sip_server')}") _LOGGER.info(f"SIP User: {CONFIG.get('sip_user')}") _LOGGER.info(f"Local IP: {get_local_ip()}") _LOGGER.info("") _LOGGER.info("⚠️ TROUBLESHOOTING:") _LOGGER.info("If calls stay in DIALING state, check:") _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("Starting Flask service on port 8099") app.run(host='0.0.0.0', port=8099, debug=False)