diff --git a/sip-notifier/Dockerfile b/sip-notifier/Dockerfile index 5a4b49c..3f7b1c1 100644 --- a/sip-notifier/Dockerfile +++ b/sip-notifier/Dockerfile @@ -1,23 +1,21 @@ -ARG BUILD_FROM -FROM $BUILD_FROM +FROM debian:bookworm-slim -# Install system dependencies including Linphone -RUN apk add --no-cache \ +# Install dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ - py3-pip \ + python3-pip \ + python3-flask \ + python3-requests \ ffmpeg \ - linphone \ - git + linphone-cli \ + && rm -rf /var/lib/apt/lists/* # Set working directory WORKDIR /app # Copy requirements and install Python dependencies COPY requirements.txt . -RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt - -# Remove git after installation to reduce image size -RUN apk del git +RUN pip3 install --break-system-packages -r requirements.txt || pip3 install -r requirements.txt # Copy application files COPY run.sh . diff --git a/sip-notifier/build.yaml b/sip-notifier/build.yaml index 7ab0ea3..6970052 100644 --- a/sip-notifier/build.yaml +++ b/sip-notifier/build.yaml @@ -1,6 +1,6 @@ build_from: - aarch64: "ghcr.io/home-assistant/aarch64-base:3.19" - amd64: "ghcr.io/home-assistant/amd64-base:3.19" - armhf: "ghcr.io/home-assistant/armhf-base:3.19" - armv7: "ghcr.io/home-assistant/armv7-base:3.19" - i386: "ghcr.io/home-assistant/i386-base:3.19" + aarch64: "debian:bookworm-slim" + amd64: "debian:bookworm-slim" + armhf: "debian:bookworm-slim" + armv7: "debian:bookworm-slim" + i386: "debian:bookworm-slim" diff --git a/sip-notifier/config.yaml b/sip-notifier/config.yaml index efc8de9..0f869c5 100644 --- a/sip-notifier/config.yaml +++ b/sip-notifier/config.yaml @@ -1,5 +1,5 @@ name: "SIP Voice Notifier" -version: "3.0.0" +version: "3.1.0" slug: "sip-notifier" description: "Send voice notifications via SIP phone calls" arch: diff --git a/sip-notifier/run.sh b/sip-notifier/run.sh index 9561efc..938a14c 100644 --- a/sip-notifier/run.sh +++ b/sip-notifier/run.sh @@ -1,40 +1,14 @@ -#!/usr/bin/with-contenv bashio +#!/bin/bash +set -e -bashio::log.info "Starting SIP Voice Notifier..." +echo "Starting SIP Voice Notifier..." -# Get config from add-on options -SIP_SERVER=$(bashio::config 'sip_server') -SIP_USER=$(bashio::config 'sip_user') -SIP_PASSWORD=$(bashio::config 'sip_password') -DEFAULT_DURATION=$(bashio::config 'default_duration') - -bashio::log.info "SIP Server: ${SIP_SERVER}" -bashio::log.info "SIP User: ${SIP_USER}" -bashio::log.info "Default Duration: ${DEFAULT_DURATION}s" - -# Wait for supervisor to be ready -sleep 3 - -# Register service with Home Assistant via Supervisor API -bashio::log.info "Registering sip_notifier.send_notification service with Home Assistant..." - -# Get add-on slug -ADDON_SLUG="088d3b92_sip-notifier" - -# Register the service -curl -sSL -X POST \ - -H "Authorization: Bearer ${SUPERVISOR_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{ - \"addon\": \"${ADDON_SLUG}\", - \"service\": \"send_notification\" - }" \ - "http://supervisor/services/sip_notifier/send_notification" \ - && bashio::log.info "Service registered successfully!" \ - || bashio::log.warning "Service registration failed (may already exist)" - -bashio::log.info "Add-on ready - listening on port 8099" -bashio::log.info "Service available as: sip_notifier.send_notification" +# Load config from options.json +if [ -f /data/options.json ]; then + echo "Config file found" +else + echo "Warning: No config file found at /data/options.json" +fi # Start the Flask service exec python3 /app/sip_service.py diff --git a/sip-notifier/sip_service.py b/sip-notifier/sip_service.py index 45f6bb2..45fb89d 100644 --- a/sip-notifier/sip_service.py +++ b/sip-notifier/sip_service.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""SIP Voice Notifier Add-on Service using Linphone CLI.""" +"""SIP Voice Notifier using Linphone CLI.""" import json import logging import os @@ -12,22 +12,16 @@ 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' -) +logging.basicConfig(level=logging.INFO, format='%(asctime)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.""" + """Create Linphone configuration.""" sip_user = CONFIG['sip_user'] sip_server = CONFIG['sip_server'] sip_password = CONFIG['sip_password'] @@ -37,28 +31,15 @@ def setup_linphone_config(): config_file = f'{config_dir}/linphonerc' - # Create Linphone configuration - config_content = f"""[sip] + config = 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} @@ -68,44 +49,36 @@ realm={sip_server} """ with open(config_file, 'w') as f: - f.write(config_content) + f.write(config) - _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" + """Download and convert audio.""" + parsed = urlparse(url) + ext = os.path.splitext(parsed.path)[1] or ".mp3" - with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as tmp: + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) 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): + for chunk in response.iter_content(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) + audio = audio.set_frame_rate(DEFAULT_SAMPLE_RATE).set_channels(1).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) @@ -114,102 +87,79 @@ def download_and_convert_audio(url: str) -> str: 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() + 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).set_channels(1).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 + + +def place_sip_call(destination: str, audio_file: str, duration: int): + """Place 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...") + _LOGGER.info(f"Calling {dest_uri} using 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'] - 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 + text=True ) # Wait for registration _LOGGER.info("Waiting for SIP registration...") time.sleep(5) - # Make the call - _LOGGER.info(f"Sending call command to: {dest_uri}") + # Make call + _LOGGER.info(f"Calling {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...") + # Wait for answer + _LOGGER.info("Waiting for answer...") time.sleep(5) # Play audio - _LOGGER.info(f"Playing audio file: {audio_file}") + _LOGGER.info(f"Playing {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...") + _LOGGER.info(f"Call active for {duration}s...") time.sleep(duration) - # Terminate call - _LOGGER.info("Terminating call...") + # Hangup + _LOGGER.info("Ending 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: @@ -218,19 +168,17 @@ def place_sip_call_linphone(destination: str, audio_file: str, duration: int): _LOGGER.info("Call completed") except Exception as e: - _LOGGER.error(f"Linphone call error: {e}", exc_info=True) + _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') @@ -247,23 +195,20 @@ def handle_send_notification(): 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: + else: _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) + place_sip_call(destination, audio_file, duration) - return jsonify({'status': 'success', 'message': 'Call completed'}), 200 + return jsonify({'status': 'success'}), 200 finally: - # Cleanup for f in temp_files: try: if os.path.exists(f): @@ -277,14 +222,11 @@ def handle_send_notification(): 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', ''), @@ -293,13 +235,9 @@ if __name__ == '__main__': } _LOGGER.info("=" * 60) - _LOGGER.info("SIP Voice Notifier Add-on Ready (Using Linphone)") - _LOGGER.info("=" * 60) + _LOGGER.info("SIP Voice Notifier Ready (Linphone CLI on Debian)") _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)