From 7a57da031bfa714503d39190dfd0bfe1cd3099ec Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Thu, 19 Nov 2020 09:07:09 -0800 Subject: [PATCH 1/6] Bumped version to 1.1.1 --- callattendant/config.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/callattendant/config.py b/callattendant/config.py index 6bdc3a8..2ca1be8 100644 --- a/callattendant/config.py +++ b/callattendant/config.py @@ -19,7 +19,7 @@ # and screened callers through to the home phone. # default_config = { - "VERSION": '1.1.0', + "VERSION": '1.1.1', "ENV": 'production', "DEBUG": False, diff --git a/setup.py b/setup.py index ee48e0e..0b4aa31 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setuptools.setup( name="callattendant", # Add user name when uploading to TestPyPI - version="1.1.0", # Ensure this is in-sync with VERSION in config.py + version="1.1.1", # Ensure this is in-sync with VERSION in config.py author="Bruce Schubert", author_email="bruce@emxsys.com", description="An automated call attendant and call blocker using a Raspberry Pi and USR-5637 modem", From db766f0dbfcdef5afbeed8768824bacc7b33825d Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Thu, 19 Nov 2020 15:30:44 -0800 Subject: [PATCH 2/6] Added 'ignore' and 'answer' actions to config - Updated app to use igonre and anwser actions - Updated config to test for ignore and answer - Updated unit tests - Re: #132 --- callattendant/app.py | 69 ++++++++----- callattendant/config.py | 69 +++++++++---- tests/test_app.py | 217 ++++++++++++++++++++++++++++++---------- 3 files changed, 255 insertions(+), 100 deletions(-) diff --git a/callattendant/app.py b/callattendant/app.py index 41a2aa3..1a66afa 100755 --- a/callattendant/app.py +++ b/callattendant/app.py @@ -189,35 +189,14 @@ def run(self): greeting = blocked_greeting_file rings_before_answer = blocked["rings_before_answer"] - # Wait for the callee to answer the phone, if configured to do so. - # In North America, the standard ring cadence is "2-4", or two seconds - # of ringing followed by four seconds of silence (33% Duty Cycle). ring_count = 1 # Already had at least 1 ring to get here - RING_WAIT_SECS = 10.0 - ok_to_answer = True - ring_count = 1 # Already had at least 1 ring to get here - last_ring = datetime.now() - while ring_count < rings_before_answer: - if not self._caller_queue.empty(): - print(" > > > Another call has come in") - # Skip this call and process the next one - ok_to_answer = False - break - elif self.modem.ring_event.wait(1.0): - ring_count += 1 - last_ring = datetime.now() - print(" > > > Ring count: {}".format(ring_count)) - elif (datetime.now() - last_ring).total_seconds() > RING_WAIT_SECS: - # wait timeout; assume ringing has stopped before the ring count - # was reached because either the callee answered or caller hung up. - ok_to_answer = False - print(" > > > Ringing stopped: Caller hung up or callee answered") - break + # Waits for the callee to answer the phone, if configured to do so. + ok_to_answer = self.wait_for_rings(rings_before_answer) # Answer the call! - if ok_to_answer: + if ok_to_answer and "answer" in actions: self.answer_call(actions, greeting, call_no, caller) else: - self.bypass_call(caller) + self.ignore_call(caller) print("Waiting for next call...") @@ -286,14 +265,50 @@ def answer_call(self, actions, greeting, call_no, caller): # Go "on-hook" self.modem.hang_up() - def bypass_call(self, caller): + def ignore_call(self, caller): """ - Bpypas (do not answer) the call. + Ignore (do not answer) the call. :param caller: The caller ID data """ pass + def wait_for_rings(self, rings_before_answer): + """ + Waits for the given number of rings to occur. + :param rings_before_answer: + the number of rings to wait for. + :return: + True if the ring count meets or exceeds the rings before answer; + False if the rings stop or if another call comes in. + """ + # In North America, the standard ring cadence is "2-4", or two seconds + # of ringing followed by four seconds of silence (33% Duty Cycle). + RING_CADENCE = 6.0 # secs + RING_WAIT_SECS = RING_CADENCE + (RING_CADENCE * 0.5) + ok_to_answer = True + ring_count = 1 # Already had at least 1 ring to get here + last_ring = datetime.now() + while ring_count < rings_before_answer: + if not self._caller_queue.empty(): + # Skip this call and process the next one + print(" > > > Another call has come in") + ok_to_answer = False + break + # Wait for a ring + elif self.modem.ring_event.wait(1.0): + # Increment the ring count and time of last ring + ring_count += 1 + last_ring = datetime.now() + print(" > > > Ring count: {}".format(ring_count)) + # On wait timeout, test for ringing stopped + elif (datetime.now() - last_ring).total_seconds() > RING_WAIT_SECS: + # Assume ringing has stopped before the ring count + # was reached because either the callee answered or caller hung up. + print(" > > > Ringing stopped: Caller hung up or callee answered") + ok_to_answer = False + break + return ok_to_answer def make_config(filename=None, datapath=None, create_folder=False): """ diff --git a/callattendant/config.py b/callattendant/config.py index 2ca1be8..08084d4 100644 --- a/callattendant/config.py +++ b/callattendant/config.py @@ -33,21 +33,22 @@ "BLOCK_ENABLED": True, "BLOCK_SERVICE": "NOMOROBO", + "BLOCK_NAME_PATTERNS": {"V[0-9]{15}": "Telemarketer Caller ID", }, "BLOCK_NUMBER_PATTERNS": {}, - "BLOCKED_ACTIONS": ("greeting", "record_message"), - "BLOCKED_RINGS_BEFORE_ANSWER": 0, + "PERMIT_NAME_PATTERNS": {}, + "PERMIT_NUMBER_PATTERNS": {}, + + "BLOCKED_ACTIONS": ("answer", "greeting", "record_message"), "BLOCKED_GREETING_FILE": "resources/blocked_greeting.wav", + "BLOCKED_RINGS_BEFORE_ANSWER": 0, - "SCREENED_ACTIONS": (), + "SCREENED_ACTIONS": ("ignore",), "SCREENED_GREETING_FILE": "resources/general_greeting.wav", "SCREENED_RINGS_BEFORE_ANSWER": 0, - "PERMIT_NAME_PATTERNS": {}, - "PERMIT_NUMBER_PATTERNS": {}, - - "PERMITTED_ACTIONS": (), + "PERMITTED_ACTIONS": ("ignore",), "PERMITTED_GREETING_FILE": "resources/general_greeting.wav", "PERMITTED_RINGS_BEFORE_ANSWER": 4, @@ -181,18 +182,12 @@ def validate(self): print("* SCREENING_MODE option is invalid: {}".format(mode)) success = False - for mode in self["BLOCKED_ACTIONS"]: - if mode not in ("greeting", "record_message", "voice_mail"): - print("* BLOCKED_ACTIONS option is invalid: {}".format(mode)) - success = False - for mode in self["SCREENED_ACTIONS"]: - if mode not in ("greeting", "record_message", "voice_mail"): - print("* SCREENED_ACTIONS option is invalid: {}".format(mode)) - success = False - for mode in self["PERMITTED_ACTIONS"]: - if mode not in ("greeting", "record_message", "voice_mail"): - print("* PERMITTED_ACTIONS option is invalid: {}".format(mode)) - success = False + if not self._validate_actions("BLOCKED_ACTIONS"): + success = False + if not self._validate_actions("SCREENED_ACTIONS"): + success = False + if not self._validate_actions("PERMITTED_ACTIONS"): + success = False if not isinstance(self["BLOCKED_RINGS_BEFORE_ANSWER"], int): print("* BLOCKED_RINGS_BEFORE_ANSWER should be an integer: {}".format(type(self["BLOCKED_RINGS_BEFORE_ANSWER"]))) @@ -244,6 +239,42 @@ def validate(self): return success + def _validate_actions(self, key): + """ + :param key: + String: "BLOCKED_ACTIONS", "SCREENED_ACTIONS" or "PERMITTED_ACTIONS" + """ + if not isinstance(self[key], tuple): + print("* {} should be a tuple, not {}".format(key, type(self[key]))) + return False + + for action in self[key]: + if action not in ("answer", "ignore", "greeting", "record_message", "voice_mail"): + print("* {} option is invalid: {}".format(key, action)) + return False + + if not any(a in self[key] for a in ("answer", "ignore")): + print("* {} must include either 'answer' or 'ignore'".format(key)) + return False + + if all(a in self[key] for a in ("answer", "ignore")): + print("* {} cannot include both 'answer' and 'ignore'".format(key)) + return False + + if all(a in self[key] for a in ("record_message", "voice_mail")): + print("* {} cannot include both 'record_message' and 'voice_mail'".format(key)) + return False + + # WARNINGS follow...they print only, they do not fail validation. + if "record_message" in self[key]: + if not "greeting" in self[key]: + print("* WARNING: {} contains 'record_message' without a 'greeting'.".format(key)) + if "ignore" in self[key]: + if any(a in self[key] for a in ("greeting", "record_message", "voice_mail")): + print("* WARNING: {} contains actions in addition to 'ignore'. They not be used.".format(key)) + + return True + def pretty_print(self): """ Pretty print the given configuration dict object. diff --git a/tests/test_app.py b/tests/test_app.py index 4b9da45..d466dd7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -39,6 +39,8 @@ play_audio_called = False record_message_called = False voice_messaging_menu_called = False +answer_call_called = False +ignore_call_called = False caller1 = {"NAME": "CALLER1", "NMBR": "1111111111", "DATE": "0101", "TIME": "0101"} caller2 = {"NAME": "CALLER2", "NMBR": "2222222222", "DATE": "0202", "TIME": "0202"} caller3 = {"NAME": "CALLER3", "NMBR": "3333333333", "DATE": "0303", "TIME": "0303"} @@ -51,12 +53,19 @@ def app(mocker): config = Config() config['DEBUG'] = False config['TESTING'] = True + config["BLOCKED_ACTIONS"] = ("answer","greeting","voice_mail") + config["BLOCKED_RINGS_BEFORE_ANSWER"] = 0 + config["SCREENED_ACTIONS"] = ("answer","greeting","record_message") + config["SCREENED_RINGS_BEFORE_ANSWER"] = 0 + config["PERMITTED_ACTIONS"] = ("ignore",) + config["PERMITTED_RINGS_BEFORE_ANSWER"] = 4 # Mock the hardware interfaces mocker.patch("hardware.modem.Modem._open_serial_port", return_value=True) mocker.patch("hardware.modem.Modem.start", return_value=True) mocker.patch("hardware.modem.Modem.pick_up", return_value=True) mocker.patch("hardware.modem.Modem.hang_up", return_value=True) + mocker.patch("hardware.modem.Modem.play_audio", return_value=True) mocker.patch("hardware.indicators.ApprovedIndicator.__init__", return_value=None) mocker.patch("hardware.indicators.ApprovedIndicator.blink") mocker.patch("hardware.indicators.ApprovedIndicator.close") @@ -75,19 +84,6 @@ def app(mocker): mocker.patch("hardware.indicators.RingIndicator.__init__", return_value=None) mocker.patch("hardware.indicators.RingIndicator.blink") mocker.patch("hardware.indicators.RingIndicator.close") - # Create and start the application - app = CallAttendant(config) - - yield app - - app.shutdown() - - -def test_run(app, mocker): - """ - Tests the logic used to process queued calls. Ensure permitted, blocked, and screened - calls are handled correctly (based on the default config). - """ def mock_is_whitelisted(caller): if caller["NAME"] in ["CALLER1", "CALLER3"]: @@ -122,84 +118,197 @@ def assert_log_caller_action(caller, action, reason): call_no += 1 # Generate a unique call # for return value return call_no - def mock_answer_call(actions, greeting, call_no, caller): + def mock_ignore_call(caller): + print("Ignoring call") + global ignore_call_called + ignore_call_called = True + + def mock_pick_up(): print("Answering call") + global answer_call_called + answer_call_called = True + return True - def mock_bypass_call(caller): - print("Bypassing call") + def mock_play_audio(audio_file): + print("Playing audio") + global play_audio_called + play_audio_called = True + return True + def mock_record_message(call_no, caller, detect_silence=True): + print("Recording audio") + global record_message_called + record_message_called = True + return True + + def mock_voice_messaging_menu(call_no, caller): + print("Entering voice mail system") + global voice_messaging_menu_called + voice_messaging_menu_called = True + return True + + # Create and start the application + app = CallAttendant(config) + + mocker.patch.object(app.modem, "pick_up", mock_pick_up) + mocker.patch.object(app, "ignore_call", mock_ignore_call) mocker.patch.object(app.screener, "is_whitelisted", mock_is_whitelisted) mocker.patch.object(app.screener, "is_blacklisted", mock_is_blacklisted) mocker.patch.object(app.logger, "log_caller", assert_log_caller_action) - mocker.patch.object(app, "answer_call", mock_answer_call) - mocker.patch.object(app, "bypass_call", mock_bypass_call) + mocker.patch.object(app.modem, "play_audio", mock_play_audio) + mocker.patch.object(app.voice_mail, "record_message", mock_record_message) + mocker.patch.object(app.voice_mail, "voice_messaging_menu", mock_voice_messaging_menu) + + yield app + + app.shutdown() + +def test_ignore_permitted(app): + """ + Tests that permitted calls are ignored (per config) + """ + thread = threading.Thread(target=app.run) + thread.start() + + global ignore_call_called + ignore_call_called = False + + app.handle_caller(caller1) # Queue a permitted caller with 4 rings + time.sleep(15) + + assert ignore_call_called + + # Stop the run thread + app._stop_event.set() + + +def test_answer_blocked(app): + """ + Tests that blocked calls are answered (per config) + """ + thread = threading.Thread(target=app.run) + thread.start() + + global answer_call_called + answer_call_called = False + + app.handle_caller(caller2) # Queue a blocked caller with zero rings + time.sleep(2) + + assert answer_call_called + + # Stop the run thread + app._stop_event.set() + + +def test_answer_screened(app): + """ + Tests that screened calls are answered (per config) + """ + thread = threading.Thread(target=app.run) + thread.start() + + global answer_call_called + answer_call_called = False + app.handle_caller(caller4) # Queue a screend caller with zero rings + time.sleep(2) + + assert answer_call_called + + # Stop the run thread + app._stop_event.set() + + +def test_queued_call(app): + """ + Test that a call is ignored because a second call is already in the queue + """ # Queue the test data - app.handle_caller(caller1) - app.handle_caller(caller2) - app.handle_caller(caller3) - app.handle_caller(caller4) + app.handle_caller(caller2) # an answerable call that should be ignored + app.handle_caller(caller3) # an answerable call that will be answered + + global ignore_call_called + global answer_call_called + ignore_call_called = False + answer_call_called = False # Process the queued test data - print("-> Calling app.run()") thread = threading.Thread(target=app.run) thread.start() # Wait here until the queue has been processed - while not app._caller_queue.empty(): - time.sleep(1) + time.sleep(15) - # Resubmitt caller1 with an empty queue to exercise the ring_count timeout logic. - app.handle_caller(caller1) - time.sleep(10) + assert ignore_call_called + assert answer_call_called # Stop the run thread app._stop_event.set() -def test_answer_call(app, mocker): +def test_answer_call_no_actions(app, mocker): """ Tests the call answering options used in the answer_call method. """ - def mock_play_audio(audio_file): - global play_audio_called - play_audio_called = True - return True + # Test no additional actions + global play_audio_called + global record_message_called + global voice_messaging_menu_called + play_audio_called = False + record_message_called = False + voice_messaging_menu_called = False - def mock_record_message(call_no, caller, detect_silence=True): - global record_message_called - record_message_called = True - return True + app.answer_call(("answer"), "greeting.wav", 1, caller1) - def mock_voice_messaging_menu(call_no, caller): - global voice_messaging_menu_called - voice_messaging_menu_called = True - return True + assert not play_audio_called + assert not record_message_called + assert not voice_messaging_menu_called - mocker.patch.object(app.modem, "play_audio", mock_play_audio) - mocker.patch.object(app.voice_mail, "record_message", mock_record_message) - mocker.patch.object(app.voice_mail, "voice_messaging_menu", mock_voice_messaging_menu) +def test_answer_call_greeting(app): - # Test no actions global play_audio_called global record_message_called global voice_messaging_menu_called - app.answer_call((), "greeting.wav", 1, caller1) - assert not play_audio_called + play_audio_called = False + record_message_called = False + voice_messaging_menu_called = False + + # Test greeting + app.answer_call(("answer", "greeting",), "greeting.wav", 2, caller2) + + assert play_audio_called assert not record_message_called assert not voice_messaging_menu_called - # Test greeting +def test_answer_call_record_message(app): + + global play_audio_called + global record_message_called + global voice_messaging_menu_called play_audio_called = False - app.answer_call(("greeting",), "greeting.wav", 2, caller2) - assert play_audio_called + record_message_called = False + voice_messaging_menu_called = False # Test recording a message - record_message_called = False - app.answer_call(("record_message"), None, 3, caller4) + app.answer_call(("answer", "record_message"), None, 3, caller4) + + assert not play_audio_called assert record_message_called + assert not voice_messaging_menu_called - # Test invoking the voice mail menu +def test_answer_call_voice_mail(app): + + global play_audio_called + global record_message_called + global voice_messaging_menu_called + play_audio_called = False + record_message_called = False voice_messaging_menu_called = False - app.answer_call(("voice_mail"), None, 4, caller4) + + # Test invoking the voice mail menu + app.answer_call(("answer", "voice_mail"), None, 4, caller4) + + assert not play_audio_called + assert not record_message_called assert voice_messaging_menu_called From 9348e5d41fc86eeb19f1cf01b7bd82b63c6ebe26 Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Thu, 19 Nov 2020 18:20:59 -0800 Subject: [PATCH 3/6] Updated with examples of 'answer' and 'ignore' --- callattendant/app.cfg.example | 91 +++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 37 deletions(-) diff --git a/callattendant/app.cfg.example b/callattendant/app.cfg.example index 8433ac6..14fe1b8 100644 --- a/callattendant/app.cfg.example +++ b/callattendant/app.cfg.example @@ -66,14 +66,28 @@ SCREENING_MODE = ("whitelist", "blacklist") # BLOCK_ENABLED: if True calls that fail screening will be blocked BLOCK_ENABLED = True -# BLOCK_NAME_PATTERNS: A regex expression dict applied to the CID names + +# BLOCK_NAME_PATTERNS: Block calls based on a RegEx expression dict applied +# to the CID names: {"regex": "description", ... } # Example: {"V[0-9]{15}": "Telemarketer Caller ID", "O": "Unknown caller"} BLOCK_NAME_PATTERNS = {"V[0-9]{15}": "Telemarketer Caller ID", } -# BLOCK_NUMBER_PATTERNS: A regx expression dict applied to the CID numbers +# BLOCK_NUMBER_PATTERNS: Block calls based on a dict of regular expressions +# applied to the CID numbers: {"regex": "description", ... } # Example: {"P": "Private number", "O": "Out of area",} BLOCK_NUMBER_PATTERNS = {} +# PERMIT_NAME_PATTERNS: Permit calls based on a RegEx expression dict +# applied to the CID names: {"regex": "description", ... } +# Example: {".*DOE": "Family", ".*SCHOOL": "Schools", } +PERMIT_NAME_PATTERNS = {} + +# PERMIT_NUMBER_PATTERNS: Permit calls based on a regx expression dict +# applied to the CID numbers: {"regex": "description", ... } +# Example: {"01628": "My area", } +PERMIT_NUMBER_PATTERNS = {} + + # BLOCK_SERVICE: The name of the online service used to lookup robocallers and spam numbers. # Currently, only NOMOROBO is supported and it is for the USA. Areas outside the USA should set # to blank. When the online service is blank (disabled), only the blacklist and blocked @@ -83,47 +97,56 @@ BLOCK_NUMBER_PATTERNS = {} BLOCK_SERVICE = "NOMOROBO" -# BLOCKED_ACTIONS: A tuple containing a combination of the following actions: -# "greeting", "record_message", "voice_mail". +# BLOCKED_ACTIONS: A tuple containing following actions: +# "ignore" -OR- a combination of the following: +# "answer", "greeting", "record_message", "voice_mail". # -# These actions are performed before hanging up. +# Note: "ignore" and "answer" are mutually exclusive; one or the other is required. +# Note: "record_message" and "voice_mail" actions are mutually exclusive. +# Note: A trailing comma is REQUIRED for a tuple with just one item. # -# Note: the "record_message", "voice_mail" actions are mutually exclusive. -# Also Note: A trailing comma is REQUIRED for a tuple with just one item +# Example: Take no action, just let the phone ring +# BLOCKED_ACTIONS = ("ignore",) +# NOTE: A tuple with one item requires a trailing comma; just like the example above # -# Example: No actions, just hang_up -# BLOCKED_ACTIONS = () +# Example: Just answer and hang_up +# BLOCKED_ACTIONS = ("answer",) +# NOTE: A tuple with one item requires a trailing comma; just like the example above # -# Example: Play an announcement before hanging up -# BLOCKED_ACTIONS = ("greeting", ) +# Example: Answer and play an announcement before hanging up +# BLOCKED_ACTIONS = ("answer", "greeting") # -# Example: Record a message before hanging up, no keypress required -# BLOCKED_ACTIONS = ("record_message", ) +# Example: Answer and record a message before hanging up; +# no keypress required +# BLOCKED_ACTIONS = ("answer", "record_message") # -# Example: Option to record a message; keypress required to leave message -# BLOCKED_ACTIONS = ("voice_mail", ) +# Example: Answer and go into the voice mail menu; +# a keypress is required to a leave message +# BLOCKED_ACTIONS = ("answer", "voice_mail") # -# Example: Play announcment and record a message; no keypress required -# BLOCKED_ACTIONS = ("greeting", "record_message" ) +# Example: Answer, play announcment and record a message; +# no keypress required +# BLOCKED_ACTIONS = ("answer", "greeting", "record_message") # -# Example: Play announcment and voice mail menu; keypress required to leave message -# BLOCKED_ACTIONS = ("greeting", "voice_mail" ) +# Example: Answer, play announcment and go into the voice mail menu; +# a keypress is required to leave message +# BLOCKED_ACTIONS = ("answer", "greeting", "voice_mail") # -BLOCKED_ACTIONS = ("greeting", "voice_mail") - -# BLOCKED_RINGS_BEFORE_ANSWER: The number of rings to wait before answering -# Example: 0 to act immediately, possibly before your local phone rings. -BLOCKED_RINGS_BEFORE_ANSWER = 0 +BLOCKED_ACTIONS = ("answer", "greeting", "voice_mail") # BLOCKED_GREETING_FILE: The wav file to be played to blocked callers. # Example: "Your number has been blocked." BLOCKED_GREETING_FILE = "resources/blocked_greeting.wav" +# BLOCKED_RINGS_BEFORE_ANSWER: The number of rings to wait before answering +# Example: 0 to act immediately, possibly before your local phone rings. +BLOCKED_RINGS_BEFORE_ANSWER = 0 # SCREENED_ACTIONS: A tuple containing a combination of the following actions: -# "greeting", "record_message", "voice_mail". See BLOCKED_ACTIONS for more info. -SCREENED_ACTIONS = ("greeting", "record_message") +# "ignore" OR a combo of "answer", "greeting", "record_message", "voice_mail". +# See BLOCKED_ACTIONS for more info. +SCREENED_ACTIONS = ("answer", "greeting", "record_message") # SCREENED_GREETING_FILE: The wav file to be played to allowed callers. # Example: "I'm sorry, I can't take your call." @@ -133,25 +156,19 @@ SCREENED_GREETING_FILE = "resources/general_greeting.wav" # Example: 0 to act immediately, possibly before your local phone rings. SCREENED_RINGS_BEFORE_ANSWER = 0 -# PERMIT_NAME_PATTERNS: A regex expression dict applied to the CID names -# Example: {".*DOE": "Family maybe", "O": "Unknown caller", } -PERMIT_NAME_PATTERNS = {} - -# PERMIT_NUMBER_PATTERNS: A regx expression dict applied to the CID numbers -# Example: {"01628": "My area", } -PERMIT_NUMBER_PATTERNS = {} # PERMITTED_ACTIONS: A tuple containing a combination of the following actions: -# "greeting", "record_message", "voice_mail". See BLOCKED_ACTIONS for more info. -PERMITTED_ACTIONS = ("greeting", "record_message") +# "ignore" OR a combo of "answer", "greeting", "record_message", "voice_mail". +# See BLOCKED_ACTIONS for more info. +PERMITTED_ACTIONS = ("ignore") # PERMITTED_GREETING_FILE: The wav file to be played to allowed callers. # Example: "I'm sorry, I can't take your call." PERMITTED_GREETING_FILE = "resources/general_greeting.wav" -# PERMITTED_RINGS_BEFORE_ANSWER: The number of rings to wait before answering +# PERMITTED_RINGS_BEFORE_ANSWER: The number of rings to wait before answering a call. # Example: 4 to allow the callee to pick up the phone before going to voice mail. -PERMITTED_RINGS_BEFORE_ANSWER = 4 +PERMITTED_RINGS_BEFORE_ANSWER = 0 # VOICE_MAIL_GREETING_FILE: The wav file played after answering: a general greeting From 30daf1c4bd2d1920f776d856d7c86748601bdbfa Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Thu, 19 Nov 2020 18:49:08 -0800 Subject: [PATCH 4/6] Added emphasis to error message --- callattendant/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/callattendant/app.py b/callattendant/app.py index 1a66afa..69cc458 100755 --- a/callattendant/app.py +++ b/callattendant/app.py @@ -452,7 +452,7 @@ def main(argv): # Ensure all specified files exist and that values are conformant if not config.validate(): - print("Configuration is invalid. Please check {}".format(config_file)) + print("ERROR: Configuration is invalid. Please check {}".format(config_file)) return 1 # Create and start the application From c5770a30866419514039ec19c6aa40ddd79b6588 Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Thu, 19 Nov 2020 18:49:58 -0800 Subject: [PATCH 5/6] Updated configuration defaults and validation warnings --- callattendant/config.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/callattendant/config.py b/callattendant/config.py index 08084d4..90463f9 100644 --- a/callattendant/config.py +++ b/callattendant/config.py @@ -40,17 +40,17 @@ "PERMIT_NAME_PATTERNS": {}, "PERMIT_NUMBER_PATTERNS": {}, - "BLOCKED_ACTIONS": ("answer", "greeting", "record_message"), + "BLOCKED_ACTIONS": ("answer", "greeting", "voice_mail"), "BLOCKED_GREETING_FILE": "resources/blocked_greeting.wav", "BLOCKED_RINGS_BEFORE_ANSWER": 0, - "SCREENED_ACTIONS": ("ignore",), + "SCREENED_ACTIONS": ("answer", "greeting", "record_message"), "SCREENED_GREETING_FILE": "resources/general_greeting.wav", "SCREENED_RINGS_BEFORE_ANSWER": 0, "PERMITTED_ACTIONS": ("ignore",), "PERMITTED_GREETING_FILE": "resources/general_greeting.wav", - "PERMITTED_RINGS_BEFORE_ANSWER": 4, + "PERMITTED_RINGS_BEFORE_ANSWER": 0, "VOICE_MAIL_GREETING_FILE": "resources/general_greeting.wav", "VOICE_MAIL_GOODBYE_FILE": "resources/goodbye.wav", @@ -237,6 +237,10 @@ def validate(self): print("* VOICE_MAIL_MESSAGE_FOLDER not found: {}".format(filepath)) success = False + # Warnings + if not self["PHONE_DISPLAY_SEPARATOR"] in self["PHONE_DISPLAY_FORMAT"]: + print("* WARNING: PHONE_DISPLAY_SEPARATOR not used in PHONE_DISPLAY_FORMAT: '{}'".format(self["PHONE_DISPLAY_SEPARATOR"])) + return success def _validate_actions(self, key): @@ -245,7 +249,7 @@ def _validate_actions(self, key): String: "BLOCKED_ACTIONS", "SCREENED_ACTIONS" or "PERMITTED_ACTIONS" """ if not isinstance(self[key], tuple): - print("* {} should be a tuple, not {}".format(key, type(self[key]))) + print("* {} must be a tuple, not {}".format(key, type(self[key]))) return False for action in self[key]: @@ -265,10 +269,7 @@ def _validate_actions(self, key): print("* {} cannot include both 'record_message' and 'voice_mail'".format(key)) return False - # WARNINGS follow...they print only, they do not fail validation. - if "record_message" in self[key]: - if not "greeting" in self[key]: - print("* WARNING: {} contains 'record_message' without a 'greeting'.".format(key)) + # WARNINGS: they print only; they do not fail validation. if "ignore" in self[key]: if any(a in self[key] for a in ("greeting", "record_message", "voice_mail")): print("* WARNING: {} contains actions in addition to 'ignore'. They not be used.".format(key)) From a8b075b428fe8907f1af03e9c0b79a5bf0bc3f6d Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Fri, 20 Nov 2020 07:14:33 -0800 Subject: [PATCH 6/6] Fixed lint warnings --- callattendant/app.py | 1 + callattendant/hardware/modem.py | 2 +- tests/test_app.py | 10 +++++++--- tests/test_modem.py | 10 +++++++--- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/callattendant/app.py b/callattendant/app.py index 69cc458..0470cd6 100755 --- a/callattendant/app.py +++ b/callattendant/app.py @@ -310,6 +310,7 @@ def wait_for_rings(self, rings_before_answer): break return ok_to_answer + def make_config(filename=None, datapath=None, create_folder=False): """ Creates the config dictionary for this application/module. diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index ecb285c..0c855e3 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -592,7 +592,7 @@ def ring(self): """ Activate the ring indicator """ - # Notify other threads that a ring occurred + # Notify other threads that a ring occurred self.ring_event.set() self.ring_event.clear() # Visual notification (LED) diff --git a/tests/test_app.py b/tests/test_app.py index d466dd7..b394485 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -29,7 +29,6 @@ import pytest import threading import time -from datetime import datetime from callattendant.app import CallAttendant from callattendant.config import Config @@ -46,6 +45,7 @@ caller3 = {"NAME": "CALLER3", "NMBR": "3333333333", "DATE": "0303", "TIME": "0303"} caller4 = {"NAME": "CALLER4", "NMBR": "4444444444", "DATE": "0404", "TIME": "0404"} + @pytest.fixture() def app(mocker): @@ -53,9 +53,9 @@ def app(mocker): config = Config() config['DEBUG'] = False config['TESTING'] = True - config["BLOCKED_ACTIONS"] = ("answer","greeting","voice_mail") + config["BLOCKED_ACTIONS"] = ("answer", "greeting", "voice_mail") config["BLOCKED_RINGS_BEFORE_ANSWER"] = 0 - config["SCREENED_ACTIONS"] = ("answer","greeting","record_message") + config["SCREENED_ACTIONS"] = ("answer", "greeting", "record_message") config["SCREENED_RINGS_BEFORE_ANSWER"] = 0 config["PERMITTED_ACTIONS"] = ("ignore",) config["PERMITTED_RINGS_BEFORE_ANSWER"] = 4 @@ -163,6 +163,7 @@ def mock_voice_messaging_menu(call_no, caller): app.shutdown() + def test_ignore_permitted(app): """ Tests that permitted calls are ignored (per config) @@ -265,6 +266,7 @@ def test_answer_call_no_actions(app, mocker): assert not record_message_called assert not voice_messaging_menu_called + def test_answer_call_greeting(app): global play_audio_called @@ -281,6 +283,7 @@ def test_answer_call_greeting(app): assert not record_message_called assert not voice_messaging_menu_called + def test_answer_call_record_message(app): global play_audio_called @@ -297,6 +300,7 @@ def test_answer_call_record_message(app): assert record_message_called assert not voice_messaging_menu_called + def test_answer_call_voice_mail(app): global play_audio_called diff --git a/tests/test_modem.py b/tests/test_modem.py index b9e858e..0ad3cb9 100644 --- a/tests/test_modem.py +++ b/tests/test_modem.py @@ -63,9 +63,11 @@ def modem(): modem.stop() + def test_modem_online(modem): assert modem.is_open + def test_profile_reset(modem): assert modem._send(RESET) @@ -126,10 +128,12 @@ def test_recording_audio(modem): filename = os.path.join(modem.config["DATA_PATH"], "message.wav") assert modem.record_audio(filename, detect_silence=False) + def test_recording_audio_detect_silence(modem): filename = os.path.join(modem.config["DATA_PATH"], "message.wav") assert not modem.record_audio(filename) + def test_call_handler(modem, mocker): # Incoming caller id test data: valid numbers are sequential 10 digit repeating nums, # plus some spurious call data intermixed between caller ids @@ -138,7 +142,7 @@ def test_call_handler(modem, mocker): b"RING", b"DATE=0202", b"TIME=0202", b"NMBR=2222222222", # Test2 - no name b"RING", b"NMBR=3333333333", # Test3 - number only b"RING", b"DATE=0404", b"TIME=0404", b"NMBR=4444444444", b"NAME=Test4", - b"RING", b"RING",b"NAME=TestNoNumber", b"RING", b"RING", # Partial data w/o number + b"RING", b"RING", b"NAME=TestNoNumber", b"RING", b"RING", # Partial data w/o number b"RING", b"DATE=0505", b"TIME=0505", b"NMBR=5555555555", b"NAME=Test5", ] data_queue = queue.Queue() @@ -182,8 +186,8 @@ def handle_call(call_record): assert all(k in call for k in ("DATE", "TIME", "NAME", "NMBR")) # Assert the number and name matches the inputs or defaults - assert call["NMBR"] == str(n)*10 - if n in [1,4,5]: + assert call["NMBR"] == str(n) * 10 + if n in [1, 4, 5]: assert call["NAME"] == "Test{}".format(n) else: assert call["NAME"] == "Unknown"