diff --git a/.github/workflows/build-release-master.yml b/.github/workflows/build-release-master.yml deleted file mode 100644 index d2d98d2..0000000 --- a/.github/workflows/build-release-master.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Build and Release Master - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Get repository information - uses: actions/github-script@v6 - with: - script: | - const repo = context.payload.repository.full_name; - console.log(`The repository is ${repo}`); - - - name: Install dependencies - run: sudo apt-get update && sudo apt-get install -y python3 python3-pip - - - name: Install pip3 requirements - run: python3 -m pip install -r requirements.txt - - - name: Install pyinstaller - run: python3 -m pip install pyinstaller - - - name: Build tinet-bridge.exe - run: pyinstaller --onefile .\tinet-bridge.py - - - name: Upload tinet-bridge.exe - uses: actions/upload-artifact@v3 - with: - name: tinet-bridge.exe - path: dist/tinet-bridge.exe - - - name: Set final commit status - uses: myrotvorets/set-commit-status-action@master - if: always() - with: - sha: ${{ steps.comment-branch.outputs.head_sha }} - token: ${{ secrets.GITHUB_TOKEN }} - status: ${{ job.status }} - - - name: Create Stable Release - id: create_release - uses: actions/create-release@v1.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: V0.0.${{ github.run_number }} - release_name: V0.0.${{ github.run_number }} - draft: false - prerelease: false - - - name: Add tinet-bridge.exe to Release - uses: actions/upload-release-asset@v1.0.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: dist/tinet-bridge.exe - asset_name: tinet-bridge.exe - asset_content_type: application/octet-stream diff --git a/.github/workflows/build-windows-executable.yml b/.github/workflows/build-windows-executable.yml new file mode 100644 index 0000000..be1e2ff --- /dev/null +++ b/.github/workflows/build-windows-executable.yml @@ -0,0 +1,27 @@ +on: + push: + +jobs: + build: + runs-on: ['windows-latest'] + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.11 + + - run: pip install -r requirements.txt pyinstaller + - run: pyinstaller --onefile --icon=tkbstudios.ico tinet-bridge.py + - uses: actions/upload-artifact@v4 + with: + name: tinet-bridge + path: dist/tinet-bridge.exe + + - name: Release + uses: softprops/action-gh-release@v1 + if: "contains(github.event.head_commit.message, 'release')" + with: + files: dist/tinet-bridge.exe + tag_name: V${{ github.run_number }} + token: ${{ secrets.CUSTOM_GITHUB_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index baf2658..9376f49 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ -# TINET Bridge +# [DEPRECATED] TINET Bridge +## Why? +TINET Bridge IS DEPRECATED. We are using [lwip-ce](https://google.com) for communication. +lwip-ce is great because it literally is an ethernet cable or a WiFi adapter supporting WPS, +which makes it better in terms of communication and protocol support. + +The [documentation](https://tinetdocs.tkbstudios.com/) is updated. + +## Old README This program makes the connection between your calculator and our main servers possible! [![wakatime](https://wakatime.com/badge/github/tkbstudios/tinet-bridge.svg)](https://wakatime.com/badge/github/tkbstudios/tinet-bridge) +[![.github/workflows/build-windows-executable.yml](https://github.com/tkbstudios/tinet-bridge/actions/workflows/build-windows-executable.yml/badge.svg)](https://github.com/tkbstudios/tinet-bridge/actions/workflows/build-windows-executable.yml) > ⚠️ please ALWAYS update the bridge to the latest version! > by doing this you make sure that you have the latest security updates available! diff --git a/plugins/testplugin.py b/plugins/testplugin.py new file mode 100644 index 0000000..6e3386c --- /dev/null +++ b/plugins/testplugin.py @@ -0,0 +1,12 @@ +# this is a test plugin + +class TINETBridgePlugin: + def __init__(self): + self.plugin_name = "TestPlugin" + print(f"Successfully loaded {self.plugin_name}") + + def custom_print(self, message): + print(f"[{self.plugin_name}]: {message}") + + def log_call(self, log_message): + self.custom_print(log_message) diff --git a/pyinstaller.py b/pyinstaller.py deleted file mode 100644 index 65d9d44..0000000 --- a/pyinstaller.py +++ /dev/null @@ -1,2 +0,0 @@ -import os -os.system('auto-py-to-exe --onefile --windowed --input=tinet-bridge.py --output=tinet-bridge.exe') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index de8d895..225e50a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,2 @@ -pyserial>=3.5 -python-dotenv>=1.0.0 -colorama>=0.4.6 -requests>=2.31.0 \ No newline at end of file +pyserial==3.5 +pyserial-asyncio==0.6 \ No newline at end of file diff --git a/testing.py b/testing.py new file mode 100644 index 0000000..d1fa3be --- /dev/null +++ b/testing.py @@ -0,0 +1,54 @@ +import math +import time +import serial +import serial.tools.list_ports + +#--- bridge config ---# + +# enable HTTP requests, be careful only using with programs you trust! +ENABLE_HTTP = False + +#---------------------# + + +def find_serial_port(): + while True: + time.sleep(0.2) + ports = list(serial.tools.list_ports.comports()) + for port in ports: + if "USB Serial Device" in port.description or "TI-84" in port.description: + return port + + +if __name__ == "__main__": + serial_port = find_serial_port() + time.sleep(2) + serial_connection = serial.Serial(serial_port.device, 115200, timeout=0.2) + serial_connection.write(b"BRIDGE_CONNECTED\n") + username = "" + while True: + data = serial_connection.readline() + if data: + decoded_data = data.decode(errors='ignore').strip() + if decoded_data == "CONNECT_TCP": + print("Connect to TCP server") + serial_connection.write(b"TCP_CONNECTED\n") + elif decoded_data.startswith("LOGIN:"): + login_data = decoded_data.replace("LOGIN:", "").split(":", 2) + # hides the key from the console + login_data[2] = login_data[2][:4] + ("*" * 50) + login_data[2][-4:] + username = login_data[1] + print(f"Login data: {login_data}") + serial_connection.write(b"LOGIN_SUCCESS\n") + elif decoded_data == "BRIDGE_PING": + serial_connection.write(b"BRIDGE_PONG\n") + elif decoded_data.startswith("RTC_CHAT:"): + chat_data = decoded_data.replace("RTC_CHAT:", "").split(":", 1) + print(f"Chat data: {chat_data}") + recipient, message = chat_data + chat_broadcast_str = f"RTC_CHAT:{recipient}:{math.floor(time.time())}:{username}:{message}" + serial_connection.write(chat_broadcast_str.encode()) + time.sleep(2) + serial_connection.write(b"RTC_CHAT:global:0:testuser:long message that should be parsed entirely..") + else: + print(decoded_data) diff --git a/tinet-bridge.py b/tinet-bridge.py index f6fbdfe..32b92e1 100644 --- a/tinet-bridge.py +++ b/tinet-bridge.py @@ -1,48 +1,19 @@ -import io -import socket -import sys +import asyncio +import importlib import os -import dotenv -import threading - -import requests -from colorama import init, Fore -import serial -import serial.threaded import time -from serial.tools import list_ports -import logging - -init(autoreset=True) - -# ---------CONFIG--------- # -SERVER_ADDRESS = "tinethub.tkbstudios.com" -SERVER_PORT = 2052 - -SERIAL = True -DEBUG = True -MANUAL_PORT = False -ENABLE_RECONNECT = True -# -------END CONFIG------- # - -os.makedirs('logs', exist_ok=True) - -logging.basicConfig(filename=f"logs/log-{round(time.time())}.log", - filemode='a', - format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s', - datefmt='%H:%M:%S', - level=logging.DEBUG) +from pathlib import Path -logger = logging.getLogger() +import serial.tools.list_ports +import serial.serialutil +from serial_asyncio import open_serial_connection -GITHUB_RELEASES_URL = "https://api.github.com/repos/tkbstudios/tinet-calc/releases?per_page=10" +# --- bridge config --- # +AUTO_RECONNECT = True -CALC_ID = dotenv.get_key(key_to_get="CALC_ID", dotenv_path=".env") -USERNAME = dotenv.get_key(key_to_get="USERNAME", dotenv_path=".env") -TOKEN = dotenv.get_key(key_to_get="TOKEN", dotenv_path=".env") +# --- end bridge config --- # -if CALC_ID is None or USERNAME is None or TOKEN is None: - print(Fore.RED + "calc ID, username or token could not be loaded from .env!") +plugin_instances = [] def find_serial_port(): @@ -52,390 +23,90 @@ def find_serial_port(): for port in ports: if "USB Serial Device" in port.description or "TI-84" in port.description: return port - - -class SocketThread(threading.Thread): - """Manages server connection""" - - def __init__(self): - super(SocketThread, self).__init__() - self.alive = False - self.socket = None - self.serial_manager = None - self._lock = threading.Lock() - - def stop(self): - self.alive = False - - if self.serial_manager.alive: - self.serial_manager.write("internetDisconnected".encode()) - print("Notified client bridge got disconnected!") - - self.socket.close() - self.join() - - def run(self): - while self.serial_manager is None: - pass - - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - print("Creating TCP socket...") - self.socket.settimeout(10) - - print("Connecting to TCP socket...") - # try: - self.socket.connect((SERVER_ADDRESS, SERVER_PORT)) - self.alive = True - - self.serial_manager.write("bridgeConnected\0".encode()) - print("Client got notified he was connected to the bridge!") - - while self.alive: - server_response = bytes() - try: - server_response = self.socket.recv(4096) - except socket.timeout: - continue - except Exception as e: - print(f"Error: {e}") - self.stop() - - if server_response is None or server_response == b"": - logging.error(server_response) - self.stop() - decoded_server_response = server_response.decode() - logging.debug(decoded_server_response) - - if DEBUG: - print(f'R - server - ED: {server_response}') - print(f'R - server: {decoded_server_response}') - - if decoded_server_response == "SERVER_PING": - self.socket.send("CLIENT_PONG".encode()) - elif decoded_server_response == "DISCONNECT": # calculator does not understand this and will crash - self.alive = False - elif decoded_server_response == "ALREADY_CONNECTED": - if DEBUG: - print("Skipping telling calc to prevent crash") # Until the bug is fixed - elif self.serial_manager.alive: - self.serial_manager.write(decoded_server_response.encode()) - print(f'W - serial: {decoded_server_response}') - - def write(self, data): - """Thread safe writing (uses lock)""" - with self._lock: - return self.socket.send(data) - - -class SerialThread(threading.Thread): - """Manages serial connection""" - def __init__(self, serial_port): - """\ - Initialize thread. - Note that the serial_instance timeout is set to 3 second! - Other settings are not changed. - """ - super(SerialThread, self).__init__() - self.daemon = True - self.serial_port = serial_port - if MANUAL_PORT: - self.serial = serial.Serial(self.serial_port, baudrate=9600, timeout=3) - else: - self.serial = serial.Serial(find_serial_port().device, baudrate=9600, timeout=3) - self.socket_manager = None - self.alive = True - self._lock = threading.Lock() - self._connection_made = threading.Event() +def clean_logging_message(message_to_clean: str): + clean_message = message_to_clean + if message_to_clean.startswith("LOGIN:"): + login_data = message_to_clean.replace("LOGIN:", "").split(":", 2) + login_data[0] = login_data[0][:4] + ("*" * 10) + login_data[0][-4:] + login_data[2] = login_data[2][:4] + ("*" * 50) + login_data[2][-4:] + clean_message = f"LOGIN:{login_data[0]}:{login_data[1]}:{login_data[2]}" + clean_message = clean_message.strip() - def stop(self): - """Stop the reader thread""" - self.alive = False - if hasattr(self.serial, 'cancel_read'): - self.serial.cancel_read() - self.join(2) + for plugin_instance in plugin_instances: + if hasattr(plugin_instance, 'log_call'): + plugin_instance.log_call(clean_message) - def run(self): - """Reader loop""" - - while self.alive and self.serial.is_open: - try: - # read all that is there or wait for one byte (blocking) - data = self.serial.read(self.serial.in_waiting) - except Exception as e: - print(f"Error: {e}") - if ENABLE_RECONNECT: - print("Trying to reconnect...") + return clean_message - while True: - time.sleep(1) - try: - if MANUAL_PORT: - self.serial = serial.Serial(self.serial_port, baudrate=9600, timeout=3) - else: - self.serial = serial.Serial(find_serial_port().device, baudrate=9600, timeout=3) - self.write("bridgeConnected\0".encode()) - print("Reconnected!") - break - except Exception: - pass - else: - self.alive = False - pass - else: - if data: - if data is None or data == b"": - logging.error("Data issue") - # TODO: make a separated try-except for called user code - decoded_data = data.decode().replace("/0", "").replace("\0", "") - logging.debug(decoded_data) - if decoded_data.startswith("LDBG_"): - """Do not pass debug from calc to server""" - debug_data = decoded_data.replace("LDBG_", "") - logging.debug(f"Received debug from calc: {debug_data}") - elif decoded_data.startswith("UPDATE_CLIENT:"): - release_type = decoded_data.replace("UPDATE_CLIENT:", "") - print("update client") - response = requests.get(GITHUB_RELEASES_URL) - data = response.json() - if release_type == "dev": - filtered_releases = [release for release in data if release["prerelease"]] - elif release_type == "stable": - filtered_releases = [release for release in data if not release["prerelease"]] - else: - self.serial.write("INVALID_RELEASE".encode()) - return - first_release = filtered_releases[0] if filtered_releases else None - if first_release: - tag_name = first_release["tag_name"] - print(f"Latest release: {tag_name}") - latest_release_download_url = ( - f"https://github.com/tkbstudios/tinet-calc/releases/download/{tag_name}/TINET.8xp" - ) - file_response = requests.get(latest_release_download_url, allow_redirects=True) - file_stream = io.BytesIO() - file_stream.write(file_response.content) - file_bytes = file_stream.getbuffer().tobytes() - file_stream_buffer = file_stream.getbuffer() - update_file_bytes_count = file_stream_buffer.nbytes - chunk_size = 512 - total_bytes_written = 0 +async def bridge(serial_device): + serial_reader, serial_writer = await open_serial_connection(url=serial_device, baudrate=115200) - while file_bytes: - chunk = file_bytes[:chunk_size] - print("new data chunk:\n\n\n") - print(chunk) - print("\n\n\n") - self.serial.write(chunk) - file_bytes = file_bytes[chunk_size:] - total_bytes_written += chunk_size - if total_bytes_written >= update_file_bytes_count: - self.serial.write('UPDATE_DONE'.encode()) - else: - if self.serial.read(self.serial.in_waiting).decode() == "UPDATE_CONTINUE": - continue - else: - update_issue_text = "UPDATE_UNKNOWN_HTTP_ERROR" - try: - response.raise_for_status() - except requests.HTTPError as e: - logging.error(str(e)) - else: - if response.status_code != 200: - update_issue_text = f"UPDATE_INCORRECT_STATUS_CODE:{response.status_code}" - self.write(update_issue_text.encode()) + serial_writer.write("BRIDGE_CONNECTED\n".encode()) + await serial_writer.drain() + print("sent BRIDGE_CONNECTED") - elif decoded_data.startswith("HTTP_"): - method, url, headers, body = decoded_data.replace("HTTP_", "", 1).split("***", 2) - print( - f"{method} request to {url}" - f"\nHeaders: {headers}" - f"\nBody: {body}" - ) - if method == "GET": - response = requests.get(url, data=body, headers=headers) - self.serial.write(response.content) - elif method == "POST": - response = requests.post(url, data=body, headers=headers) - self.serial.write(response.content) - elif method == "PUT": - response = requests.put(url, data=body, headers=headers) - self.serial.write(response.content) - elif method == "PATCH": - response = requests.patch(url, data=body, headers=headers) - self.serial.write(response.content) - elif method == "DELETE": - response = requests.delete(url, data=body, headers=headers) - self.serial.write(response.content) - elif decoded_data.startswith('DOWNLOAD_FILE'): - file_url = decoded_data.replace('DOWNLOAD_FILE', '') - download_file_response = requests.get(file_url) - download_file_stream = io.BytesIO() - download_file_stream.write(download_file_response.content) - download_file_bytes = download_file_stream.getbuffer().tobytes() - download_file_stream_buffer = download_file_stream.getbuffer() - download_file_bytes_count = download_file_stream_buffer.nbytes - - chunk_size = 512 - total_bytes_written = 0 - - while download_file_bytes: - chunk = download_file_bytes[:chunk_size] - print("new data chunk:\n\n\n") - print(chunk) - print("\n\n\n") - self.serial.write(chunk) - download_file_bytes = download_file_bytes[chunk_size:] - total_bytes_written += chunk_size - if total_bytes_written >= download_file_bytes_count: - self.serial.write('UPDATE_DONE'.encode()) - else: - if self.serial.read(self.serial.in_waiting).decode() == "UPDATE_CONTINUE": - continue - - else: - if DEBUG: - print(f'R - serial - ED: {data}') - print(f'R - serial: {decoded_data}') - self.socket_manager.write(decoded_data.encode()) - print(f'W - server: {decoded_data}') - - self.alive = False - - def write(self, data): - """Thread safe writing (uses lock)""" - with self._lock: - return self.serial.write(data) - - def close(self): - """Close the serial port and exit reader thread (uses lock)""" - # use the lock to let other threads finish writing - with self._lock: - # first stop reading, so that closing can be done on idle port - self.stop() - self.serial.close() - - def connect(self): - """ - Wait until connection is set up and return the transport and protocol - instances. - """ - if self.alive: - self._connection_made.wait() - if not self.alive: - raise RuntimeError('connection_lost already called') - return self, self.protocol - else: - raise RuntimeError('already stopped') - - # - - context manager, returns protocol - - def __enter__(self): - """\ - Enter context handler. May raise RuntimeError in case the connection - could not be created. - """ - self.start() - self._connection_made.wait() - if not self.alive: - raise RuntimeError('connection_lost already called') - return self.protocol + while True: + line = await serial_reader.readline() + message = str(line, 'utf-8').strip() + print(message) + if message == "CONNECT_TCP": + print("received CONNECT_TCP") + break - def __exit__(self, exc_type, exc_val, exc_tb): - """Leave context: close port""" - self.close() + tcp_reader, tcp_writer = await asyncio.open_connection('localhost', 2052) + serial_writer.write("TCP_CONNECTED\n".encode()) + await serial_writer.drain() + print("sent TCP_CONNECTED") -def receive_response(sock): - sock.settimeout(0.1) - try: - response = sock.recv(4096) + while True: try: - decoded_response = response.decode('utf-8').strip() - if decoded_response.startswith("RTC_CHAT:"): - print(Fore.MAGENTA + "Received RTC_CHAT:", decoded_response[len("RTC_CHAT:"):]) - elif decoded_response == "SERVER_PONG": - print(Fore.CYAN + "Received SERVER_PONG") - else: - print(Fore.GREEN + "Received:", decoded_response) - return decoded_response - except UnicodeDecodeError: - print(Fore.YELLOW + "Received non-UTF-8 bytes:", response) - except socket.timeout: - return None + serial_data = await serial_reader.readline() + except serial.serialutil.SerialException: + print("Calculator disconnected") + break + serial_message = str(serial_data, 'utf-8') + cleaned_message = clean_logging_message(serial_message) + print(f"receive from calculator: {cleaned_message}") -def command_help(): - print("Available commands:") - print("? - Show a list of all available (local) commands.") - print("exit - Quit the terminal.") - print("clear - Clear the terminal screen.") + tcp_writer.write(serial_message.encode()) + await tcp_writer.drain() + print(f"transfer to TINET: {clean_logging_message(serial_message)}") + tcp_data = await tcp_reader.readline() + tcp_message = tcp_data.decode() + print(f"receive from TINET: {clean_logging_message(tcp_message)}") -# Prompts user to select a serial port -def select_serial_port(): - while True: - ports = list_ports.comports() - for i, port in enumerate(ports): - print(f"{i + 1}. {port.device} - {port.description}") + serial_writer.write(tcp_message.encode()) + await serial_writer.drain() + print(f"transfer to calculator: {clean_logging_message(tcp_message)}") - if len(ports) == 0: - print("No devices detected! Is your calculator connected?") - time.sleep(1) - continue + print("Exiting bridge..") - selected_index = input("Enter the number of the serial device you want to select: ") - if selected_index == "": - print("Please select a valid port!") - sys.exit(1) - if selected_index in [str(x + 1) for x in range(len(ports))]: - port_number = int(selected_index) - 1 - print(port_number) - return ports[port_number] - else: - print("Invalid selection. Please try again.") +if __name__ == "__main__": + os.makedirs("plugins", exist_ok=True) -def main(): - if SERIAL: + for file_path in Path("plugins").glob('*.py'): + plugin_name = os.path.basename(file_path)[:-3] + print(f"Loading {plugin_name}") try: - print("\rInitiating serial...\n") - - selected_port = None - if MANUAL_PORT: - selected_port = select_serial_port() - else: - selected_port = find_serial_port() - - except serial.SerialException as err: - if err.errno == 13: - print("Missing USB permissions, please add them: ") - print("sudo groupadd dialout") - print("sudo usermod -a -G dialout $USER") - print(f"sudo chmod a+rw {selected_port.device}") - user_response = input("Add the permissions automatically? (y or n): ").lower() - if user_response == "y": - os.system("sudo groupadd dialout") - os.system("sudo usermod -a -G dialout $USER") - os.system(f"sudo chmod a+rw {selected_port.device}") - sys.exit(1) - - socket_thread = SocketThread() - serial_thread = SerialThread(selected_port.device) - socket_thread.serial_manager = serial_thread - serial_thread.socket_manager = socket_thread - - serial_thread.start() - socket_thread.start() + module = importlib.import_module(f'plugins.{plugin_name}') + new_plugin_instance = module.TINETBridgePlugin() + plugin_instances.append(new_plugin_instance) + except Exception as e: + print(f"Error loading plugin from {plugin_name}: {e}") - serial_thread.join() - socket_thread.join() - - sys.exit(0) - - -if __name__ == "__main__": - main() + while True: + loop = asyncio.new_event_loop() + print("Waiting for a calculator..") + serial_port = find_serial_port() + print(serial_port) + time.sleep(2) + loop.run_until_complete(bridge(serial_port.device)) + if AUTO_RECONNECT is False: + break diff --git a/tkbstudios.ico b/tkbstudios.ico new file mode 100644 index 0000000..9bbb274 Binary files /dev/null and b/tkbstudios.ico differ