Switch to Debian base image with linphone-cli (v3.1.0)
This commit is contained in:
@@ -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 .
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user