diff --git a/.env.example b/.env.example index b04c655..d0d16c5 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,9 @@ HA_WEBSOCKET_URL="ws://homeassistant.local:8123/api/websocket" HA_TOKEN="token from home assistant" HA_WEBHOOK_ID="sip_call_webhook_id_test" +SENSOR_ENABLED="True" +SENSOR_ENTITY_PREFIX="ha-sip" + BROKER_ADDRESS="" BROKER_PORT="1883" BROKER_USERNAME="" diff --git a/README.md b/README.md index 6a99ea6..88ad9f4 100644 --- a/README.md +++ b/README.md @@ -556,6 +556,118 @@ Additionally, the event name and event specific fields are available: } ``` +### `outgoing_call_initiated` + +```json +{ + "event": "outgoing_call_initiated" +} +``` + +## Sensors + +ha-sip can expose sensor entities to Home Assistant for monitoring SIP account status and call activity. +These only show information regarding ha-sip itself, not the SIP provider (you cannot see calls that are +answered on other SIP devices, as this is not supported by the SIP protocol). + +To enable sensors, add the following to your add-on configuration: + +```yaml +sensors: + enabled: true + entity_prefix: ha_sip # optional, defaults to "ha_sip" +``` + +### Call Activity Sensor + +Tracks whether a call is currently active on each SIP account. + +| Entity ID | State | Description | +|-----------|-------|-------------| +| `sensor.{prefix}_account_{n}` | `true` / `false` | Whether a call is active | + +**Attributes when active:** +- `caller`: Full caller URI +- `called`: Full called URI +- `parsed_caller`: Extracted caller number +- `parsed_called`: Extracted called number +- `sip_account`: Account number +- `call_id`: SIP call ID +- `headers`: Extracted SIP headers (if configured) + +### Registration Status Sensor + +Monitors the SIP registration state for each account. Useful for alerting when your SIP connection drops. + +| Entity ID | State | Description | +|-----------|-------|-------------| +| `sensor.{prefix}_registration_{n}` | `registered` / `unregistered` / `failed` / `unknown` | Registration state | + +**Attributes:** +- `status_code`: SIP status code (200 = registered) +- `reason`: Status reason text +- `last_change`: ISO timestamp of last state change + +**Icons:** +- `mdi:phone-check` - Registered +- `mdi:phone-off` - Unregistered +- `mdi:phone-alert` - Failed +- `mdi:phone-clock` - Unknown (initial state) + +### Last Call Sensor + +Tracks information about the most recent call on each account. + +| Entity ID | State | Description | +|-----------|-------|-------------| +| `sensor.{prefix}_last_call_{n}` | `incoming` / `outgoing` / `none` | Direction of last call | + +**Attributes:** +- `caller`: Full caller URI +- `called`: Full called URI +- `parsed_caller`: Extracted caller number +- `parsed_called`: Extracted called number +- `call_id`: SIP call ID +- `timestamp`: ISO timestamp when call ended + +**Icons:** +- `mdi:phone-incoming` - Incoming call +- `mdi:phone-outgoing` - Outgoing call +- `mdi:phone` - No calls yet + +### Example Automations + +Alert when SIP registration fails: + +```yaml +automation: + - alias: "SIP Registration Alert" + trigger: + - platform: state + entity_id: sensor.ha_sip_registration_1 + to: "failed" + action: + - service: notify.mobile_app + data: + message: "SIP account 1 registration failed!" +``` + +Log last call: + +```yaml +automation: + - alias: "Log Incoming Calls" + trigger: + - platform: state + entity_id: sensor.ha_sip_last_call_1 + to: "incoming" + action: + - service: logbook.log + data: + name: "Incoming Call" + message: "Call from {{ state_attr('sensor.ha_sip_last_call_1', 'parsed_caller') }}" +``` + ## SIP Header Extraction You can extract specific SIP headers from incoming and outgoing calls and include them in all webhook events. This is useful for accessing custom headers like `X-Caller-ID`, `P-Asserted-Identity`, or any other SIP header your provider sends. diff --git a/ha-sip/CHANGELOG.md b/ha-sip/CHANGELOG.md index 8fa217e..0747093 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -2,6 +2,10 @@ If you find ha-sip useful, consider starring ⭐ the [GitHub repo](https://github.com/arnonym/ha-plugins)! +# 5.5 +- Add sensors for call and account state +- Fix bug with post_action for play_message and play_audio_file not working for incoming calls + # 5.4.1 - Fix crash for outgoing calls diff --git a/ha-sip/config.json b/ha-sip/config.json index 7a349b7..35d8bcc 100644 --- a/ha-sip/config.json +++ b/ha-sip/config.json @@ -1,6 +1,6 @@ { "name": "ha-sip", - "version": "5.4.1", + "version": "5.5", "slug": "ha-sip", "url": "https://github.com/arnonym/ha-plugins", "description": "Home-Assistant SIP Gateway", @@ -68,6 +68,10 @@ }, "webhook": { "id": "sip_call_webhook_id" + }, + "sensors": { + "enabled": false, + "entity_prefix": "ha_sip" } }, "schema": { @@ -123,6 +127,10 @@ }, "webhook": { "id": "str" + }, + "sensors": { + "enabled": "bool", + "entity_prefix": "str" } }, "host_network": true, diff --git a/ha-sip/run-in-ha.sh b/ha-sip/run-in-ha.sh index bc3611f..a2c976a 100755 --- a/ha-sip/run-in-ha.sh +++ b/ha-sip/run-in-ha.sh @@ -47,6 +47,9 @@ export TTS_DEBUG_PRINT="$(bashio::config 'tts.debug_print')" export HA_WEBHOOK_ID="$(bashio::config 'webhook.id')" +export SENSOR_ENABLED="$(bashio::config 'sensors.enabled')" +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}" diff --git a/ha-sip/src/account.py b/ha-sip/src/account.py index c12a83b..951c7e0 100644 --- a/ha-sip/src/account.py +++ b/ha-sip/src/account.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Dict, List, Optional +from typing import Callable, Dict, List, Optional import pjsua2 as pj @@ -17,6 +17,9 @@ from options_global import GlobalOptions from options_sip import SipOptions +# Type for registration state callback: (account_index, code, reason) +OnRegStateCallback = Callable[[int, int, str], None] + class MyAccountConfig(object): def __init__( @@ -56,7 +59,8 @@ def __init__( command_handler: CommandHandler, event_sender: EventSender, ha_config: ha.HaConfig, - make_default=False + make_default: bool, + on_reg_state_callback: OnRegStateCallback ): pj.Account.__init__(self) self.config = config @@ -65,6 +69,7 @@ def __init__( self.event_sender = event_sender self.ha_config = ha_config self.make_default = make_default + self.on_reg_state_callback = on_reg_state_callback def init(self) -> None: account_config = pj.AccountConfig() @@ -93,6 +98,8 @@ def init(self) -> None: def onRegState(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: if not self.config: @@ -193,8 +200,9 @@ def create_account( command_handler: CommandHandler, event_sender: EventSender, ha_config: ha.HaConfig, - is_default: bool + on_reg_state_callback: OnRegStateCallback, + is_default: bool, ) -> Account: - account = Account(end_point, config, command_handler, event_sender, ha_config, is_default) + account = Account(end_point, config, command_handler, event_sender, ha_config, is_default, on_reg_state_callback) account.init() return account diff --git a/ha-sip/src/call.py b/ha-sip/src/call.py index c86c4fe..8bd03fc 100644 --- a/ha-sip/src/call.py +++ b/ha-sip/src/call.py @@ -118,7 +118,7 @@ def __init__( self.current_playback: Optional[ha.CurrentPlayback] = None self.sip_headers: Dict[str, Optional[str]] = sip_headers if sip_headers is not None else {} self.callback_id, other_ids = self.get_callback_ids() - self.menu = self.normalize_menu(menu) if menu else self.get_standard_menu() + self.menu = self.normalize_menu(menu) if menu else None self.menu_map = self.create_menu_map(self.menu) Call.pretty_print_menu(self.menu) log(self.account.config.index, f'Registering call with id {self.callback_id}') @@ -143,10 +143,12 @@ def handle_events(self) -> None: return if not self.connected: return - if time.time() - self.last_seen > self.menu['timeout']: - log(self.account.config.index, f"Timeout of {self.menu['timeout']} triggered") - self.handle_menu(self.menu['timeout_choice']) - self.trigger_webhook({'event': 'timeout', 'menu_id': self.menu['id']}) + timeout = self.menu and self.menu['timeout'] or DEFAULT_RING_TIMEOUT + if time.time() - self.last_seen > timeout: + log(self.account.config.index, f"Timeout of {timeout} triggered") + if self.menu: + self.handle_menu(self.menu['timeout_choice']) + self.trigger_webhook({'event': 'timeout', 'menu_id': self.menu['id']}) return if self.playback_is_done and self.scheduled_post_action: post_action = self.scheduled_post_action @@ -163,6 +165,9 @@ def handle_post_action(self, post_action: PostAction): if post_action["action"] == 'noop': pass elif post_action["action"] == 'return': + if not self.menu: + log(self.account.config.index, 'No menu to return to') + return m = self.menu for _ in range(0, post_action['level']): if m: @@ -463,7 +468,11 @@ def answer_call(self, new_menu: Optional[MenuFromStdin], overwrite_webhooks: Opt self.pretty_print_menu(self.menu) if overwrite_webhooks: self.webhooks = overwrite_webhooks - self.answer_at = time.time() + if self.connected: + if new_menu: + self.handle_menu(self.menu) + else: + self.answer_at = time.time() def transfer(self, transfer_to): log(self.account.config.index, f'Transfer call to {transfer_to}') @@ -618,7 +627,7 @@ def get_default_or_timeout_choice(choice: Union[Literal['default'], Literal['tim return normalized_menu @staticmethod - def create_menu_map(menu: Menu) -> dict[str, Menu]: + def create_menu_map(menu: Optional[Menu]) -> dict[str, Menu]: def add_to_map(menu_map: dict[str, Menu], m: Menu) -> dict[str, Menu]: if m['id']: menu_map[m['id']] = m @@ -626,6 +635,8 @@ def add_to_map(menu_map: dict[str, Menu], m: Menu) -> dict[str, Menu]: for m in m['choices'].values(): add_to_map(menu_map, m) return menu_map + if not menu: + return {} return add_to_map({}, menu) @staticmethod @@ -702,7 +713,10 @@ def get_standard_menu() -> Menu: return standard_menu @staticmethod - def pretty_print_menu(menu: Menu) -> None: + def pretty_print_menu(menu: Optional[Menu]) -> None: + if not menu: + print('No menu defined.') + return lines = yaml.dump(menu, sort_keys=False).split('\n') lines_with_pipe = map(lambda line: '| ' + line, lines) print('\n'.join(lines_with_pipe)) @@ -722,6 +736,7 @@ def make_call( new_call = Call(ep, acc, pj.PJSUA_INVALID_ID, uri_to_call, menu, command_handler, event_sender, ha_config, ring_timeout, webhooks, {}) call_param = pj.CallOpParam(True) new_call.makeCall(uri_to_call, call_param) + new_call.trigger_webhook({'event': 'outgoing_call_initiated'}) return new_call diff --git a/ha-sip/src/config.py b/ha-sip/src/config.py index a6d5742..ace9dfa 100644 --- a/ha-sip/src/config.py +++ b/ha-sip/src/config.py @@ -57,6 +57,9 @@ HA_TOKEN = os.environ.get('HA_TOKEN', '') HA_WEBHOOK_ID = os.environ.get('HA_WEBHOOK_ID', '') +SENSOR_ENABLED = os.environ.get('SENSOR_ENABLED', 'false') +SENSOR_ENTITY_PREFIX = os.environ.get('SENSOR_ENTITY_PREFIX', 'ha_sip') + BROKER_ADDRESS = os.environ.get('BROKER_ADDRESS', '') BROKER_PORT = os.environ.get('BROKER_PORT', '1833') MQTT_USERNAME = os.environ.get('BROKER_USERNAME', '') diff --git a/ha-sip/src/ha.py b/ha-sip/src/ha.py index a8f3ea8..8aa6106 100644 --- a/ha-sip/src/ha.py +++ b/ha-sip/src/ha.py @@ -56,6 +56,10 @@ class RingTimeout(TypedDict): event: Literal['ring_timeout'] +class OutgoingCallInitiatedEvent(TypedDict): + event: Literal['outgoing_call_initiated'] + + class PlaybackDoneAudioFile(TypedDict): event: Literal['playback_done'] type: Literal['audio_file'] @@ -80,6 +84,7 @@ class RecordingStopped(TypedDict): WebhookEvent = Union[ IncomingCallEvent, + OutgoingCallInitiatedEvent, CallEstablishedEvent, CallDisconnectedEvent, EnteredMenuEvent, diff --git a/ha-sip/src/main.py b/ha-sip/src/main.py index 0c73d85..caaeaab 100755 --- a/ha-sip/src/main.py +++ b/ha-sip/src/main.py @@ -23,6 +23,8 @@ from event_sender import EventSender from ha import TtsConfigFromEnv from log import log +from sensor import SensorConfig, SensorUpdater +from sensor_event_handler import SensorEventHandler def handle_command_list(command_client: CommandClient, command_handler: CommandHandler) -> None: @@ -135,10 +137,32 @@ def main(): event_sender = EventSender() command_client = CommandClient() command_handler = CommandHandler(end_point, sip_accounts, call_state, ha_config, event_sender) + + sensor_entity_prefix = config.SENSOR_ENTITY_PREFIX + if not sensor_entity_prefix: + sensor_entity_prefix = 'ha_sip' + sensor_config = SensorConfig( + enabled=config.SENSOR_ENABLED.lower() == 'true', + entity_prefix=sensor_entity_prefix, + ) + enabled_account_indices = [key for key, acc in account_configs.items() if acc.enabled] + sensor_updater = SensorUpdater(ha_config, sensor_config, enabled_account_indices) + def on_reg_state_callback(account_index: int, code: int, reason: str) -> None: + sensor_updater.update_registration_status(account_index, code, reason) + for key, account_config in account_configs.items(): if account_config.enabled: - sip_accounts[key] = account.create_account(end_point, account_config, command_handler, event_sender, ha_config, is_first_enabled_account) + sip_accounts[key] = account.create_account( + end_point, + account_config, + command_handler, + event_sender, + ha_config, + on_reg_state_callback, + is_first_enabled_account, + ) is_first_enabled_account = False + mqtt_mode = config.COMMAND_SOURCE.lower().strip() == 'mqtt' mqtt_client = mqtt.create_client_and_connect(command_handler) if mqtt_mode else None def trigger_webhook(event: Any, webhook_id: Optional[str] = None): @@ -148,6 +172,9 @@ def send_mqtt_event(event: Any, webhook_id: Optional[str] = None): mqtt_client.send_event(event) event_sender.register_sender(trigger_webhook) event_sender.register_sender(send_mqtt_event) + sensor_event_handler = SensorEventHandler(sensor_updater) + event_sender.register_sender(sensor_event_handler.handle_event) + sensor_updater.initialize_sensors() while True: if mqtt_client: mqtt_client.handle() diff --git a/ha-sip/src/sensor.py b/ha-sip/src/sensor.py new file mode 100644 index 0000000..148f0fb --- /dev/null +++ b/ha-sip/src/sensor.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, Any, List, Optional + +import requests + +from ha import HaConfig +from log import log + + +@dataclass +class SensorConfig: + enabled: bool + entity_prefix: str + + +class SensorUpdater: + def __init__(self, ha_config: HaConfig, sensor_config: SensorConfig, enabled_accounts: List[int]): + self.ha_config = ha_config + self.sensor_config = sensor_config + self.enabled_accounts = enabled_accounts + + def _get_sanitized_prefix(self) -> str: + return self.sensor_config.entity_prefix.replace('-', '_').lower() + + def _get_entity_id(self, account_index: int) -> str: + # Sanitize prefix: replace hyphens with underscores, lowercase only + prefix = self._get_sanitized_prefix() + return f"sensor.{prefix}_account_{account_index}" + + def _get_registration_entity_id(self, account_index: int) -> str: + prefix = self._get_sanitized_prefix() + return f"sensor.{prefix}_registration_{account_index}" + + def _get_last_call_entity_id(self, account_index: int) -> str: + prefix = self._get_sanitized_prefix() + return f"sensor.{prefix}_last_call_{account_index}" + + def _get_states_url(self, entity_id: str) -> str: + return self.ha_config.base_url + '/states/' + entity_id + + def _update_sensor(self, entity_id: str, state: str, attributes: Dict[str, Any]) -> None: + url = self._get_states_url(entity_id) + headers = self.ha_config.create_headers() + # Filter out None values as HA REST API doesn't accept them + filtered_attributes = {k: v for k, v in attributes.items() if v is not None} + payload = { + "state": state, + "attributes": filtered_attributes, + } + try: + response = requests.post(url, json=payload, headers=headers) + if response.ok: + log(None, f"Sensor update {entity_id}: {response.status_code}") + else: + log(None, f"Sensor update {entity_id} failed: {response.status_code} {response.text}") + except Exception as e: + log(None, f"Error updating sensor {entity_id}: {e}") + + def set_call_active(self, account_index: int, call_info: Dict[str, Any]) -> None: + if not self.sensor_config.enabled: + return + entity_id = self._get_entity_id(account_index) + attributes = { + "friendly_name": f"SIP Account {account_index}", + "icon": "mdi:phone-in-talk", + "caller": call_info.get("caller"), + "called": call_info.get("called"), + "parsed_caller": call_info.get("parsed_caller"), + "parsed_called": call_info.get("parsed_called"), + "sip_account": call_info.get("sip_account"), + "call_id": call_info.get("call_id"), + "internal_id": call_info.get("internal_id"), + "headers": call_info.get("headers", {}), + } + self._update_sensor(entity_id, "true", attributes) + + def set_call_inactive(self, account_index: int) -> None: + if not self.sensor_config.enabled: + return + entity_id = self._get_entity_id(account_index) + attributes = { + "friendly_name": f"SIP Account {account_index}", + "icon": "mdi:phone", + } + self._update_sensor(entity_id, "false", attributes) + + def update_registration_status(self, account_index: int, code: int, reason: str) -> None: + if not self.sensor_config.enabled: + return + entity_id = self._get_registration_entity_id(account_index) + if code == 200: + state = "registered" + icon = "mdi:phone-check" + elif code == 0: + # Code 0 typically means registration in progress or unregistered + state = "unregistered" + icon = "mdi:phone-off" + else: + state = "failed" + icon = "mdi:phone-alert" + attributes = { + "friendly_name": f"SIP Registration {account_index}", + "icon": icon, + "status_code": code, + "reason": reason, + "last_change": datetime.now().isoformat(), + } + self._update_sensor(entity_id, state, attributes) + + def update_last_call( + self, + account_index: int, + direction: str, + call_info: Optional[Dict[str, Any]] = None + ) -> None: + if not self.sensor_config.enabled: + return + entity_id = self._get_last_call_entity_id(account_index) + if direction == "none": + icon = "mdi:phone" + elif direction == "incoming": + icon = "mdi:phone-incoming" + else: # outgoing + icon = "mdi:phone-outgoing" + attributes: Dict[str, Any] = { + "friendly_name": f"SIP Last Call {account_index}", + "icon": icon, + } + if call_info: + attributes["caller"] = call_info.get("caller") + attributes["called"] = call_info.get("called") + attributes["parsed_caller"] = call_info.get("parsed_caller") + attributes["parsed_called"] = call_info.get("parsed_called") + attributes["call_id"] = call_info.get("call_id") + attributes["timestamp"] = datetime.now().isoformat() + self._update_sensor(entity_id, direction, attributes) + + def initialize_sensors(self) -> None: + if not self.sensor_config.enabled: + return + log(None, f"Initializing sensors with prefix '{self.sensor_config.entity_prefix}' for accounts: {self.enabled_accounts}") + for account_index in self.enabled_accounts: + self.set_call_inactive(account_index) + self._update_sensor( + self._get_registration_entity_id(account_index), + "unknown", + { + "friendly_name": f"SIP Registration {account_index}", + "icon": "mdi:phone-clock", + } + ) + self.update_last_call(account_index, "none") diff --git a/ha-sip/src/sensor_event_handler.py b/ha-sip/src/sensor_event_handler.py new file mode 100644 index 0000000..6cf8263 --- /dev/null +++ b/ha-sip/src/sensor_event_handler.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sensor import SensorUpdater + + +class SensorEventHandler: + def __init__(self, sensor_updater: SensorUpdater): + self.sensor_updater = sensor_updater + # Track call direction per account: account_index -> "incoming" or "outgoing" + self.call_directions: Dict[int, str] = {} + + def handle_event(self, event: Any, webhook_id: Optional[str] = None) -> None: + event_type = event.get("event") + sip_account = event.get("sip_account") + if sip_account is None: + return + if event_type == "incoming_call": + self.call_directions[sip_account] = "incoming" + self.sensor_updater.set_call_active(sip_account, event) + elif event_type == "outgoing_call_initiated": + self.call_directions[sip_account] = "outgoing" + elif event_type == "call_established": + self.sensor_updater.set_call_active(sip_account, event) + elif event_type == "call_disconnected": + self.sensor_updater.set_call_inactive(sip_account) + # Update last call sensor with the direction we tracked + direction = self.call_directions.pop(sip_account, "incoming") + self.sensor_updater.update_last_call(sip_account, direction, event) diff --git a/ha-sip/src/webhook.py b/ha-sip/src/webhook.py index fe90462..31b7874 100644 --- a/ha-sip/src/webhook.py +++ b/ha-sip/src/webhook.py @@ -10,6 +10,7 @@ class WebhookToCall(TypedDict): + outgoing_call_initiated: Optional[str] call_established: Optional[str] entered_menu: Optional[str] dtmf_digit: Optional[str]