Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sh text eol=lf
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ webhook:
id: sip_call_webhook_id
```

> **Note:**
> For advanced deployments you can optionally set `HA_WEBHOOK_BASE_URL` to use a different endpoint for webhook POSTs.
> If not set, webhook calls use `HA_BASE_URL`.
> In Home Assistant add-on mode, ha-sip now defaults webhook POSTs to the local Home Assistant API endpoint to keep `local_only: true` webhook automations working on newer core versions.

> **Note:**
> When your `user_name` or `password` starts with a number, you need to put it in quotes like `"1234"`.

Expand Down
1 change: 1 addition & 0 deletions ha-sip/run-in-ha.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,6 @@ export SENSOR_ENTITY_PREFIX="$(bashio::config 'sensors.entity_prefix')"
export HA_BASE_URL="http://supervisor/core/api"
export HA_WEBSOCKET_URL="ws://supervisor/core/websocket"
export HA_TOKEN="${SUPERVISOR_TOKEN}"
export HA_WEBHOOK_BASE_URL="http://homeassistant:8123/api"

python3 /ha-sip/main.py
8 changes: 6 additions & 2 deletions ha-sip/src/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ def __init__(
self.ha_config = ha_config
self.make_default = make_default
self.on_reg_state_callback = on_reg_state_callback
# Some pjsua2/swIG builds resolve callbacks from instance attributes only.
# Binding them explicitly prevents runtime AttributeError crashes on callback dispatch.
self.onRegState = self._on_reg_state # type: ignore[assignment]
self.onIncomingCall = self._on_incoming_call # type: ignore[assignment]

def init(self) -> None:
account_config = pj.AccountConfig()
Expand All @@ -96,12 +100,12 @@ def init(self) -> None:
account_config.sipConfig.proxies.append(self.config.options.proxy)
return pj.Account.create(self, account_config, self.make_default)

def onRegState(self, prm) -> None:
def _on_reg_state(self, prm) -> None:
log(self.config.index, f'OnRegState: {prm.code} {prm.reason}')
if self.on_reg_state_callback:
self.on_reg_state_callback(self.config.index, prm.code, prm.reason)

def onIncomingCall(self, prm) -> None:
def _on_incoming_call(self, prm) -> None:
if not self.config:
log(None, 'Error: No config set when onIncomingCall was called.')
return
Expand Down
1 change: 1 addition & 0 deletions ha-sip/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
HA_WEBSOCKET_URL = os.environ.get('HA_WEBSOCKET_URL', '')
HA_TOKEN = os.environ.get('HA_TOKEN', '')
HA_WEBHOOK_ID = os.environ.get('HA_WEBHOOK_ID', '')
HA_WEBHOOK_BASE_URL = os.environ.get('HA_WEBHOOK_BASE_URL', '')

SENSOR_ENABLED = os.environ.get('SENSOR_ENABLED', 'false')
SENSOR_ENTITY_PREFIX = os.environ.get('SENSOR_ENTITY_PREFIX', 'ha_sip')
Expand Down
42 changes: 37 additions & 5 deletions ha-sip/src/ha.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,16 @@ class TtsConfig(TypedDict):


class HaConfig(object):
def __init__(self, base_url: str, websocket_url: str, token: str, tts_config: TtsConfigFromEnv, webhook_id: str, cache_dir: Optional[str]):
def __init__(
self,
base_url: str,
websocket_url: str,
token: str,
tts_config: TtsConfigFromEnv,
webhook_id: str,
cache_dir: Optional[str],
webhook_base_url: Optional[str] = None,
):
self.base_url = base_url
self.websocket_url = websocket_url
self.token = token
Expand All @@ -148,6 +157,7 @@ def __init__(self, base_url: str, websocket_url: str, token: str, tts_config: Tt
elif self.tts_config['platform']:
log(None, f"TTS: Using platform {self.tts_config['platform']} with language {self.tts_config['language']} with voice {self.tts_config['voice']}")
self.webhook_id = webhook_id
self.webhook_base_url = webhook_base_url or ''
self.cache_dir = cache_dir

def create_headers(self) -> Dict[str, str]:
Expand All @@ -166,7 +176,18 @@ def get_service_url(self, domain: str, service: str) -> str:
return self.base_url + '/services/' + domain + '/' + service

def get_webhook_url(self, webhook_id: str) -> str:
return self.base_url + '/webhook/' + webhook_id
return self.get_webhook_urls(webhook_id)[0]

def get_webhook_urls(self, webhook_id: str) -> list[str]:
urls: list[str] = []
if self.webhook_base_url:
urls.append(self.webhook_base_url + '/webhook/' + webhook_id)
# On HA OS, this often resolves directly to Core and preserves local_only behavior.
urls.append('http://homeassistant:8123/api/webhook/' + webhook_id)
urls.append('http://127.0.0.1:8123/api/webhook/' + webhook_id)
urls.append(self.base_url + '/webhook/' + webhook_id)
# Keep order, remove duplicates.
return list(dict.fromkeys(urls))


def create_and_get_tts(ha_config: HaConfig, message: str, language: str) -> tuple[str, bool, bool]:
Expand Down Expand Up @@ -233,10 +254,21 @@ def trigger_webhook(ha_config: HaConfig, event: Any, overwrite_webhook_id: Optio
if not webhook_id:
log(None, 'Warning: No webhook defined.')
return
log(None, f'Calling webhook {webhook_id} with data {event}')
webhook_urls = ha_config.get_webhook_urls(webhook_id)
headers = ha_config.create_headers()
service_response = requests.post(ha_config.get_webhook_url(webhook_id), json=event, headers=headers)
log(None, f'Webhook response {service_response.status_code!r} {service_response.content!r}')
for webhook_url in webhook_urls:
log(None, f'Calling webhook {webhook_id} ({webhook_url}) with data {event}')
try:
service_response = requests.post(webhook_url, json=event, headers=headers, timeout=8)
except requests.RequestException as exc:
log(None, f'Webhook request exception (url={webhook_url!r}, error={exc!r})')
continue
if service_response.ok:
log(None, f'Webhook response {service_response.status_code!r} {service_response.content!r}')
return
response_excerpt = service_response.text[:400]
log(None, f'Webhook request failed (url={webhook_url!r}, status={service_response.status_code!r}, body={response_excerpt!r})')
log(None, f'Webhook delivery failed for all targets: {webhook_urls!r}')


async def print_tts_providers(ha_config: HaConfig) -> None:
Expand Down
10 changes: 9 additions & 1 deletion ha-sip/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,15 @@ def main():
'voice': config.TTS_VOICE,
'debug_print': config.TTS_DEBUG_PRINT,
}
ha_config = ha.HaConfig(config.HA_BASE_URL, config.HA_WEBSOCKET_URL, config.HA_TOKEN, tts_config_from_env, config.HA_WEBHOOK_ID, cache_dir)
ha_config = ha.HaConfig(
config.HA_BASE_URL,
config.HA_WEBSOCKET_URL,
config.HA_TOKEN,
tts_config_from_env,
config.HA_WEBHOOK_ID,
cache_dir,
config.HA_WEBHOOK_BASE_URL,
)
if ha_config.tts_config['debug_print']:
asyncio.run(ha.print_tts_providers(ha_config))
call_state = state.create()
Expand Down
56 changes: 56 additions & 0 deletions ha-sip/src/tests/test_ha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import unittest
import sys
from unittest.mock import MagicMock, patch

sys.modules.setdefault('websockets', MagicMock())

import ha


def make_ha_config(webhook_base_url=None):
return ha.HaConfig(
base_url='http://supervisor/core/api',
websocket_url='ws://supervisor/core/websocket',
token='token123',
tts_config={
'platform': None,
'engine_id': None,
'language': 'en',
'voice': None,
'debug_print': 'false',
},
webhook_id='default_hook',
cache_dir=None,
webhook_base_url=webhook_base_url,
)


class HaWebhookTest(unittest.TestCase):
def test_get_webhook_url_defaults_to_base_url(self):
config = make_ha_config()
self.assertEqual(config.get_webhook_url('abc'), 'http://homeassistant:8123/api/webhook/abc')

def test_get_webhook_url_uses_webhook_base_url(self):
config = make_ha_config(webhook_base_url='http://127.0.0.1:8123/api')
self.assertEqual(config.get_webhook_url('abc'), 'http://127.0.0.1:8123/api/webhook/abc')

@patch('ha.requests.post')
def test_trigger_webhook_uses_dedicated_webhook_base_url(self, post_mock):
response = MagicMock()
response.ok = True
response.status_code = 200
response.content = b'ok'
post_mock.return_value = response

config = make_ha_config(webhook_base_url='http://127.0.0.1:8123/api')
ha.trigger_webhook(config, {'event': 'incoming_call'})

post_mock.assert_called_once_with(
'http://127.0.0.1:8123/api/webhook/default_hook',
json={'event': 'incoming_call'},
headers={
'Authorization': 'Bearer token123',
'content-type': 'application/json',
},
timeout=8,
)