Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
112 changes: 112 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions ha-sip/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion ha-sip/config.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -68,6 +68,10 @@
},
"webhook": {
"id": "sip_call_webhook_id"
},
"sensors": {
"enabled": false,
"entity_prefix": "ha_sip"
}
},
"schema": {
Expand Down Expand Up @@ -123,6 +127,10 @@
},
"webhook": {
"id": "str"
},
"sensors": {
"enabled": "bool",
"entity_prefix": "str"
}
},
"host_network": true,
Expand Down
3 changes: 3 additions & 0 deletions ha-sip/run-in-ha.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
16 changes: 12 additions & 4 deletions ha-sip/src/account.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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__(
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
31 changes: 23 additions & 8 deletions ha-sip/src/call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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}')
Expand Down Expand Up @@ -618,14 +627,16 @@ 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
if m['choices']:
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
Expand Down Expand Up @@ -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))
Expand All @@ -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


Expand Down
3 changes: 3 additions & 0 deletions ha-sip/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', '')
Expand Down
5 changes: 5 additions & 0 deletions ha-sip/src/ha.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -80,6 +84,7 @@ class RecordingStopped(TypedDict):

WebhookEvent = Union[
IncomingCallEvent,
OutgoingCallInitiatedEvent,
CallEstablishedEvent,
CallDisconnectedEvent,
EnteredMenuEvent,
Expand Down
Loading