From 06bf09d48fc83e6c6b438617c36e11ddc80b56f1 Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Mon, 31 Aug 2020 19:29:37 -0700 Subject: [PATCH 1/9] Work in progress on #10 and #94 - Unable to get the Zoom modem to exit the AT+VTX playback mode. --- callattendant/hardware/modem.py | 268 ++++++++++++++++++++++---------- tests/test_modem.py | 34 ++-- 2 files changed, 202 insertions(+), 100 deletions(-) diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index 2455f6a..6b06e65 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -50,24 +50,31 @@ CR_CODE = chr(13) # Carraige return LF_CODE = chr(10) # Line feed +# Supported modem product codes returned by ATI0 +USR_5637_PRODUCT_CODE = b'5601' +ZOOM_3905_PRODUCT_CODE = b'56000' + # Modem AT commands: # See http://support.usr.com/support/5637/5637-ug/ref_data.html - RESET = "ATZ" -FACTORY_RESET = "ATZ3" -DISPLAY_MODEM_SETTINGS = "ATI4" -ENABLE_ECHO_COMMANDS = "ATE1" +GET_MODEM_PRODUCT_CODE = "ATI0" +GET_MODEM_SETTINGS = "ATI4" # USR only. Zoom modem returns empty string DISABLE_ECHO_COMMANDS = "ATE0" +ENABLE_ECHO_COMMANDS = "ATE1" ENABLE_FORMATTED_CID = "AT+VCID=1" ENABLE_VERBOSE_CODES = "ATV1" DISABLE_SILENCE_DETECTION = "AT+VSD=128,0" ENABLE_SILENCE_DETECTION_5_SECS = "AT+VSD=128,50" ENABLE_SILENCE_DETECTION_10_SECS = "AT+VSD=128,100" ENTER_VOICE_MODE = "AT+FCLASS=8" -ENTER_TELEPHONE_ANSWERING_DEVICE_OFF_HOOK = "AT+VLS=1" # DCE off-hook, connected to telco. -ENTER_VOICE_TRANSMIT_DATA_STATE = "AT+VTX" ENTER_VOICE_RECIEVE_DATA_STATE = "AT+VRX" -SET_VOICE_COMPRESSION_8BIT_SAMPLING_8K = "AT+VSM=128,8000" # 128 = 8-bit linear, 8.0 kHz +ENTER_VOICE_TRANSMIT_DATA_STATE = "AT+VTX" +ENTER_TAD_OFF_HOOK = "AT+VLS=1" # Telephone Answering Device (TAD) off-hook, connected to telco +GET_VOICE_COMPRESSION_SETTING = "AT+VSM?" +GET_VOICE_COMPRESSION_OPTIONS = "AT+VSM=?" +SET_VOICE_COMPRESSION = "" # Set by modem detection function +SET_VOICE_COMPRESSION_USR = "AT+VSM=128,8000" # USR 5637: 128 = 8-bit linear, 8.0 kHz +SET_VOICE_COMPRESSION_ZOOM = "AT+VSM=1,8000,0,0" # Zoom 3095: 1 = 8-bit unsigned pcm, 8.0 kHz GO_OFF_HOOK = "ATH1" GO_ON_HOOK = "ATH0" TERMINATE_CALL = "ATH" @@ -82,13 +89,15 @@ DCE_PHONE_OFF_HOOK = (chr(16) + chr(72)).encode() # -H DCE_RING = (chr(16) + chr(82)).encode() # -R DCE_SILENCE_DETECTED = (chr(16) + chr(115)).encode() # -s +DCE_TX_BUFFER_UNDERRUN = (chr(16) + chr(117)).encode() # -u DCE_END_VOICE_DATA_TX = (chr(16) + chr(3)).encode() # -# System DLE shielded codes - DTE to DCE commands -DTE_RAISE_VOLUME = (chr(16) + chr(117)) # -u -DTE_LOWER_VOLUME = (chr(16) + chr(100)) # -d -DTE_END_VOICE_DATA_TX = (chr(16) + chr(3)) # -DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(33)) # -! +# System DLE shielded codes (single DLE) - DTE to DCE commands +DTE_RAISE_VOLUME = (chr(16) + chr(117)) # -u +DTE_LOWER_VOLUME = (chr(16) + chr(100)) # -d +DTE_END_VOICE_DATA_TX = (chr(16) + chr(3)) # +DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(24)) # +DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(33)) # -! # Return codes CRLF = (chr(13) + chr(10)).encode() @@ -107,17 +116,20 @@ class Modem(object): """ This class is responsible for serial communications between the - Raspberry Pi and a US Robotics 5637 modem. + Raspberry Pi and a voice/data/fax modem. """ def __init__(self, config, handle_caller): """ Constructs a modem object for serial communications. - :param config: application configuration dict - :param handle_caller: callback function that takes a caller dict + :param config: + application configuration dict + :param handle_caller: + callback function that takes a caller dict """ self.config = config self.handle_caller = handle_caller + self.model = None # Thread synchronization object self._lock = threading.RLock() @@ -135,6 +147,7 @@ def handle_calls(self): """ Starts the thread that processes incoming data. """ + # TODO Pass in call handler here instead of ctor self._init_modem() self.event_thread = threading.Thread(target=self._call_handler) self.event_thread.name = "modem_call_handler" @@ -243,8 +256,8 @@ def pick_up(self): if not self._send(DISABLE_SILENCE_DETECTION): raise RuntimeError("Failed to disable silence detection.") - if not self._send(ENTER_TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): - raise RuntimeError("Unable put modem into TAD mode.") + if not self._send(ENTER_TAD_OFF_HOOK): + raise RuntimeError("Unable put modem into telephone answering device mode.") # Flush any existing input outout data from the buffers # self._serial.flushInput() @@ -296,8 +309,8 @@ def hang_up(self): def play_audio(self, audio_file_name): """ Play the given audio file. - :param audio_file_name: a wav file with 8-bit linear - compression recored at 8.0 kHz sampling rate + :param audio_file_name: + a wav file with 8-bit linear compression recored at 8.0 kHz sampling rate """ if self.config["DEBUG"]: print("> Playing {}...".format(audio_file_name)) @@ -309,39 +322,47 @@ def play_audio(self, audio_file_name): if not self._send(ENTER_VOICE_MODE): print("* Error: Failed to put modem into voice mode.") return False - if not self._send(SET_VOICE_COMPRESSION_8BIT_SAMPLING_8K): + if not self._send(SET_VOICE_COMPRESSION): print("* Error: Failed to set compression method and sampling rate specifications.") return False - if not self._send(ENTER_TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): - print("* Error: Unable put modem into TAD mode.") + if not self._send(ENTER_TAD_OFF_HOOK): + print("* Error: Unable put modem into telephone answering device mode.") return False if not self._send(ENTER_VOICE_TRANSMIT_DATA_STATE, "CONNECT"): - print("* Error: Unable put modem into TAD data transmit state.") + print("* Error: Unable put modem into voice data transmit state.") return False # Play Audio File - with wave.open(audio_file_name, 'rb') as wf: + with wave.open(audio_file_name, 'rb') as wavefile: sleep_interval = .12 # 120ms; You may need to change to smooth-out audio chunk = 1024 - data = wf.readframes(chunk) + data = wavefile.readframes(chunk) while data != b'': self._serial.write(data) - data = wf.readframes(chunk) - time.sleep(sleep_interval) + data = wavefile.readframes(chunk) + # ~ time.sleep(sleep_interval) + self._send("+++") + print("DTE_CLEAR_TRASMIT_BUFFER") + # ~ self._send(DTE_CLEAR_TRASMIT_BUFFER) + self._send((chr(16) + chr(16) + chr(24)), "OK", 15) - # self._serial.flushInput() - # self._serial.flushOutput() + # ~ self._serial.flushOutput() + # ~ self._serial.flushInput() + + print("DTE_END_VOICE_DATA_TX") + self._send((chr(16) + chr(16) + chr(3)), "OK", 15) + self._send(RESET) + # ~ self._send(DTE_END_VOICE_DATA_TX, "OK", 15) - self._send(DTE_END_VOICE_DATA_TX) return True def record_audio(self, audio_file_name): """ Records audio from the model to the given audio file. - :param audio_file_name: the wav file to be created with the - recorded audio; recored with 8-bit linear compression - at 8.0 kHz sampling rate + :param audio_file_name: + the wav file to be created with the recorded audio; + recorded with 8-bit linear compression at 8.0 kHz sampling rate """ if self.config["DEBUG"]: print("> Recording {}...".format(audio_file_name)) @@ -357,14 +378,15 @@ def record_audio(self, audio_file_name): if not self._send("AT+VGT=128"): raise RuntimeError("Failed to set speaker volume to normal.") - if not self._send(SET_VOICE_COMPRESSION_8BIT_SAMPLING_8K): + + if not self._send(SET_VOICE_COMPRESSION): raise RuntimeError("Failed to set compression method and sampling rate specifications.") if not self._send(DISABLE_SILENCE_DETECTION): raise RuntimeError("Failed to disable silence detection.") - if not self._send(ENTER_TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): - raise RuntimeError("Unable put modem into TAD mode.") + if not self._send(ENTER_TAD_OFF_HOOK): + raise RuntimeError("Unable put modem into telephone answering device mode.") # Play 1.2 beep if not self._send("AT+VTS=[933,900,120]"): @@ -433,9 +455,14 @@ def record_audio(self, audio_file_name): self._serial.reset_input_buffer() # Send End of Recieve Data state by passing "!" - # Note: the command returns , but the DLE is stripped - # from the response during the test, so we only test for the ETX. - if not self._send(DTE_END_RECIEVE_DATA_STATE, ETX_CODE): + response = "" + if self.model == "ZOOM": + response = "OK" + elif self.model == "USR": + # Note: the command returns , but the DLE is stripped + # from the response during the test, so we only test for the ETX. + response = ETX_CODE + if not self._send(DTE_END_RECIEVE_DATA_STATE, response): print("* Error: Unable to signal end of data receive state") return True @@ -443,8 +470,10 @@ def record_audio(self, audio_file_name): def wait_for_keypress(self, wait_time_secs=15): """ Waits n seconds for a key-press. - :params wait_time_secs: the number of seconds to wait for a keypress - :return: success (bool), key-press value (str) + :params wait_time_secs: + the number of seconds to wait for a keypress + :return: + success (bool), key-press value (str) """ print("> Waiting for key-press...") @@ -460,8 +489,8 @@ def wait_for_keypress(self, wait_time_secs=15): if not self._send(ENABLE_SILENCE_DETECTION_10_SECS): raise RuntimeError("Failed to enable silence detection.") - if not self._send(ENTER_TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): - raise RuntimeError("Unable put modem into TAD mode.") + if not self._send(ENTER_TAD_OFF_HOOK): + raise RuntimeError("Unable put modem into Telephone Answering Device mode.") # Wait for keypress start_time = datetime.now() @@ -506,11 +535,30 @@ def wait_for_keypress(self, wait_time_secs=15): def _send(self, command, expected_response="OK", response_timeout=5): """ Sends a command string (e.g., AT command) to the modem. - :param command: the command string to send - :param expected response: the expected response to the command, e.g. "OK" - :param response_timeout: number of seconds to wait for the command to respond + :param command: + the command string to send + :param expected_response: + the expected response to the command, e.g. "OK" + :param response_timeout: + number of seconds to wait for the command to respond + :return: + True: if the command response matches the expected_response; + """ + success, result = self._send_and_read(command, expected_response, response_timeout) + return success - :return: True if the command response matches the expected_response + def _send_and_read(self, command, expected_response="OK", response_timeout=5): + """ + Sends a command string (e.g., AT command) to the modem and reads the result + :param command: + the command string to send + :param expected_response: + the expected response to the command, e.g. "OK" + :param response_timeout: + number of seconds to wait for the command to respond + :return: + True: if the command response matches the expected_response; + plus the result preceeding the command response, if any. """ with self._lock: try: @@ -519,51 +567,58 @@ def _send(self, command, expected_response="OK", response_timeout=5): self._serial.write((command + '\r').encode()) self._serial.flush() - - if expected_response is None: - return True - else: - execution_status = self._read_response(expected_response, response_timeout) - return execution_status + # Get the execution status plus any preceeding result(s) from the modem + success, result = self._read_response(expected_response, response_timeout) + return (success, result) except Exception as e: print(e) print("Error: Failed to execute the command: {}".format(command)) - return False + return False, None def _read_response(self, expected_response, response_timeout_secs): """ Handles the command response code from the modem. - - Called by _send(); operates within the _send method's lock context. - :param expected response: the expected response, e.g. "OK" - :param response_timeout_secs: number of seconds to wait for - the command to respond - - :return: True if the response matches the expected_response; - False if ERROR is returned or if it times out + Called by _send() and operates within the _send method's lock context. + :param expected response: + the expected response, e.g. "OK" + :param response_timeout_secs: + number of seconds to wait for the command to respond + :return: (boolean, result) + True if the response matches the expected_response or False + if ERROR is returned or if it times out; followed by any preceeding + value(s) returned by the modem. """ start_time = datetime.now() try: + result = b'' + self._serial.flushInput() while 1: modem_data = self._serial.readline() + result += modem_data if self.config["DEBUG"]: pprint(modem_data) response = decode(modem_data) # strips DLE_CODE - if expected_response == response: - return True + + if expected_response == None: + return (True, None) + + elif expected_response == response: + return (True, result) + elif "ERROR" in response: if self.config["DEBUG"]: print(">>> _read_response returned ERROR") - return False + return (False, result) + elif (datetime.now() - start_time).seconds > response_timeout_secs: if self.config["DEBUG"]: print(">>> _read_response timed out") - return False + return (False, result) except Exception as e: print("Error in read_response function...") print(e) - return False + return (False, None) def _init_modem(self): """Auto-detects and initializes the modem.""" @@ -611,6 +666,59 @@ def _init_modem(self): print("Error: unable to Initialize the Modem") sys.exit() + def _init_serial_port(self, com_port): + """Initializes the given COM port for communications with the modem.""" + self._serial.port = com_port + self._serial.baudrate = 57600 # 9600 + self._serial.bytesize = serial.EIGHTBITS # number of bits per bytes + self._serial.parity = serial.PARITY_NONE # set parity check: no parity + self._serial.stopbits = serial.STOPBITS_ONE # number of stop bits + self._serial.timeout = 3 # non-block read + self._serial.writeTimeout = 3 # timeout for write + self._serial.xonxoff = False # disable software flow control + self._serial.rtscts = False # disable hardware (RTS/CTS) flow control + self._serial.dsrdtr = False # disable hardware (DSR/DTR) flow control + + def _detect_modem(self): + + global SET_VOICE_COMPRESSION, DTE_RAISE_VOLUME, DTE_LOWER_VOLUME, \ + DTE_END_VOICE_DATA_TX, DTE_END_RECIEVE_DATA_STATE, DTE_CLEAR_TRASMIT_BUFFER + + # Attempt to identify the modem + success, result = self._send_and_read(GET_MODEM_PRODUCT_CODE) + if success: + if ZOOM_3905_PRODUCT_CODE in result: + print("******* Zoom Model 3905 Detected **********") + self.model = "ZOOM" + # Define the compression settings + SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_ZOOM + # System DLE shielded codes (double DLE) - DTE to DCE commands + DTE_RAISE_VOLUME = (chr(16) + chr(16) + chr(117)) # -u + DTE_LOWER_VOLUME = (chr(16) + chr(16) + chr(100)) # -d + DTE_END_VOICE_DATA_TX = (chr(16) + chr(16) + chr(3)) # + DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(16) + chr(33)) # -! + DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(16) + chr(24)) # + elif USR_5637_PRODUCT_CODE in result: + print("******* US Robotics Model 5637 detected **********") + self.model = "USR" + # Define the compression settings + SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_USR + # System DLE shielded codes (single DLE) - DTE to DCE commands + DTE_RAISE_VOLUME = (chr(16) + chr(117)) # -u + DTE_LOWER_VOLUME = (chr(16) + chr(100)) # -d + DTE_END_VOICE_DATA_TX = (chr(16) + chr(3)) # + DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(33)) # -! + else: + print("******* Unknown modem detected **********") + # We'll try to use it with the defined AT commands if it supports VOICE mode + # Validate modem selection by trying to put it in Voice Mode + if self._send(ENTER_VOICE_MODE): + self.model = "UNKNOWN" + else: + print("Error: Failed to put modem into voice mode.") + success = False + return success + def open_serial_port(self): """Detects and opens the serial port attached to the modem.""" # List all the Serial COM Ports on Raspberry Pi @@ -620,6 +728,7 @@ def open_serial_port(self): com_ports_list = com_ports.split(b'\n') # Find the right port associated with the Voice Modem + found = False for com_port in com_ports_list: if b'tty' in com_port: # Try to open the COM Port and execute AT Command @@ -632,32 +741,19 @@ def open_serial_port(self): print("Unable to open COM Port: " + str(com_port.decode("utf-8"))) pass else: - # Validate modem selection by trying to put it in Voice Mode - if not self._send(ENTER_VOICE_MODE): - print("Error: Failed to put modem into voice mode.") + # Detect the modem model + if not self._detect_modem(): + print("Error: Failed to detect a compatible modem.") if self._serial.isOpen(): self._serial.close() else: - # Found the COM Port exit the loop + # Found a compatible modem on the COM Port - exit the loop print("Modem COM Port is: " + com_port.decode("utf-8")) self._serial.flushInput() self._serial.flushOutput() return True return False - def _init_serial_port(self, com_port): - """Initializes the given COM port for communications with the modem.""" - self._serial.port = com_port - self._serial.baudrate = 57600 # 9600 - self._serial.bytesize = serial.EIGHTBITS # number of bits per bytes - self._serial.parity = serial.PARITY_NONE # set parity check: no parity - self._serial.stopbits = serial.STOPBITS_ONE # number of stop bits - self._serial.timeout = 3 # non-block read - self._serial.writeTimeout = 3 # timeout for write - self._serial.xonxoff = False # disable software flow control - self._serial.rtscts = False # disable hardware (RTS/CTS) flow control - self._serial.dsrdtr = False # disable hardware (DSR/DTR) flow control - def close_serial_port(self): """Closes the serial port attached to the modem.""" print("Closing Serial Port") @@ -670,7 +766,7 @@ def close_serial_port(self): print("Error: Unable to close the Serial Port.") sys.exit() - def decode(bytestr): - string = bytestr.decode("utf-8").strip(' \t\n\r' + DLE_CODE) + # Remove non-printable chars before decoding. + string = re.sub(b'[^\x00-\x7f]', b'', bytestr).decode("utf-8").strip(' \t\n\r' + DLE_CODE) return string diff --git a/tests/test_modem.py b/tests/test_modem.py index 6f296e4..237fae6 100644 --- a/tests/test_modem.py +++ b/tests/test_modem.py @@ -23,6 +23,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. + +global SET_VOICE_COMPRESSION + import os import sys import tempfile @@ -32,10 +35,11 @@ import pytest from callattendant.config import Config -from callattendant.hardware.modem import Modem, FACTORY_RESET, RESET, DISPLAY_MODEM_SETTINGS, \ - ENTER_VOICE_MODE, SET_VOICE_COMPRESSION_8BIT_SAMPLING_8K, ENTER_TELEPHONE_ANSWERING_DEVICE_OFF_HOOK, \ +from callattendant.hardware.modem import Modem, RESET, GET_MODEM_PRODUCT_CODE, \ + GET_MODEM_SETTINGS, ENTER_VOICE_MODE, ENTER_TAD_OFF_HOOK, SET_VOICE_COMPRESSION, \ ENTER_VOICE_TRANSMIT_DATA_STATE, DTE_END_VOICE_DATA_TX, ENTER_VOICE_RECIEVE_DATA_STATE, \ - DTE_END_RECIEVE_DATA_STATE, TERMINATE_CALL, ETX_CODE + DTE_END_RECIEVE_DATA_STATE, TERMINATE_CALL, ETX_CODE, DLE_CODE, \ + SET_VOICE_COMPRESSION_USR, SET_VOICE_COMPRESSION_ZOOM # Skip the test when running under continous integraion pytestmark = pytest.mark.skipif(os.getenv("CI")=="true", reason="Hardware not installed") @@ -63,16 +67,12 @@ def modem(): modem.ring_indicator.close() -def test_factory_reset(modem): - assert modem._send(FACTORY_RESET) - - def test_profile_reset(modem): assert modem._send(RESET) def test_display_modem_settings(modem): - assert modem._send(DISPLAY_MODEM_SETTINGS) + assert modem._send(GET_MODEM_SETTINGS) def test_put_modem_into_voice_mode(modem): @@ -80,11 +80,13 @@ def test_put_modem_into_voice_mode(modem): def test_set_compression_method_and_sampling_rate_specifications(modem): - assert modem._send(SET_VOICE_COMPRESSION_8BIT_SAMPLING_8K) - + if modem.model == "ZOOM": + assert modem._send(SET_VOICE_COMPRESSION_ZOOM) + elif modem.model == "USR": + assert modem._send(SET_VOICE_COMPRESSION_USR) def test_put_modem_into_TAD_mode(modem): - assert modem._send(ENTER_TELEPHONE_ANSWERING_DEVICE_OFF_HOOK) + assert modem._send(ENTER_TAD_OFF_HOOK) def test_put_modem_into_voice_transmit_data_state(modem): @@ -99,9 +101,13 @@ def test_put_modem_into_voice_recieve_data_state(modem): assert modem._send(ENTER_VOICE_RECIEVE_DATA_STATE, "CONNECT") -def test_cancel_data_transmit_state(modem): - assert modem._send(DTE_END_RECIEVE_DATA_STATE, ETX_CODE) - +def test_cancel_data_receive_state(modem): + response = "" + if modem.model == "ZOOM": + response = "OK" + elif modem.model == "USR": + response = ETX_CODE + assert modem._send(DTE_END_RECIEVE_DATA_STATE, response) def test_terminate_call(modem): assert modem._send(TERMINATE_CALL) From 61e97673ae31394c2c196657f2aa9293a0b765cb Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Tue, 1 Sep 2020 07:29:13 -0700 Subject: [PATCH 2/9] Using triple sequence to cancel VTX and VRX modes. - Discovered via interactive experimentation with modem: = ctrl-p, = ctrl-c, = ctrl-x. - Re: #94 --- callattendant/hardware/modem.py | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index 6b06e65..3bb56b0 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -70,6 +70,7 @@ ENTER_VOICE_RECIEVE_DATA_STATE = "AT+VRX" ENTER_VOICE_TRANSMIT_DATA_STATE = "AT+VTX" ENTER_TAD_OFF_HOOK = "AT+VLS=1" # Telephone Answering Device (TAD) off-hook, connected to telco +SEND_VOICE_TONE_BEEP = "AT+VTS=[933,900,120]" # 1.2 second beep GET_VOICE_COMPRESSION_SETTING = "AT+VSM?" GET_VOICE_COMPRESSION_OPTIONS = "AT+VSM=?" SET_VOICE_COMPRESSION = "" # Set by modem detection function @@ -341,19 +342,8 @@ def play_audio(self, audio_file_name): self._serial.write(data) data = wavefile.readframes(chunk) # ~ time.sleep(sleep_interval) - self._send("+++") - print("DTE_CLEAR_TRASMIT_BUFFER") - # ~ self._send(DTE_CLEAR_TRASMIT_BUFFER) - self._send((chr(16) + chr(16) + chr(24)), "OK", 15) - - # ~ self._serial.flushOutput() - # ~ self._serial.flushInput() - - print("DTE_END_VOICE_DATA_TX") - self._send((chr(16) + chr(16) + chr(3)), "OK", 15) - self._send(RESET) - # ~ self._send(DTE_END_VOICE_DATA_TX, "OK", 15) + self._send(DTE_END_VOICE_DATA_TX) return True @@ -378,7 +368,6 @@ def record_audio(self, audio_file_name): if not self._send("AT+VGT=128"): raise RuntimeError("Failed to set speaker volume to normal.") - if not self._send(SET_VOICE_COMPRESSION): raise RuntimeError("Failed to set compression method and sampling rate specifications.") @@ -389,7 +378,7 @@ def record_audio(self, audio_file_name): raise RuntimeError("Unable put modem into telephone answering device mode.") # Play 1.2 beep - if not self._send("AT+VTS=[933,900,120]"): + if not self._send(SEND_VOICE_TONE_BEEP): raise RuntimeError("Failed to play 1.2 second beep.") if not self._send(ENABLE_SILENCE_DETECTION_5_SECS): @@ -681,8 +670,9 @@ def _init_serial_port(self, com_port): def _detect_modem(self): - global SET_VOICE_COMPRESSION, DTE_RAISE_VOLUME, DTE_LOWER_VOLUME, \ - DTE_END_VOICE_DATA_TX, DTE_END_RECIEVE_DATA_STATE, DTE_CLEAR_TRASMIT_BUFFER + global SET_VOICE_COMPRESSION, ENABLE_SILENCE_DETECTION_5_SECS, \ + DTE_RAISE_VOLUME, DTE_LOWER_VOLUME, DTE_END_VOICE_DATA_TX, \ + DTE_END_RECIEVE_DATA_STATE, DTE_CLEAR_TRASMIT_BUFFER # Attempt to identify the modem success, result = self._send_and_read(GET_MODEM_PRODUCT_CODE) @@ -692,12 +682,13 @@ def _detect_modem(self): self.model = "ZOOM" # Define the compression settings SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_ZOOM + # ~ ENABLE_SILENCE_DETECTION_5_SECS = "AT+VSD=0,50" # System DLE shielded codes (double DLE) - DTE to DCE commands - DTE_RAISE_VOLUME = (chr(16) + chr(16) + chr(117)) # -u - DTE_LOWER_VOLUME = (chr(16) + chr(16) + chr(100)) # -d - DTE_END_VOICE_DATA_TX = (chr(16) + chr(16) + chr(3)) # - DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(16) + chr(33)) # -! - DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(16) + chr(24)) # + DTE_RAISE_VOLUME = (chr(16) + chr(16) + chr(117)) # -u + DTE_LOWER_VOLUME = (chr(16) + chr(16) + chr(100)) # -d + DTE_END_VOICE_DATA_TX = (chr(16) + chr(16) + chr(16) + chr(3)) # + DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(16) + chr(16) + chr(33)) # -! + DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(16) + chr(24)) # elif USR_5637_PRODUCT_CODE in result: print("******* US Robotics Model 5637 detected **********") self.model = "USR" From 14f7f06c2dc616567a4b6bb308e1db2ff6278fae Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Tue, 1 Sep 2020 09:25:33 -0700 Subject: [PATCH 3/9] Minor edits to reorganize modem code - re: #94 Zoom 3095 modem --- callattendant/hardware/modem.py | 205 +++++++++++++++----------------- tests/test_modem.py | 24 ++-- 2 files changed, 111 insertions(+), 118 deletions(-) diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index 3bb56b0..d315db2 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -93,12 +93,12 @@ DCE_TX_BUFFER_UNDERRUN = (chr(16) + chr(117)).encode() # -u DCE_END_VOICE_DATA_TX = (chr(16) + chr(3)).encode() # -# System DLE shielded codes (single DLE) - DTE to DCE commands +# System DLE shielded codes (single DLE) - DTE to DCE commands (used by USR 5637 modem) DTE_RAISE_VOLUME = (chr(16) + chr(117)) # -u DTE_LOWER_VOLUME = (chr(16) + chr(100)) # -d +DTE_END_VOICE_DATA_RX = (chr(16) + chr(33)) # -! DTE_END_VOICE_DATA_TX = (chr(16) + chr(3)) # DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(24)) # -DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(33)) # -! # Return codes CRLF = (chr(13) + chr(10)).encode() @@ -144,6 +144,53 @@ def __init__(self, config, handle_caller): # Setup and open the serial port self._serial = serial.Serial() + + def open_serial_port(self): + """Detects and opens the serial port attached to the modem.""" + # List all the Serial COM Ports on Raspberry Pi + proc = subprocess.Popen(['ls /dev/tty[A-Za-z]*'], shell=True, stdout=subprocess.PIPE) + com_ports = proc.communicate()[0] + # In order to split, need to pass a bytes-like object + com_ports_list = com_ports.split(b'\n') + + # Find the right port associated with the Voice Modem + found = False + for com_port in com_ports_list: + if b'tty' in com_port: + # Try to open the COM Port and execute AT Command + try: + # Initialize the serial port and attempt to open + self._init_serial_port(com_port.decode("utf-8")) + self._serial.open() + except Exception as e: + print(e) + print("Unable to open COM Port: " + str(com_port.decode("utf-8"))) + pass + else: + # Detect the modem model + if not self._detect_modem(): + print("Error: Failed to detect a compatible modem.") + if self._serial.isOpen(): + self._serial.close() + else: + # Found a compatible modem on the COM Port - exit the loop + print("Modem COM Port is: " + com_port.decode("utf-8")) + self._serial.flushInput() + self._serial.flushOutput() + return True + return False + + def close_serial_port(self): + """Closes the serial port attached to the modem.""" + print("Closing Serial Port") + try: + if self._serial.isOpen(): + self._serial.close() + print("Serial Port closed...") + except Exception as e: + print(e) + print("Error: Unable to close the Serial Port.") + sys.exit() def handle_calls(self): """ Starts the thread that processes incoming data. @@ -444,14 +491,10 @@ def record_audio(self, audio_file_name): self._serial.reset_input_buffer() # Send End of Recieve Data state by passing "!" - response = "" - if self.model == "ZOOM": - response = "OK" - elif self.model == "USR": - # Note: the command returns , but the DLE is stripped - # from the response during the test, so we only test for the ETX. - response = ETX_CODE - if not self._send(DTE_END_RECIEVE_DATA_STATE, response): + # USR-5637 note: The command returns , but the DLE is stripped + # from the response during the test, so we only test for the ETX. + response = lambda model : "OK" if model == "ZOOM" else ETX_CODE + if not self._send(DTE_END_VOICE_DATA_RX, response(self.model)): print("* Error: Unable to signal end of data receive state") return True @@ -609,52 +652,6 @@ def _read_response(self, expected_response, response_timeout_secs): print(e) return (False, None) - def _init_modem(self): - """Auto-detects and initializes the modem.""" - # Detect and open the Modem Serial COM Port - try: - self.open_serial_port() - except Exception as e: - print(e) - print("Error: Unable to open the Serial Port.") - sys.exit() - - # Initialize the Modem - try: - # Flush any existing input outout data from the buffers - self._serial.flushInput() - self._serial.flushOutput() - - # Test Modem connection, using basic AT command. - if not self._send("AT"): - print("Error: Unable to access the Modem") - if not self._send(FACTORY_RESET): - print("Error: Unable reset to factory default") - if not self._send(ENABLE_VERBOSE_CODES): - print("Error: Unable set response in verbose form") - if not self._send(DISABLE_ECHO_COMMANDS): - print("Error: Failed to disable local echo mode") - if not self._send(ENABLE_FORMATTED_CID): - print("Error: Failed to enable formatted caller report.") - - # Save these settings to a profile - if not self._send("AT&W0"): - print("Error: Failed to store profile.") - - self._send(DISPLAY_MODEM_SETTINGS) - - # Flush any existing input outout data from the buffers - self._serial.flushInput() - self._serial.flushOutput() - - # Automatically close the serial port at program termination - atexit.register(self.close_serial_port) - - except Exception as e: - print(e) - print("Error: unable to Initialize the Modem") - sys.exit() - def _init_serial_port(self, com_port): """Initializes the given COM port for communications with the modem.""" self._serial.port = com_port @@ -672,7 +669,7 @@ def _detect_modem(self): global SET_VOICE_COMPRESSION, ENABLE_SILENCE_DETECTION_5_SECS, \ DTE_RAISE_VOLUME, DTE_LOWER_VOLUME, DTE_END_VOICE_DATA_TX, \ - DTE_END_RECIEVE_DATA_STATE, DTE_CLEAR_TRASMIT_BUFFER + DTE_END_VOICE_DATA_RX, DTE_CLEAR_TRASMIT_BUFFER # Attempt to identify the modem success, result = self._send_and_read(GET_MODEM_PRODUCT_CODE) @@ -682,81 +679,77 @@ def _detect_modem(self): self.model = "ZOOM" # Define the compression settings SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_ZOOM - # ~ ENABLE_SILENCE_DETECTION_5_SECS = "AT+VSD=0,50" # System DLE shielded codes (double DLE) - DTE to DCE commands DTE_RAISE_VOLUME = (chr(16) + chr(16) + chr(117)) # -u DTE_LOWER_VOLUME = (chr(16) + chr(16) + chr(100)) # -d + DTE_END_VOICE_DATA_RX = (chr(16) + chr(16) + chr(16) + chr(33)) # -! DTE_END_VOICE_DATA_TX = (chr(16) + chr(16) + chr(16) + chr(3)) # - DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(16) + chr(16) + chr(33)) # -! - DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(16) + chr(24)) # + DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(16) + chr(16) + chr(24)) # elif USR_5637_PRODUCT_CODE in result: print("******* US Robotics Model 5637 detected **********") self.model = "USR" # Define the compression settings SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_USR - # System DLE shielded codes (single DLE) - DTE to DCE commands - DTE_RAISE_VOLUME = (chr(16) + chr(117)) # -u - DTE_LOWER_VOLUME = (chr(16) + chr(100)) # -d - DTE_END_VOICE_DATA_TX = (chr(16) + chr(3)) # - DTE_END_RECIEVE_DATA_STATE = (chr(16) + chr(33)) # -! else: print("******* Unknown modem detected **********") # We'll try to use it with the defined AT commands if it supports VOICE mode # Validate modem selection by trying to put it in Voice Mode if self._send(ENTER_VOICE_MODE): self.model = "UNKNOWN" + # Use the default settings (used by the USR 5637 modem) + SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_USR else: print("Error: Failed to put modem into voice mode.") success = False return success - def open_serial_port(self): - """Detects and opens the serial port attached to the modem.""" - # List all the Serial COM Ports on Raspberry Pi - proc = subprocess.Popen(['ls /dev/tty[A-Za-z]*'], shell=True, stdout=subprocess.PIPE) - com_ports = proc.communicate()[0] - # In order to split, need to pass a bytes-like object - com_ports_list = com_ports.split(b'\n') - - # Find the right port associated with the Voice Modem - found = False - for com_port in com_ports_list: - if b'tty' in com_port: - # Try to open the COM Port and execute AT Command - try: - # Initialize the serial port and attempt to open - self._init_serial_port(com_port.decode("utf-8")) - self._serial.open() - except Exception as e: - print(e) - print("Unable to open COM Port: " + str(com_port.decode("utf-8"))) - pass - else: - # Detect the modem model - if not self._detect_modem(): - print("Error: Failed to detect a compatible modem.") - if self._serial.isOpen(): - self._serial.close() - else: - # Found a compatible modem on the COM Port - exit the loop - print("Modem COM Port is: " + com_port.decode("utf-8")) - self._serial.flushInput() - self._serial.flushOutput() - return True - return False + def _init_modem(self): + """Auto-detects and initializes the modem.""" + # Detect and open the Modem Serial COM Port + try: + self.open_serial_port() + except Exception as e: + print(e) + print("Error: Unable to open the Serial Port.") + sys.exit() - def close_serial_port(self): - """Closes the serial port attached to the modem.""" - print("Closing Serial Port") + # Initialize the Modem try: - if self._serial.isOpen(): - self._serial.close() - print("Serial Port closed...") + # Flush any existing input outout data from the buffers + self._serial.flushInput() + self._serial.flushOutput() + + # Test Modem connection, using basic AT command. + if not self._send("AT"): + print("Error: Unable to access the Modem") + if not self._send(RESET): + print("Error: Unable reset to factory default") + if not self._send(ENABLE_VERBOSE_CODES): + print("Error: Unable set response in verbose form") + if not self._send(DISABLE_ECHO_COMMANDS): + print("Error: Failed to disable local echo mode") + if not self._send(ENABLE_FORMATTED_CID): + print("Error: Failed to enable formatted caller report.") + + # Save these settings to a profile + if not self._send("AT&W0"): + print("Error: Failed to store profile.") + + self._send(GET_MODEM_SETTINGS) + + # Flush any existing input outout data from the buffers + self._serial.flushInput() + self._serial.flushOutput() + + # Automatically close the serial port at program termination + atexit.register(self.close_serial_port) + except Exception as e: print(e) - print("Error: Unable to close the Serial Port.") + print("Error: unable to Initialize the Modem") sys.exit() + def decode(bytestr): # Remove non-printable chars before decoding. string = re.sub(b'[^\x00-\x7f]', b'', bytestr).decode("utf-8").strip(' \t\n\r' + DLE_CODE) diff --git a/tests/test_modem.py b/tests/test_modem.py index 237fae6..2cd674b 100644 --- a/tests/test_modem.py +++ b/tests/test_modem.py @@ -35,11 +35,13 @@ import pytest from callattendant.config import Config -from callattendant.hardware.modem import Modem, RESET, GET_MODEM_PRODUCT_CODE, \ - GET_MODEM_SETTINGS, ENTER_VOICE_MODE, ENTER_TAD_OFF_HOOK, SET_VOICE_COMPRESSION, \ - ENTER_VOICE_TRANSMIT_DATA_STATE, DTE_END_VOICE_DATA_TX, ENTER_VOICE_RECIEVE_DATA_STATE, \ - DTE_END_RECIEVE_DATA_STATE, TERMINATE_CALL, ETX_CODE, DLE_CODE, \ - SET_VOICE_COMPRESSION_USR, SET_VOICE_COMPRESSION_ZOOM +from callattendant.hardware.modem import Modem, RESET, \ + GET_MODEM_PRODUCT_CODE, GET_MODEM_SETTINGS, \ + ENTER_VOICE_MODE, ENTER_TAD_OFF_HOOK, \ + ENTER_VOICE_TRANSMIT_DATA_STATE, DTE_END_VOICE_DATA_TX, \ + ENTER_VOICE_RECIEVE_DATA_STATE, DTE_END_VOICE_DATA_RX, \ + TERMINATE_CALL, ETX_CODE, DLE_CODE, \ + SET_VOICE_COMPRESSION, SET_VOICE_COMPRESSION_USR, SET_VOICE_COMPRESSION_ZOOM # Skip the test when running under continous integraion pytestmark = pytest.mark.skipif(os.getenv("CI")=="true", reason="Hardware not installed") @@ -71,7 +73,7 @@ def test_profile_reset(modem): assert modem._send(RESET) -def test_display_modem_settings(modem): +def test_get_modem_settings(modem): assert modem._send(GET_MODEM_SETTINGS) @@ -85,6 +87,7 @@ def test_set_compression_method_and_sampling_rate_specifications(modem): elif modem.model == "USR": assert modem._send(SET_VOICE_COMPRESSION_USR) + def test_put_modem_into_TAD_mode(modem): assert modem._send(ENTER_TAD_OFF_HOOK) @@ -102,12 +105,9 @@ def test_put_modem_into_voice_recieve_data_state(modem): def test_cancel_data_receive_state(modem): - response = "" - if modem.model == "ZOOM": - response = "OK" - elif modem.model == "USR": - response = ETX_CODE - assert modem._send(DTE_END_RECIEVE_DATA_STATE, response) + response = lambda model : "OK" if model == "ZOOM" else ETX_CODE + assert modem._send(DTE_END_VOICE_DATA_RX, response(modem.model)) + def test_terminate_call(modem): assert modem._send(TERMINATE_CALL) From c91df54fc40d285fec852508d1898094803c9c2d Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Tue, 1 Sep 2020 10:08:43 -0700 Subject: [PATCH 4/9] Refactored modem initialization and startup. - Removed modem handle_caller property; now it's an argument passed to the thread function. - Simplified construction of modem object in unit tests - Fixes #10 --- callattendant/app.py | 10 +++++----- callattendant/hardware/modem.py | 23 ++++++++++++++--------- tests/test_modem.py | 7 +------ tests/test_voicemail.py | 6 +----- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/callattendant/app.py b/callattendant/app.py index 2df7d5e..034bece 100755 --- a/callattendant/app.py +++ b/callattendant/app.py @@ -72,8 +72,8 @@ def __init__(self, config): self.screener = CallScreener(self.db, self.config) # Hardware subsystem - # Create (and starts) the modem with callback functions - self.modem = Modem(self.config, self.handle_caller) + # Create (and open) the modem + self.modem = Modem(self.config) # Messaging subsystem self.voice_mail = VoiceMail(self.db, self.config, self.modem) @@ -98,7 +98,7 @@ def handle_caller(self, caller): pprint(caller) self._caller_queue.put(caller) - def run(self): + def process_calls(self): """ Processes incoming callers by logging, screening, blocking and/or recording messages. @@ -113,7 +113,7 @@ def run(self): permitted_greeting_file = permitted['greeting_file'] # Instruct the modem to start feeding calls into the caller queue - self.modem.handle_calls() + self.modem.handle_calls(self.handle_caller) # Process incoming calls while 1: @@ -385,7 +385,7 @@ def main(argv): # Create and start the application app = CallAttendant(config) - app.run() + app.process_calls() return 0 diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index d315db2..c34e11f 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -120,16 +120,13 @@ class Modem(object): Raspberry Pi and a voice/data/fax modem. """ - def __init__(self, config, handle_caller): + def __init__(self, config): """ Constructs a modem object for serial communications. :param config: application configuration dict - :param handle_caller: - callback function that takes a caller dict """ self.config = config - self.handle_caller = handle_caller self.model = None # Thread synchronization object @@ -191,19 +188,27 @@ def close_serial_port(self): print(e) print("Error: Unable to close the Serial Port.") sys.exit() - def handle_calls(self): + + + def handle_calls(self, handle_caller): """ Starts the thread that processes incoming data. + :param handle_caller: + A callback function that takes a caller dict object. """ - # TODO Pass in call handler here instead of ctor self._init_modem() - self.event_thread = threading.Thread(target=self._call_handler) + + self.event_thread = threading.Thread( + target=self._call_handler, + kwargs={'handle_caller': handle_caller}) self.event_thread.name = "modem_call_handler" self.event_thread.start() - def _call_handler(self): + def _call_handler(self, handle_caller): """ Thread function that processes the incoming modem data. + :param handle_caller: + A callback function that takes a caller dict object. """ # Common constants RING = "RING".encode("utf-8") @@ -276,7 +281,7 @@ def _call_handler(self): if all(k in call_record for k in ("DATE", "TIME", "NAME", "NMBR")): # Queue caller for screening print("> Queueing call {} for processing".format(call_record["NMBR"])) - self.handle_caller(call_record) + handle_caller(call_record) call_record = {} finally: diff --git a/tests/test_modem.py b/tests/test_modem.py index 2cd674b..37a60a8 100644 --- a/tests/test_modem.py +++ b/tests/test_modem.py @@ -47,11 +47,6 @@ pytestmark = pytest.mark.skipif(os.getenv("CI")=="true", reason="Hardware not installed") -# Dummy callback function -def dummy_handle_caller(caller): - pass - - @pytest.fixture(scope='module') def modem(): @@ -61,7 +56,7 @@ def modem(): config['TESTING'] = True config['VOICE_MAIL_MESSAGE_FOLDER'] = gettempdir() - modem = Modem(config, dummy_handle_caller) + modem = Modem(config) modem.open_serial_port() yield modem diff --git a/tests/test_voicemail.py b/tests/test_voicemail.py index 1374780..e58ac67 100644 --- a/tests/test_voicemail.py +++ b/tests/test_voicemail.py @@ -36,10 +36,6 @@ from callattendant.screening.calllogger import CallLogger from callattendant.messaging.voicemail import VoiceMail -# Dummy call back function for modem -def dummy_handle_caller(caller): - pass - # Test data caller = {"NAME": "Bruce", "NMBR": "1234567890", "DATE": "1012", "TIME": "0600"} @@ -71,7 +67,7 @@ def logger(db, config): @pytest.fixture(scope='module') def modem(db, config): - modem = Modem(config, dummy_handle_caller) + modem = Modem(config) modem.open_serial_port() yield modem modem.ring_indicator.close() From 90475a699581bb442142524743cb87e84ad9f2ea Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Tue, 1 Sep 2020 10:37:48 -0700 Subject: [PATCH 5/9] Fixed parsing of caller data for Zoom modem CID formatting - Re: #94 --- callattendant/hardware/modem.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index c34e11f..9dc580a 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -108,9 +108,10 @@ TEST_DATA = [ b"RING", b"DATE=0801", b"TIME=1801", b"NMBR=8055554567", b"NAME=Test1 - Permitted", b"RING", b"RING", b"RING", b"RING", - b"RING", b"DATE=0801", b"TIME=1800", b"NMBR=5551234567", b"NAME=Test2 - Spammer", - b"RING", b"DATE=0801", b"TIME=1802", b"NMBR=3605554567", b"NAME=Test3 - Blocked", - b"RING", b"DATE=0801", b"TIME=1802", b"NMBR=8005554567", b"NAME=V123456789012345", + b"RING", b"DATE=0802", b"TIME=1802", b"NMBR=5551234567", b"NAME=Test2 - Spammer", + b"RING", b"DATE=0803", b"TIME=1803", b"NMBR=3605554567", b"NAME=Test3 - Blocked", + b"RING", b"DATE=0804", b"TIME=1804", b"NMBR=8005554567", b"NAME=V123456789012345", + b"RING", b"DATE = 0805", b"TIME = 1805", b"NMBR = 8055554567", b"NAME = Test5 - Permitted", ] @@ -267,15 +268,20 @@ def _call_handler(self, handle_caller): self.ring_event.clear() # Visual notification (LED) self.ring_indicator.ring() + # Extract caller info if DATE in modem_data: - call_record['DATE'] = decode(modem_data[5:]) - if TIME in modem_data: - call_record['TIME'] = decode(modem_data[5:]) - if NAME in modem_data: - call_record['NAME'] = decode(modem_data[5:]) - if NMBR in modem_data: - call_record['NMBR'] = decode(modem_data[5:]) + items = decode(modem_data).split('=') + call_record['DATE'] = items[1].strip() + elif TIME in modem_data: + items = decode(modem_data).split('=') + call_record['TIME'] = items[1].strip() + elif NAME in modem_data: + items = decode(modem_data).split('=') + call_record['NAME'] = items[1].strip() + elif NMBR in modem_data: + items = decode(modem_data).split('=') + call_record['NMBR'] = items[1].strip() # https://stackoverflow.com/questions/1285911/how-do-i-check-that-multiple-keys-are-in-a-dict-in-a-single-pass if all(k in call_record for k in ("DATE", "TIME", "NAME", "NMBR")): From 4bcd8ec63f0db9daeff84840db783683c97967dd Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Sat, 5 Sep 2020 05:34:20 -0700 Subject: [PATCH 6/9] Work in progress --- callattendant/hardware/modem.py | 47 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index 9dc580a..86fb5bd 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -3,7 +3,7 @@ # # file: modem.py # -# Copyright 2018 Bruce Schubert +# Copyright 2018-2020 Bruce Schubert # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -86,8 +86,11 @@ DCE_FAX_CALLING_TONE = (chr(16) + chr(99)).encode() # -c DCE_DIAL_TONE = (chr(16) + chr(100)).encode() # -d DCE_DATA_CALLING_TONE = (chr(16) + chr(101)).encode() # -e +DCE_LINE_REVERSAL = (chr(16) + chr(108)).encode() # -l DCE_PHONE_ON_HOOK = (chr(16) + chr(104)).encode() # -h DCE_PHONE_OFF_HOOK = (chr(16) + chr(72)).encode() # -H +DCE_PHONE_OFF_HOOK2 = (chr(16) + chr(80)).encode() # -P Zoom +DCE_QUIET_DETECTED = (chr(16) + chr(113)).encode() # -q Zoom DCE_RING = (chr(16) + chr(82)).encode() # -R DCE_SILENCE_DETECTED = (chr(16) + chr(115)).encode() # -s DCE_TX_BUFFER_UNDERRUN = (chr(16) + chr(117)).encode() # -u @@ -97,8 +100,9 @@ DTE_RAISE_VOLUME = (chr(16) + chr(117)) # -u DTE_LOWER_VOLUME = (chr(16) + chr(100)) # -d DTE_END_VOICE_DATA_RX = (chr(16) + chr(33)) # -! +DTE_END_VOICE_DATA_RX2 = (chr(16) + chr(94)) # -^ Zoom DTE_END_VOICE_DATA_TX = (chr(16) + chr(3)) # -DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(24)) # +DTE_CLEAR_TRANSMIT_BUFFER = (chr(16) + chr(24)) # # Return codes CRLF = (chr(13) + chr(10)).encode() @@ -458,29 +462,34 @@ def record_audio(self, audio_file_name): audio_data = self._serial.read(CHUNK) + # Check if is in the stream + if (DCE_END_VOICE_DATA_TX in audio_data): + print(">> Char Recieved... Stop recording.") + break + # Check if s is in the stream + if (DCE_SILENCE_DETECTED in audio_data): + print(">> Silence Detected... Stop recording.") + break + # Check if q is in the stream + if (DCE_QUIET_DETECTED in audio_data): + print(">> Silence Detected... Stop recording.") + break + # Check if H is in the stream if (DCE_PHONE_OFF_HOOK in audio_data): print(">> Local phone off hook... Stop recording") break - - if (DCE_RING in audio_data): - print(">> Ring detected... Stop recording; new call coming in") + # Check if P is in the stream + if (DCE_PHONE_OFF_HOOK2 in audio_data): + print(">> Local extension off hook... Stop recording") + break + # Check if l is in the stream + if (DCE_LINE_REVERSAL in audio_data): + print(">> Local phone off hook... Stop recording") break - # Check if b is in the stream if (DCE_BUSY_TONE in audio_data): print(">> Busy Tone... Stop recording.") break - - # Check if s is in the stream - if (DCE_SILENCE_DETECTED in audio_data): - print(">> Silence Detected... Stop recording.") - break - - # Check if is in the stream - if (DCE_END_VOICE_DATA_TX in audio_data): - print(">> Char Recieved... Stop recording.") - break - # Timeout if ((datetime.now() - start_time).seconds) > REC_VM_MAX_DURATION: print(">> Stop recording: max time limit reached.") @@ -680,7 +689,7 @@ def _detect_modem(self): global SET_VOICE_COMPRESSION, ENABLE_SILENCE_DETECTION_5_SECS, \ DTE_RAISE_VOLUME, DTE_LOWER_VOLUME, DTE_END_VOICE_DATA_TX, \ - DTE_END_VOICE_DATA_RX, DTE_CLEAR_TRASMIT_BUFFER + DTE_END_VOICE_DATA_RX, DTE_CLEAR_TRANSMIT_BUFFER # Attempt to identify the modem success, result = self._send_and_read(GET_MODEM_PRODUCT_CODE) @@ -695,7 +704,7 @@ def _detect_modem(self): DTE_LOWER_VOLUME = (chr(16) + chr(16) + chr(100)) # -d DTE_END_VOICE_DATA_RX = (chr(16) + chr(16) + chr(16) + chr(33)) # -! DTE_END_VOICE_DATA_TX = (chr(16) + chr(16) + chr(16) + chr(3)) # - DTE_CLEAR_TRASMIT_BUFFER = (chr(16) + chr(16) + chr(16) + chr(24)) # + DTE_CLEAR_TRANSMIT_BUFFER = (chr(16) + chr(16) + chr(16) + chr(24)) # elif USR_5637_PRODUCT_CODE in result: print("******* US Robotics Model 5637 detected **********") self.model = "USR" From 6d5f47b915165b9326509324be3c63a36e74e986 Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Sat, 5 Sep 2020 06:36:39 -0700 Subject: [PATCH 7/9] Work in progress --- callattendant/hardware/modem.py | 25 ++++++++++--------------- tests/test_modem.py | 13 ++++++------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index 0cbd45b..da12ec7 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -69,13 +69,14 @@ ENTER_VOICE_MODE = "AT+FCLASS=8" ENTER_VOICE_RECIEVE_DATA_STATE = "AT+VRX" ENTER_VOICE_TRANSMIT_DATA_STATE = "AT+VTX" -ENTER_TAD_OFF_HOOK = "AT+VLS=1" # Telephone Answering Device (TAD) off-hook, connected to telco SEND_VOICE_TONE_BEEP = "AT+VTS=[933,900,120]" # 1.2 second beep GET_VOICE_COMPRESSION_SETTING = "AT+VSM?" GET_VOICE_COMPRESSION_OPTIONS = "AT+VSM=?" SET_VOICE_COMPRESSION = "" # Set by modem detection function SET_VOICE_COMPRESSION_USR = "AT+VSM=128,8000" # USR 5637: 128 = 8-bit linear, 8.0 kHz SET_VOICE_COMPRESSION_ZOOM = "AT+VSM=1,8000,0,0" # Zoom 3095: 1 = 8-bit unsigned pcm, 8.0 kHz +TELEPHONE_ANSWERING_DEVICE_OFF_HOOK = "AT+VLS=1" # TAD (DCE) off-hook, connected to telco +TELEPHONE_ANSWERING_DEVICE_ON_HOOK = "AT+VLS=1" # TAD (DCE) on-hook GO_OFF_HOOK = "ATH1" GO_ON_HOOK = "ATH0" TERMINATE_CALL = "ATH" @@ -317,7 +318,7 @@ def pick_up(self): if not self._send(DISABLE_SILENCE_DETECTION): raise RuntimeError("Failed to disable silence detection.") - if not self._send(ENTER_TAD_OFF_HOOK): + if not self._send(TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): raise RuntimeError("Unable put modem into telephone answering device mode.") # Flush any existing input outout data from the buffers @@ -386,7 +387,7 @@ def play_audio(self, audio_file_name): if not self._send(SET_VOICE_COMPRESSION): print("* Error: Failed to set compression method and sampling rate specifications.") return False - if not self._send(ENTER_TAD_OFF_HOOK): + if not self._send(TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): print("* Error: Unable put modem into telephone answering device mode.") return False if not self._send(ENTER_VOICE_TRANSMIT_DATA_STATE, "CONNECT"): @@ -425,25 +426,18 @@ def record_audio(self, audio_file_name): if not self._send(ENTER_VOICE_MODE): raise RuntimeError("Failed to put modem into voice mode.") - if not self._send("AT+VGT=128"): - raise RuntimeError("Failed to set speaker volume to normal.") - if not self._send(SET_VOICE_COMPRESSION): raise RuntimeError("Failed to set compression method and sampling rate specifications.") - if not self._send(DISABLE_SILENCE_DETECTION): - raise RuntimeError("Failed to disable silence detection.") + if not self._send(ENABLE_SILENCE_DETECTION_5_SECS): + raise RuntimeError("Failed to enable silence detection.") - if not self._send(ENTER_TAD_OFF_HOOK): - raise RuntimeError("Unable put modem into telephone answering device mode.") + if not self._send(TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): + raise RuntimeError("Unable put modem (TAD) off hook.") - # Play 1.2 beep if not self._send(SEND_VOICE_TONE_BEEP): raise RuntimeError("Failed to play 1.2 second beep.") - if not self._send(ENABLE_SILENCE_DETECTION_5_SECS): - raise RuntimeError("Failed to enable silence detection.") - if not self._send(ENTER_VOICE_RECIEVE_DATA_STATE, "CONNECT"): raise RuntimeError("Error: Unable put modem into voice receive mode.") @@ -502,6 +496,7 @@ def record_audio(self, audio_file_name): wf.setsampwidth(1) wf.setframerate(8000) wf.writeframes(b''.join(audio_frames)) + print(">> Recording stopped after {} seconds".format((datetime.now() - start_time).seconds)) # Clear input buffer before sending commands else its @@ -539,7 +534,7 @@ def wait_for_keypress(self, wait_time_secs=15): if not self._send(ENABLE_SILENCE_DETECTION_10_SECS): raise RuntimeError("Failed to enable silence detection.") - if not self._send(ENTER_TAD_OFF_HOOK): + if not self._send(TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): raise RuntimeError("Unable put modem into Telephone Answering Device mode.") # Wait for keypress diff --git a/tests/test_modem.py b/tests/test_modem.py index 37a60a8..0ace78f 100644 --- a/tests/test_modem.py +++ b/tests/test_modem.py @@ -37,7 +37,7 @@ from callattendant.config import Config from callattendant.hardware.modem import Modem, RESET, \ GET_MODEM_PRODUCT_CODE, GET_MODEM_SETTINGS, \ - ENTER_VOICE_MODE, ENTER_TAD_OFF_HOOK, \ + ENTER_VOICE_MODE, TELEPHONE_ANSWERING_DEVICE_OFF_HOOK, \ ENTER_VOICE_TRANSMIT_DATA_STATE, DTE_END_VOICE_DATA_TX, \ ENTER_VOICE_RECIEVE_DATA_STATE, DTE_END_VOICE_DATA_RX, \ TERMINATE_CALL, ETX_CODE, DLE_CODE, \ @@ -77,14 +77,13 @@ def test_put_modem_into_voice_mode(modem): def test_set_compression_method_and_sampling_rate_specifications(modem): - if modem.model == "ZOOM": - assert modem._send(SET_VOICE_COMPRESSION_ZOOM) - elif modem.model == "USR": - assert modem._send(SET_VOICE_COMPRESSION_USR) + assert modem._send( + SET_VOICE_COMPRESSION_ZOOM if modem.model == "ZOOM" else SET_VOICE_COMPRESSION_USR + ) def test_put_modem_into_TAD_mode(modem): - assert modem._send(ENTER_TAD_OFF_HOOK) + assert modem._send(TELEPHONE_ANSWERING_DEVICE_OFF_HOOK) def test_put_modem_into_voice_transmit_data_state(modem): @@ -100,7 +99,7 @@ def test_put_modem_into_voice_recieve_data_state(modem): def test_cancel_data_receive_state(modem): - response = lambda model : "OK" if model == "ZOOM" else ETX_CODE + response = lambda model: "OK" if model == "ZOOM" else ETX_CODE assert modem._send(DTE_END_VOICE_DATA_RX, response(modem.model)) From 019370dbe85c694b508d270ad29c1bc4747356a1 Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Sat, 5 Sep 2020 14:37:55 -0700 Subject: [PATCH 8/9] Work in progress --- callattendant/hardware/modem.py | 55 +++++++++++++++++++-------------- tests/test_modem.py | 4 +-- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index da12ec7..6e27df8 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -57,26 +57,29 @@ # Modem AT commands: # See http://support.usr.com/support/5637/5637-ug/ref_data.html RESET = "ATZ" +RESET_PROFILE = "ATZ0" GET_MODEM_PRODUCT_CODE = "ATI0" -GET_MODEM_SETTINGS = "ATI4" # USR only. Zoom modem returns empty string +GET_MODEM_SETTINGS = "AT&V" DISABLE_ECHO_COMMANDS = "ATE0" ENABLE_ECHO_COMMANDS = "ATE1" ENABLE_FORMATTED_CID = "AT+VCID=1" ENABLE_VERBOSE_CODES = "ATV1" DISABLE_SILENCE_DETECTION = "AT+VSD=128,0" +DISABLE_SILENCE_DETECTION_ZOOM = "AT+VSD=0,0" ENABLE_SILENCE_DETECTION_5_SECS = "AT+VSD=128,50" +ENABLE_SILENCE_DETECTION_5_SECS_ZOOM = "AT+VSD=0,50" ENABLE_SILENCE_DETECTION_10_SECS = "AT+VSD=128,100" +ENABLE_SILENCE_DETECTION_10_SECS_ZOOM = "AT+VSD=0,100" ENTER_VOICE_MODE = "AT+FCLASS=8" ENTER_VOICE_RECIEVE_DATA_STATE = "AT+VRX" ENTER_VOICE_TRANSMIT_DATA_STATE = "AT+VTX" SEND_VOICE_TONE_BEEP = "AT+VTS=[933,900,120]" # 1.2 second beep GET_VOICE_COMPRESSION_SETTING = "AT+VSM?" GET_VOICE_COMPRESSION_OPTIONS = "AT+VSM=?" -SET_VOICE_COMPRESSION = "" # Set by modem detection function -SET_VOICE_COMPRESSION_USR = "AT+VSM=128,8000" # USR 5637: 128 = 8-bit linear, 8.0 kHz +SET_VOICE_COMPRESSION = "AT+VSM=128,8000" # USR 5637: 128 = 8-bit linear, 8.0 kHz SET_VOICE_COMPRESSION_ZOOM = "AT+VSM=1,8000,0,0" # Zoom 3095: 1 = 8-bit unsigned pcm, 8.0 kHz TELEPHONE_ANSWERING_DEVICE_OFF_HOOK = "AT+VLS=1" # TAD (DCE) off-hook, connected to telco -TELEPHONE_ANSWERING_DEVICE_ON_HOOK = "AT+VLS=1" # TAD (DCE) on-hook +TELEPHONE_ANSWERING_DEVICE_ON_HOOK = "AT+VLS=0" # TAD (DCE) on-hook GO_OFF_HOOK = "ATH1" GO_ON_HOOK = "ATH0" TERMINATE_CALL = "ATH" @@ -429,8 +432,8 @@ def record_audio(self, audio_file_name): if not self._send(SET_VOICE_COMPRESSION): raise RuntimeError("Failed to set compression method and sampling rate specifications.") - if not self._send(ENABLE_SILENCE_DETECTION_5_SECS): - raise RuntimeError("Failed to enable silence detection.") + if not self._send(DISABLE_SILENCE_DETECTION): + raise RuntimeError("Failed to disable silence detection.") if not self._send(TELEPHONE_ANSWERING_DEVICE_OFF_HOOK): raise RuntimeError("Unable put modem (TAD) off hook.") @@ -438,6 +441,9 @@ def record_audio(self, audio_file_name): if not self._send(SEND_VOICE_TONE_BEEP): raise RuntimeError("Failed to play 1.2 second beep.") + if not self._send(ENABLE_SILENCE_DETECTION_5_SECS): + raise RuntimeError("Failed to enable silence detection.") + if not self._send(ENTER_VOICE_RECIEVE_DATA_STATE, "CONNECT"): raise RuntimeError("Error: Unable put modem into voice receive mode.") @@ -470,14 +476,14 @@ def record_audio(self, audio_file_name): if (DCE_PHONE_OFF_HOOK in audio_data): print(">> Local phone off hook... Stop recording") break - # Check if P is in the stream - if (DCE_PHONE_OFF_HOOK2 in audio_data): - print(">> Local extension off hook... Stop recording") - break - # Check if l is in the stream - if (DCE_LINE_REVERSAL in audio_data): - print(">> Local phone off hook... Stop recording") - break + # ~ # Check if P is in the stream + # ~ if (DCE_PHONE_OFF_HOOK2 in audio_data): + # ~ print(">> Local extension off hook... Stop recording") + # ~ break + # ~ # Check if l is in the stream + # ~ if (DCE_LINE_REVERSAL in audio_data): + # ~ print(">> Local phone off hook... Stop recording") + # ~ break # Check if b is in the stream if (DCE_BUSY_TONE in audio_data): print(">> Busy Tone... Stop recording.") @@ -618,7 +624,7 @@ def _send_and_read(self, command, expected_response="OK", response_timeout=5): except Exception as e: print(e) print("Error: Failed to execute the command: {}".format(command)) - return False, None + return False, None def _read_response(self, expected_response, response_timeout_secs): """ @@ -663,7 +669,7 @@ def _read_response(self, expected_response, response_timeout_secs): except Exception as e: print("Error in read_response function...") print(e) - return (False, None) + return (False, None) def _init_serial_port(self, com_port): """Initializes the given COM port for communications with the modem.""" @@ -680,9 +686,10 @@ def _init_serial_port(self, com_port): def _detect_modem(self): - global SET_VOICE_COMPRESSION, ENABLE_SILENCE_DETECTION_5_SECS, \ - DTE_RAISE_VOLUME, DTE_LOWER_VOLUME, DTE_END_VOICE_DATA_TX, \ - DTE_END_VOICE_DATA_RX, DTE_CLEAR_TRANSMIT_BUFFER + global SET_VOICE_COMPRESSION, DISABLE_SILENCE_DETECTION, \ + ENABLE_SILENCE_DETECTION_5_SECS, ENABLE_SILENCE_DETECTION_10_SECS, \ + DTE_RAISE_VOLUME, DTE_LOWER_VOLUME, DTE_END_VOICE_DATA_TX, \ + DTE_END_VOICE_DATA_RX, DTE_CLEAR_TRANSMIT_BUFFER # Attempt to identify the modem success, result = self._send_and_read(GET_MODEM_PRODUCT_CODE) @@ -691,14 +698,15 @@ def _detect_modem(self): if USR_5637_PRODUCT_CODE in result: print("******* US Robotics Model 5637 detected **********") self.model = "USR" - # Define the compression settings - SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_USR elif ZOOM_3905_PRODUCT_CODE in result: print("******* Zoom Model 3905 Detected **********") self.model = "ZOOM" - # Define the compression settings + # Define the settings for the Zoom3905 where they differ from the USR5637 SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_ZOOM + DISABLE_SILENCE_DETECTION = DISABLE_SILENCE_DETECTION_ZOOM + ENABLE_SILENCE_DETECTION_5_SECS = ENABLE_SILENCE_DETECTION_5_SECS_ZOOM + ENABLE_SILENCE_DETECTION_10_SECS = ENABLE_SILENCE_DETECTION_10_SECS_ZOOM # System DLE shielded codes (double DLE) - DTE to DCE commands DTE_RAISE_VOLUME = (chr(16) + chr(16) + chr(117)) # -u DTE_LOWER_VOLUME = (chr(16) + chr(16) + chr(100)) # -d @@ -768,5 +776,6 @@ def _init_modem(self): def decode(bytestr): # Remove non-printable chars before decoding. - string = re.sub(b'[^\x00-\x7f]', b'', bytestr).decode("utf-8").strip(' \t\n\r' + DLE_CODE) + # ~ string = re.sub(b'[^\x00-\x7f]', b'', bytestr).decode("utf-8").strip(' \t\n\r' + DLE_CODE) + string = bytestr.decode("utf-8", "ignore").strip(' \t\n\r' + DLE_CODE) return string diff --git a/tests/test_modem.py b/tests/test_modem.py index 0ace78f..b8ecdc8 100644 --- a/tests/test_modem.py +++ b/tests/test_modem.py @@ -41,7 +41,7 @@ ENTER_VOICE_TRANSMIT_DATA_STATE, DTE_END_VOICE_DATA_TX, \ ENTER_VOICE_RECIEVE_DATA_STATE, DTE_END_VOICE_DATA_RX, \ TERMINATE_CALL, ETX_CODE, DLE_CODE, \ - SET_VOICE_COMPRESSION, SET_VOICE_COMPRESSION_USR, SET_VOICE_COMPRESSION_ZOOM + SET_VOICE_COMPRESSION, SET_VOICE_COMPRESSION_ZOOM # Skip the test when running under continous integraion pytestmark = pytest.mark.skipif(os.getenv("CI")=="true", reason="Hardware not installed") @@ -78,7 +78,7 @@ def test_put_modem_into_voice_mode(modem): def test_set_compression_method_and_sampling_rate_specifications(modem): assert modem._send( - SET_VOICE_COMPRESSION_ZOOM if modem.model == "ZOOM" else SET_VOICE_COMPRESSION_USR + SET_VOICE_COMPRESSION_ZOOM if modem.model == "ZOOM" else SET_VOICE_COMPRESSION ) From 19474595dc45f7b985750becbe73bc1b527032c2 Mon Sep 17 00:00:00 2001 From: Bruce Schubert Date: Sat, 5 Sep 2020 14:48:11 -0700 Subject: [PATCH 9/9] Fixed unknown modem detection code --- callattendant/hardware/modem.py | 1 - 1 file changed, 1 deletion(-) diff --git a/callattendant/hardware/modem.py b/callattendant/hardware/modem.py index 6e27df8..4fcb223 100644 --- a/callattendant/hardware/modem.py +++ b/callattendant/hardware/modem.py @@ -720,7 +720,6 @@ def _detect_modem(self): if self._send(ENTER_VOICE_MODE): self.model = "UNKNOWN" # Use the default settings (used by the USR 5637 modem) - SET_VOICE_COMPRESSION = SET_VOICE_COMPRESSION_USR else: print("Error: Failed to put modem into voice mode.") success = False