commit af36c23be3e4426cf13a6df53c8a3ce413a4eb57 Author: Sean Cross Date: Tue Nov 21 19:44:52 2023 +0800 initial commit Signed-off-by: Sean Cross diff --git a/README.md b/README.md new file mode 100644 index 0000000..22ab481 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Liftbot.py + +A Python implementation of Liftbot. + +## Requirements + +This requires some sort of modem, particularly one of the cx9300x lineage. You can get this information by running `ATI` on your modem. + +You also need a phone line. Ideally one with Caller ID. + +You must also have a Telegram bot token. You can get this by talking to the [BotFather](https://t.me/botfather). Once you start a chat with your new bot, you can extract the chat ID from the incoming messages. + +## Installation + +To install, use whatever is most popular to create a Python environment. Then install the requirements: + +```bash +python -mpip install -r requirements.txt +``` + +Then, run this program: + +```bash +python liftbot.py --key 8675309:AAZ849320666Wl --chat -5123944 --allowed-numbers 8675309 91133221=Geoff "17603235532=James Madison" --modem /dev/ttyUSB0 +``` + +## Persistence + +To make things persistent, copy `liftbot-py@.service` to `/etc/systemd/system` and run `sudo systemctl enable liftbot-py@ttyACM0.service` followed by `sudo systemctl start liftbot-py@ttyACM0.service`. You can change the `ttyACM0` to whatever your modem is. diff --git a/cx93001/__init__.py b/cx93001/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cx93001/__pycache__/__init__.cpython-310.pyc b/cx93001/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..951d006 Binary files /dev/null and b/cx93001/__pycache__/__init__.cpython-310.pyc differ diff --git a/cx93001/__pycache__/cx93001.cpython-310.pyc b/cx93001/__pycache__/cx93001.cpython-310.pyc new file mode 100644 index 0000000..616e13c Binary files /dev/null and b/cx93001/__pycache__/cx93001.cpython-310.pyc differ diff --git a/cx93001/cx93001.py b/cx93001/cx93001.py new file mode 100644 index 0000000..7feda1e --- /dev/null +++ b/cx93001/cx93001.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +Python 3 interface for Conexant CX93001 chipset based voice modems. +""" + +__author__ = "havocsec" +__version__ = "0.0.3" +__license__ = "GPLv3" + +import os +import sys +import time +import wave +from datetime import datetime +import serial +# from pydub import AudioSegment + + +class CouldNotInitializeException(Exception): + pass + + +class CX93001: + """Main modem class + + Main class to interface with Conexant CX93001 chipset based voicemodems. + """ + + __con = None + + def __init__(self, port='/dev/ttyACM0', baudrate=115200): + """Constructor + + Class constructor that accepts a custom serial port and baudrate. + By default, parameters are set to 8N1 comm @ 115200 bauds, no + XONXOFF, no RTS/CTS, no DST/DTR and no write timeout. Then, the + serial connection is open and the modem configuration is reset. + Finally, the verbosity, echoing and caller ID are enabled through + AT commands. + """ + + self.__con = serial.Serial() + # Set the port and the baudrate + self.__con.port = port + self.__con.baudrate = baudrate + # Set 8N1 + self.__con.bytesize = serial.EIGHTBITS + self.__con.parity = serial.PARITY_NONE + self.__con.stopbits = serial.STOPBITS_ONE + self.__con.timeout = 3 + self.__con.xonxoff = False + self.__con.rtscts = False + self.__con.dsrdtr = False + self.__con.writeTimeout = False + # Try to open the connection + try: + self.__con.open() + except serial.SerialException: + raise CouldNotInitializeException("Could not open a serial connection") + # Try to initialize + if not self.__at('ATZ'): + raise CouldNotInitializeException("Could not reset the modem") + if not self.__at('ATE1'): + raise CouldNotInitializeException("Could not enable command echoing") + if not self.__at('AT'): + raise CouldNotInitializeException("Could not execute AT commands") + if not self.__at('AT&F0'): + raise CouldNotInitializeException("Could not reset to default state") + if not self.__at('ATV1'): + raise CouldNotInitializeException("Could not enable verbose reporting") + if not self.__at('ATE1'): + raise CouldNotInitializeException("Could not enable command echoing") + if not self.__at('AT+VCID=1'): + raise CouldNotInitializeException("Could not enable caller ID") + if not self.__at('AT+VDR=1,1'): + raise CouldNotInitializeException("Could not enable ring duration reporting") + if not self.__at('ATS11=20'): + raise CouldNotInitializeException("Could not set delay to 200ms") + + def __del__(self): + """Destructor + + Closes the serial connection. + """ + + self.__con.close() + + def self_test(self): + """Returns True if the modem is working correctly + + Returns True if the modem is working correctly, False otherwise + """ + + return self.__at('AT') and self.__at('AT&F0') and self.__at('ATV1') and self.__at('ATE1') \ + and self.__at('AT+VCID=1') and self.__at('ATI1') \ + and self.__at('ATI2') + # and self.__at('ATI3', 'CX93001-EIS_V0.2013-V92') and self.__at('ATI0', '56000') + + def __at(self, cmd, expected='OK'): + """Execute AT command, returns True if the expected response is received, False otherwise. + + Executes an AT command and waits for the expected response, which is 'OK' by default. If the + expected response is received, True is returned. In any other case, False is returned. + """ + + if self.__con.in_waiting != 0: + print(f"There are {self.__con.in_waiting} bytes waiting -- draining them") + while self.__con.in_waiting != 0: + b = self.__con.read(self.__con.in_waiting) + print(f"Drained bytes: {b.decode()}") + self.__con.write((cmd + "\r").encode()) + resp = self.__con.readline() + while resp == b'\r\n' or resp == b'OK\r\n': + resp = self.__con.readline() + if resp != (cmd + '\r\r\n').encode(): + print(f'Not OK: {cmd} -> returned {resp}') + return False + resp = self.__con.readline() + if resp == (expected + '\r\n').encode(): + print(f'OK: {cmd} -> {resp}') + return True + else: + print(f'NOK: {cmd} Expected {expected}, got {resp}') + return False + + def __detect_end(self, data): + """Detects the end of an ongoing call + + Returns if s (silence), b (busy tone) or (End of TX) are detected in the + provided data. + """ + + return b'\x10s' in data or b'\x10b' in data or b'\x10\x03' in data + + def wait_call(self, max_rings_ignore_cid=4): + """Waits until an incoming call is detected, then returns its caller ID data + + Waits until an incoming call is detected, then returns its caller ID data if possible (date, number). If after + max_rings_ignore_cid rings no caller ID data is detected, then returns the tuple (date, ''). + """ + + rings = 0 + while True: + data = self.__con.readline().decode().replace('\r\n', '') + if data != '': + print(data) + if 'NMBR' in data: + return datetime.now(), data.replace('NMBR = ', '') + if 'RING' in data: + # Just in case Caller ID isn't working + rings += 1 + if rings >= max_rings_ignore_cid: + return datetime.now(), '' + + def accept_call(self): + """Accept an incoming call + + Sets voice mode, voice sampling mode to 8-bit PCM mono @ 8000Hz, enables transmitting operating mode + and answers the call. + """ + + self.__at('AT+FCLASS=8') + self.__at('AT+VSM=1,8000,0,0') + return self.__at('AT+VLS=1') +## time.sleep(1) +## # Pick up +# if not self.__at('ATA'): +# print('got error trying to answer phone... trying again (try 2)') +## time.sleep(1) +# if not self.__at('ATA'): +# print('got error trying to answer phone... trying again (try 3)') +## time.sleep(1) +# if not self.__at('ATA'): +# print('unable to answer phone after three tries!') + + def play_audio_obj(self, wavobj, timeout=0): + """Transmits a wave audio object over an ongoing call + + Transmits a wave audio object over an ongoing call. Enables voice transmit mode and the audio is + played until it's finished if the timeout is 0 or until the timeout is reached. + """ + + if timeout == 0: + timeout = wavobj.getnframes() / wavobj.getframerate() + self.__at('AT+VTX') + #print(timeout) + chunksize = 1024 + start_time = time.time() + data = wavobj.readframes(chunksize) + while data != '': + self.__con.write(data) + data = wavobj.readframes(chunksize) + time.sleep(.06) + if time.time() - start_time >= timeout: + break + + def play_audio_file(self, wavfile, timeout=0): + """Transmits a wave 8-bit PCM mono @ 8000Hz audio file over an ongoing call + + Transmits a wave 8-bit PCM mono @ 8000Hz audio file over an ongoing call. Enables voice transmit mode + and the audio is played until it finished if the timeout is 0 or until the timeout is reached. + """ + + wavobj = wave.open(wavfile, 'rb') + self.play_audio_obj(wavobj, timeout=timeout) + wavobj.close() + + def tts_say(self, phrase, lang='english'): + """Transmits a TTS phrase over an ongoing call + + Uses espeak and ffmpeg to generate a wav file of the phrase. Then, it's transmitted over the ongoing call. + """ + + os.system('espeak -w temp.wav -v' + lang + ' \"' + phrase + '\" ; ffmpeg -i temp.wav -ar 8000 -acodec pcm_u8 ' + ' -ac 1 phrase.wav') + os.remove('temp.wav') + self.play_audio_file('phrase.wav') + os.remove('../phrase.wav') + + def play_tones(self, sequence, duration=300): + """Plays a sequence of DTMF tones + + Plays a sequence of DTMF tones over an ongoing call. + """ + duration_str = str(duration // 10) + keys = ','.join(["{" + word + "," + duration_str + "}" for word in sequence]) + self.__at('AT+VTS=' + keys) + # time.sleep(len(sequence)) + + def reject_call(self): + """Rejects an incoming call + + Answers the call and immediately hangs up in order to correctly terminate the incoming call. + """ + + self.accept_call() + self.hang_up() + + def hang_up(self): + """Terminates an ongoing call + + Terminates the currently ongoing call + """ + + self.__at('AT+FCLASS=8') + self.__at('ATH') + + def dial(self, number): + """Initiate a call with the desired number + + Sets the modem to voice mode, sets the sampling mode to 8-bit PCM mono @ 8000 Hz, enables transmitting + operating mode, silence detection over a period of 5 seconds and dials to the desired number. + """ + + self.__at('AT+FCLASS=8') + self.__at('AT+VSM=1,8000,0,0') + self.__at('AT+VLS=1') + self.__at('AT+VSD=128,50') + self.__at('ATD' + number) + + # def record_call(self, date=datetime.now(), number='unknown', timeout=7200): + # """Records an ongoing call until it's finished or the timeout is reached + + # Sets the modem to voice mode, sets the sampling mode to 8-bit PCM mono @ 8000 Hz, enables transmitting + # operating mode, silence detection over a period of 5 seconds and voice reception mode. Then, a mp3 file + # is written until the end of the call or until the timeout is reached. + # """ + + # self.__at('AT+FCLASS=8') + # self.__at('AT+VSM=1,8000,0,0') + # self.__at('AT+VLS=1') + # self.__at('AT+VSD=128,50') + # self.__at('AT+VRX', 'CONNECT') + + # chunksize = 1024 + # frames = [] + # start = time.time() + # while True: + # chunk = self.__con.read(chunksize) + # if self.__detect_end(chunk): + # break + # if time.time() - start >= timeout: + # #print('Timeout reached') + # break + # frames.append(chunk) + # self.hang_up() + # # Merge frames and save temporarily as .wav + # wav_path = date.strftime('%d-%m-%Y_%H:%M:%S_') + number + '.wav' + # wav_file = wave.open(wav_path, 'wb') + # wav_file.setnchannels(1) + # wav_file.setsampwidth(1) + # wav_file.setframerate(8000) + # wav_file.writeframes(b''.join(frames)) + # wav_file.close() + # # Convert from .wav to .mp3 in order to save space + # segment = AudioSegment.from_wav(wav_path) + # segment.export(wav_path[:-3] + 'mp3', format='mp3') + # os.remove(wav_path) diff --git a/liftbot-py@.service b/liftbot-py@.service new file mode 100644 index 0000000..5f595f0 --- /dev/null +++ b/liftbot-py@.service @@ -0,0 +1,17 @@ +# copy this file to /etc/systemd/system +# then run: +# sudo systemctl start liftbot-py@ttyACM0.service +# sudo systemctl enable liftbot-py@ttyACM0.service + +[Unit] +Description=Run Liftbot + +[Service] +User=xobs +WorkingDirectory=/usr/local/share/liftbot +ExecStart=/usr/bin/python3 -u /usr/local/bin/liftbot.py --key [your chat key] --chat [your chat ID] --modem %I +Restart=on-failure +RestartSec=2s + +[Install] +WantedBy=multi-user.target diff --git a/liftbot.py b/liftbot.py new file mode 100644 index 0000000..fdb7365 --- /dev/null +++ b/liftbot.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +import os +import sys +sys.path.append(os.getcwd()) + +from cx93001.cx93001 import CX93001 +import telebot +import time +import argparse + +def telegram_bot(key): + bot = telebot.TeleBot( + key, parse_mode=None + ) + return bot + +def allow_user(modem): + print("Accepting call...") + for i in range(3): + if modem.accept_call(): + print(f"Call accepted on try #{i+1}") + break + time.sleep(0.4) + time.sleep(0.9) + for _ in range(3): + modem.play_tones('1#', duration=700) + time.sleep(0.8) + print(f"Hanging up") + modem.hang_up() + +def reject_user(modem): + modem.reject_call() + +def run_liftbot(): + parser = argparse.ArgumentParser(description="Liftbot") + parser.add_argument( + "--key", + type=str, + required=True, + help="Telegram bot key", + ) + parser.add_argument( + "--chat", + type=str, + required=True, + nargs="+", + help="Chat to alert when things happen", + ) + parser.add_argument( + "--allowed-numbers", + type=str, + nargs="+", + help="Add a number to the list of valid numbers", + ) + parser.add_argument( + "--modem", + type=str, + default="/dev/ttyACM0", + help="Port of the modem to use", + ) + + args = parser.parse_args() + + bot = telegram_bot(args.key) + valid_numbers = { + '66942627': 'Centro Lift', + } + for num in args.allowed_numbers: + fields = num.split("=", 2) + num = fields[0] + num_name =fields[0] + if len(fields) > 1: + num_name = "=".join(fields[1:]) + valid_numbers[num] = num_name + chats = [args.chat] + print("Starting Liftbot...") + modem = CX93001(port=args.modem) + while True: + ringtime, cid = modem.wait_call(max_rings_ignore_cid=3) + print(f"Incoming call from {cid} @ {ringtime}") + if cid in valid_numbers: + print(f"Call from {valid_numbers[cid]}") + allow_user(modem) + for chat in chats: + bot.send_message(chat, f"Incoming call from {cid} for {valid_numbers[cid]} -- allowing them up") + print(f"Done with loop") + else: + print(f"Unrecognized number: {cid} -- rejecting") + allow_user(modem) + for chat in chats: + bot.send_message(chat, f"Unrecognized call from {cid} -- allowing them for now, until we figure out the bug") + #reject_user(modem) + + +if __name__ == '__main__': + run_liftbot() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9bfa5ff --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +telebot +pyserial