269 lines
8.3 KiB
Python
269 lines
8.3 KiB
Python
#!/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:
|
|
# Create a socket to determine the local IP
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
# Connect to an external address (doesn't actually send data)
|
|
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"Calling: {destination}")
|
|
|
|
try:
|
|
# Create VoIP phone
|
|
phone = VoIPPhone(
|
|
sip_server,
|
|
5060,
|
|
sip_user,
|
|
sip_password,
|
|
callCallback=None,
|
|
myIP=local_ip,
|
|
sipPort=5060,
|
|
rtpPortLow=10000,
|
|
rtpPortHigh=20000
|
|
)
|
|
|
|
phone.start()
|
|
_LOGGER.info("SIP phone started, waiting for registration...")
|
|
time.sleep(3) # Wait for registration
|
|
|
|
_LOGGER.info(f"Making call to: {destination}")
|
|
|
|
# Make call
|
|
call = phone.call(destination)
|
|
|
|
# Wait for call to be answered
|
|
timeout = 15
|
|
elapsed = 0
|
|
while call.state != CallState.ANSWERED and elapsed < timeout:
|
|
time.sleep(0.5)
|
|
elapsed += 0.5
|
|
if elapsed % 2 == 0:
|
|
_LOGGER.info(f"Waiting for answer... ({elapsed}s)")
|
|
|
|
if call.state != CallState.ANSWERED:
|
|
_LOGGER.warning(f"Call not answered within {timeout}s timeout")
|
|
_LOGGER.info(f"Final call state: {call.state}")
|
|
call.hangup()
|
|
phone.stop()
|
|
return
|
|
|
|
_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()
|
|
time.sleep(1)
|
|
|
|
# Cleanup
|
|
phone.stop()
|
|
_LOGGER.info("Call completed successfully")
|
|
|
|
except Exception as e:
|
|
_LOGGER.error(f"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
|
|
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 from Home Assistant add-on options
|
|
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, using environment variables")
|
|
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("Using pyVoIP from git (latest version)")
|
|
_LOGGER.info(f"SIP Server: {CONFIG.get('sip_server', 'not configured')}")
|
|
_LOGGER.info(f"SIP User: {CONFIG.get('sip_user', 'not configured')}")
|
|
_LOGGER.info(f"Local IP: {get_local_ip()}")
|
|
_LOGGER.info("")
|
|
_LOGGER.info("To use this add-on, add configuration to configuration.yaml")
|
|
_LOGGER.info("See add-on README for details")
|
|
_LOGGER.info("=" * 60)
|
|
_LOGGER.info("Starting Flask service on port 8099")
|
|
app.run(host='0.0.0.0', port=8099, debug=False)
|