From 3b4c137ba216f87a522e310d56eede3b45468c11 Mon Sep 17 00:00:00 2001 From: sevorl Date: Wed, 27 May 2026 22:00:32 +0200 Subject: [PATCH] Fix webhook reliability and account callback crash --- .gitattributes | 1 + README.md | 5 ++++ ha-sip/run-in-ha.sh | 1 + ha-sip/src/account.py | 8 ++++-- ha-sip/src/config.py | 1 + ha-sip/src/ha.py | 42 ++++++++++++++++++++++++---- ha-sip/src/main.py | 10 ++++++- ha-sip/src/tests/test_ha.py | 56 +++++++++++++++++++++++++++++++++++++ 8 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 .gitattributes create mode 100644 ha-sip/src/tests/test_ha.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/README.md b/README.md index 88ad9f4..9cf48bb 100644 --- a/README.md +++ b/README.md @@ -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"`. diff --git a/ha-sip/run-in-ha.sh b/ha-sip/run-in-ha.sh index a2c976a..e8700ae 100755 --- a/ha-sip/run-in-ha.sh +++ b/ha-sip/run-in-ha.sh @@ -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 diff --git a/ha-sip/src/account.py b/ha-sip/src/account.py index 951c7e0..7e775c7 100644 --- a/ha-sip/src/account.py +++ b/ha-sip/src/account.py @@ -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() @@ -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 diff --git a/ha-sip/src/config.py b/ha-sip/src/config.py index ace9dfa..31974b0 100644 --- a/ha-sip/src/config.py +++ b/ha-sip/src/config.py @@ -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') diff --git a/ha-sip/src/ha.py b/ha-sip/src/ha.py index 8aa6106..515b2a1 100644 --- a/ha-sip/src/ha.py +++ b/ha-sip/src/ha.py @@ -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 @@ -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]: @@ -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]: @@ -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: diff --git a/ha-sip/src/main.py b/ha-sip/src/main.py index caaeaab..02a1253 100755 --- a/ha-sip/src/main.py +++ b/ha-sip/src/main.py @@ -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() diff --git a/ha-sip/src/tests/test_ha.py b/ha-sip/src/tests/test_ha.py new file mode 100644 index 0000000..2863958 --- /dev/null +++ b/ha-sip/src/tests/test_ha.py @@ -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, + )