diff --git a/.env.example b/.env.example index d0d16c5..c57558e 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,8 @@ -COMMAND_SOURCE="stdin" # or "mqtt" - PORT="5060" LOG_LEVEL="2" NAME_SERVER="" CACHE_DIR="" -GLOBAL_OPTIONS="" +GLOBAL_OPTIONS="--enable-mqtt --mqtt-address 192.168.1.1 --mqtt-port 1883 --mqtt-username admin --mqtt-password password --mqtt-topic=hasip/execute --mqtt-state-topic hasip/state" SIP1_ENABLED="True" SIP1_ID_URI="sip:homeassistant@fritz.box" @@ -49,10 +47,3 @@ HA_WEBHOOK_ID="sip_call_webhook_id_test" SENSOR_ENABLED="True" SENSOR_ENTITY_PREFIX="ha-sip" - -BROKER_ADDRESS="" -BROKER_PORT="1883" -BROKER_USERNAME="" -BROKER_PASSWORD="" -MQTT_TOPIC="hasip/execute" -MQTT_STATE_TOPIC="hasip/state" diff --git a/.github/workflows/build-armhf.yml b/.github/workflows/build-armhf.yml deleted file mode 100644 index 12171eb..0000000 --- a/.github/workflows/build-armhf.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Build armhf - -on: - push: - branches: [ next ] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - build-arm: - runs-on: ubuntu-latest - environment: next - strategy: - matrix: - architecture: [arm64] - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Run build.sh for ARM - run: | - export REPO_URL=${{ vars.REPO_URL }} - export DOCKER_HUB_PASSWORD=${{ secrets.DOCKER_HUB_PASSWORD }} - ./build.sh build-armhf diff --git a/.github/workflows/build-armv7.yml b/.github/workflows/build-armv7.yml deleted file mode 100644 index 5b5140b..0000000 --- a/.github/workflows/build-armv7.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Build armv7 - -on: - push: - branches: [ next ] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - build-arm: - runs-on: ubuntu-latest - environment: next - strategy: - matrix: - architecture: [arm64] - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Run build.sh for ARM - run: | - export REPO_URL=${{ vars.REPO_URL }} - export DOCKER_HUB_PASSWORD=${{ secrets.DOCKER_HUB_PASSWORD }} - ./build.sh build-armv7 diff --git a/.github/workflows/build-i386.yml b/.github/workflows/build-i386.yml deleted file mode 100644 index 44bfc51..0000000 --- a/.github/workflows/build-i386.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Build i386 - -on: - push: - branches: [ next ] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - build-amd64: - runs-on: ubuntu-latest - environment: next - strategy: - matrix: - architecture: [amd64] - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - name: Run build.sh for AMD64 - run: | - export REPO_URL=${{ vars.REPO_URL }} - export DOCKER_HUB_PASSWORD=${{ secrets.DOCKER_HUB_PASSWORD }} - ./build.sh build-i386 diff --git a/README.md b/README.md index 88ad9f4..78d1f0c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ![logo](icon.png) ha-sip -### Home Assistant SIP/VoIP Gateway is a Home Assistant add-on which +### Home Assistant SIP/VoIP Gateway is a Home Assistant app which - allows the dialing and hanging up of phone numbers through a SIP end-point - triggering of services through dial tones (DTMF) after the call was established. - listens for incoming calls and can trigger actions through a web-hook (the call is not picked up) @@ -12,11 +12,11 @@ ## Installation -[![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Farnonym%2Fha-plugins) +[![Open your Home Assistant instance and show the add app repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Farnonym%2Fha-plugins) -This add-on is for the Home Assistant OS or supervised installation methods mentioned in +This app is for the Home Assistant OS or supervised installation methods mentioned in https://www.home-assistant.io/installation/. With that in place you can install this third-party plug-in like described in -https://www.home-assistant.io/common-tasks/os#installing-a-third-party-add-on-repository. The repository URL is +https://www.home-assistant.io/common-tasks/os#installing-a-third-party-app-repository. The repository URL is `https://github.com/arnonym/ha-plugins`. > **Note:** @@ -40,7 +40,7 @@ sip: realm: '*' user_name: homeassistant password: secure - answer_mode: listen # "listen" or "accept", see below + answer_mode: listen # "listen", "accept", or "reject". see below settle_time: 1 # time to wait for playing the message/actions/etc. after call was established incoming_call_file: "" # config and menu definition file for incoming calls, see below options: '' @@ -70,12 +70,12 @@ webhook: > **Note** > For TTS you need to install one of the [TTS integrations](https://www.home-assistant.io/integrations/#text-to-speech). -> If you're unsure about the entity id used for `engine_id`, set `debug_print` to `true` and restart the add-on. -> The add-on will output a list of all available engines and languages into the log. If the configured engine and language +> If you're unsure about the entity id used for `engine_id`, set `debug_print` to `true` and restart the app. +> The app will output a list of all available engines and languages into the log. If the configured engine and language > is valid, it will also log the available voices (if the engine supports it). > **Note** -> You are able to access the /config and /media directory inside the add-on for config files, audio files, cache and recordings. +> You are able to access the /config and /media directory inside the app for config files, audio files, cache and recordings. #### For `global_options` you can specify the following options @@ -91,6 +91,19 @@ webhook: --tls-port TLS_PORT Port to use for TLS transport (default: 5061) --debug-headers {enabled,enable,true,yes,on,1,disabled,disable,false,no,off,0} Enable debug printing of all available SIP headers (default: disabled) + --enable-mqtt Enable MQTT as a command source (default: disabled) + --mqtt-address MQTT_ADDRESS + MQTT broker address (default: empty) + --mqtt-port MQTT_PORT + MQTT broker port (default: 1883) + --mqtt-username MQTT_USERNAME + MQTT broker username (default: empty) + --mqtt-password MQTT_PASSWORD + MQTT broker password (default: empty) + --mqtt-topic MQTT_TOPIC + MQTT topic to subscribe to for incoming commands (default: hasip/execute) + --mqtt-state-topic MQTT_STATE_TOPIC + MQTT topic to publish call state events to (default: hasip/state) ``` #### For `options` on each SIP account there are @@ -121,6 +134,8 @@ webhook: Set the TURN password (default: None) --extract-headers EXTRACT_HEADERS Comma-separated list of SIP headers to extract and include in webhooks (default: None) + --reject-sip-code REJECT_SIP_CODE + SIP response code used when rejecting incoming calls in reject mode (default: 603) ``` ## Usage @@ -168,6 +183,9 @@ data: input: command: hangup number: sip:**620@fritz.box + sip_code: 486 # optional SIP status code (e.g. 486 "Busy Here", 603 "Decline") + # only applied when the call has not been answered yet; + # ignored for already active calls ``` #### To send DTMF digits to an established call: @@ -293,7 +311,7 @@ data: In `listen` mode no call will be answered (picked up) but you can trigger an automation through a [Webhook trigger](https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger) for every incoming call. The webhook ID must match the ID set in the configuration. -You can get the caller from `{{trigger.json.caller}}` or `{{trigger.json.parsed_caller}}` for usage in e.g. the action of your automation. +You can get the remote from `{{trigger.json.remote_uri}}` or `{{trigger.json.parsed_remote_uri}}` for usage in e.g. the action of your automation. If you want to react on a webhook message with another command you should use `{{ trigger.json.internal_id }}` as the number. If you also use the menu ID webhook you need to check for `{{ trigger.json.event == "incoming_call" }}` e.g. in a "Choose" action type. @@ -302,8 +320,9 @@ Example of "incoming call" webhook message: ```json { "event": "incoming_call", - "caller": "", - "parsed_caller": "5551234456", + "call_direction": "incoming", + "remote_uri": "", + "parsed_remote_uri": "5551234456", "sip_account": 1, "internal_id": "something-unique" } @@ -335,7 +354,7 @@ If you don't provide a menu the menu from `incoming_call_file` will be used. #### Accept mode In `accept` mode you can additionally make ha-sip to accept the call. For this you can define a menu per SIP account. Put a config file -into your `/config` directory of your home-assistant installation (e.g. use the samba add-on to access that directory). +into your `/config` directory of your home-assistant installation (e.g. use the samba app to access that directory). Example content of `/config/sip-1-incoming.yaml`: @@ -372,7 +391,16 @@ menu: post_action: return ``` -After that you set `incoming_call_file` in the add-on configuration to `/config/sip-1-incoming.yaml`. +After that you set `incoming_call_file` in the app configuration to `/config/sip-1-incoming.yaml`. + +#### Reject mode + +In `reject` mode every incoming call is automatically rejected. No audio is played and the call is never +answered. A webhook is still triggered for each rejected call, so you can still react to incoming calls +in your automations (e.g. for logging or notifications). + +The SIP response code sent to the caller defaults to `603 Decline` and can be changed via the +`--reject-sip-code` option on the SIP account (e.g. `--reject-sip-code 486` for "Busy Here"). ## Call menu definition @@ -382,7 +410,7 @@ used for incoming and outgoing calls. menu: id: main # If "id" is present, a message will be sent via webhook (entered_menu), see below (optional) message: Please enter your access code # the message to be played via TTS (optional, defaults to empty) - language: en # TTS language (optional, defaults to the global language from add-on config) + language: en # TTS language (optional, defaults to the global language from app config) choices_are_pin: true # If the choices should be handled like PINs (optional, defaults to false) timeout: 10 # time in seconds before "timeout" choice is triggered (optional, defaults to 300) post_action: noop # this action will be triggered after the message was played. Can be @@ -432,7 +460,7 @@ menu: ``` > **Note:** -> The audio files need to reside in your home-assistant `config` directory, as this is the only directory accessible inside the add-on. +> The audio files need to reside in your home-assistant `config` or `media` directory, as these are the only directory accessible inside the app. ## Web-hooks @@ -444,16 +472,19 @@ These are the common fields available in all webhook events: ```json { "internal_id": "something-unique", - "caller": "", - "called": "", - "parsed_caller": "5551234456", - "parsed_called": "sip-user", + "call_direction": "incoming", + "local_uri": "", + "remote_uri": "", + "parsed_local_uri": "sip-user", + "parsed_remote_uri": "5551234456", "sip_account": 1, "call_id": "7490FE75C2CB1D45@192.168.178.1", "headers": {} } ``` +`call_direction` is either `"incoming"` or `"outgoing"`. + > **Note:** The `headers` field contains extracted SIP headers if `--extract-headers` is configured on the SIP account, > otherwise it's an empty object. See [SIP Header Extraction](#sip-header-extraction) for more details. @@ -570,7 +601,7 @@ ha-sip can expose sensor entities to Home Assistant for monitoring SIP account s 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: +To enable sensors, add the following to your app configuration: ```yaml sensors: @@ -587,10 +618,10 @@ Tracks whether a call is currently active on each SIP account. | `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 +- `remote_uri`: Full remote party URI +- `local_uri`: Full local SIP account URI +- `parsed_remote_uri`: Extracted remote party number +- `parsed_local_uri`: Extracted local SIP account number - `sip_account`: Account number - `call_id`: SIP call ID - `headers`: Extracted SIP headers (if configured) @@ -623,10 +654,10 @@ Tracks information about the most recent call on each account. | `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 +- `remote_uri`: Full remote party URI +- `local_uri`: Full local SIP account URI +- `parsed_remote_uri`: Extracted remote party number +- `parsed_local_uri`: Extracted local SIP account number - `call_id`: SIP call ID - `timestamp`: ISO timestamp when call ended @@ -665,7 +696,7 @@ automation: - service: logbook.log data: name: "Incoming Call" - message: "Call from {{ state_attr('sensor.ha_sip_last_call_1', 'parsed_caller') }}" + message: "Call from {{ state_attr('sensor.ha_sip_last_call_1', 'parsed_remote_uri') }}" ``` ## SIP Header Extraction @@ -687,8 +718,9 @@ The extracted headers will be included in all webhook events for calls on that a ```json { "event": "incoming_call", - "caller": "", - "parsed_caller": "5551234456", + "call_direction": "incoming", + "remote_uri": "", + "parsed_remote_uri": "5551234456", "sip_account": 1, "headers": { "X-Caller-ID": "John Doe", @@ -796,7 +828,7 @@ All the examples are working also for incoming calls when you copy the `menu` pa ## Troubleshooting -The first place to look is the log of the ha-sip add-on. There you can see individual SIP messages and the logs of +The first place to look is the log of the ha-sip app. There you can see individual SIP messages and the logs of ha-sip itself (prefixed with "|"). ## Stand-alone mode @@ -806,10 +838,14 @@ Instead of stdin - MQTT will be used for communication. 1. Follow the instructions from home assistant to set up a working MQTT broker and install the MQTT integration [MQTT Broker](https://www.home-assistant.io/integrations/mqtt/) 2. Copy `.env.example` to `.env` and replace the variable place-holders with your real configuration. -3. Make sure you switched the `COMMAND_SOURCE` in your .env file from "stdin" to "mqtt" and set the `BROKER_*` variables to connect to your MQTT broker address +3. In your `.env` file, enable MQTT through `GLOBAL_OPTIONS` and point it at your broker, for example: + + ``` + GLOBAL_OPTIONS="--enable-mqtt --mqtt-address 192.168.1.1 --mqtt-port 1883 --mqtt-username admin --mqtt-password secret --mqtt-topic hasip/execute --mqtt-state-topic hasip/state" + ``` 4. Install [docker compose plugin](https://docs.docker.com/compose/install/linux/#install-using-the-repository) 5. Run `docker compose up -d` in the main folder of the application to run the ha-sip service -6. Now you can use the `mqtt.publish` service in home assistant to send commands as json to the `hasip/execute` topic from your automations +6. Now you can use the `mqtt.publish` service in home assistant to send commands as json to the topic configured in `--mqtt-topic` (defaults to `hasip/execute`) from your automations Example: ```yaml @@ -820,7 +856,7 @@ Instead of stdin - MQTT will be used for communication. topic: hasip/execute ``` -7. You can listen to call state event on the topic configured in `MQTT_STATE_TOPIC` (defaults to `hasip/state`). +7. You can listen to call state events on the topic configured in `--mqtt-state-topic` (defaults to `hasip/state`). ## Support @@ -836,8 +872,8 @@ Your support helps others discover the project and keeps me motivated. `HA_BASE_URL` is something like "http://homeassistant.local:8123/api" The access token is created from http://homeassistant.local:8123/profile -4. Run `./build.sh run-local` to run the add-on locally -5. Paste commands as json (without line-breaks) into stdin of the running add-on: +4. Run `./build.sh run-local` to run the app locally +5. Paste commands as json (without line-breaks) into stdin of the running app: Example: ```json diff --git a/VOICE-ASSISTANT.md b/VOICE-ASSISTANT.md index 58484c6..08d1648 100644 --- a/VOICE-ASSISTANT.md +++ b/VOICE-ASSISTANT.md @@ -12,7 +12,7 @@ By combining ha-sip's phone capabilities with Home Assistant's AI conversation f ## Prerequisites -### 1. ha-sip add-on installed and configured with at least one working SIP account +### 1. ha-sip app installed and configured with at least one working SIP account see [README.md](README.md). We are using the SIP Account in "Listen Mode" for our examples. diff --git a/build.sh b/build.sh index b384d6b..f2c16ad 100755 --- a/build.sh +++ b/build.sh @@ -143,7 +143,7 @@ case "$1" in pip3 install -r "$SCRIPT_DIR"/ha-sip/requirements.txt mkdir "$SCRIPT_DIR"/deps cd "$SCRIPT_DIR"/deps || exit - git clone --depth 1 --branch 2.16 https://github.com/pjsip/pjproject.git + git clone --depth 1 --branch 2.17 https://github.com/pjsip/pjproject.git cd pjproject || exit ./configure CFLAGS="-O3 -DNDEBUG -fPIC" --enable-shared --disable-libwebrtc --with-ssl --with-opus=/usr --prefix "$SCRIPT_DIR"/venv make diff --git a/ha-sip/CHANGELOG.md b/ha-sip/CHANGELOG.md index 0747093..1855447 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -2,11 +2,29 @@ If you find ha-sip useful, consider starring ⭐ the [GitHub repo](https://github.com/arnonym/ha-plugins)! -# 5.5 +## 5.6 + +- Allow to specify a SIP status code in the `hangup` command for unanswered calls +- Add "reject" mode for SIP accounts to reject incoming calls by default +- Add `call_direction` field to all webhook payloads to distinguish between `"incoming"` and `"outgoing"` calls +- Fix not able to jump to certain menu IDs (thanks nbe95@github for the fix!) +- Allow use of MQTT also inside supervisor mode +- Update pjsip to latest version +- Remove unsupported architectures i386, armhf and armv7 + +> **Deprecation notice**: `caller`, `called`, `parsed_caller`, and `parsed_called` webhook and sensor fields are deprecated and will be removed in a future version +> as those were interchanged in outgoing calls. +> +> Use `remote_uri`, `local_uri`, `parsed_remote_uri`, and `parsed_local_uri` instead. + +> **Backward incompatible changes for stand-alone mode**: +> use `GLOBAL_OPTIONS` instead of individual environment variables to configure MQTT. + +## 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 +## 5.4.1 - Fix crash for outgoing calls ## 5.4 @@ -22,7 +40,7 @@ If you find ha-sip useful, consider starring ⭐ the [GitHub repo](https://githu ## 5.2 - Add recording support for calls (thanks dirtyharryiv@github for implementing this!) -- Allow /media to be used inside add-on +- Allow /media to be used inside app - Bugfix for Python 3.13 - Update pjsip to latest version @@ -30,7 +48,7 @@ If you find ha-sip useful, consider starring ⭐ the [GitHub repo](https://githu - Allow setting voice and other options for TTS messages - Add opus codec support for home-assistant VoIP integration - Add webhook overwrite option for 'answer' command -#### Deprecation notice: `platform` in TTS options is replaced by `engine_id` +> **Deprecation notice**: `platform` in TTS options is replaced by `engine_id` ## 5.0 - Add MQTT topic for call state events @@ -104,7 +122,7 @@ If you find ha-sip useful, consider starring ⭐ the [GitHub repo](https://githu ## 2.6 - Call additional web-hooks for incoming and outgoing calls -#### Deprecation notice: `webhook_to_call_after_call_was_established` will be removed in the next release and is replaced by the more granular `webhook_to_call`. +> **Deprecation notice**: `webhook_to_call_after_call_was_established` will be removed in the next release and is replaced by the more granular `webhook_to_call`. The old config option can be converted from diff --git a/ha-sip/Dockerfile b/ha-sip/Dockerfile index 7827d5c..96c6960 100644 --- a/ha-sip/Dockerfile +++ b/ha-sip/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get install -y build-essential git swig python3 python3-dev python3-pip COPY requirements.txt /tmp/requirements.txt RUN pip3 install -r /tmp/requirements.txt --break-system-packages -RUN git clone --depth 1 --branch 2.16 https://github.com/pjsip/pjproject.git /tmp/pjproject +RUN git clone --depth 1 --branch 2.17 https://github.com/pjsip/pjproject.git /tmp/pjproject WORKDIR /tmp/pjproject diff --git a/ha-sip/config.json b/ha-sip/config.json index 35d8bcc..1268e1e 100644 --- a/ha-sip/config.json +++ b/ha-sip/config.json @@ -1,15 +1,12 @@ { "name": "ha-sip", - "version": "5.5", + "version": "5.6", "slug": "ha-sip", "url": "https://github.com/arnonym/ha-plugins", "description": "Home-Assistant SIP Gateway", "arch": [ "aarch64", - "amd64", - "armhf", - "armv7", - "i386" + "amd64" ], "image": "agellhaus/{arch}-ha-sip", "init": "false", @@ -89,7 +86,7 @@ "realm": "str", "user_name": "str", "password": "str", - "answer_mode": "list(listen|accept)", + "answer_mode": "list(listen|accept|reject)", "settle_time": "float", "incoming_call_file": "str", "options": "str" @@ -101,7 +98,7 @@ "realm": "str", "user_name": "str", "password": "str", - "answer_mode": "list(listen|accept)", + "answer_mode": "list(listen|accept|reject)", "settle_time": "float", "incoming_call_file": "str", "options": "str" @@ -113,7 +110,7 @@ "realm": "str", "user_name": "str", "password": "str", - "answer_mode": "list(listen|accept)", + "answer_mode": "list(listen|accept|reject)", "settle_time": "float", "incoming_call_file": "str", "options": "str" diff --git a/ha-sip/src/account.py b/ha-sip/src/account.py index 951c7e0..52157b9 100644 --- a/ha-sip/src/account.py +++ b/ha-sip/src/account.py @@ -121,8 +121,8 @@ def onIncomingCall(self, prm) -> None: self.ha_config, DEFAULT_RING_TIMEOUT, webhook_to_call, sip_headers, ) ci = incoming_call_instance.get_call_info() - answer_mode = self.get_sip_return_code(self.config.mode, allowed_numbers, blocked_numbers, ci['parsed_caller']) - log(self.config.index, f"Incoming call from '{ci['remote_uri']}' (parsed: '{ci['parsed_caller']}') to '{ci['local_uri']}' (parsed: '{ci['parsed_called']}')") + answer_mode = self.get_sip_return_code(self.config.mode, allowed_numbers, blocked_numbers, ci['parsed_remote_uri']) + log(self.config.index, f"Incoming call from '{ci['remote_uri']}' (parsed: '{ci['parsed_remote_uri']}') to '{ci['local_uri']}' (parsed: '{ci['parsed_local_uri']}')") if allowed_numbers: log(self.config.index, f'Allowed numbers: {allowed_numbers}') if blocked_numbers: @@ -142,15 +142,15 @@ def get_sip_return_code( mode: call.CallHandling, allowed_numbers: Optional[list[str]], blocked_numbers: Optional[list[str]], - parsed_caller: Optional[str], + parsed_number: Optional[str], ) -> call.CallHandling: if allowed_numbers and blocked_numbers: log(self.config.index, 'Error: cannot specify both of allowed and blocked numbers. Call won\'t be accepted!') return call.CallHandling.LISTEN if mode == call.CallHandling.ACCEPT and allowed_numbers: - return call.CallHandling.ACCEPT if Account.is_number_in_list(parsed_caller, allowed_numbers) else call.CallHandling.LISTEN + return call.CallHandling.ACCEPT if Account.is_number_in_list(parsed_number, allowed_numbers) else call.CallHandling.LISTEN if mode == call.CallHandling.ACCEPT and blocked_numbers: - return call.CallHandling.ACCEPT if not Account.is_number_in_list(parsed_caller, blocked_numbers) else call.CallHandling.LISTEN + return call.CallHandling.ACCEPT if not Account.is_number_in_list(parsed_number, blocked_numbers) else call.CallHandling.LISTEN return mode @staticmethod diff --git a/ha-sip/src/call.py b/ha-sip/src/call.py index 8bd03fc..f54ee87 100644 --- a/ha-sip/src/call.py +++ b/ha-sip/src/call.py @@ -4,67 +4,36 @@ import re import time from enum import Enum -from typing import Dict, Optional, Callable, Union, Any, List +from typing import Dict, Optional, Callable, Union, List +from typing_extensions import Literal import pjsua2 as pj -import yaml -from typing_extensions import TypedDict, Literal import account import audio import audio_cache import ha import player -import utils import webhook from call_state_change import CallStateChange from command_client import Command from command_handler import CommandHandler -from constants import DEFAULT_RING_TIMEOUT, DEFAULT_DTMF_ON, DEFAULT_DTMF_OFF -from log import log +from constants import DEFAULT_RING_TIMEOUT, DEFAULT_DTMF_ON from event_sender import EventSender -from post_action import PostAction, PostActionNoop, PostActionHangup, PostActionRepeatMessage, PostActionReturn, PostActionJump +from log import log +from menu import MenuFromStdin, Menu, normalize_menu, pretty_print_menu +from sip_status import REASON_PHRASES +from tone_digit import create_tone_digit_vector +from post_action import PostAction CallCallback = Callable[[CallStateChange, str, 'Call'], None] DtmfMethod = Union[Literal['in_band'], Literal['rfc2833'], Literal['sip_info']] -class MenuFromStdin(TypedDict): - id: Optional[str] - message: Optional[str] - handle_as_template: Optional[bool] - audio_file: Optional[str] - language: Optional[str] - action: Optional[Command] - choices_are_pin: Optional[bool] - post_action: Optional[str] - timeout: Optional[int] - choices: Optional[dict[Any, MenuFromStdin]] - cache_audio: Optional[bool] - wait_for_audio_to_finish: Optional[bool] - - -class Menu(TypedDict): - id: Optional[str] - message: Optional[str] - handle_as_template: bool - audio_file: Optional[str] - language: str - action: Optional[Command] - choices_are_pin: bool - post_action: PostAction - timeout: float - choices: Optional[dict[str, Menu]] - default_choice: Optional[Menu] - timeout_choice: Optional[Menu] - parent_menu: Optional[Menu] - cache_audio: bool - wait_for_audio_to_finish: bool - - class CallHandling(Enum): LISTEN = 'LISTEN' ACCEPT = 'ACCEPT' + REJECT = 'REJECT' @staticmethod def get_or_else(name: Optional[str], default: CallHandling) -> CallHandling: @@ -93,34 +62,36 @@ def __init__( self.player: Optional[player.Player] = None self.audio_media: Optional[pj.AudioMedia] = None self.recorder: Optional[pj.AudioMediaRecorder] = None - self.recording_file: Optional[str] = None - self.requested_recording_filename: Optional[str] = None - self.connected = False - self.current_input = '' + self.tone_gen: Optional[pj.ToneGenerator] = None + self.end_point = end_point self.account = sip_account self.uri_to_call = uri_to_call + self.direction: Literal['incoming', 'outgoing'] = 'outgoing' if uri_to_call else 'incoming' + self.command_handler = command_handler + self.event_sender = event_sender self.ha_config = ha_config self.ring_timeout = ring_timeout - self.settle_time = sip_account.config.settle_time self.webhooks: Optional[webhook.WebhookToCall] = webhooks - self.command_handler = command_handler - self.event_sender = event_sender + self.sip_headers: Dict[str, Optional[str]] = sip_headers if sip_headers is not None else {} + + self.recording_file: Optional[str] = None + self.requested_recording_filename: Optional[str] = None + self.connected = False + self.current_input = '' self.scheduled_post_action: Optional[PostAction] = None self.playback_is_done = True self.wait_for_audio_to_finish = False self.last_seen = time.time() self.call_settled_at: Optional[float] = None self.answer_at: Optional[float] = None - self.tone_gen: Optional[pj.ToneGenerator] = None self.call_info: Optional[webhook.CallInfo] = None self.pressed_digit_list: List[str] = [] 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 None - self.menu_map = self.create_menu_map(self.menu) - Call.pretty_print_menu(self.menu) + self.menu, self.menu_map = normalize_menu(menu, self.ha_config.tts_config['language'], self.account.config.index) + pretty_print_menu(self.menu) log(self.account.config.index, f'Registering call with id {self.callback_id}') self.command_handler.register_call(self.callback_id, self, other_ids) @@ -162,30 +133,31 @@ def handle_events(self) -> None: def handle_post_action(self, post_action: PostAction): log(self.account.config.index, f'Scheduled post action: {post_action["action"]}') - 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']): + match post_action["action"]: + case 'noop': + pass + case '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: + m = m['parent_menu'] if m: - m = m['parent_menu'] - if m: - self.handle_menu(m) - else: - log(self.account.config.index, f'Could not return {post_action["level"]} level in current menu') - elif post_action["action"] == 'jump': - new_menu = self.menu_map.get(post_action['menu_id']) - if new_menu: - self.handle_menu(new_menu) - else: - log(self.account.config.index, f'Could not find menu_id: "{post_action["menu_id"]}". Valid IDs are {self.menu_map.keys()}') - elif post_action["action"] == 'hangup': - self.hangup_call() - elif post_action["action"] == 'repeat_message': - self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) + self.handle_menu(m) + else: + log(self.account.config.index, f'Could not return {post_action["level"]} level in current menu') + case 'jump': + new_menu = self.menu_map.get(post_action['menu_id']) + if new_menu: + self.handle_menu(new_menu) + else: + log(self.account.config.index, f'Could not find menu_id: "{post_action["menu_id"]}". Valid IDs are {self.menu_map.keys()}') + case 'hangup': + self.hangup_call() + case 'repeat_message': + self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) def trigger_webhook(self, event: ha.WebhookEvent): webhook.trigger_webhook( @@ -208,28 +180,29 @@ def onCallState(self, prm) -> None: if not self.call_info: self.call_info = self.get_call_info() ci = self.getInfo() - if ci.state == pj.PJSIP_INV_STATE_EARLY: - log(self.account.config.index, 'Early') - elif ci.state == pj.PJSIP_INV_STATE_CALLING: - log(self.account.config.index, 'Calling') - elif ci.state == pj.PJSIP_INV_STATE_CONNECTING: - log(self.account.config.index, 'Call connecting...') - elif ci.state == pj.PJSIP_INV_STATE_CONFIRMED: - log(self.account.config.index, 'Call connected') - self.extract_headers_from_response(prm) - self.call_settled_at = time.time() + self.settle_time - elif ci.state == pj.PJSIP_INV_STATE_DISCONNECTED: - log(self.account.config.index, 'Call disconnected') - self.stop_recording() - self.trigger_webhook({'event': 'call_disconnected'}) - self.connected = False - self.current_input = '' - self.player = None - self.audio_media = None - self.tone_gen = None - self.command_handler.forget_call(self.callback_id) - else: - log(self.account.config.index, f'Unknown state: {ci.state}') + match ci.state: + case pj.PJSIP_INV_STATE_EARLY: + log(self.account.config.index, 'Early') + case pj.PJSIP_INV_STATE_CALLING: + log(self.account.config.index, 'Calling') + case pj.PJSIP_INV_STATE_CONNECTING: + log(self.account.config.index, 'Call connecting...') + case pj.PJSIP_INV_STATE_CONFIRMED: + log(self.account.config.index, 'Call connected') + self.extract_headers_from_response(prm) + self.call_settled_at = time.time() + self.account.config.settle_time + case pj.PJSIP_INV_STATE_DISCONNECTED: + log(self.account.config.index, 'Call disconnected') + self.stop_recording() + self.trigger_webhook({'event': 'call_disconnected'}) + self.connected = False + self.current_input = '' + self.player = None + self.audio_media = None + self.tone_gen = None + self.command_handler.forget_call(self.callback_id) + case _: + log(self.account.config.index, f'Unknown state: {ci.state}') def onCallMediaState(self, prm) -> None: call_info = self.getInfo() @@ -449,23 +422,33 @@ def stop_recording(self) -> None: self.recording_file = None def accept(self, answer_mode: CallHandling, answer_after: float) -> None: + if answer_mode == CallHandling.REJECT: + sip_code = self.account.config.options.reject_sip_code + log(self.account.config.index, f'Rejecting call with SIP code {sip_code}.') + call_prm = pj.CallOpParam() + call_prm.statusCode = sip_code + call_prm.reason = REASON_PHRASES.get(sip_code, "") + self.answer(call_prm) + return call_prm = pj.CallOpParam() call_prm.statusCode = 180 self.answer(call_prm) if answer_mode == CallHandling.ACCEPT: self.answer_at = time.time() + answer_after - def hangup_call(self) -> None: + def hangup_call(self, sip_code: int = 0) -> None: log(self.account.config.index, 'Hang-up.') call_prm = pj.CallOpParam(True) + if sip_code and not self.connected: + call_prm.statusCode = sip_code + call_prm.reason = REASON_PHRASES.get(sip_code, "") self.hangup(call_prm) def answer_call(self, new_menu: Optional[MenuFromStdin], overwrite_webhooks: Optional[webhook.WebhookToCall]) -> None: log(self.account.config.index, 'Trigger answer of call (if not established already)') if new_menu: - self.menu = self.normalize_menu(new_menu) - self.menu_map = self.create_menu_map(self.menu) - self.pretty_print_menu(self.menu) + self.menu, self.menu_map = normalize_menu(new_menu, self.ha_config.tts_config['language'], self.account.config.index) + pretty_print_menu(self.menu) if overwrite_webhooks: self.webhooks = overwrite_webhooks if self.connected: @@ -517,22 +500,23 @@ def send_dtmf(self, digits: str, method: DtmfMethod = 'in_band') -> None: def get_callback_ids(self) -> tuple[str, List[str]]: if self.uri_to_call: # On outgoing calls we use the uri_to_call, as other info is not available yet - parsed_caller = self.parse_caller(self.uri_to_call) - return self.uri_to_call, [x for x in [parsed_caller] if x is not None] + parsed_uri = self.parse_sip_uri(self.uri_to_call) + return self.uri_to_call, [x for x in [parsed_uri] if x is not None] call_info = self.get_call_info() - return call_info['remote_uri'], [x for x in [call_info['parsed_caller'], call_info['call_id']] if x is not None] + return call_info['remote_uri'], [x for x in [call_info['parsed_remote_uri'], call_info['call_id']] if x is not None] def get_call_info(self) -> webhook.CallInfo: ci = self.getInfo() - parsed_caller = self.parse_caller(ci.remoteUri) - parsed_called = self.parse_caller(ci.localUri) + parsed_remote_uri = self.parse_sip_uri(ci.remoteUri) + parsed_local_uri = self.parse_sip_uri(ci.localUri) return { 'remote_uri': ci.remoteUri, 'local_uri': ci.localUri, - 'parsed_caller': parsed_caller, - 'parsed_called': parsed_called, + 'parsed_remote_uri': parsed_remote_uri, + 'parsed_local_uri': parsed_local_uri, 'call_id': ci.callIdString, 'headers': self.sip_headers, + 'direction': self.direction, } def extract_headers_from_response(self, prm) -> None: @@ -559,169 +543,16 @@ def reset_timeout(self): def set_current_playback(self, current_playback: ha.CurrentPlayback): self.current_playback = current_playback - def normalize_menu(self, menu: MenuFromStdin, parent_menu: Optional[Menu] = None, is_default_or_timeout_choice=False) -> Menu: - def parse_post_action(action: Optional[str]) -> PostAction: - if (not action) or (action == 'noop'): - return PostActionNoop(action='noop') - elif action == 'hangup': - return PostActionHangup(action='hangup') - elif action == 'repeat_message': - return PostActionRepeatMessage(action='repeat_message') - elif action.startswith('return'): - _, *params = action.split() - level_str = utils.safe_list_get(params, 0, 1) - level = utils.convert_to_int(level_str, 1) - return PostActionReturn(action='return', level=level) - elif action.startswith('jump'): - _, *params = action.split(None) - jump_to = utils.safe_list_get(params, 0, '') - if not jump_to: - log(self.account.config.index, 'Error: jump action requires a menu id as parameter') - return PostActionJump(action='jump', menu_id=jump_to.strip()) - else: - log(self.account.config.index, f'Unknown post_action: {action}') - return PostActionNoop(action='noop') - - def normalize_choice(item: tuple[Any, MenuFromStdin], parent_menu_for_choice: Menu) -> tuple[str, Menu]: - choice, sub_menu = item - normalized_choice = str(choice).lower() - normalized_sub_menu = self.normalize_menu(sub_menu, parent_menu_for_choice, normalized_choice in ['default', 'timeout']) - return normalized_choice, normalized_sub_menu - - def get_default_or_timeout_choice(choice: Union[Literal['default'], Literal['timeout']], parent_menu_for_choice: Menu) -> Optional[Menu]: - if is_default_or_timeout_choice: - return None - elif choice in normalized_choices: - return normalized_choices.pop(choice) - else: - if choice == 'default': - return Call.get_default_menu(parent_menu_for_choice) - else: - return Call.get_timeout_menu(parent_menu_for_choice) - - menu_id = menu.get('id') - normalized_menu: Menu = { - 'id': menu_id.strip() if menu_id else None, - 'message': menu.get('message'), - 'handle_as_template': menu.get('handle_as_template') or False, - 'audio_file': menu.get('audio_file'), - 'language': menu.get('language') or self.ha_config.tts_config['language'], - 'action': menu.get('action'), - 'choices_are_pin': menu.get('choices_are_pin') or False, - 'choices': None, - 'default_choice': None, - 'timeout_choice': None, - 'timeout': utils.convert_to_float(menu.get('timeout'), DEFAULT_RING_TIMEOUT), - 'post_action': parse_post_action(menu.get('post_action')), - 'parent_menu': parent_menu, - 'cache_audio': menu.get('cache_audio') or False, - 'wait_for_audio_to_finish': menu.get('wait_for_audio_to_finish') or False, - } - choices = menu.get('choices') - normalized_choices = dict(map(lambda c: normalize_choice(c, normalized_menu), choices.items())) if choices else dict() - default_choice = get_default_or_timeout_choice('default', normalized_menu) - timeout_choice = get_default_or_timeout_choice('timeout', normalized_menu) - normalized_menu['choices'] = normalized_choices - normalized_menu['default_choice'] = default_choice - normalized_menu['timeout_choice'] = timeout_choice - return normalized_menu - - @staticmethod - 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 - def parse_caller(remote_uri: str) -> Optional[str]: - parsed_caller_match = re.search(']', remote_uri) - if parsed_caller_match: - return parsed_caller_match.group(1) - parsed_caller_match_2nd_try = re.search('sip:(.+?)($|[@;])', remote_uri) - if parsed_caller_match_2nd_try: - return parsed_caller_match_2nd_try.group(1) + def parse_sip_uri(sip_uri: str) -> Optional[str]: + match = re.search(']', sip_uri) + if match: + return match.group(1) + match_fallback = re.search('sip:(.+?)($|[@;])', sip_uri) + if match_fallback: + return match_fallback.group(1) return None - @staticmethod - def get_default_menu(parent_menu: Menu) -> Menu: - return { - 'id': None, - 'message': 'Unknown option', - 'handle_as_template': False, - 'audio_file': None, - 'language': 'en', - 'action': None, - 'choices_are_pin': False, - 'choices': None, - 'default_choice': None, - 'timeout_choice': None, - 'post_action': PostActionReturn(action="return", level=1), - 'timeout': DEFAULT_RING_TIMEOUT, - 'parent_menu': parent_menu, - 'cache_audio': False, - 'wait_for_audio_to_finish': False - } - - @staticmethod - def get_timeout_menu(parent_menu: Menu) -> Menu: - return { - 'id': None, - 'message': None, - 'handle_as_template': False, - 'audio_file': None, - 'language': 'en', - 'action': None, - 'choices_are_pin': False, - 'choices': None, - 'default_choice': None, - 'timeout_choice': None, - 'post_action': PostActionHangup(action="hangup"), - 'timeout': DEFAULT_RING_TIMEOUT, - 'parent_menu': parent_menu, - 'cache_audio': False, - 'wait_for_audio_to_finish': False - } - - @staticmethod - def get_standard_menu() -> Menu: - standard_menu: Menu = { - 'id': None, - 'message': None, - 'handle_as_template': False, - 'audio_file': None, - 'language': 'en', - 'action': None, - 'choices_are_pin': False, - 'choices': dict(), - 'default_choice': None, - 'timeout_choice': None, - 'post_action': PostActionNoop(action="noop"), - 'timeout': DEFAULT_RING_TIMEOUT, - 'parent_menu': None, - 'cache_audio': False, - 'wait_for_audio_to_finish': False - } - standard_menu['default_choice'] = Call.get_default_menu(standard_menu) - standard_menu['timeout_choice'] = Call.get_timeout_menu(standard_menu) - return standard_menu - - @staticmethod - 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)) - - def make_call( ep: pj.Endpoint, acc: account.Account, @@ -738,19 +569,3 @@ def make_call( new_call.makeCall(uri_to_call, call_param) new_call.trigger_webhook({'event': 'outgoing_call_initiated'}) return new_call - - -def create_tone_digit(digit: str) -> pj.ToneDigit: - td = pj.ToneDigit() - td.digit = digit - td.volume = 0 - td.on_msec = DEFAULT_DTMF_ON - td.off_msec = DEFAULT_DTMF_OFF - return td - - -def create_tone_digit_vector(digits: str) -> pj.ToneDigitVector: - tone_digits_vector = pj.ToneDigitVector() - for d in digits: - tone_digits_vector.append(create_tone_digit(d)) - return tone_digits_vector diff --git a/ha-sip/src/command_client.py b/ha-sip/src/command_client.py index 81d244b..c39c19e 100644 --- a/ha-sip/src/command_client.py +++ b/ha-sip/src/command_client.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: import call + import menu class CommandCallService(TypedDict): @@ -27,7 +28,7 @@ class CommandCallService(TypedDict): class CommandDial(TypedDict): command: Literal['dial'] number: str - menu: Optional[call.MenuFromStdin] + menu: Optional[menu.MenuFromStdin] ring_timeout: Optional[str] sip_account: Optional[str] webhook_to_call_after_call_was_established: Optional[str] @@ -37,12 +38,13 @@ class CommandDial(TypedDict): class CommandHangup(TypedDict): command: Literal['hangup'] number: str + sip_code: int class CommandAnswer(TypedDict): command: Literal['answer'] number: str - menu: Optional[call.MenuFromStdin] + menu: Optional[menu.MenuFromStdin] webhook_to_call: Optional[webhook.WebhookToCall] diff --git a/ha-sip/src/command_handler.py b/ha-sip/src/command_handler.py index 1cc45db..81a725a 100644 --- a/ha-sip/src/command_handler.py +++ b/ha-sip/src/command_handler.py @@ -34,14 +34,14 @@ def __init__( self.event_sender = event_sender self.call_state = call_state - def get_call_from_state(self, caller_id: str) -> Optional[call.Call]: - return self.call_state.get_call(caller_id) + def get_call_from_state(self, call_key: str) -> Optional[call.Call]: + return self.call_state.get_call(call_key) - def get_call_from_state_unsafe(self, caller_id: str) -> call.Call: - return self.call_state.get_call_unsafe(caller_id) + def get_call_from_state_unsafe(self, call_key: str) -> call.Call: + return self.call_state.get_call_unsafe(call_key) - def is_active(self, caller_id: str) -> bool: - return self.call_state.is_active(caller_id) + def is_active(self, call_key: str) -> bool: + return self.call_state.is_active(call_key) def register_call(self, callback_id: str, new_call: call.Call, additional_ids: List[str]) -> None: self.call_state.register_call(callback_id, new_call, additional_ids) @@ -93,7 +93,8 @@ def handle_command(self, command: command_client.Command, from_call: Optional[ca self.call_not_in_progress_error(number) return current_call = self.get_call_from_state_unsafe(number) - current_call.hangup_call() + sip_code = utils.convert_to_int(command.get('sip_code'), 0) + current_call.hangup_call(sip_code=sip_code) case 'answer': if not number: log(None, 'Error: Missing number for command "answer"') diff --git a/ha-sip/src/config.py b/ha-sip/src/config.py index ace9dfa..70b370d 100644 --- a/ha-sip/src/config.py +++ b/ha-sip/src/config.py @@ -4,9 +4,6 @@ load_dotenv() - -COMMAND_SOURCE = os.environ.get('COMMAND_SOURCE', 'stdin') - PORT = os.environ.get('PORT', '') LOG_LEVEL = os.environ.get('LOG_LEVEL', '') NAME_SERVER = os.environ.get('NAME_SERVER', '') @@ -59,10 +56,3 @@ 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', '') -MQTT_PASSWORD = os.environ.get('BROKER_PASSWORD', '') -MQTT_TOPIC = os.environ.get('MQTT_TOPIC', 'hasip/execute') -MQTT_TOPIC_STATE = os.environ.get('MQTT_STATE_TOPIC', 'hasip/state') diff --git a/ha-sip/src/ha.py b/ha-sip/src/ha.py index 8aa6106..6b2b925 100644 --- a/ha-sip/src/ha.py +++ b/ha-sip/src/ha.py @@ -15,14 +15,20 @@ class WebhookBaseFields(TypedDict): - caller: str - called: str - parsed_caller: Optional[str] - parsed_called: Optional[str] + remote_uri: str + local_uri: str + parsed_remote_uri: Optional[str] + parsed_local_uri: Optional[str] sip_account: int call_id: Optional[str] internal_id: str headers: Dict[str, Optional[str]] + call_direction: Literal['incoming', 'outgoing'] + # Deprecated + caller: str + called: str + parsed_caller: Optional[str] + parsed_called: Optional[str] class IncomingCallEvent(TypedDict): diff --git a/ha-sip/src/incoming_call.py b/ha-sip/src/incoming_call.py index 05e3092..ba4f070 100644 --- a/ha-sip/src/incoming_call.py +++ b/ha-sip/src/incoming_call.py @@ -2,7 +2,7 @@ from typing_extensions import TypedDict -import call +import menu import webhook @@ -11,4 +11,4 @@ class IncomingCallConfig(TypedDict): blocked_numbers: Optional[list[str]] answer_after: Optional[int] webhook_to_call: Optional[webhook.WebhookToCall] - menu: call.MenuFromStdin + menu: menu.MenuFromStdin diff --git a/ha-sip/src/main.py b/ha-sip/src/main.py index caaeaab..eae803f 100755 --- a/ha-sip/src/main.py +++ b/ha-sip/src/main.py @@ -2,8 +2,10 @@ import asyncio import faulthandler import os +import socket import sys from typing import Optional, Any +from urllib.parse import urlparse import yaml @@ -55,6 +57,20 @@ def get_name_server(raw_name_server: str): return name_server_without_empty +def log_hostname_resolution(label: str, url: str) -> None: + if not url: + return + hostname = urlparse(url).hostname + if not hostname: + log(None, f'Error: could not extract hostname from {label}: {url}') + return + try: + ip = socket.gethostbyname(hostname) + log(None, f'{label} hostname {hostname} resolves to {ip}') + except socket.gaierror as e: + log(None, f'Error: could not resolve {label} hostname {hostname}: {e}') + + def get_cache_dir(raw_cache_dir: str) -> Optional[str]: if not raw_cache_dir: log(None, 'No cache directory configured.') @@ -127,6 +143,8 @@ def main(): 'voice': config.TTS_VOICE, 'debug_print': config.TTS_DEBUG_PRINT, } + log_hostname_resolution('HA_BASE_URL', config.HA_BASE_URL) + log_hostname_resolution('HA_WEBSOCKET_URL', config.HA_WEBSOCKET_URL) 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) if ha_config.tts_config['debug_print']: asyncio.run(ha.print_tts_providers(ha_config)) @@ -163,8 +181,7 @@ def on_reg_state_callback(account_index: int, code: int, reason: str) -> None: ) 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 + mqtt_client = mqtt.create_client_and_connect(global_options, command_handler) if global_options.enable_mqtt else None def trigger_webhook(event: Any, webhook_id: Optional[str] = None): ha.trigger_webhook(ha_config, event, webhook_id) def send_mqtt_event(event: Any, webhook_id: Optional[str] = None): diff --git a/ha-sip/src/menu.py b/ha-sip/src/menu.py new file mode 100644 index 0000000..f7aec52 --- /dev/null +++ b/ha-sip/src/menu.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from typing import Any, Optional, Union, TYPE_CHECKING + +import yaml +from typing_extensions import TypedDict, Literal + +import utils +from constants import DEFAULT_RING_TIMEOUT +from log import log +from post_action import PostAction, PostActionNoop, PostActionHangup, PostActionRepeatMessage, PostActionReturn, PostActionJump + +if TYPE_CHECKING: + from command_client import Command + + +class MenuFromStdin(TypedDict): + id: Optional[str] + message: Optional[str] + handle_as_template: Optional[bool] + audio_file: Optional[str] + language: Optional[str] + action: Optional[Command] + choices_are_pin: Optional[bool] + post_action: Optional[str] + timeout: Optional[int] + choices: Optional[dict[Any, MenuFromStdin]] + cache_audio: Optional[bool] + wait_for_audio_to_finish: Optional[bool] + + +class Menu(TypedDict): + id: Optional[str] + message: Optional[str] + handle_as_template: bool + audio_file: Optional[str] + language: str + action: Optional[Command] + choices_are_pin: bool + post_action: PostAction + timeout: float + choices: Optional[dict[str, Menu]] + default_choice: Optional[Menu] + timeout_choice: Optional[Menu] + parent_menu: Optional[Menu] + cache_audio: bool + wait_for_audio_to_finish: bool + + +def normalize_menu( + menu: Optional[MenuFromStdin], + default_language: str, + account_index: int, + parent_menu: Optional[Menu] = None, + is_default_or_timeout_choice: bool = False, +) -> tuple[Optional[Menu], dict[str, Menu]]: + if not menu: + return None, dict() + normalized_menu = _normalize_menu(menu, default_language, account_index, parent_menu, is_default_or_timeout_choice) + menu_map = _create_menu_map(normalized_menu) + return normalized_menu, menu_map + + +def _normalize_menu( + menu: MenuFromStdin, + default_language: str, + account_index: int, + parent_menu: Optional[Menu] = None, + is_default_or_timeout_choice: bool = False, +) -> Menu: + def parse_post_action(action: Optional[str]) -> PostAction: + if (not action) or (action == 'noop'): + return PostActionNoop(action='noop') + elif action == 'hangup': + return PostActionHangup(action='hangup') + elif action == 'repeat_message': + return PostActionRepeatMessage(action='repeat_message') + elif action.startswith('return'): + _, *params = action.split() + level_str = utils.safe_list_get(params, 0, 1) + level = utils.convert_to_int(level_str, 1) + return PostActionReturn(action='return', level=level) + elif action.startswith('jump'): + _, *params = action.split(None) + jump_to = utils.safe_list_get(params, 0, '') + if not jump_to: + log(account_index, 'Error: jump action requires a menu id as parameter, will be treated as noop') + return PostActionNoop(action='noop') + return PostActionJump(action='jump', menu_id=jump_to.strip()) + else: + log(account_index, f'Unknown post_action: {action}') + return PostActionNoop(action='noop') + + def normalize_choice(item: tuple[Any, MenuFromStdin], parent_menu_for_choice: Menu) -> tuple[str, Menu]: + choice, sub_menu = item + normalized_choice = str(choice).lower() + normalized_sub_menu = _normalize_menu(sub_menu, default_language, account_index, parent_menu_for_choice, normalized_choice in ['default', 'timeout']) + return normalized_choice, normalized_sub_menu + + def get_default_or_timeout_choice(choice: Union[Literal['default'], Literal['timeout']], parent_menu_for_choice: Menu) -> Optional[Menu]: + if is_default_or_timeout_choice: + return None + elif choice in normalized_choices: + return normalized_choices.pop(choice) + else: + if choice == 'default': + return _get_default_menu(parent_menu_for_choice) + else: + return _get_timeout_menu(parent_menu_for_choice) + + menu_id = menu.get('id') + normalized_menu: Menu = { + 'id': menu_id.strip() if menu_id else None, + 'message': menu.get('message'), + 'handle_as_template': menu.get('handle_as_template') or False, + 'audio_file': menu.get('audio_file'), + 'language': menu.get('language') or default_language, + 'action': menu.get('action'), + 'choices_are_pin': menu.get('choices_are_pin') or False, + 'choices': None, + 'default_choice': None, + 'timeout_choice': None, + 'timeout': utils.convert_to_float(menu.get('timeout'), DEFAULT_RING_TIMEOUT), + 'post_action': parse_post_action(menu.get('post_action')), + 'parent_menu': parent_menu, + 'cache_audio': menu.get('cache_audio') or False, + 'wait_for_audio_to_finish': menu.get('wait_for_audio_to_finish') or False, + } + choices = menu.get('choices') + normalized_choices = dict(map(lambda c: normalize_choice(c, normalized_menu), choices.items())) if choices else dict() + default_choice = get_default_or_timeout_choice('default', normalized_menu) + timeout_choice = get_default_or_timeout_choice('timeout', normalized_menu) + normalized_menu['choices'] = normalized_choices + normalized_menu['default_choice'] = default_choice + normalized_menu['timeout_choice'] = timeout_choice + return normalized_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 choice in m['choices'].values(): + add_to_map(menu_map, choice) + if m['default_choice']: + add_to_map(menu_map, m['default_choice']) + if m['timeout_choice']: + add_to_map(menu_map, m['timeout_choice']) + return menu_map + if not menu: + return {} + return add_to_map({}, menu) + + +def _get_default_menu(parent_menu: Menu) -> Menu: + return { + 'id': None, + 'message': 'Unknown option', + 'handle_as_template': False, + 'audio_file': None, + 'language': 'en', + 'action': None, + 'choices_are_pin': False, + 'choices': None, + 'default_choice': None, + 'timeout_choice': None, + 'post_action': PostActionReturn(action="return", level=1), + 'timeout': DEFAULT_RING_TIMEOUT, + 'parent_menu': parent_menu, + 'cache_audio': False, + 'wait_for_audio_to_finish': False + } + + +def _get_timeout_menu(parent_menu: Menu) -> Menu: + return { + 'id': None, + 'message': None, + 'handle_as_template': False, + 'audio_file': None, + 'language': 'en', + 'action': None, + 'choices_are_pin': False, + 'choices': None, + 'default_choice': None, + 'timeout_choice': None, + 'post_action': PostActionHangup(action="hangup"), + 'timeout': DEFAULT_RING_TIMEOUT, + 'parent_menu': parent_menu, + 'cache_audio': False, + 'wait_for_audio_to_finish': False + } + + +def _get_standard_menu() -> Menu: + standard_menu: Menu = { + 'id': None, + 'message': None, + 'handle_as_template': False, + 'audio_file': None, + 'language': 'en', + 'action': None, + 'choices_are_pin': False, + 'choices': dict(), + 'default_choice': None, + 'timeout_choice': None, + 'post_action': PostActionNoop(action="noop"), + 'timeout': DEFAULT_RING_TIMEOUT, + 'parent_menu': None, + 'cache_audio': False, + 'wait_for_audio_to_finish': False + } + standard_menu['default_choice'] = _get_default_menu(standard_menu) + standard_menu['timeout_choice'] = _get_timeout_menu(standard_menu) + return standard_menu + + +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)) diff --git a/ha-sip/src/mqtt.py b/ha-sip/src/mqtt.py index fbca898..cb6fccf 100644 --- a/ha-sip/src/mqtt.py +++ b/ha-sip/src/mqtt.py @@ -5,11 +5,10 @@ import paho.mqtt.client as paho_mqtt from paho.mqtt.enums import CallbackAPIVersion -import config -import utils from command_client import CommandClient from command_handler import CommandHandler from log import log +from options_global import GlobalOptions class MqttClient: @@ -76,13 +75,16 @@ def send_event(self, event: Any): log(None, f'Sending mqtt message: {event} to topic: {self.topic}') self.client.publish(self.topic_state, json.dumps(event)) -def create_client_and_connect(command_handler: CommandHandler) -> MqttClient: - broker_address = config.BROKER_ADDRESS - port = utils.convert_to_int(config.BROKER_PORT, 1883) - mqtt_username = config.MQTT_USERNAME - mqtt_password = config.MQTT_PASSWORD - topic = config.MQTT_TOPIC - topic_state = config.MQTT_TOPIC_STATE or None - client = MqttClient(broker_address, port, mqtt_username, mqtt_password, topic, topic_state, command_handler) +def create_client_and_connect(global_options: GlobalOptions, command_handler: CommandHandler) -> MqttClient: + topic_state = global_options.mqtt_state_topic or None + client = MqttClient( + global_options.mqtt_address, + global_options.mqtt_port, + global_options.mqtt_username, + global_options.mqtt_password, + global_options.mqtt_topic, + topic_state, + command_handler, + ) client.connect() return client diff --git a/ha-sip/src/options_global.py b/ha-sip/src/options_global.py index f7269ce..bcd2798 100644 --- a/ha-sip/src/options_global.py +++ b/ha-sip/src/options_global.py @@ -12,19 +12,55 @@ class GlobalOptions: enable_tls: bool = False tls_port: int = 5061 debug_headers: bool = False + enable_mqtt: bool = False + mqtt_address: str = '' + mqtt_port: int = 1883 + mqtt_username: str = '' + mqtt_password: str = '' + mqtt_topic: str = 'hasip/execute' + mqtt_state_topic: str = 'hasip/state' - def __init__(self, stun_server: Optional[str], enable_udp: bool, enable_tcp: bool, enable_tls: bool, tls_port: int, debug_headers: bool): + def __init__( + self, + stun_server: Optional[str], + enable_udp: bool, + enable_tcp: bool, + enable_tls: bool, + tls_port: int, + debug_headers: bool, + enable_mqtt: bool, + mqtt_address: str, + mqtt_port: int, + mqtt_username: str, + mqtt_password: str, + mqtt_topic: str, + mqtt_state_topic: str, + ): self.stun_server = stun_server self.enable_udp = enable_udp self.enable_tcp = enable_tcp self.enable_tls = enable_tls self.tls_port = tls_port self.debug_headers = debug_headers + self.enable_mqtt = enable_mqtt + self.mqtt_address = mqtt_address + self.mqtt_port = mqtt_port + self.mqtt_username = mqtt_username + self.mqtt_password = mqtt_password + self.mqtt_topic = mqtt_topic + self.mqtt_state_topic = mqtt_state_topic log(None, f'STUN Server: {self.stun_server}') log(None, f'UDP Enabled: {self.enable_udp}') log(None, f'TCP Enabled: {self.enable_tcp}') log(None, f'TLS Enabled: {self.enable_tls}') log(None, f'TLS Port: {self.tls_port}') + log(None, f'MQTT Enabled: {self.enable_mqtt}') + if self.enable_mqtt: + log(None, f'MQTT Address: {self.mqtt_address}') + log(None, f'MQTT Port: {self.mqtt_port}') + log(None, f'MQTT Username: {self.mqtt_username}') + log(None, f'MQTT Topic: {self.mqtt_topic}') + log(None, f'MQTT State Topic: {self.mqtt_state_topic}') def create_parser() -> argparse.ArgumentParser: @@ -64,6 +100,42 @@ def create_parser() -> argparse.ArgumentParser: default='disabled', help='Enable debug printing of extracted SIP headers (default: disabled)' ) + parser.add_argument( + '--enable-mqtt', + action='store_true', + help='Enable MQTT as a command source (default: disabled)' + ) + parser.add_argument( + '--mqtt-address', + default='', + help='MQTT broker address (default: empty)' + ) + parser.add_argument( + '--mqtt-port', + type=int, + default=1883, + help='MQTT broker port (default: 1883)' + ) + parser.add_argument( + '--mqtt-username', + default='', + help='MQTT broker username (default: empty)' + ) + parser.add_argument( + '--mqtt-password', + default='', + help='MQTT broker password (default: empty)' + ) + parser.add_argument( + '--mqtt-topic', + default='hasip/execute', + help='MQTT topic to subscribe to for incoming commands (default: hasip/execute)' + ) + parser.add_argument( + '--mqtt-state-topic', + default='hasip/state', + help='MQTT topic to publish call state events to (default: hasip/state)' + ) return parser def parse_global_options(raw: Optional[str]) -> GlobalOptions: @@ -77,4 +149,11 @@ def parse_global_options(raw: Optional[str]) -> GlobalOptions: enable_tls=is_true(args.tls), tls_port=args.tls_port, debug_headers=is_true(args.debug_headers), + enable_mqtt=args.enable_mqtt, + mqtt_address=args.mqtt_address, + mqtt_port=args.mqtt_port, + mqtt_username=args.mqtt_username, + mqtt_password=args.mqtt_password, + mqtt_topic=args.mqtt_topic, + mqtt_state_topic=args.mqtt_state_topic, ) diff --git a/ha-sip/src/options_sip.py b/ha-sip/src/options_sip.py index bcc559c..a51e479 100644 --- a/ha-sip/src/options_sip.py +++ b/ha-sip/src/options_sip.py @@ -60,6 +60,7 @@ class SipOptions: sdp_nat_rewrite_use: bool sip_outbound_use: bool extract_headers: List[str] + reject_sip_code: int def __init__( self, @@ -74,6 +75,7 @@ def __init__( turn_server: Optional[TurnServer], extract_headers: List[str], account_index: int, + reject_sip_code: int, ): self.proxy = proxy self.enable_ice = enable_ice @@ -85,6 +87,7 @@ def __init__( self.sdp_nat_rewrite_use = sdp_nat_rewrite_use self.sip_outbound_use = sip_outbound_use self.extract_headers = extract_headers + self.reject_sip_code = reject_sip_code log(account_index, f'Proxy set to: {self.proxy}') log(account_index, f'ICE is enabled: {self.enable_ice}') log(account_index, f'TURN server is enabled: {self.turn_server is not None}') @@ -167,6 +170,12 @@ def create_parser() -> ArgumentParser: default=None, help='Comma-separated list of SIP headers to extract (default: None)' ) + parser.add_argument( + '--reject-sip-code', + type=int, + default=603, + help='SIP response code used when rejecting incoming calls in reject mode (default: 603)' + ) return parser @@ -193,5 +202,6 @@ def parse_sip_options(raw: str, account_index: int = 0) -> SipOptions: sip_outbound_use=is_true(args.use_sip_outbound), turn_server=turn_server, extract_headers=extract_headers, - account_index=account_index + account_index=account_index, + reject_sip_code=args.reject_sip_code, ) diff --git a/ha-sip/src/sensor.py b/ha-sip/src/sensor.py index 148f0fb..e1673b8 100644 --- a/ha-sip/src/sensor.py +++ b/ha-sip/src/sensor.py @@ -66,10 +66,14 @@ def set_call_active(self, account_index: int, call_info: Dict[str, Any]) -> None 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"), + "caller": call_info.get("remote_uri"), + "called": call_info.get("local_uri"), + "parsed_caller": call_info.get("parsed_remote_uri"), + "parsed_called": call_info.get("parsed_local_uri"), + "remote_uri": call_info.get("remote_uri"), + "local_uri": call_info.get("local_uri"), + "parsed_remote_uri": call_info.get("parsed_remote_uri"), + "parsed_local_uri": call_info.get("parsed_local_uri"), "sip_account": call_info.get("sip_account"), "call_id": call_info.get("call_id"), "internal_id": call_info.get("internal_id"), @@ -130,10 +134,14 @@ def update_last_call( "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["caller"] = call_info.get("remote_uri") + attributes["called"] = call_info.get("local_uri") + attributes["parsed_caller"] = call_info.get("parsed_remote_uri") + attributes["parsed_called"] = call_info.get("parsed_local_uri") + attributes["remote_uri"] = call_info.get("remote_uri") + attributes["local_uri"] = call_info.get("local_uri") + attributes["parsed_remote_uri"] = call_info.get("parsed_remote_uri") + attributes["parsed_local_uri"] = call_info.get("parsed_local_uri") attributes["call_id"] = call_info.get("call_id") attributes["timestamp"] = datetime.now().isoformat() self._update_sensor(entity_id, direction, attributes) diff --git a/ha-sip/src/sip_status.py b/ha-sip/src/sip_status.py new file mode 100644 index 0000000..75730d8 --- /dev/null +++ b/ha-sip/src/sip_status.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +# Reason phrases taken verbatim from pjsip/src/pjsip/sip_msg.c +# init_status_phrase() (pjproject tag 2.17). +# +# pjsip_get_status_text() exists at the C level but is not exposed by the +# SWIG Python binding (only the pjsip_status_code enum constants are imported). +# We mirror the table here so that CallOpParam.reason can be set explicitly +# whenever a custom SIP status code is used on hangup. + +REASON_PHRASES: dict[int, str] = { + # 1xx Provisional + 100: "Trying", + 180: "Ringing", + 181: "Call Is Being Forwarded", + 182: "Queued", + 183: "Session Progress", + 199: "Early Dialog Terminated", + + # 2xx Success + 200: "OK", + 202: "Accepted", + 204: "No Notification", + + # 3xx Redirection + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Moved Temporarily", + 305: "Use Proxy", + 380: "Alternative Service", + + # 4xx Request Failure + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Conditional Request Failed", + 413: "Request Entity Too Large", + 414: "Request-URI Too Long", + 415: "Unsupported Media Type", + 416: "Unsupported URI Scheme", + 417: "Unknown Resource-Priority", + 420: "Bad Extension", + 421: "Extension Required", + 422: "Session Interval Too Small", + 423: "Interval Too Brief", + 424: "Bad Location Information", + 428: "Use Identity Header", + 429: "Provide Referrer Identity", + 430: "Flow Failed", + 433: "Anonymity Disallowed", + 436: "Bad Identity-Info", + 437: "Unsupported Certificate", + 438: "Invalid Identity Header", + 439: "First Hop Lacks Outbound Support", + 440: "Max-Breadth Exceeded", + 469: "Bad Info Package", + 470: "Consent Needed", + 480: "Temporarily Unavailable", + 481: "Call/Transaction Does Not Exist", + 482: "Loop Detected", + 483: "Too Many Hops", + 484: "Address Incomplete", + 485: "Ambiguous", + 486: "Busy Here", + 487: "Request Terminated", + 488: "Not Acceptable Here", + 489: "Bad Event", + 490: "Request Updated", + 491: "Request Pending", + 493: "Undecipherable", + 494: "Security Agreement Required", + + # 5xx Server Failure + 500: "Server Internal Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Server Time-out", + 505: "Version Not Supported", + 513: "Message Too Large", + 555: "Push Notification Service Not Supported", + 580: "Precondition Failure", + + # 6xx Global Failure + 600: "Busy Everywhere", + 603: "Decline", + 604: "Does Not Exist Anywhere", + 606: "Not Acceptable", + 607: "Unwanted", + 608: "Rejected", +} diff --git a/ha-sip/src/tests/test_global_options.py b/ha-sip/src/tests/test_global_options.py index a0b091d..f14e624 100644 --- a/ha-sip/src/tests/test_global_options.py +++ b/ha-sip/src/tests/test_global_options.py @@ -50,3 +50,31 @@ def test_parse_debug_headers_enabled(self): def test_parse_debug_headers_disabled(self): options = parse_global_options('--debug-headers disabled') self.assertEqual(options.debug_headers, False) + + def test_parse_mqtt_defaults(self): + options = parse_global_options('') + self.assertEqual(options.enable_mqtt, False) + self.assertEqual(options.mqtt_address, '') + self.assertEqual(options.mqtt_port, 1883) + self.assertEqual(options.mqtt_username, '') + self.assertEqual(options.mqtt_password, '') + self.assertEqual(options.mqtt_topic, 'hasip/execute') + self.assertEqual(options.mqtt_state_topic, 'hasip/state') + + def test_parse_mqtt_enabled(self): + options = parse_global_options('--enable-mqtt') + self.assertEqual(options.enable_mqtt, True) + + def test_parse_mqtt_full(self): + options = parse_global_options( + '--enable-mqtt --mqtt-address 192.168.1.1 --mqtt-port 1884 ' + '--mqtt-username admin --mqtt-password secret ' + '--mqtt-topic=custom/execute --mqtt-state-topic custom/state' + ) + self.assertEqual(options.enable_mqtt, True) + self.assertEqual(options.mqtt_address, '192.168.1.1') + self.assertEqual(options.mqtt_port, 1884) + self.assertEqual(options.mqtt_username, 'admin') + self.assertEqual(options.mqtt_password, 'secret') + self.assertEqual(options.mqtt_topic, 'custom/execute') + self.assertEqual(options.mqtt_state_topic, 'custom/state') diff --git a/ha-sip/src/tests/test_menu.py b/ha-sip/src/tests/test_menu.py new file mode 100644 index 0000000..8446624 --- /dev/null +++ b/ha-sip/src/tests/test_menu.py @@ -0,0 +1,269 @@ +import unittest +from menu import normalize_menu, pretty_print_menu, MenuFromStdin +from constants import DEFAULT_RING_TIMEOUT + + +def make_menu(**overrides) -> MenuFromStdin: + base: MenuFromStdin = { + 'id': None, + 'message': None, + 'handle_as_template': None, + 'audio_file': None, + 'language': None, + 'action': None, + 'choices_are_pin': None, + 'post_action': None, + 'timeout': None, + 'choices': None, + 'cache_audio': None, + 'wait_for_audio_to_finish': None, + } + base.update(overrides) # type: ignore[typeddict-item] + return base + + +class NormalizeMenuNoneTest(unittest.TestCase): + def test_none_input_returns_none_and_empty_map(self): + menu, menu_map = normalize_menu(None, 'en', 0) + self.assertIsNone(menu) + self.assertEqual(menu_map, {}) + + +class NormalizeMenuDefaultsTest(unittest.TestCase): + def test_minimal_menu_gets_defaults(self): + menu, _ = normalize_menu(make_menu(), 'de', 1) + assert menu is not None + self.assertIsNone(menu['id']) + self.assertIsNone(menu['message']) + self.assertFalse(menu['handle_as_template']) + self.assertIsNone(menu['audio_file']) + self.assertEqual(menu['language'], 'de') + self.assertIsNone(menu['action']) + self.assertFalse(menu['choices_are_pin']) + self.assertEqual(menu['post_action'], {'action': 'noop'}) + self.assertEqual(menu['timeout'], DEFAULT_RING_TIMEOUT) + self.assertFalse(menu['cache_audio']) + self.assertFalse(menu['wait_for_audio_to_finish']) + self.assertIsNone(menu['parent_menu']) + + def test_explicit_values_are_preserved(self): + menu, _ = normalize_menu(make_menu( + id=' main ', + message='Hello', + handle_as_template=True, + audio_file='/tmp/test.wav', + language='fr', + choices_are_pin=True, + timeout=30, + cache_audio=True, + wait_for_audio_to_finish=True, + ), 'de', 1) + assert menu is not None + self.assertEqual(menu['id'], 'main') + self.assertEqual(menu['message'], 'Hello') + self.assertTrue(menu['handle_as_template']) + self.assertEqual(menu['audio_file'], '/tmp/test.wav') + self.assertEqual(menu['language'], 'fr') + self.assertTrue(menu['choices_are_pin']) + self.assertEqual(menu['timeout'], 30.0) + self.assertTrue(menu['cache_audio']) + self.assertTrue(menu['wait_for_audio_to_finish']) + + def test_language_falls_back_to_default(self): + menu, _ = normalize_menu(make_menu(language=None), 'sv', 1) + assert menu is not None + self.assertEqual(menu['language'], 'sv') + + def test_id_whitespace_stripped(self): + menu, _ = normalize_menu(make_menu(id=' hello '), 'en', 0) + assert menu is not None + self.assertEqual(menu['id'], 'hello') + + +class PostActionTest(unittest.TestCase): + def test_noop(self): + menu, _ = normalize_menu(make_menu(post_action='noop'), 'en', 0) + assert menu is not None + self.assertEqual(menu['post_action'], {'action': 'noop'}) + + def test_none_becomes_noop(self): + menu, _ = normalize_menu(make_menu(post_action=None), 'en', 0) + assert menu is not None + self.assertEqual(menu['post_action'], {'action': 'noop'}) + + def test_hangup(self): + menu, _ = normalize_menu(make_menu(post_action='hangup'), 'en', 0) + assert menu is not None + self.assertEqual(menu['post_action'], {'action': 'hangup'}) + + def test_repeat_message(self): + menu, _ = normalize_menu(make_menu(post_action='repeat_message'), 'en', 0) + assert menu is not None + self.assertEqual(menu['post_action'], {'action': 'repeat_message'}) + + def test_return_default_level(self): + menu, _ = normalize_menu(make_menu(post_action='return'), 'en', 0) + assert menu is not None + self.assertEqual(menu['post_action'], {'action': 'return', 'level': 1}) + + def test_return_with_level(self): + menu, _ = normalize_menu(make_menu(post_action='return 3'), 'en', 0) + assert menu is not None + self.assertEqual(menu['post_action'], {'action': 'return', 'level': 3}) + + def test_jump(self): + menu, _ = normalize_menu(make_menu(post_action='jump submenu'), 'en', 0) + assert menu is not None + self.assertEqual(menu['post_action'], {'action': 'jump', 'menu_id': 'submenu'}) + + def test_unknown_post_action_becomes_noop(self): + menu, _ = normalize_menu(make_menu(post_action='explode'), 'en', 0) + assert menu is not None + self.assertEqual(menu['post_action'], {'action': 'noop'}) + + +class DefaultAndTimeoutChoiceTest(unittest.TestCase): + def test_auto_generated_default_choice(self): + menu, _ = normalize_menu(make_menu(), 'en', 0) + assert menu is not None + default = menu['default_choice'] + assert default is not None + self.assertEqual(default['message'], 'Unknown option') + self.assertEqual(default['post_action'], {'action': 'return', 'level': 1}) + self.assertIs(default['parent_menu'], menu) + + def test_auto_generated_timeout_choice(self): + menu, _ = normalize_menu(make_menu(), 'en', 0) + assert menu is not None + timeout = menu['timeout_choice'] + assert timeout is not None + self.assertIsNone(timeout['message']) + self.assertEqual(timeout['post_action'], {'action': 'hangup'}) + self.assertIs(timeout['parent_menu'], menu) + + def test_custom_default_choice(self): + menu, _ = normalize_menu(make_menu(choices={ + '1': make_menu(message='Option 1'), + 'default': make_menu(message='Try again'), + }), 'en', 0) + assert menu is not None + default = menu['default_choice'] + assert default is not None + self.assertEqual(default['message'], 'Try again') + # The 'default' key should be removed from regular choices + assert menu['choices'] is not None + self.assertNotIn('default', menu['choices']) + + def test_custom_timeout_choice(self): + menu, _ = normalize_menu(make_menu(choices={ + '1': make_menu(message='Option 1'), + 'timeout': make_menu(message='Timed out', post_action='hangup'), + }), 'en', 0) + assert menu is not None + timeout = menu['timeout_choice'] + assert timeout is not None + self.assertEqual(timeout['message'], 'Timed out') + assert menu['choices'] is not None + self.assertNotIn('timeout', menu['choices']) + + +class ChoicesTest(unittest.TestCase): + def test_choices_normalized(self): + menu, _ = normalize_menu(make_menu(choices={ + 1: make_menu(message='First'), + 2: make_menu(message='Second'), + }), 'en', 0) + assert menu is not None + assert menu['choices'] is not None + self.assertIn('1', menu['choices']) + self.assertIn('2', menu['choices']) + self.assertEqual(menu['choices']['1']['message'], 'First') + self.assertEqual(menu['choices']['2']['message'], 'Second') + + def test_choice_keys_lowercased(self): + menu, _ = normalize_menu(make_menu(choices={ + 'A': make_menu(message='Alpha'), + }), 'en', 0) + assert menu is not None + assert menu['choices'] is not None + self.assertIn('a', menu['choices']) + self.assertNotIn('A', menu['choices']) + + def test_nested_choices_parent_set(self): + menu, _ = normalize_menu(make_menu(choices={ + '1': make_menu(message='Sub'), + }), 'en', 0) + assert menu is not None + assert menu['choices'] is not None + sub = menu['choices']['1'] + self.assertIs(sub['parent_menu'], menu) + + def test_no_choices_gives_empty_dict(self): + menu, _ = normalize_menu(make_menu(choices=None), 'en', 0) + assert menu is not None + self.assertEqual(menu['choices'], {}) + + +class MenuMapTest(unittest.TestCase): + def test_menu_map_from_ids(self): + _, menu_map = normalize_menu(make_menu(id='root', choices={ + '1': make_menu(id='child1', message='C1'), + '2': make_menu(id='child2', message='C2'), + }), 'en', 0) + self.assertIn('root', menu_map) + self.assertIn('child1', menu_map) + self.assertIn('child2', menu_map) + self.assertEqual(menu_map['child1']['message'], 'C1') + + def test_menu_map_excludes_none_ids(self): + _, menu_map = normalize_menu(make_menu(choices={ + '1': make_menu(message='no id'), + }), 'en', 0) + self.assertEqual(menu_map, {}) + + def test_menu_map_nested(self): + _, menu_map = normalize_menu(make_menu(id='root', choices={ + '1': make_menu(id='level1', choices={ + '1': make_menu(id='level2'), + }), + }), 'en', 0) + self.assertIn('root', menu_map) + self.assertIn('level1', menu_map) + self.assertIn('level2', menu_map) + + +class DefaultTimeoutChoiceSuppressionTest(unittest.TestCase): + """Menus that ARE the default/timeout choice should not get their own default/timeout.""" + + def test_default_choice_has_no_own_default_or_timeout(self): + menu, _ = normalize_menu(make_menu(choices={ + '1': make_menu(message='Option 1'), + 'default': make_menu(message='Error'), + }), 'en', 0) + assert menu is not None + default = menu['default_choice'] + assert default is not None + self.assertIsNone(default['default_choice']) + self.assertIsNone(default['timeout_choice']) + + def test_timeout_choice_has_no_own_default_or_timeout(self): + menu, _ = normalize_menu(make_menu(choices={ + '1': make_menu(message='Option 1'), + 'timeout': make_menu(message='Bye'), + }), 'en', 0) + assert menu is not None + timeout = menu['timeout_choice'] + assert timeout is not None + self.assertIsNone(timeout['default_choice']) + self.assertIsNone(timeout['timeout_choice']) + + +class PrettyPrintMenuTest(unittest.TestCase): + def test_none_menu(self, ): + # Should not raise + pretty_print_menu(None) + + def test_with_menu(self): + menu, _ = normalize_menu(make_menu(message='Hello'), 'en', 0) + # Should not raise + pretty_print_menu(menu) diff --git a/ha-sip/src/tests/test_sip_options.py b/ha-sip/src/tests/test_sip_options.py index 99ca1d6..36b487e 100644 --- a/ha-sip/src/tests/test_sip_options.py +++ b/ha-sip/src/tests/test_sip_options.py @@ -17,6 +17,7 @@ def test_parse_without_options(self): self.assertEqual(options.via_rewrite_use, True) self.assertEqual(options.sdp_nat_rewrite_use, True) self.assertEqual(options.sip_outbound_use, True) + self.assertEqual(options.reject_sip_code, 603) def test_parse_proxy(self): options = parse_sip_options('--proxy sip:example.com') @@ -87,3 +88,11 @@ def test_parse_extract_headers_multiple(self): options = parse_sip_options('--extract-headers X-Custom-Header,P-Asserted-Identity,X-Another') self.assertEqual(options.extract_headers, ['X-Custom-Header', 'P-Asserted-Identity', 'X-Another']) + def test_parse_reject_sip_code_default(self): + options = parse_sip_options('') + self.assertEqual(options.reject_sip_code, 603) + + def test_parse_reject_sip_code_custom(self): + options = parse_sip_options('--reject-sip-code 486') + self.assertEqual(options.reject_sip_code, 486) + diff --git a/ha-sip/src/tone_digit.py b/ha-sip/src/tone_digit.py new file mode 100644 index 0000000..4939811 --- /dev/null +++ b/ha-sip/src/tone_digit.py @@ -0,0 +1,19 @@ +import pjsua2 as pj + +from constants import DEFAULT_DTMF_ON, DEFAULT_DTMF_OFF + + +def create_tone_digit(digit: str) -> pj.ToneDigit: + td = pj.ToneDigit() + td.digit = digit + td.volume = 0 + td.on_msec = DEFAULT_DTMF_ON + td.off_msec = DEFAULT_DTMF_OFF + return td + + +def create_tone_digit_vector(digits: str) -> pj.ToneDigitVector: + tone_digits_vector = pj.ToneDigitVector() + for d in digits: + tone_digits_vector.append(create_tone_digit(d)) + return tone_digits_vector diff --git a/ha-sip/src/webhook.py b/ha-sip/src/webhook.py index 31b7874..51cee56 100644 --- a/ha-sip/src/webhook.py +++ b/ha-sip/src/webhook.py @@ -2,7 +2,7 @@ from typing import Dict, Optional -from typing_extensions import TypedDict +from typing_extensions import TypedDict, Literal import ha from event_sender import EventSender @@ -23,10 +23,11 @@ class WebhookToCall(TypedDict): class CallInfo(TypedDict): local_uri: str remote_uri: str - parsed_caller: Optional[str] - parsed_called: Optional[str] + parsed_local_uri: Optional[str] + parsed_remote_uri: Optional[str] call_id: str headers: Dict[str, Optional[str]] + direction: Literal['incoming', 'outgoing'] def trigger_webhook( @@ -38,14 +39,20 @@ def trigger_webhook( webhooks: Optional[WebhookToCall] = None, ) -> None: base_event: ha.WebhookBaseFields = { - 'caller': call_info['remote_uri'] if call_info else 'unknown', - 'called': call_info['local_uri'] if call_info else 'unknown', - 'parsed_caller': call_info['parsed_caller'] if call_info else None, - 'parsed_called': call_info['parsed_called'] if call_info else None, + 'local_uri': call_info['local_uri'] if call_info else 'unknown', + 'remote_uri': call_info['remote_uri'] if call_info else 'unknown', + 'parsed_local_uri': call_info['parsed_local_uri'] if call_info else None, + 'parsed_remote_uri': call_info['parsed_remote_uri'] if call_info else None, 'sip_account': sip_account, 'call_id': call_info['call_id'] if call_info else None, 'internal_id': internal_id, 'headers': call_info['headers'] if call_info else {}, + 'call_direction': call_info['direction'] if call_info else 'incoming', + # Deprecated + 'caller': call_info['remote_uri'] if call_info else 'unknown', + 'called': call_info['local_uri'] if call_info else 'unknown', + 'parsed_caller': call_info['parsed_remote_uri'] if call_info else None, + 'parsed_called': call_info['parsed_local_uri'] if call_info else None, } complete_event = { **base_event,