Switch to Debian base image with linphone-cli (v3.1.0)

This commit is contained in:
2026-02-08 16:03:40 +01:00
parent f35a83761c
commit 303f01ddc7
5 changed files with 73 additions and 163 deletions

View File

@@ -1,23 +1,21 @@
ARG BUILD_FROM FROM debian:bookworm-slim
FROM $BUILD_FROM
# Install system dependencies including Linphone # Install dependencies
RUN apk add --no-cache \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \
py3-pip \ python3-pip \
python3-flask \
python3-requests \
ffmpeg \ ffmpeg \
linphone \ linphone-cli \
git && rm -rf /var/lib/apt/lists/*
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Copy requirements and install Python dependencies # Copy requirements and install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt RUN pip3 install --break-system-packages -r requirements.txt || pip3 install -r requirements.txt
# Remove git after installation to reduce image size
RUN apk del git
# Copy application files # Copy application files
COPY run.sh . COPY run.sh .

View File

@@ -1,6 +1,6 @@
build_from: build_from:
aarch64: "ghcr.io/home-assistant/aarch64-base:3.19" aarch64: "debian:bookworm-slim"
amd64: "ghcr.io/home-assistant/amd64-base:3.19" amd64: "debian:bookworm-slim"
armhf: "ghcr.io/home-assistant/armhf-base:3.19" armhf: "debian:bookworm-slim"
armv7: "ghcr.io/home-assistant/armv7-base:3.19" armv7: "debian:bookworm-slim"
i386: "ghcr.io/home-assistant/i386-base:3.19" i386: "debian:bookworm-slim"

View File

@@ -1,5 +1,5 @@
name: "SIP Voice Notifier" name: "SIP Voice Notifier"
version: "3.0.0" version: "3.1.0"
slug: "sip-notifier" slug: "sip-notifier"
description: "Send voice notifications via SIP phone calls" description: "Send voice notifications via SIP phone calls"
arch: arch:

View File

@@ -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 # Load config from options.json
SIP_SERVER=$(bashio::config 'sip_server') if [ -f /data/options.json ]; then
SIP_USER=$(bashio::config 'sip_user') echo "Config file found"
SIP_PASSWORD=$(bashio::config 'sip_password') else
DEFAULT_DURATION=$(bashio::config 'default_duration') echo "Warning: No config file found at /data/options.json"
fi
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"
# Start the Flask service # Start the Flask service
exec python3 /app/sip_service.py exec python3 /app/sip_service.py

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SIP Voice Notifier Add-on Service using Linphone CLI.""" """SIP Voice Notifier using Linphone CLI."""
import json import json
import logging import logging
import os import os
@@ -12,22 +12,16 @@ import requests
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from pydub import AudioSegment from pydub import AudioSegment
# Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
# Global config
CONFIG = {} CONFIG = {}
DEFAULT_SAMPLE_RATE = 8000 DEFAULT_SAMPLE_RATE = 8000
def setup_linphone_config(): def setup_linphone_config():
"""Create Linphone configuration file.""" """Create Linphone configuration."""
sip_user = CONFIG['sip_user'] sip_user = CONFIG['sip_user']
sip_server = CONFIG['sip_server'] sip_server = CONFIG['sip_server']
sip_password = CONFIG['sip_password'] sip_password = CONFIG['sip_password']
@@ -37,28 +31,15 @@ def setup_linphone_config():
config_file = f'{config_dir}/linphonerc' config_file = f'{config_dir}/linphonerc'
# Create Linphone configuration config = f"""[sip]
config_content = f"""[sip]
sip_port=5060 sip_port=5060
sip_tcp_port=-1
sip_tls_port=-1
default_proxy=0 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] [proxy_0]
reg_proxy=sip:{sip_server} reg_proxy=sip:{sip_server}
reg_identity=sip:{sip_user}@{sip_server} reg_identity=sip:{sip_user}@{sip_server}
reg_expires=3600 reg_expires=3600
reg_sendregister=1 reg_sendregister=1
publish=0
dial_escape_plus=0
[auth_info_0] [auth_info_0]
username={sip_user} username={sip_user}
@@ -68,44 +49,36 @@ realm={sip_server}
""" """
with open(config_file, 'w') as f: 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 return config_file
def download_and_convert_audio(url: str) -> str: def download_and_convert_audio(url: str) -> str:
"""Download and convert audio to WAV.""" """Download and convert audio."""
parsed_url = urlparse(url) parsed = urlparse(url)
extension = os.path.splitext(parsed_url.path)[1] or ".mp3" 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 download_path = tmp.name
response = requests.get(url, stream=True, timeout=30) response = requests.get(url, stream=True, timeout=30)
response.raise_for_status() response.raise_for_status()
with open(download_path, 'wb') as f: 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) f.write(chunk)
_LOGGER.debug(f"Downloaded: {download_path}")
try: try:
audio = AudioSegment.from_file(download_path) audio = AudioSegment.from_file(download_path)
audio = audio.set_frame_rate(DEFAULT_SAMPLE_RATE) audio = audio.set_frame_rate(DEFAULT_SAMPLE_RATE).set_channels(1).set_sample_width(2)
audio = audio.set_channels(1)
audio = audio.set_sample_width(2)
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
wav_path = tmp.name wav_path = tmp.name
audio.export(wav_path, format="wav") audio.export(wav_path, format="wav")
_LOGGER.debug(f"Converted: {wav_path}")
os.remove(download_path) os.remove(download_path)
return wav_path return wav_path
except Exception as e: except Exception as e:
if os.path.exists(download_path): if os.path.exists(download_path):
os.remove(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: def generate_tts_audio(message: str) -> str:
"""Generate TTS audio.""" """Generate TTS audio."""
try: from gtts import gTTS
from gtts import gTTS
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp:
mp3_path = tmp.name mp3_path = tmp.name
tts = gTTS(text=message, lang='en') tts = gTTS(text=message, lang='en')
tts.save(mp3_path) tts.save(mp3_path)
audio = AudioSegment.from_mp3(mp3_path) audio = AudioSegment.from_mp3(mp3_path)
audio = audio.set_frame_rate(DEFAULT_SAMPLE_RATE) audio = audio.set_frame_rate(DEFAULT_SAMPLE_RATE).set_channels(1).set_sample_width(2)
audio = audio.set_channels(1)
audio = audio.set_sample_width(2)
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
wav_path = tmp.name wav_path = tmp.name
audio.export(wav_path, format="wav") audio.export(wav_path, format="wav")
os.remove(mp3_path) os.remove(mp3_path)
return wav_path
return wav_path
except ImportError:
raise Exception("gTTS not available")
def place_sip_call_linphone(destination: str, audio_file: str, duration: int): def place_sip_call(destination: str, audio_file: str, duration: int):
"""Place a SIP call using Linphone CLI.""" """Place SIP call using Linphone CLI."""
config_file = setup_linphone_config() config_file = setup_linphone_config()
sip_server = CONFIG['sip_server'] sip_server = CONFIG['sip_server']
# Build destination URI
if not destination.startswith('sip:'): if not destination.startswith('sip:'):
dest_uri = f"sip:{destination}@{sip_server}" dest_uri = f"sip:{destination}@{sip_server}"
else: else:
dest_uri = destination dest_uri = destination
_LOGGER.info(f"Calling {dest_uri} with Linphone...") _LOGGER.info(f"Calling {dest_uri} using Linphone...")
try: try:
# Use linphonec (Linphone console) to make the call cmd = ['linphonec', '-c', config_file, '-C']
# 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( process = subprocess.Popen(
cmd, cmd,
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True
bufsize=1
) )
# Wait for registration # Wait for registration
_LOGGER.info("Waiting for SIP registration...") _LOGGER.info("Waiting for SIP registration...")
time.sleep(5) time.sleep(5)
# Make the call # Make call
_LOGGER.info(f"Sending call command to: {dest_uri}") _LOGGER.info(f"Calling {dest_uri}...")
process.stdin.write(f"call {dest_uri}\n") process.stdin.write(f"call {dest_uri}\n")
process.stdin.flush() process.stdin.flush()
# Wait for call to establish # Wait for answer
_LOGGER.info("Waiting for call to connect...") _LOGGER.info("Waiting for answer...")
time.sleep(5) time.sleep(5)
# Play audio # 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.write(f"play {audio_file}\n")
process.stdin.flush() process.stdin.flush()
# Keep call active # Keep call active
_LOGGER.info(f"Keeping call active for {duration}s...") _LOGGER.info(f"Call active for {duration}s...")
time.sleep(duration) time.sleep(duration)
# Terminate call # Hangup
_LOGGER.info("Terminating call...") _LOGGER.info("Ending call...")
process.stdin.write("terminate\n") process.stdin.write("terminate\n")
process.stdin.flush() process.stdin.flush()
time.sleep(2) time.sleep(2)
# Quit linphone
process.stdin.write("quit\n") process.stdin.write("quit\n")
process.stdin.flush() process.stdin.flush()
# Wait for process to end
try: try:
process.wait(timeout=5) process.wait(timeout=5)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -218,19 +168,17 @@ def place_sip_call_linphone(destination: str, audio_file: str, duration: int):
_LOGGER.info("Call completed") _LOGGER.info("Call completed")
except Exception as e: except Exception as e:
_LOGGER.error(f"Linphone call error: {e}", exc_info=True) _LOGGER.error(f"Call error: {e}", exc_info=True)
raise raise
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health(): def health():
"""Health check endpoint."""
return jsonify({'status': 'ok'}), 200 return jsonify({'status': 'ok'}), 200
@app.route('/send_notification', methods=['POST']) @app.route('/send_notification', methods=['POST'])
def handle_send_notification(): def handle_send_notification():
"""Handle send_notification service call."""
try: try:
data = request.json data = request.json
destination = data.get('destination') destination = data.get('destination')
@@ -247,23 +195,20 @@ def handle_send_notification():
temp_files = [] temp_files = []
try: try:
# Prepare audio
if audio_url: if audio_url:
_LOGGER.info(f"Downloading: {audio_url}") _LOGGER.info(f"Downloading: {audio_url}")
audio_file = download_and_convert_audio(audio_url) audio_file = download_and_convert_audio(audio_url)
temp_files.append(audio_file) temp_files.append(audio_file)
elif message: else:
_LOGGER.info(f"Generating TTS: {message}") _LOGGER.info(f"Generating TTS: {message}")
audio_file = generate_tts_audio(message) audio_file = generate_tts_audio(message)
temp_files.append(audio_file) temp_files.append(audio_file)
# Place call using Linphone place_sip_call(destination, audio_file, duration)
place_sip_call_linphone(destination, audio_file, duration)
return jsonify({'status': 'success', 'message': 'Call completed'}), 200 return jsonify({'status': 'success'}), 200
finally: finally:
# Cleanup
for f in temp_files: for f in temp_files:
try: try:
if os.path.exists(f): if os.path.exists(f):
@@ -277,14 +222,11 @@ def handle_send_notification():
if __name__ == '__main__': if __name__ == '__main__':
# Load config
options_file = '/data/options.json' options_file = '/data/options.json'
if os.path.exists(options_file): if os.path.exists(options_file):
with open(options_file, 'r') as f: with open(options_file, 'r') as f:
CONFIG = json.load(f) CONFIG = json.load(f)
_LOGGER.info("Config loaded from options.json")
else: else:
_LOGGER.warning("No options.json found")
CONFIG = { CONFIG = {
'sip_server': os.getenv('SIP_SERVER', ''), 'sip_server': os.getenv('SIP_SERVER', ''),
'sip_user': os.getenv('SIP_USER', ''), 'sip_user': os.getenv('SIP_USER', ''),
@@ -293,13 +235,9 @@ if __name__ == '__main__':
} }
_LOGGER.info("=" * 60) _LOGGER.info("=" * 60)
_LOGGER.info("SIP Voice Notifier Add-on Ready (Using Linphone)") _LOGGER.info("SIP Voice Notifier Ready (Linphone CLI on Debian)")
_LOGGER.info("=" * 60)
_LOGGER.info(f"SIP Server: {CONFIG.get('sip_server')}") _LOGGER.info(f"SIP Server: {CONFIG.get('sip_server')}")
_LOGGER.info(f"SIP User: {CONFIG.get('sip_user')}") _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("=" * 60)
_LOGGER.info("Starting Flask service on port 8099")
app.run(host='0.0.0.0', port=8099, debug=False) app.run(host='0.0.0.0', port=8099, debug=False)