From 7fde25486b1d6cf6c2d0b49eb17a4eb02ce88ffb Mon Sep 17 00:00:00 2001 From: topic2k Date: Sat, 1 Apr 2023 17:13:16 +0200 Subject: [PATCH 1/7] - 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). --- README.md | 9 ++++- ha-sip/CHANGELOG.md | 14 ++++++++ ha-sip/src/call.py | 85 +++++++++++++++++++++++++++++++++++++++++--- ha-sip/src/player.py | 4 +++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 16655e3..bd558d3 100644 --- a/README.md +++ b/README.md @@ -202,14 +202,21 @@ 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) + # items must begin with 'message:' or 'audio_file:'. + # items must be put into quotes + - "message: Hello World!" + - "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), # "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) # (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// diff --git a/ha-sip/CHANGELOG.md b/ha-sip/CHANGELOG.md index fe24caf..315ae59 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2.7 + +- 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: + - "message: Hello World!" + - "audio_file: /config/audio/welcome.mp3" + post_action: "repeat_playlist" + repeat_wait: 2 + ``` + ## 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/src/call.py b/ha-sip/src/call.py index 8877dd6..ce1bdc0 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,11 +24,12 @@ 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']] +PostAction = Union[Literal['return'], Literal['hangup'], Literal['noop'], Literal['repeat_message'], Literal['repeat_playlist']] DtmfMethod = Union[Literal['in_band'], Literal['rfc2833'], Literal['sip_info']] @@ -49,10 +50,12 @@ class MenuFromStdin(TypedDict): id: Optional[str] message: Optional[str] audio_file: Optional[str] + playlist: Optional[List[str]] language: Optional[str] action: Optional[Action] choices_are_pin: Optional[bool] post_action: Optional[PostAction] + repeat_wait: Optional[int] timeout: Optional[int] choices: Optional[dict[Any, MenuFromStdin]] @@ -61,10 +64,12 @@ class Menu(TypedDict): id: Optional[str] message: Optional[str] audio_file: Optional[str] + playlist: Optional[List[str]] language: str action: Optional[Action] choices_are_pin: bool post_action: PostAction + repeat_wait: Optional[float] timeout: float choices: Optional[dict[str, Menu]] default_choice: Optional[Menu] @@ -117,6 +122,7 @@ def __init__(self, end_point: pj.Endpoint, sip_account: account.Account, call_id self.pressed_digit_list = [] self.callback_id = self.get_callback_id() self.menu = self.normalize_menu(menu) if menu else self.get_standard_menu() + self.repeat_wait_begin = None 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) @@ -145,8 +151,8 @@ def handle_events(self) -> None: return 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 not in ['repeat_message', 'repeat_playlist']: + log(self.account.config.index, 'Scheduled post action: %s' % post_action) if post_action == 'noop': pass elif post_action == 'return': @@ -154,15 +160,37 @@ def handle_events(self) -> None: 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) + pl = self.menu['playlist'] + if pl: + item_type, content = pl[-1].split(':', 1) + self.menu[item_type] = content.lstrip() + self.menu['playlist'] = None + self.handle_repeat_wait() + return + elif post_action == 'repeat_playlist': + self.handle_repeat_wait() + return else: log(self.account.config.index, 'Unknown post_action: %s' % post_action) + self.scheduled_post_action = None return if len(self.pressed_digit_list) > 0: next_digit = self.pressed_digit_list.pop(0) self.handle_dtmf_digit(next_digit) return + def handle_repeat_wait(self): + if not self.menu['repeat_wait']: + log(self.account.config.index, 'Scheduled post action: %s' % self.scheduled_post_action) + self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) + elif self.menu['repeat_wait'] and not self.repeat_wait_begin: + log(self.account.config.index, 'Wait %f seconds before %s' % (self.menu['repeat_wait'], self.scheduled_post_action)) + self.repeat_wait_begin = time.time() + elif self.menu['repeat_wait'] and time.time() - self.repeat_wait_begin > self.menu['repeat_wait']: + self.repeat_wait_begin = None + log(self.account.config.index, 'Scheduled post action: %s' % self.scheduled_post_action) + 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) @@ -310,6 +338,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 +346,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 +379,42 @@ 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: List[str], language: str) -> None: + log(self.account.config.index, 'Playing playlist: %s' % repr(playlist)) + wav_playlist = list() + remove_list = list() + for playlist_item in playlist: + try: + item_type, content = playlist_item.split(':', 1) + content = content.lstrip() + except ValueError: + log(self.account.config.index, 'Playlist item has wrong format: "%s.". Must start with "message:" or "audio_file:"' % repr(playlist_item)) + continue + if item_type == 'message': + sound_file_name, must_be_deleted = ha.create_and_get_tts(self.ha_config, content, language) + # log(self.account.config.index, 'playlist: adding message: %s (filename: %s)' % (repr(content), sound_file_name)) + if must_be_deleted: + remove_list.append(sound_file_name) + elif item_type == 'audio_file': + sound_file_name = audio.convert_audio_to_wav(content) + if not sound_file_name: + log(self.account.config.index, "Sound file couldn't be converted: %s" % repr(content)) + continue + # log(self.account.config.index, 'playlist: adding sound file: %s)' % repr(sound_file_name)) + else: + log(self.account.config.index, 'Playlist item has wrong format: "%s.". Must start with "message:" or "audio_file:"' % repr(playlist_item)) + continue + wav_playlist.append(sound_file_name) + if not wav_playlist: + log(self.account.config.index, 'Playlist is empty') + return + self.player = player.Player(self.on_playback_done) + self.playback_is_done = False + self.player.play_playlist(self.audio_media, wav_playlist) + if remove_list: + for file_name in remove_list: + os.remove(file_name) + 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 @@ -422,6 +489,7 @@ def normalize_menu(self, menu: MenuFromStdin, parent_menu: Optional[Menu] = None '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, @@ -430,6 +498,7 @@ def normalize_menu(self, menu: MenuFromStdin, parent_menu: Optional[Menu] = 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), 'parent_menu': parent_menu, } choices = menu.get('choices') @@ -472,6 +541,7 @@ 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, @@ -480,6 +550,7 @@ def get_default_menu(parent_menu: Menu) -> Menu: 'timeout_choice': None, 'post_action': 'return', 'timeout': DEFAULT_TIMEOUT, + 'repeat_wait': DEFAULT_REPEAT_WAIT, 'parent_menu': parent_menu, } @@ -489,6 +560,7 @@ 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, @@ -497,6 +569,7 @@ def get_timeout_menu(parent_menu: Menu) -> Menu: 'timeout_choice': None, 'post_action': 'hangup', 'timeout': DEFAULT_TIMEOUT, + 'repeat_wait': DEFAULT_REPEAT_WAIT, 'parent_menu': parent_menu, } @@ -506,6 +579,7 @@ def get_standard_menu() -> Menu: 'id': None, 'message': None, 'audio_file': None, + 'playlist': None, 'language': 'en', 'action': None, 'choices_are_pin': False, @@ -514,6 +588,7 @@ def get_standard_menu() -> Menu: 'timeout_choice': None, 'post_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/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) From 24c39abe4ccbfc369fa3a183a820afd6766d4408 Mon Sep 17 00:00:00 2001 From: topic2k Date: Mon, 10 Apr 2023 18:47:52 +0200 Subject: [PATCH 2/7] change playlist: to be a list of dicts don't alter playlist when repeat_message is given. --- README.md | 8 ++-- ha-sip/CHANGELOG.md | 6 ++- ha-sip/src/audio.py | 5 ++- ha-sip/src/call.py | 106 +++++++++++++++++--------------------------- ha-sip/src/main.py | 9 ++-- 5 files changed, 57 insertions(+), 77 deletions(-) mode change 100755 => 100644 ha-sip/src/main.py diff --git a/README.md b/README.md index bd558d3..9dce388 100644 --- a/README.md +++ b/README.md @@ -203,10 +203,10 @@ 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) - # items must begin with 'message:' or 'audio_file:'. - # items must be put into quotes - - "message: Hello World!" - - "audio_file: /config/audio/welcome.mp3" + - type: message + value: Hello World! + - type: audio_file + value: ''/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) diff --git a/ha-sip/CHANGELOG.md b/ha-sip/CHANGELOG.md index 315ae59..58a6e65 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -8,8 +8,10 @@ ```yaml menu: playlist: - - "message: Hello World!" - - "audio_file: /config/audio/welcome.mp3" + - type: message + value: "Hello World!" + - type: audio_file + value: "/config/audio/welcome.mp3" post_action: "repeat_playlist" repeat_wait: 2 ``` diff --git a/ha-sip/src/audio.py b/ha-sip/src/audio.py index e012b9e..4b665dd 100644 --- a/ha-sip/src/audio.py +++ b/ha-sip/src/audio.py @@ -7,7 +7,8 @@ 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 +26,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 ce1bdc0..6499338 100644 --- a/ha-sip/src/call.py +++ b/ha-sip/src/call.py @@ -31,6 +31,15 @@ class CallStateChange(Enum): CallCallback = Callable[[CallStateChange, str, 'Call'], None] PostAction = Union[Literal['return'], Literal['hangup'], Literal['noop'], Literal['repeat_message'], Literal['repeat_playlist']] DtmfMethod = Union[Literal['in_band'], Literal['rfc2833'], Literal['sip_info']] +PlaylistItemType = Literal['message', 'audio_file'] + + +class PlaylistItem(TypedDict): + type: PlaylistItemType + value: str + + +Playlist = List[PlaylistItem] class WebhookToCall(TypedDict): @@ -50,12 +59,12 @@ class MenuFromStdin(TypedDict): id: Optional[str] message: Optional[str] audio_file: Optional[str] - playlist: Optional[List[str]] + playlist: Optional[Playlist] language: Optional[str] action: Optional[Action] choices_are_pin: Optional[bool] post_action: Optional[PostAction] - repeat_wait: Optional[int] + repeat_wait: int timeout: Optional[int] choices: Optional[dict[Any, MenuFromStdin]] @@ -64,12 +73,12 @@ class Menu(TypedDict): id: Optional[str] message: Optional[str] audio_file: Optional[str] - playlist: Optional[List[str]] + playlist: Optional[Playlist] language: str action: Optional[Action] choices_are_pin: bool post_action: PostAction - repeat_wait: Optional[float] + repeat_wait: float timeout: float choices: Optional[dict[str, Menu]] default_choice: Optional[Menu] @@ -99,6 +108,7 @@ 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 @@ -122,7 +132,7 @@ def __init__(self, end_point: pj.Endpoint, sip_account: account.Account, call_id self.pressed_digit_list = [] self.callback_id = self.get_callback_id() self.menu = self.normalize_menu(menu) if menu else self.get_standard_menu() - self.repeat_wait_begin = None + 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) @@ -157,14 +167,10 @@ def handle_events(self) -> None: pass elif post_action == 'return': self.handle_menu(self.menu['parent_menu']) + return elif post_action == 'hangup': self.hangup_call() elif post_action == 'repeat_message': - pl = self.menu['playlist'] - if pl: - item_type, content = pl[-1].split(':', 1) - self.menu[item_type] = content.lstrip() - self.menu['playlist'] = None self.handle_repeat_wait() return elif post_action == 'repeat_playlist': @@ -181,14 +187,14 @@ def handle_events(self) -> None: def handle_repeat_wait(self): if not self.menu['repeat_wait']: - log(self.account.config.index, 'Scheduled post action: %s' % self.scheduled_post_action) + log(self.account.config.index, 'Scheduled post action (no wait): %s' % self.scheduled_post_action) self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) - elif self.menu['repeat_wait'] and not self.repeat_wait_begin: - log(self.account.config.index, 'Wait %f seconds before %s' % (self.menu['repeat_wait'], self.scheduled_post_action)) + 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)) self.repeat_wait_begin = time.time() elif self.menu['repeat_wait'] and time.time() - self.repeat_wait_begin > self.menu['repeat_wait']: - self.repeat_wait_begin = None - log(self.account.config.index, 'Scheduled post action: %s' % self.scheduled_post_action) + self.repeat_wait_begin = -1 + log(self.account.config.index, 'Scheduled post action (with wait): %s' % self.scheduled_post_action) self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) def trigger_webhook(self, event: ha.WebhookEvent): @@ -296,30 +302,6 @@ def handle_dtmf_digit(self, pressed_digit: str) -> None: log(self.account.config.index, 'Invalid input %s' % self.current_input) self.handle_menu(self.menu['default_choice']) - def onCallTransferRequest(self, prm): - log(self.account.config.index, 'onCallTransferRequest') - - def onCallTransferStatus(self, prm): - log(self.account.config.index, 'onCallTransferStatus') - - def onCallReplaceRequest(self, prm): - log(self.account.config.index, 'onCallReplaceRequest') - - def onCallReplaced(self, prm): - log(self.account.config.index, 'onCallReplaced') - - def onCallRxOffer(self, prm): - log(self.account.config.index, 'onCallRxOffer') - - def onCallRxReinvite(self, prm): - log(self.account.config.index, 'onCallRxReinvite') - - def onCallTxOffer(self, prm): - log(self.account.config.index, 'onCallTxOffer') - - 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: if not menu: log(self.account.config.index, 'No menu supplied') @@ -379,41 +361,31 @@ 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: List[str], language: str) -> None: - log(self.account.config.index, 'Playing playlist: %s' % repr(playlist)) + def play_playlist(self, playlist: Playlist, language: str) -> None: + log(self.account.config.index, 'Playing playlist') wav_playlist = list() - remove_list = list() + playlist = [playlist[-1]] if self.scheduled_post_action == 'repeat_message' else playlist for playlist_item in playlist: - try: - item_type, content = playlist_item.split(':', 1) - content = content.lstrip() - except ValueError: - log(self.account.config.index, 'Playlist item has wrong format: "%s.". Must start with "message:" or "audio_file:"' % repr(playlist_item)) - continue - if item_type == 'message': - sound_file_name, must_be_deleted = ha.create_and_get_tts(self.ha_config, content, language) - # log(self.account.config.index, 'playlist: adding message: %s (filename: %s)' % (repr(content), sound_file_name)) + if playlist_item['type'] == 'message': + sound_file_name, must_be_deleted = ha.create_and_get_tts(self.ha_config, playlist_item['value'], language) + wav_playlist.append(sound_file_name) if must_be_deleted: - remove_list.append(sound_file_name) - elif item_type == 'audio_file': - sound_file_name = audio.convert_audio_to_wav(content) - if not sound_file_name: - log(self.account.config.index, "Sound file couldn't be converted: %s" % repr(content)) - continue - # log(self.account.config.index, 'playlist: adding sound file: %s)' % repr(sound_file_name)) + self.files_to_remove.append(sound_file_name) + elif playlist_item['type'] == 'audio_file': + sound_file_name = audio.convert_audio_to_wav(playlist_item['value'], 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['value'])) else: log(self.account.config.index, 'Playlist item has wrong format: "%s.". Must start with "message:" or "audio_file:"' % repr(playlist_item)) - continue - wav_playlist.append(sound_file_name) if not wav_playlist: log(self.account.config.index, 'Playlist is empty') return - self.player = player.Player(self.on_playback_done) self.playback_is_done = False + self.player = player.Player(self.on_playback_done) self.player.play_playlist(self.audio_media, wav_playlist) - if remove_list: - for file_name in remove_list: - os.remove(file_name) def play_wav_file(self, sound_file_name: str, must_be_deleted: bool) -> None: self.player = player.Player(self.on_playback_done) @@ -423,8 +395,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() diff --git a/ha-sip/src/main.py b/ha-sip/src/main.py old mode 100755 new mode 100644 index 36b7303..ec82fb5 --- a/ha-sip/src/main.py +++ b/ha-sip/src/main.py @@ -165,10 +165,11 @@ def main(): is_first_enabled_account = False command_server = command_client.CommandClient() while True: - end_point.libHandleEvents(20) - handle_command_list(command_server, end_point, sip_accounts, call_state, ha_config) - for c in list(call_state.current_call_dict.values()): - c.handle_events() + if end_point: + end_point.libHandleEvents(20) + handle_command_list(command_server, end_point, sip_accounts, call_state, ha_config) + for c in list(call_state.current_call_dict.values()): + c.handle_events() if __name__ == '__main__': From 1a8bbd7ca8c45f3fedb58cb063bbea0d943e010d Mon Sep 17 00:00:00 2001 From: topic2k Date: Mon, 10 Apr 2023 21:50:41 +0200 Subject: [PATCH 3/7] oops, deleted to much and forgot to remove an unneeded line --- ha-sip/src/call.py | 24 ++++++++++++++++++++++++ ha-sip/src/main.py | 9 ++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/ha-sip/src/call.py b/ha-sip/src/call.py index 6499338..6202605 100644 --- a/ha-sip/src/call.py +++ b/ha-sip/src/call.py @@ -302,6 +302,30 @@ def handle_dtmf_digit(self, pressed_digit: str) -> None: log(self.account.config.index, 'Invalid input %s' % self.current_input) self.handle_menu(self.menu['default_choice']) + def onCallTransferRequest(self, prm): + log(self.account.config.index, 'onCallTransferRequest') + + def onCallTransferStatus(self, prm): + log(self.account.config.index, 'onCallTransferStatus') + + def onCallReplaceRequest(self, prm): + log(self.account.config.index, 'onCallReplaceRequest') + + def onCallReplaced(self, prm): + log(self.account.config.index, 'onCallReplaced') + + def onCallRxOffer(self, prm): + log(self.account.config.index, 'onCallRxOffer') + + def onCallRxReinvite(self, prm): + log(self.account.config.index, 'onCallRxReinvite') + + def onCallTxOffer(self, prm): + log(self.account.config.index, 'onCallTxOffer') + + 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: if not menu: log(self.account.config.index, 'No menu supplied') diff --git a/ha-sip/src/main.py b/ha-sip/src/main.py index ec82fb5..36b7303 100644 --- a/ha-sip/src/main.py +++ b/ha-sip/src/main.py @@ -165,11 +165,10 @@ def main(): is_first_enabled_account = False command_server = command_client.CommandClient() while True: - if end_point: - end_point.libHandleEvents(20) - handle_command_list(command_server, end_point, sip_accounts, call_state, ha_config) - for c in list(call_state.current_call_dict.values()): - c.handle_events() + end_point.libHandleEvents(20) + handle_command_list(command_server, end_point, sip_accounts, call_state, ha_config) + for c in list(call_state.current_call_dict.values()): + c.handle_events() if __name__ == '__main__': From b383f647e5b5e91b2e3bf8e2b23738cfc0dbe165 Mon Sep 17 00:00:00 2001 From: topic2k Date: Mon, 10 Apr 2023 22:27:31 +0200 Subject: [PATCH 4/7] update playlist item config --- README.md | 6 +++--- ha-sip/CHANGELOG.md | 6 +++--- ha-sip/src/call.py | 18 +++++++++++------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9dce388..e04d8de 100644 --- a/README.md +++ b/README.md @@ -203,10 +203,10 @@ 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: message - value: Hello World! + - type: tts + message: Hello World! - type: audio_file - value: ''/config/audio/welcome.mp3' + 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) diff --git a/ha-sip/CHANGELOG.md b/ha-sip/CHANGELOG.md index 58a6e65..159c785 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -8,10 +8,10 @@ ```yaml menu: playlist: - - type: message - value: "Hello World!" + - type: tts + message: "Hello World!" - type: audio_file - value: "/config/audio/welcome.mp3" + audio_file: "/config/audio/welcome.mp3" post_action: "repeat_playlist" repeat_wait: 2 ``` diff --git a/ha-sip/src/call.py b/ha-sip/src/call.py index 6202605..d4156a9 100644 --- a/ha-sip/src/call.py +++ b/ha-sip/src/call.py @@ -31,12 +31,13 @@ class CallStateChange(Enum): CallCallback = Callable[[CallStateChange, str, 'Call'], None] PostAction = Union[Literal['return'], Literal['hangup'], Literal['noop'], Literal['repeat_message'], Literal['repeat_playlist']] DtmfMethod = Union[Literal['in_band'], Literal['rfc2833'], Literal['sip_info']] -PlaylistItemType = Literal['message', 'audio_file'] +PlaylistItemType = Literal['tts', 'audio_file'] class PlaylistItem(TypedDict): type: PlaylistItemType - value: str + message: str + audio_file: str Playlist = List[PlaylistItem] @@ -390,20 +391,23 @@ def play_playlist(self, playlist: Playlist, language: str) -> None: wav_playlist = list() playlist = [playlist[-1]] if self.scheduled_post_action == 'repeat_message' else playlist for playlist_item in playlist: - if playlist_item['type'] == 'message': - sound_file_name, must_be_deleted = ha.create_and_get_tts(self.ha_config, playlist_item['value'], language) + 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['value'], parameters=["-ac", "1", "-ar", "24000"]) + 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['value'])) + 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.". Must start with "message:" or "audio_file:"' % repr(playlist_item)) + 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 From e9df5bb8a159bc2558e151ef29a35ca1a877abfd Mon Sep 17 00:00:00 2001 From: Arne Gellhaus Date: Wed, 12 Apr 2023 17:19:21 +0200 Subject: [PATCH 5/7] Add wildcard option to number filters. Make return post action more powerful. Add jump post action. --- .gitignore | 2 +- README.md | 7 +- build.sh | 19 ++-- ha-sip/CHANGELOG.md | 5 ++ ha-sip/config.json | 2 +- ha-sip/src/account.py | 24 ++++- ha-sip/src/call.py | 148 +++++++++++++++++++++++-------- ha-sip/src/tests/__init__.py | 0 ha-sip/src/tests/test_account.py | 21 +++++ ha-sip/src/utils.py | 7 ++ 10 files changed, 184 insertions(+), 51 deletions(-) create mode 100644 ha-sip/src/tests/__init__.py create mode 100644 ha-sip/src/tests/test_account.py 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..3caf2c8 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" @@ -207,9 +209,10 @@ menu: 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 # "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) + # "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 +230,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..145f879 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)..." @@ -59,19 +61,22 @@ case "$1" in ;; test) echo "Running type-check..." + python3 -m unittest discover -s "$SCRIPT_DIR"/ha-sip/src 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..2c6bbf5 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 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 + ## 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..9333b9b 100644 --- a/ha-sip/config.json +++ b/ha-sip/config.json @@ -1,6 +1,6 @@ { "name": "ha-sip", - "version": "2.6", + "version": "2.7", "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/call.py b/ha-sip/src/call.py index 8877dd6..7ece208 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 @@ -28,7 +28,6 @@ class CallStateChange(Enum): 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']] @@ -45,6 +44,31 @@ 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'] + + +PostAction = Union[PostActionReturn, PostActionJump, PostActionHangup, PostActionNoop, PostActionRepeatMessage] + + class MenuFromStdin(TypedDict): id: Optional[str] message: Optional[str] @@ -52,7 +76,7 @@ class MenuFromStdin(TypedDict): language: Optional[str] action: Optional[Action] choices_are_pin: Optional[bool] - post_action: Optional[PostAction] + post_action: Optional[str] timeout: Optional[int] choices: Optional[dict[Any, MenuFromStdin]] @@ -96,7 +120,7 @@ def __init__(self, end_point: pj.Endpoint, sip_account: account.Account, call_id pj.Call.__init__(self, sip_account, call_id) 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 +138,10 @@ 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) 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 +171,37 @@ 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) + self.handle_post_action(post_action) return 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): + 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"] == 'repeat_message': + 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) @@ -418,6 +457,43 @@ def get_call_info(self) -> CallInfo: } 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) + 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'), @@ -429,33 +505,29 @@ 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', + '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) @@ -478,7 +550,7 @@ def get_default_menu(parent_menu: Menu) -> Menu: 'choices': None, 'default_choice': None, 'timeout_choice': None, - 'post_action': 'return', + 'post_action': PostActionReturn(action="return", level=1), 'timeout': DEFAULT_TIMEOUT, 'parent_menu': parent_menu, } @@ -495,7 +567,7 @@ def get_timeout_menu(parent_menu: Menu) -> Menu: 'choices': None, 'default_choice': None, 'timeout_choice': None, - 'post_action': 'hangup', + 'post_action': PostActionHangup(action="hangup"), 'timeout': DEFAULT_TIMEOUT, 'parent_menu': parent_menu, } @@ -512,7 +584,7 @@ def get_standard_menu() -> Menu: 'choices': dict(), 'default_choice': None, 'timeout_choice': None, - 'post_action': 'noop', + 'post_action': PostActionNoop(action="noop"), 'timeout': DEFAULT_TIMEOUT, 'parent_menu': None, } 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 From 637cb707603a37685d0b819ce73061cb4ce6e214 Mon Sep 17 00:00:00 2001 From: Arne Gellhaus Date: Wed, 12 Apr 2023 20:45:52 +0200 Subject: [PATCH 6/7] Fix time-out not reset when returning to parent menu --- build.sh | 3 ++- ha-sip/CHANGELOG.md | 1 + ha-sip/src/call.py | 10 +++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/build.sh b/build.sh index 145f879..4ad8fa2 100755 --- a/build.sh +++ b/build.sh @@ -60,8 +60,9 @@ case "$1" in docker pull homeassistant/amd64-builder:dev ;; test) - echo "Running type-check..." + echo "Running unit tests..." python3 -m unittest discover -s "$SCRIPT_DIR"/ha-sip/src + echo "Running type-check..." pyright ha-sip ;; run-local) diff --git a/ha-sip/CHANGELOG.md b/ha-sip/CHANGELOG.md index 2c6bbf5..a9b3a22 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -4,6 +4,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 diff --git a/ha-sip/src/call.py b/ha-sip/src/call.py index 7ece208..3799231 100644 --- a/ha-sip/src/call.py +++ b/ha-sip/src/call.py @@ -212,7 +212,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', @@ -273,7 +273,7 @@ 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.reset_timeout() self.pressed_digit_list.append(prm.digit) def handle_dtmf_digit(self, pressed_digit: str) -> None: @@ -332,6 +332,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 @@ -417,7 +418,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: @@ -456,6 +457,9 @@ 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'): From ac7313662ad3fa1888578b68fe1d13ec9c3a4eed Mon Sep 17 00:00:00 2001 From: topic2k Date: Thu, 13 Apr 2023 20:37:44 +0000 Subject: [PATCH 7/7] rebase --- README.md | 7 +- ha-sip/CHANGELOG.md | 9 ++- ha-sip/config.json | 2 +- ha-sip/src/audio.py | 1 - ha-sip/src/call.py | 184 ++++++++++++++++++++++++++++++++------------ 5 files changed, 146 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index e04d8de..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" @@ -213,10 +215,11 @@ menu: 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 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// @@ -234,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/ha-sip/CHANGELOG.md b/ha-sip/CHANGELOG.md index 159c785..8826345 100644 --- a/ha-sip/CHANGELOG.md +++ b/ha-sip/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog -## 2.7 - +## 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). @@ -16,6 +15,12 @@ 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/audio.py b/ha-sip/src/audio.py index 4b665dd..89d2921 100644 --- a/ha-sip/src/audio.py +++ b/ha-sip/src/audio.py @@ -8,7 +8,6 @@ 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': diff --git a/ha-sip/src/call.py b/ha-sip/src/call.py index d4156a9..e6febe0 100644 --- a/ha-sip/src/call.py +++ b/ha-sip/src/call.py @@ -29,7 +29,6 @@ class CallStateChange(Enum): DEFAULT_DTMF_OFF = 220 CallCallback = Callable[[CallStateChange, str, 'Call'], None] -PostAction = Union[Literal['return'], Literal['hangup'], Literal['noop'], Literal['repeat_message'], Literal['repeat_playlist']] DtmfMethod = Union[Literal['in_band'], Literal['rfc2833'], Literal['sip_info']] PlaylistItemType = Literal['tts', 'audio_file'] @@ -56,6 +55,35 @@ 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] @@ -64,7 +92,7 @@ class MenuFromStdin(TypedDict): 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]] @@ -112,7 +140,7 @@ def __init__(self, end_point: pj.Endpoint, sip_account: account.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 @@ -130,9 +158,10 @@ 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) @@ -162,40 +191,51 @@ def handle_events(self) -> None: return if self.playback_is_done and self.scheduled_post_action: post_action = self.scheduled_post_action - if post_action not in ['repeat_message', 'repeat_playlist']: - 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']) - return - elif post_action == 'hangup': - self.hangup_call() - elif post_action == 'repeat_message': - self.handle_repeat_wait() - return - elif post_action == 'repeat_playlist': - self.handle_repeat_wait() - return - else: - log(self.account.config.index, 'Unknown post_action: %s' % post_action) self.scheduled_post_action = None - 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) + 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)) + 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 - log(self.account.config.index, 'Scheduled post action (with wait): %s' % self.scheduled_post_action) self.handle_menu(self.menu, send_webhook_event=False, handle_action=False, reset_input=False) def trigger_webhook(self, event: ha.WebhookEvent): @@ -208,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', @@ -269,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: @@ -287,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 @@ -328,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 @@ -389,7 +432,8 @@ def play_audio_file(self, audio_file: str) -> None: def play_playlist(self, playlist: Playlist, language: str) -> None: log(self.account.config.index, 'Playing playlist') wav_playlist = list() - playlist = [playlist[-1]] if self.scheduled_post_action == 'repeat_message' else playlist + 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'): @@ -449,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: @@ -488,7 +532,49 @@ 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'), @@ -501,34 +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) @@ -552,7 +634,7 @@ def get_default_menu(parent_menu: Menu) -> Menu: '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, @@ -571,7 +653,7 @@ def get_timeout_menu(parent_menu: Menu) -> Menu: '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, @@ -590,7 +672,7 @@ def get_standard_menu() -> Menu: '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,