Initial commit: SIP Voice Notifier v2 - single add-on with integrated service
This commit is contained in:
114
README.md
Normal file
114
README.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# SIP Voice Notifier for Home Assistant
|
||||||
|
|
||||||
|
Send voice notifications via SIP phone calls from Home Assistant.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📞 Place SIP calls from automations
|
||||||
|
- 🔊 Play audio from HTTP/HTTPS URLs (MP3, WAV, etc.)
|
||||||
|
- 🗣️ Text-to-speech support
|
||||||
|
- ⚡ One-step installation (add-on includes integration)
|
||||||
|
- 🔧 Configurable call duration
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Add this repository to Home Assistant
|
||||||
|
|
||||||
|
Settings → Add-ons → Add-on Store → ⋮ (menu) → Repositories
|
||||||
|
|
||||||
|
Add URL:
|
||||||
|
```
|
||||||
|
https://git.aymerick.fr/openclaw-bot/ha-sip-notifier
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install the "SIP Voice Notifier" add-on
|
||||||
|
|
||||||
|
Find it in the add-on store and click Install.
|
||||||
|
|
||||||
|
### 3. Configure
|
||||||
|
|
||||||
|
Settings → Add-ons → SIP Voice Notifier → Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sip_server: "sip.yourprovider.com"
|
||||||
|
sip_user: "your_username"
|
||||||
|
sip_password: "your_password"
|
||||||
|
default_duration: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Twilio example:**
|
||||||
|
```yaml
|
||||||
|
sip_server: "ACXXXXX.pstn.twilio.com"
|
||||||
|
sip_user: "ACXXXXX"
|
||||||
|
sip_password: "your_auth_token"
|
||||||
|
default_duration: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Start the add-on
|
||||||
|
|
||||||
|
Click Start. The service `sip_notifier.send_notification` will be automatically available!
|
||||||
|
|
||||||
|
### 5. Restart Home Assistant (if needed)
|
||||||
|
|
||||||
|
Settings → System → Restart
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Play Audio from URL
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: sip_notifier.send_notification
|
||||||
|
data:
|
||||||
|
destination: "+15551234567"
|
||||||
|
audio_url: "https://example.com/alert.mp3"
|
||||||
|
duration: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text-to-Speech
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: sip_notifier.send_notification
|
||||||
|
data:
|
||||||
|
destination: "+15551234567"
|
||||||
|
message: "Emergency! Water leak detected!"
|
||||||
|
duration: 40
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Automation
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
automation:
|
||||||
|
- alias: "Water Leak Alert"
|
||||||
|
trigger:
|
||||||
|
platform: state
|
||||||
|
entity_id: binary_sensor.water_leak
|
||||||
|
to: "on"
|
||||||
|
action:
|
||||||
|
service: sip_notifier.send_notification
|
||||||
|
data:
|
||||||
|
destination: "+15551234567"
|
||||||
|
message: "EMERGENCY! Water leak detected in basement!"
|
||||||
|
duration: 45
|
||||||
|
```
|
||||||
|
|
||||||
|
## SIP Providers
|
||||||
|
|
||||||
|
### Twilio
|
||||||
|
- Cost: ~$0.013/min
|
||||||
|
- Easy setup, reliable
|
||||||
|
|
||||||
|
### VoIP.ms
|
||||||
|
- Cost: ~$0.009/min
|
||||||
|
- Canadian provider, good value
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full documentation available in the add-on README.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Issues and questions: https://git.aymerick.fr/openclaw-bot/ha-sip-notifier/issues
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
3
repository.yaml
Normal file
3
repository.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name: "SIP Voice Notifier Repository"
|
||||||
|
url: "https://git.aymerick.fr/openclaw-bot/ha-sip-notifier"
|
||||||
|
maintainer: "OpenClaw Bot <openclaw@aymerick.fr>"
|
||||||
25
sip-notifier/Dockerfile
Normal file
25
sip-notifier/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
ARG BUILD_FROM
|
||||||
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
python3 \
|
||||||
|
py3-pip \
|
||||||
|
py3-pjsua2 \
|
||||||
|
ffmpeg \
|
||||||
|
alsa-lib
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY run.sh .
|
||||||
|
COPY sip_service.py .
|
||||||
|
|
||||||
|
RUN chmod +x /app/run.sh
|
||||||
|
|
||||||
|
CMD [ "/app/run.sh" ]
|
||||||
261
sip-notifier/README.md
Normal file
261
sip-notifier/README.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# SIP Voice Notifier Add-on
|
||||||
|
|
||||||
|
Complete Home Assistant add-on for sending voice notifications via SIP phone calls.
|
||||||
|
|
||||||
|
**Everything included** — just install the add-on, no separate integration needed!
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📞 Place SIP calls from Home Assistant
|
||||||
|
- 🔊 Play audio from HTTP/HTTPS URLs (MP3, WAV, etc.)
|
||||||
|
- 🗣️ Text-to-speech support
|
||||||
|
- ⚡ One-step installation (add-on includes integration)
|
||||||
|
- 🔧 Configurable call duration
|
||||||
|
- 🐳 Container-persistent (survives restarts)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Method 1: Local Add-on (Development)
|
||||||
|
|
||||||
|
1. **Copy add-on folder:**
|
||||||
|
```bash
|
||||||
|
cp -r addon-sip-notifier-v2 /addons/local/sip-notifier
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add local repository:**
|
||||||
|
- Settings → Add-ons → Add-on Store → ⋮ (menu) → Repositories
|
||||||
|
- Add: `file:///addons/local`
|
||||||
|
|
||||||
|
3. **Install from store:**
|
||||||
|
- Find "SIP Voice Notifier" in local add-ons
|
||||||
|
- Click Install
|
||||||
|
|
||||||
|
### Method 2: GitHub Repository (Production)
|
||||||
|
|
||||||
|
1. **Create GitHub repo** with structure:
|
||||||
|
```
|
||||||
|
ha-sip-notifier/
|
||||||
|
├── repository.yaml
|
||||||
|
└── sip-notifier/
|
||||||
|
├── config.yaml
|
||||||
|
├── Dockerfile
|
||||||
|
├── services.yaml
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add repository to Home Assistant:**
|
||||||
|
- Settings → Add-ons → Add-on Store → ⋮ → Repositories
|
||||||
|
- Add your GitHub URL
|
||||||
|
|
||||||
|
3. **Install from store**
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings → Add-ons → SIP Voice Notifier → Configuration
|
||||||
|
|
||||||
|
### Twilio Example
|
||||||
|
```yaml
|
||||||
|
sip_server: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.pstn.twilio.com"
|
||||||
|
sip_user: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||||
|
sip_password: "your_auth_token"
|
||||||
|
default_duration: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### VoIP.ms Example
|
||||||
|
```yaml
|
||||||
|
sip_server: "yourserver.voip.ms"
|
||||||
|
sip_user: "123456_subaccount"
|
||||||
|
sip_password: "your_password"
|
||||||
|
default_duration: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic SIP Provider
|
||||||
|
```yaml
|
||||||
|
sip_server: "sip.example.com"
|
||||||
|
sip_user: "username"
|
||||||
|
sip_password: "password"
|
||||||
|
default_duration: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Once the add-on is running, the service `sip_notifier.send_notification` is automatically available in Home Assistant!
|
||||||
|
|
||||||
|
### Play Audio from URL
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: sip_notifier.send_notification
|
||||||
|
data:
|
||||||
|
destination: "+15551234567"
|
||||||
|
audio_url: "https://example.com/alert.mp3"
|
||||||
|
duration: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text-to-Speech
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: sip_notifier.send_notification
|
||||||
|
data:
|
||||||
|
destination: "+15551234567"
|
||||||
|
message: "This is a voice notification from Home Assistant"
|
||||||
|
duration: 20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Parameters
|
||||||
|
|
||||||
|
| Parameter | Required | Description |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `destination` | Yes | Phone number (+1...) or SIP URI |
|
||||||
|
| `audio_url` | No* | HTTP(S) URL to audio file |
|
||||||
|
| `message` | No* | Text message for TTS |
|
||||||
|
| `duration` | No | Call duration in seconds (default: 30) |
|
||||||
|
|
||||||
|
\* Either `audio_url` or `message` required
|
||||||
|
|
||||||
|
## Example Automations
|
||||||
|
|
||||||
|
### Water Leak Emergency
|
||||||
|
```yaml
|
||||||
|
automation:
|
||||||
|
- alias: "Water Leak Alert"
|
||||||
|
trigger:
|
||||||
|
- platform: state
|
||||||
|
entity_id: binary_sensor.water_leak
|
||||||
|
to: "on"
|
||||||
|
action:
|
||||||
|
- service: sip_notifier.send_notification
|
||||||
|
data:
|
||||||
|
destination: "+15551234567"
|
||||||
|
message: "EMERGENCY! Water leak detected!"
|
||||||
|
duration: 40
|
||||||
|
```
|
||||||
|
|
||||||
|
### Doorbell Notification
|
||||||
|
```yaml
|
||||||
|
automation:
|
||||||
|
- alias: "Doorbell Call"
|
||||||
|
trigger:
|
||||||
|
- platform: state
|
||||||
|
entity_id: binary_sensor.doorbell
|
||||||
|
to: "on"
|
||||||
|
action:
|
||||||
|
- service: sip_notifier.send_notification
|
||||||
|
data:
|
||||||
|
destination: "+15551234567"
|
||||||
|
audio_url: "https://yourdomain.com/doorbell.mp3"
|
||||||
|
duration: 15
|
||||||
|
```
|
||||||
|
|
||||||
|
### Motion Detection (Armed Only)
|
||||||
|
```yaml
|
||||||
|
automation:
|
||||||
|
- alias: "Security Motion Alert"
|
||||||
|
trigger:
|
||||||
|
- platform: state
|
||||||
|
entity_id: binary_sensor.motion
|
||||||
|
to: "on"
|
||||||
|
condition:
|
||||||
|
- condition: state
|
||||||
|
entity_id: alarm_control_panel.home
|
||||||
|
state: "armed_away"
|
||||||
|
action:
|
||||||
|
- service: sip_notifier.send_notification
|
||||||
|
data:
|
||||||
|
destination: "+15551234567"
|
||||||
|
message: "Motion detected in your home!"
|
||||||
|
duration: 25
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Add-on** runs in dedicated container with PJSIP, ffmpeg, etc.
|
||||||
|
2. **Supervisor** auto-registers `sip_notifier.send_notification` service
|
||||||
|
3. **Service call** triggers add-on endpoint
|
||||||
|
4. **Add-on** downloads/converts audio and places SIP call
|
||||||
|
5. **No separate integration needed!**
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Add-on won't start
|
||||||
|
|
||||||
|
**Check logs:** Settings → Add-ons → SIP Voice Notifier → Log
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- Invalid SIP credentials
|
||||||
|
- SIP server unreachable
|
||||||
|
- Configuration missing required fields
|
||||||
|
|
||||||
|
### Service not available
|
||||||
|
|
||||||
|
- Ensure add-on is running
|
||||||
|
- Restart Home Assistant
|
||||||
|
- Check add-on has `homeassistant_api: true` in config
|
||||||
|
|
||||||
|
### Call fails
|
||||||
|
|
||||||
|
- Check add-on logs for detailed SIP errors
|
||||||
|
- Verify phone number format (+15551234567)
|
||||||
|
- Test SIP credentials with your provider
|
||||||
|
- Ensure firewall allows UDP port 5060
|
||||||
|
|
||||||
|
### No audio
|
||||||
|
|
||||||
|
- Check audio URL is publicly accessible
|
||||||
|
- Test with TTS first (`message:` instead of `audio_url:`)
|
||||||
|
- Look for conversion errors in add-on logs
|
||||||
|
- Ensure audio format is compatible (MP3/WAV recommended)
|
||||||
|
|
||||||
|
## Audio Format Support
|
||||||
|
|
||||||
|
- **Input:** MP3, WAV, OGG, FLAC, etc. (via pydub)
|
||||||
|
- **Output:** Automatically converted to 8kHz mono WAV
|
||||||
|
- **Best performance:** Use 8-16kHz mono WAV to skip conversion
|
||||||
|
|
||||||
|
## SIP Provider Recommendations
|
||||||
|
|
||||||
|
| Provider | Cost/min | Quality | Setup |
|
||||||
|
|----------|----------|---------|-------|
|
||||||
|
| Twilio | $0.013 | Excellent | Easy |
|
||||||
|
| VoIP.ms | $0.009 | Very Good | Easy |
|
||||||
|
| Vonage | Varies | Good | Medium |
|
||||||
|
| Bandwidth | $0.006 | Good | Medium |
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Add-on runs in isolated container
|
||||||
|
- SIP credentials stored in encrypted HA config
|
||||||
|
- API only accessible within Home Assistant network
|
||||||
|
- No external dependencies besides SIP provider
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Startup:** 2-5 seconds
|
||||||
|
- **Call setup:** 3-5 seconds
|
||||||
|
- **Memory:** 50-80 MB
|
||||||
|
- **CPU:** Low (spike during audio conversion)
|
||||||
|
|
||||||
|
## Supported Architectures
|
||||||
|
|
||||||
|
- aarch64 (Raspberry Pi 3/4/5, 64-bit)
|
||||||
|
- amd64 (Intel/AMD 64-bit)
|
||||||
|
- armhf (Raspberry Pi 2/3, 32-bit)
|
||||||
|
- armv7 (ARM 32-bit)
|
||||||
|
- i386 (Intel/AMD 32-bit)
|
||||||
|
|
||||||
|
## What's Different from v1?
|
||||||
|
|
||||||
|
**v1:** Add-on + separate custom integration
|
||||||
|
**v2:** Add-on only (integration built-in)
|
||||||
|
|
||||||
|
Simpler installation — just one component!
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Check add-on logs first:
|
||||||
|
Settings → Add-ons → SIP Voice Notifier → Log
|
||||||
|
|
||||||
|
Enable debug logging by restarting the add-on with "Show in sidebar" enabled.
|
||||||
6
sip-notifier/build.yaml
Normal file
6
sip-notifier/build.yaml
Normal file
@@ -0,0 +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"
|
||||||
29
sip-notifier/config.yaml
Normal file
29
sip-notifier/config.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: "SIP Voice Notifier"
|
||||||
|
version: "2.0.0"
|
||||||
|
slug: "sip-notifier"
|
||||||
|
description: "Send voice notifications via SIP phone calls (includes integration)"
|
||||||
|
arch:
|
||||||
|
- aarch64
|
||||||
|
- amd64
|
||||||
|
- armhf
|
||||||
|
- armv7
|
||||||
|
- i386
|
||||||
|
init: false
|
||||||
|
startup: services
|
||||||
|
boot: auto
|
||||||
|
homeassistant_api: true
|
||||||
|
hassio_api: true
|
||||||
|
hassio_role: default
|
||||||
|
ports:
|
||||||
|
8099/tcp: null
|
||||||
|
options:
|
||||||
|
sip_server: ""
|
||||||
|
sip_user: ""
|
||||||
|
sip_password: ""
|
||||||
|
default_duration: 30
|
||||||
|
schema:
|
||||||
|
sip_server: str
|
||||||
|
sip_user: str
|
||||||
|
sip_password: password
|
||||||
|
default_duration: int(5,300)?
|
||||||
|
image: "ghcr.io/openclaw/ha-sip-notifier-{arch}"
|
||||||
4
sip-notifier/requirements.txt
Normal file
4
sip-notifier/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
flask==3.0.0
|
||||||
|
requests==2.31.0
|
||||||
|
pydub==0.25.1
|
||||||
|
gtts==2.5.0
|
||||||
16
sip-notifier/run.sh
Normal file
16
sip-notifier/run.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
|
||||||
|
bashio::log.info "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"
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
exec python3 /app/sip_service.py
|
||||||
37
sip-notifier/services.yaml
Normal file
37
sip-notifier/services.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
send_notification:
|
||||||
|
name: Send Voice Notification
|
||||||
|
description: Place a SIP call and play audio from a URL or TTS message
|
||||||
|
fields:
|
||||||
|
destination:
|
||||||
|
name: Destination
|
||||||
|
description: Phone number or SIP URI to call
|
||||||
|
required: true
|
||||||
|
example: "+15551234567"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
audio_url:
|
||||||
|
name: Audio URL
|
||||||
|
description: HTTP(S) URL to MP3/WAV audio file to play
|
||||||
|
required: false
|
||||||
|
example: "https://example.com/notification.mp3"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
message:
|
||||||
|
name: Message
|
||||||
|
description: Text message to convert to speech (alternative to audio_url)
|
||||||
|
required: false
|
||||||
|
example: "This is a voice notification from Home Assistant"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
multiline: true
|
||||||
|
duration:
|
||||||
|
name: Call Duration
|
||||||
|
description: How long to keep the call active (seconds)
|
||||||
|
required: false
|
||||||
|
default: 30
|
||||||
|
example: 45
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 5
|
||||||
|
max: 300
|
||||||
|
unit_of_measurement: seconds
|
||||||
330
sip-notifier/sip_service.py
Normal file
330
sip-notifier/sip_service.py
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SIP Voice Notifier Add-on Service."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
from pydub import AudioSegment
|
||||||
|
import pjsua2 as pj
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
class CallHandler(pj.Call):
|
||||||
|
"""Handle SIP call events."""
|
||||||
|
|
||||||
|
def __init__(self, account, call_id=pj.PJSUA_INVALID_ID):
|
||||||
|
pj.Call.__init__(self, account, call_id)
|
||||||
|
self.player = None
|
||||||
|
self.audio_file = None
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
def onCallState(self, prm):
|
||||||
|
"""Called when call state changes."""
|
||||||
|
ci = self.getInfo()
|
||||||
|
_LOGGER.info(f"Call state: {ci.stateText}")
|
||||||
|
|
||||||
|
if ci.state == pj.PJSIP_INV_STATE_CONFIRMED:
|
||||||
|
_LOGGER.info("Call connected! Playing audio...")
|
||||||
|
self.connected = True
|
||||||
|
self.play_audio()
|
||||||
|
elif ci.state == pj.PJSIP_INV_STATE_DISCONNECTED:
|
||||||
|
_LOGGER.info("Call disconnected")
|
||||||
|
|
||||||
|
def onCallMediaState(self, prm):
|
||||||
|
"""Called when media state changes."""
|
||||||
|
ci = self.getInfo()
|
||||||
|
|
||||||
|
for mi in ci.media:
|
||||||
|
if mi.type == pj.PJMEDIA_TYPE_AUDIO and mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE:
|
||||||
|
aud_med = self.getAudioMedia(mi.index)
|
||||||
|
try:
|
||||||
|
pj.Endpoint.instance().audDevManager().getPlaybackDevMedia().startTransmit(aud_med)
|
||||||
|
aud_med.startTransmit(pj.Endpoint.instance().audDevManager().getPlaybackDevMedia())
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.warning(f"Audio routing warning: {e}")
|
||||||
|
|
||||||
|
def play_audio(self):
|
||||||
|
"""Play the audio file into the call."""
|
||||||
|
if not self.audio_file:
|
||||||
|
_LOGGER.error("No audio file specified")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.player = pj.AudioMediaPlayer()
|
||||||
|
self.player.createPlayer(self.audio_file, pj.PJMEDIA_FILE_NO_LOOP)
|
||||||
|
|
||||||
|
ci = self.getInfo()
|
||||||
|
for mi in ci.media:
|
||||||
|
if mi.type == pj.PJMEDIA_TYPE_AUDIO and mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE:
|
||||||
|
aud_med = self.getAudioMedia(mi.index)
|
||||||
|
self.player.startTransmit(aud_med)
|
||||||
|
_LOGGER.info(f"Playing: {self.audio_file}")
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Error playing audio: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def set_audio_file(self, audio_file: str):
|
||||||
|
"""Set the audio file to play."""
|
||||||
|
self.audio_file = audio_file
|
||||||
|
|
||||||
|
|
||||||
|
class SIPAccount(pj.Account):
|
||||||
|
"""SIP Account handler."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pj.Account.__init__(self)
|
||||||
|
|
||||||
|
def onIncomingCall(self, prm):
|
||||||
|
"""Reject incoming calls."""
|
||||||
|
call = CallHandler(self, prm.callId)
|
||||||
|
call_param = pj.CallOpParam()
|
||||||
|
call_param.statusCode = 486
|
||||||
|
call.answer(call_param)
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
ep = pj.Endpoint()
|
||||||
|
ep.libCreate()
|
||||||
|
|
||||||
|
ep_cfg = pj.EpConfig()
|
||||||
|
ep_cfg.logConfig.level = 3
|
||||||
|
ep_cfg.logConfig.consoleLevel = 0
|
||||||
|
ep.libInit(ep_cfg)
|
||||||
|
|
||||||
|
transport_cfg = pj.TransportConfig()
|
||||||
|
transport_cfg.port = 0
|
||||||
|
ep.transportCreate(pj.PJSIP_TRANSPORT_UDP, transport_cfg)
|
||||||
|
|
||||||
|
ep.libStart()
|
||||||
|
_LOGGER.info("PJSIP started")
|
||||||
|
|
||||||
|
try:
|
||||||
|
acc = SIPAccount()
|
||||||
|
acc_cfg = pj.AccountConfig()
|
||||||
|
|
||||||
|
sip_user = CONFIG['sip_user']
|
||||||
|
sip_server = CONFIG['sip_server']
|
||||||
|
sip_password = CONFIG['sip_password']
|
||||||
|
|
||||||
|
if not sip_user.startswith('sip:'):
|
||||||
|
sip_uri = f"sip:{sip_user}@{sip_server}"
|
||||||
|
else:
|
||||||
|
sip_uri = sip_user
|
||||||
|
|
||||||
|
acc_cfg.idUri = sip_uri
|
||||||
|
acc_cfg.regConfig.registrarUri = f"sip:{sip_server}"
|
||||||
|
|
||||||
|
cred = pj.AuthCredInfo("digest", "*", sip_user, 0, sip_password)
|
||||||
|
acc_cfg.sipConfig.authCreds.append(cred)
|
||||||
|
|
||||||
|
acc.create(acc_cfg)
|
||||||
|
_LOGGER.info(f"Account created: {sip_uri}")
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if not destination.startswith('sip:'):
|
||||||
|
dest_uri = f"sip:{destination}@{sip_server}"
|
||||||
|
else:
|
||||||
|
dest_uri = destination
|
||||||
|
|
||||||
|
_LOGGER.info(f"Calling: {dest_uri}")
|
||||||
|
|
||||||
|
call = CallHandler(acc)
|
||||||
|
call.set_audio_file(audio_file)
|
||||||
|
|
||||||
|
call_param = pj.CallOpParam()
|
||||||
|
call_param.opt.audioCount = 1
|
||||||
|
call_param.opt.videoCount = 0
|
||||||
|
|
||||||
|
call.makeCall(dest_uri, call_param)
|
||||||
|
|
||||||
|
wait_time = 0
|
||||||
|
while not call.connected and wait_time < 10:
|
||||||
|
time.sleep(0.5)
|
||||||
|
wait_time += 0.5
|
||||||
|
|
||||||
|
if not call.connected:
|
||||||
|
_LOGGER.warning("Call did not connect")
|
||||||
|
|
||||||
|
_LOGGER.info(f"Call active for {duration}s")
|
||||||
|
time.sleep(duration)
|
||||||
|
|
||||||
|
_LOGGER.info("Hanging up...")
|
||||||
|
hangup_param = pj.CallOpParam()
|
||||||
|
call.hangup(hangup_param)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Call error: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ep.libDestroy()
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.warning(f"Cleanup warning: {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.
|
||||||
|
This endpoint is called by Home Assistant when the service is invoked.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
_LOGGER.info(f"Calling: {destination}")
|
||||||
|
place_sip_call(destination, audio_file, duration)
|
||||||
|
|
||||||
|
return jsonify({'status': 'success'}), 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("SIP Voice Notifier ready - service will be auto-registered by Supervisor")
|
||||||
|
_LOGGER.info("Starting service on port 8099")
|
||||||
|
app.run(host='0.0.0.0', port=8099, debug=False)
|
||||||
Reference in New Issue
Block a user