diff --git a/.gitignore b/.gitignore index 56acdc1..e03d968 100644 --- a/.gitignore +++ b/.gitignore @@ -128,8 +128,8 @@ dmypy.json # Pyre type checker .pyre/ +/deps/ /attic/ -/pjproject/ .idea /ha-sip/src/config_local.py /incoming.yaml diff --git a/README.md b/README.md index 16655e3..393b876 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ Example content of `/config/sip-1-incoming.yaml`: allowed_numbers: # list of numbers which will be answered. If removed all numbers will be accepted - "5551234456" - "5559876543" + - "555{*}" # matches every number starting with 555 + - "555{?}" # matches every number starting with 555 which is 4 digits long # blocked_numbers: # alternatively you can specify the numbers not to be answered. You can't have both. # - "5551234456" # - "5559876543" @@ -202,14 +204,22 @@ 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) + playlist: # (optional) + - type: tts + message: Hello World! + - type: audio_file + audio_file: /config/audio/welcome.mp3' language: en # TTS language (optional, defaults to the global language from add-on 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) + repeat_wait: 2 # time in seconds to wait before repeating a message/playlist (optional, defaults to 0 seconds) post_action: noop # this action will be triggered after the message was played. Can be # "noop" (do nothing), - # "return" (makes only sense in a sub-menu), + # "return " (makes only sense in a sub-menu, returns levels, defaults to 1), # "hangup" (hang-up the call) and - # "repeat_message" (repeat the message until the time-out is reached) + # "repeat_message" (repeat the message or last playlist item until the time-out is reached) + # "repeat_playlist" (repeat the whole playlist until the time-out is reached) + # "jump " (jumps to menu with id ) # (optional, defaults to noop) action: # action to run when menu was entered (before playing the message) (optional) # For details visit https://developers.home-assistant.io/docs/api/rest/, POST on /api/services// @@ -227,7 +237,7 @@ menu: post_action: hangup '7777': audio_file: '/config/audio/welcome.mp3' # audio file to be played (.wav or .mp3). - post_action: hangup + post_action: jump owner # jump to menu id 'owner' 'default': # this will be triggered if the input does not match any specified choice id: wrong_code message: Wrong code, please try again diff --git a/build.sh b/build.sh index 6b63f31..4ad8fa2 100755 --- a/build.sh +++ b/build.sh @@ -11,6 +11,8 @@ if [ -z "$DOCKER_HUB_PASSWORD" ] exit 1 fi +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) + case "$1" in build-next) echo "Building on next repo (aarch64 only)..." @@ -58,20 +60,24 @@ case "$1" in docker pull homeassistant/amd64-builder:dev ;; test) + echo "Running unit tests..." + python3 -m unittest discover -s "$SCRIPT_DIR"/ha-sip/src echo "Running type-check..." pyright ha-sip ;; + run-local) + "$SCRIPT_DIR"/ha-sip/src/main.py local + ;; create-venv) - SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) - rm -rf $SCRIPT_DIR/venv $SCRIPT_DIR/deps - python3 -m venv $SCRIPT_DIR/venv - source $SCRIPT_DIR/venv/bin/activate + rm -rf "$SCRIPT_DIR"/venv "$SCRIPT_DIR"/deps + python3 -m venv "$SCRIPT_DIR"/venv + source "$SCRIPT_DIR"/venv/bin/activate pip3 install pydub requests PyYAML typing_extensions - mkdir $SCRIPT_DIR/deps - cd $SCRIPT_DIR/deps || exit + mkdir "$SCRIPT_DIR"/deps + cd "$SCRIPT_DIR"/deps || exit git clone --depth 1 --branch 2.13 https://github.com/pjsip/pjproject.git cd pjproject || exit - ./configure --enable-shared --disable-libwebrtc --prefix $SCRIPT_DIR/venv + ./configure --enable-shared --disable-libwebrtc --prefix "$SCRIPT_DIR"/venv make make dep make install diff --git a/ha-sip/CHANGELOG.md b/ha-sip/CHANGELOG.md index fe24caf..8826345 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 2.8 +- Added option to play a list of messages and/or audio files +- Added option `repeat_playlist` to `post_action`: repeat whole playlist. `repeat_message` will repeat only the last item in case of a playlist. +- Added option `repeat_wait` for an extra delay between repeated messages (in seconds). + ```yaml + menu: + playlist: + - type: tts + message: "Hello World!" + - type: audio_file + audio_file: "/config/audio/welcome.mp3" + post_action: "repeat_playlist" + repeat_wait: 2 + ``` + +## 2.7 +- More flexible `return` post action: specify how many levels to go back from the sub-menu +- Added `jump` post action to jump to any menu with an id +- Add wildcard support for incoming call `allowed_numbers` and `blocked_numbers` filter +- Bugfix: time-out not reset when returning to parent menu + ## 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`. diff --git a/ha-sip/config.json b/ha-sip/config.json index 1db06ef..d6b64e5 100644 --- a/ha-sip/config.json +++ b/ha-sip/config.json @@ -1,6 +1,6 @@ { "name": "ha-sip", - "version": "2.6", + "version": "2.8", "slug": "ha-sip", "url": "https://github.com/arnonym/ha-plugins", "description": "Home-Assistant SIP Gateway", diff --git a/ha-sip/src/account.py b/ha-sip/src/account.py index 7072214..bb98952 100644 --- a/ha-sip/src/account.py +++ b/ha-sip/src/account.py @@ -1,4 +1,5 @@ from __future__ import annotations +import re from typing import Optional @@ -98,11 +99,30 @@ def get_sip_return_code( 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 parsed_caller in allowed_numbers else call.CallHandling.LISTEN + return call.CallHandling.ACCEPT if Account.is_number_in_list(parsed_caller, allowed_numbers) else call.CallHandling.LISTEN if mode == call.CallHandling.ACCEPT and blocked_numbers: - return call.CallHandling.ACCEPT if parsed_caller not in blocked_numbers else call.CallHandling.LISTEN + return call.CallHandling.ACCEPT if not Account.is_number_in_list(parsed_caller, blocked_numbers) else call.CallHandling.LISTEN return mode + @staticmethod + def is_number_in_list(number: Optional[str], number_list: list[str]) -> bool: + def map_to_regex(st: str) -> str: + if st == '{*}': + return '.*' + if st == '{?}': + return '.' + return re.escape(st) + if not number: + return False + for n in number_list: + # split by {*} and {?} keeping delimiters + n_split = re.split(r'(\{\*}|\{\?})', n) + n_regex = '^' + ''.join(map(map_to_regex, n_split)) + '$' + match = re.match(n_regex, number) + if match: + return True + return False + def create_account(end_point: pj.Endpoint, config: MyAccountConfig, callback: call.CallCallback, ha_config: ha.HaConfig, is_default: bool) -> Account: account = Account(end_point, config, callback, ha_config, is_default) diff --git a/ha-sip/src/audio.py b/ha-sip/src/audio.py index e012b9e..89d2921 100644 --- a/ha-sip/src/audio.py +++ b/ha-sip/src/audio.py @@ -7,7 +7,7 @@ import pydub -def convert_audio_to_wav(audio_file_name: str) -> Optional[str]: +def convert_audio_to_wav(audio_file_name: str, parameters: Optional[list] = None) -> Optional[str]: def get_audio_segment(file_name: str) -> Optional[pydub.AudioSegment]: _, file_extension = os.path.splitext(file_name) if file_extension == '.mp3': @@ -25,7 +25,7 @@ def get_audio_segment(file_name: str) -> Optional[pydub.AudioSegment]: if not audio_segment: print('Error: could not figure out file format (.mp3, .ogg, .wav is supported):', audio_file_name) return None - audio_segment.export(wave_file_handler.name, format='wav') + audio_segment.export(wave_file_handler.name, format='wav', parameters=parameters if parameters else None) return wave_file_handler.name diff --git a/ha-sip/src/call.py b/ha-sip/src/call.py index 8877dd6..e6febe0 100644 --- a/ha-sip/src/call.py +++ b/ha-sip/src/call.py @@ -4,7 +4,7 @@ import time import re from enum import Enum -from typing import Optional, Callable, Union, Any +from typing import Optional, Callable, Union, Any, List import pjsua2 as pj import yaml @@ -24,12 +24,22 @@ class CallStateChange(Enum): DEFAULT_TIMEOUT = 300.0 +DEFAULT_REPEAT_WAIT = 0.0 DEFAULT_DTMF_ON = 180 DEFAULT_DTMF_OFF = 220 CallCallback = Callable[[CallStateChange, str, 'Call'], None] -PostAction = Union[Literal['return'], Literal['hangup'], Literal['noop'], Literal['repeat_message']] DtmfMethod = Union[Literal['in_band'], Literal['rfc2833'], Literal['sip_info']] +PlaylistItemType = Literal['tts', 'audio_file'] + + +class PlaylistItem(TypedDict): + type: PlaylistItemType + message: str + audio_file: str + + +Playlist = List[PlaylistItem] class WebhookToCall(TypedDict): @@ -45,14 +55,45 @@ class Action(TypedDict): entity_id: str +class PostActionReturn(TypedDict): + action: Literal['return'] + level: int + + +class PostActionJump(TypedDict): + action: Literal['jump'] + menu_id: str + + +class PostActionHangup(TypedDict): + action: Literal['hangup'] + + +class PostActionNoop(TypedDict): + action: Literal['noop'] + + +class PostActionRepeatMessage(TypedDict): + action: Literal['repeat_message'] + + +class PostActionRepeatPlaylist(TypedDict): + action: Literal['repeat_playlist'] + + +PostAction = Union[PostActionReturn, PostActionJump, PostActionHangup, PostActionNoop, PostActionRepeatMessage, PostActionRepeatPlaylist] + + class MenuFromStdin(TypedDict): id: Optional[str] message: Optional[str] audio_file: Optional[str] + playlist: Optional[Playlist] language: Optional[str] action: Optional[Action] choices_are_pin: Optional[bool] - post_action: Optional[PostAction] + post_action: Optional[str] + repeat_wait: int timeout: Optional[int] choices: Optional[dict[Any, MenuFromStdin]] @@ -61,10 +102,12 @@ class Menu(TypedDict): id: Optional[str] message: Optional[str] audio_file: Optional[str] + playlist: Optional[Playlist] language: str action: Optional[Action] choices_are_pin: bool post_action: PostAction + repeat_wait: float timeout: float choices: Optional[dict[str, Menu]] default_choice: Optional[Menu] @@ -94,9 +137,10 @@ class Call(pj.Call): def __init__(self, end_point: pj.Endpoint, sip_account: account.Account, call_id: str, uri_to_call: Optional[str], menu: Optional[MenuFromStdin], callback: CallCallback, ha_config: ha.HaConfig, ring_timeout: float, webhook_to_call: Optional[str], webhooks: Optional[WebhookToCall]): pj.Call.__init__(self, sip_account, call_id) + self.files_to_remove: list[str] = [] self.player: Optional[player.Player] = None self.audio_media: Optional[pj.AudioMedia] = None - self.connected: bool = False + self.connected = False self.current_input = '' self.end_point = end_point self.account = sip_account @@ -114,9 +158,11 @@ def __init__(self, end_point: pj.Endpoint, sip_account: account.Account, call_id self.answer_at: Optional[float] = None self.tone_gen: Optional[pj.ToneGenerator] = None self.call_info: Optional[CallInfo] = None - self.pressed_digit_list = [] + self.pressed_digit_list: List[str] = [] self.callback_id = self.get_callback_id() self.menu = self.normalize_menu(menu) if menu else self.get_standard_menu() + self.menu_map = self.create_menu_map(self.menu) + self.repeat_wait_begin: float | int = -1 Call.pretty_print_menu(self.menu) log(self.account.config.index, 'Registering call with id %s' % self.callback_id) self.callback(CallStateChange.CALL, self.callback_id, self) @@ -146,23 +192,52 @@ def handle_events(self) -> None: if self.playback_is_done and self.scheduled_post_action: post_action = self.scheduled_post_action self.scheduled_post_action = None - log(self.account.config.index, 'Scheduled post action: %s' % post_action) - if post_action == 'noop': - pass - elif post_action == 'return': - self.handle_menu(self.menu['parent_menu']) - elif post_action == 'hangup': - self.hangup_call() - elif post_action == 'repeat_message': - self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) - else: - log(self.account.config.index, 'Unknown post_action: %s' % post_action) - return + self.handle_post_action(post_action) if len(self.pressed_digit_list) > 0: next_digit = self.pressed_digit_list.pop(0) self.handle_dtmf_digit(next_digit) return + def handle_post_action(self, post_action: PostAction): + if post_action["action"] not in ['repeat_message', 'repeat_playlist']: + log(self.account.config.index, 'Scheduled post action: %s' % post_action["action"]) + if post_action["action"] == 'noop': + pass + elif post_action["action"] == 'return': + m = self.menu + for _ in range(0, post_action['level']): + if m: + m = m['parent_menu'] + if m: + self.handle_menu(m) + else: + log(self.account.config.index, 'Could not return %s level in current menu' % post_action["level"]) + 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, 'Could not find menu id: %s' % post_action["menu_id"]) + elif post_action["action"] == 'hangup': + self.hangup_call() + elif post_action["action"] in ['repeat_message', 'repeat_playlist']: + self.handle_repeat_wait() + else: + log(self.account.config.index, 'Unknown post_action: %s' % repr(post_action["action"])) + + def handle_repeat_wait(self): + self.scheduled_post_action = self.menu["post_action"] + if not self.menu['repeat_wait']: + log(self.account.config.index, 'Scheduled post action (no wait): %s' % self.scheduled_post_action["action"]) + self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) + elif self.menu['repeat_wait'] and self.repeat_wait_begin == -1: + log(self.account.config.index, 'Wait %.3f seconds before %s' % (self.menu['repeat_wait'], self.scheduled_post_action["action"])) + self.repeat_wait_begin = time.time() + elif self.menu['repeat_wait'] and time.time() - self.repeat_wait_begin > self.menu['repeat_wait']: + log(self.account.config.index, 'Scheduled post action (with wait): %s' % self.scheduled_post_action["action"]) + self.repeat_wait_begin = -1 + self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) + def trigger_webhook(self, event: ha.WebhookEvent): event_id = event.get('event') additional_webhook = self.webhooks.get(event_id) @@ -173,7 +248,7 @@ def trigger_webhook(self, event: ha.WebhookEvent): def handle_connected_state(self): log(self.account.config.index, 'Call is established.') self.connected = True - self.last_seen = time.time() + self.reset_timeout() if self.webhook_to_call: ha.trigger_webhook(self.ha_config, { 'event': 'call_established', @@ -234,7 +309,8 @@ def onDtmfDigit(self, prm: pj.OnDtmfDigitParam) -> None: if self.player: self.player.stopTransmit(self.audio_media) self.playback_is_done = True - self.last_seen = time.time() + self.scheduled_post_action = None + self.reset_timeout() self.pressed_digit_list.append(prm.digit) def handle_dtmf_digit(self, pressed_digit: str) -> None: @@ -252,6 +328,7 @@ def handle_dtmf_digit(self, pressed_digit: str) -> None: log(self.account.config.index, 'Current input: %s' % self.current_input) choices = self.menu.get('choices') if choices is not None: + self.repeat_wait_begin = -1 if self.current_input in choices: self.handle_menu(choices[self.current_input]) return @@ -293,6 +370,7 @@ def onCallRedirected(self, prm): log(self.account.config.index, 'onCallRedirected') def handle_menu(self, menu: Optional[Menu], send_webhook_event=True, handle_action=True, reset_input=True) -> None: + self.reset_timeout() if not menu: log(self.account.config.index, 'No menu supplied') return @@ -310,6 +388,7 @@ def handle_menu(self, menu: Optional[Menu], send_webhook_event=True, handle_acti self.current_input = '' message = menu['message'] audio_file = menu['audio_file'] + playlist = menu['playlist'] language = menu['language'] action = menu['action'] post_action = menu['post_action'] @@ -317,6 +396,8 @@ def handle_menu(self, menu: Optional[Menu], send_webhook_event=True, handle_acti self.play_message(message, language) if audio_file: self.play_audio_file(audio_file) + if playlist: + self.play_playlist(playlist, language) if handle_action: self.handle_action(action) self.scheduled_post_action = post_action @@ -348,6 +429,36 @@ def play_audio_file(self, audio_file: str) -> None: if sound_file_name: self.play_wav_file(sound_file_name, True) + def play_playlist(self, playlist: Playlist, language: str) -> None: + log(self.account.config.index, 'Playing playlist') + wav_playlist = list() + if self.scheduled_post_action: + playlist = [playlist[-1]] if self.scheduled_post_action.get("action", '') == 'repeat_message' else playlist + for playlist_item in playlist: + if playlist_item['type'] == 'tts': + if not playlist_item.get('message'): + log(self.account.config.index, "'message:' missing for playlist item.") + continue + sound_file_name, must_be_deleted = ha.create_and_get_tts(self.ha_config, playlist_item['message'], language) + wav_playlist.append(sound_file_name) + if must_be_deleted: + self.files_to_remove.append(sound_file_name) + elif playlist_item['type'] == 'audio_file': + sound_file_name = audio.convert_audio_to_wav(playlist_item.get('audio_file', ''), parameters=["-ac", "1", "-ar", "24000"]) + if sound_file_name: + wav_playlist.append(sound_file_name) + self.files_to_remove.append(sound_file_name) + else: + log(self.account.config.index, "Sound file couldn't be converted: %s" % repr(playlist_item.get('audio_file'))) + else: + log(self.account.config.index, 'Playlist item has wrong format: %s.' % repr(playlist_item)) + if not wav_playlist: + log(self.account.config.index, 'Playlist is empty') + return + self.playback_is_done = False + self.player = player.Player(self.on_playback_done) + self.player.play_playlist(self.audio_media, wav_playlist) + def play_wav_file(self, sound_file_name: str, must_be_deleted: bool) -> None: self.player = player.Player(self.on_playback_done) self.playback_is_done = False @@ -356,8 +467,12 @@ def play_wav_file(self, sound_file_name: str, must_be_deleted: bool) -> None: os.remove(sound_file_name) def on_playback_done(self) -> None: - log(self.account.config.index, 'Playback done.') self.playback_is_done = True + if self.files_to_remove: + for file_name in self.files_to_remove: + os.remove(file_name) + self.files_to_remove = [] + log(self.account.config.index, 'Playback done.') def accept(self, answer_mode: CallHandling, answer_after: float) -> None: call_prm = pj.CallOpParam() @@ -378,7 +493,7 @@ def answer_call(self, new_menu: Optional[MenuFromStdin]) -> None: self.answer_at = time.time() def send_dtmf(self, digits: str, method: DtmfMethod = 'in_band') -> None: - self.last_seen = time.time() + self.reset_timeout() log(self.account.config.index, 'Sending DTMF %s' % digits) if method == 'in_band': if not self.tone_gen: @@ -417,11 +532,54 @@ def get_call_info(self) -> CallInfo: 'parsed_caller': parsed_caller, } + def reset_timeout(self): + self.last_seen = time.time() + 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 == 'repeat_playlist': + return PostActionRepeatPlaylist(action='repeat_playlist') + 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) + menu_id = utils.safe_list_get(params, 0, None) + return PostActionJump(action='jump', menu_id=menu_id) + else: + log(self.account.config.index, 'Unknown post_action: %s' % 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) + normalized_menu: Menu = { 'id': menu.get('id'), 'message': menu.get('message'), 'audio_file': menu.get('audio_file'), + 'playlist': menu.get('playlist'), 'language': menu.get('language') or self.ha_config.tts_language, 'action': menu.get('action'), 'choices_are_pin': menu.get('choices_are_pin') or False, @@ -429,33 +587,30 @@ def normalize_menu(self, menu: MenuFromStdin, parent_menu: Optional[Menu] = None 'default_choice': None, 'timeout_choice': None, 'timeout': utils.convert_to_float(menu.get('timeout'), DEFAULT_TIMEOUT), - 'post_action': menu.get('post_action') or 'noop', + 'repeat_wait': utils.convert_to_float(menu.get('repeat_wait'), DEFAULT_REPEAT_WAIT), + 'post_action': parse_post_action(menu.get('post_action')), 'parent_menu': parent_menu, } choices = menu.get('choices') - - def normalize_choice(item: tuple[Any, MenuFromStdin]) -> tuple[str, Menu]: - choice, sub_menu = item - normalized_choice = str(choice).lower() - normalized_sub_menu = self.normalize_menu(sub_menu, normalized_menu, normalized_choice in ['default', 'timeout']) - return normalized_choice, normalized_sub_menu - - def get_default_or_timeout_choice(choice: Union[Literal['default'], Literal['timeout']]) -> Optional[Menu]: - if is_default_or_timeout_choice: - return None - elif choice in normalized_choices: - return normalized_choices.pop(choice) - else: - return Call.get_default_menu(normalized_menu) if choice == 'default' else Call.get_timeout_menu(normalized_menu) - - normalized_choices = dict(map(normalize_choice, choices.items())) if choices else dict() - default_choice = get_default_or_timeout_choice('default') - timeout_choice = get_default_or_timeout_choice('timeout') + 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: 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 + return add_to_map({}, menu) + @staticmethod def parse_caller(remote_uri: str) -> Optional[str]: parsed_caller_match = re.search(']', remote_uri) @@ -472,14 +627,16 @@ def get_default_menu(parent_menu: Menu) -> Menu: 'id': None, 'message': 'Unknown option', 'audio_file': None, + 'playlist': None, 'language': 'en', 'action': None, 'choices_are_pin': False, 'choices': None, 'default_choice': None, 'timeout_choice': None, - 'post_action': 'return', + 'post_action': PostActionReturn(action="return", level=1), 'timeout': DEFAULT_TIMEOUT, + 'repeat_wait': DEFAULT_REPEAT_WAIT, 'parent_menu': parent_menu, } @@ -489,14 +646,16 @@ def get_timeout_menu(parent_menu: Menu) -> Menu: 'id': None, 'message': None, 'audio_file': None, + 'playlist': None, 'language': 'en', 'action': None, 'choices_are_pin': False, 'choices': None, 'default_choice': None, 'timeout_choice': None, - 'post_action': 'hangup', + 'post_action': PostActionHangup(action="hangup"), 'timeout': DEFAULT_TIMEOUT, + 'repeat_wait': DEFAULT_REPEAT_WAIT, 'parent_menu': parent_menu, } @@ -506,14 +665,16 @@ def get_standard_menu() -> Menu: 'id': None, 'message': None, 'audio_file': None, + 'playlist': None, 'language': 'en', 'action': None, 'choices_are_pin': False, 'choices': dict(), 'default_choice': None, 'timeout_choice': None, - 'post_action': 'noop', + 'post_action': PostActionNoop(action="noop"), 'timeout': DEFAULT_TIMEOUT, + 'repeat_wait': DEFAULT_REPEAT_WAIT, 'parent_menu': None, } standard_menu['default_choice'] = Call.get_default_menu(standard_menu) diff --git a/ha-sip/src/main.py b/ha-sip/src/main.py old mode 100755 new mode 100644 diff --git a/ha-sip/src/player.py b/ha-sip/src/player.py index 00cbffa..d47d5ca 100644 --- a/ha-sip/src/player.py +++ b/ha-sip/src/player.py @@ -18,3 +18,7 @@ def onEof2(self) -> None: def play_file(self, audio_media: pj.AudioMedia, sound_file_name: str) -> None: self.createPlayer(file_name=sound_file_name, options=pj.PJMEDIA_FILE_NO_LOOP) self.startTransmit(audio_media) + + def play_playlist(self, audio_media: pj.AudioMedia, wav_playlist: list) -> None: + self.createPlaylist(wav_playlist, "thePlaylist", pj.PJMEDIA_FILE_NO_LOOP) + self.startTransmit(audio_media) diff --git a/ha-sip/src/tests/__init__.py b/ha-sip/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ha-sip/src/tests/test_account.py b/ha-sip/src/tests/test_account.py new file mode 100644 index 0000000..ea0365e --- /dev/null +++ b/ha-sip/src/tests/test_account.py @@ -0,0 +1,21 @@ +import unittest +from account import Account + + +class AccountTest(unittest.TestCase): + def test_is_number_in_list(self): + self.assertEqual(Account.is_number_in_list(None, ["12345"]), False) + self.assertEqual(Account.is_number_in_list(None, []), False) + self.assertEqual(Account.is_number_in_list("12345", []), False) + self.assertEqual(Account.is_number_in_list("12345", ["12345"]), True) + self.assertEqual(Account.is_number_in_list("1234", ["12345"]), False) + self.assertEqual(Account.is_number_in_list("123456", ["1234{*}"]), True) + self.assertEqual(Account.is_number_in_list("123456", ["1234{?}"]), False) + self.assertEqual(Account.is_number_in_list("12345", ["1234{?}"]), True) + self.assertEqual(Account.is_number_in_list("12345", ["1{*}5"]), True) + self.assertEqual(Account.is_number_in_list("12345", ["12{?}45"]), True) + self.assertEqual(Account.is_number_in_list("12345", ["{*}45"]), True) + self.assertEqual(Account.is_number_in_list("12345", ["{?}345"]), False) + self.assertEqual(Account.is_number_in_list("12345", ["{?}2345"]), True) + self.assertEqual(Account.is_number_in_list("**620", ["**620"]), True) + self.assertEqual(Account.is_number_in_list("**620", ["**{*}"]), True) diff --git a/ha-sip/src/utils.py b/ha-sip/src/utils.py index a757882..2bc4391 100644 --- a/ha-sip/src/utils.py +++ b/ha-sip/src/utils.py @@ -15,3 +15,10 @@ def convert_to_float(s: Any, default=0.0) -> float: except (ValueError, TypeError): return default return i + + +def safe_list_get(source_list: list, index: int, default: Any = None) -> Any: + try: + return source_list[index] + except IndexError: + return default