diff --git a/Backend/.gitignore b/Backend/.gitignore index 0d36319c..def57c14 100644 --- a/Backend/.gitignore +++ b/Backend/.gitignore @@ -7,4 +7,7 @@ recordedData/sessions/*.bin recordedData/processedData/*.csv # Python -*.egg-info \ No newline at end of file +*.egg-info + +# File sync +Downloaded*/ \ No newline at end of file diff --git a/Backend/core/comms.py b/Backend/core/comms.py index 80c3e3ee..4e57b4f1 100644 --- a/Backend/core/comms.py +++ b/Backend/core/comms.py @@ -4,6 +4,7 @@ import aiohttp import asyncio import config +import serial import signal import sys @@ -12,15 +13,17 @@ from multiprocessing.managers import BaseManager from . import db from file_sync.file_sync_down.main import * +import re format_string = '<' # little-endian byte_length = 0 properties = [] frontend_data = {} -solar_car_connection = {'lte': False, 'udp': False} +solar_car_connection = {'lte': False, 'udp': False, 'serial': False} # Convert dataformat to format string for struct conversion # Docs: https://docs.python.org/3/library/struct.html types = {'bool': '?', 'float': 'f', 'char': 'c', 'uint8': 'B', 'uint16': 'H', 'uint64': 'Q'} +serial_port = {"device": "", 'baud': 115200} # shared object with core_api for setting serial device from frontend def set_format(file_path: str): global format_string, byte_length, properties @@ -45,7 +48,7 @@ def unpack_data(data): class Telemetry: - __tmp_data = {'tcp': b'', 'lte': b'', 'udp': b'', 'file_sync': b''} + __tmp_data = {'tcp': b'', 'lte': b'', 'udp': b'', 'file_sync': b'', 'serial': b''} latest_tstamp = 0 def listen_udp(self, port: int): @@ -140,6 +143,51 @@ def listen_tcp(self, server_addr: str, port: int): solar_car_connection['tcp'] = False break + def serial_read(self): + global frontend_data, serial_port + latest_tstamp = 0 + while True: + curr_device = serial_port['device'] + curr_baud = serial_port['baud'] + if(curr_device): + # Establish a serial connection) + ser = serial.Serial(curr_device, curr_baud) + # if device has been updated then exit loop and connect to new device + while curr_device == serial_port['device'] and curr_baud == serial_port['baud']: + if time.time() - latest_tstamp > 5: + solar_car_connection['serial'] = False + # Read data from serial port + try: + data = b'' + if(ser.in_waiting > 0): + data = ser.read(ser.in_waiting) + else: + time.sleep(0.1) + if not data: + # No data received, continue listening + continue + packets = self.parse_packets(data, 'serial') + for packet in packets: + if len(packet) == byte_length: + d = unpack_data(packet) + latest_tstamp = time.time() + try: + frontend_data = d.copy() + db.insert_data(d) + except Exception as e: + print(traceback.format_exc()) + continue + solar_car_connection['serial'] = True + except Exception: + print(traceback.format_exc()) + solar_car_connection['serial'] = False + serial_port['device'] = "" + break + else: + solar_car_connection['serial'] = False + # wait before retry + time.sleep(1) + async def fetch(self, session, url): try: async with session.get(url, timeout=2) as response: @@ -194,35 +242,40 @@ async def remote_db_fetch(self, server_url: str): def parse_packets(self, new_data: bytes, tmp_source: str): """ - Parse and check the length of each packet - :param new_data: Newly received bytes from the comm channel - :param tmp_source: Name of tmp data source, put comm channel name here e.g. tcp, lte + Parse and check the length of each packet. + + :param new_data: Newly received bytes from the comm channel. + :param tmp_source: Name of tmp data source, put comm channel name here e.g. tcp, lte. """ - header = b'' - footer = b'' + header = b"" + footer = b" and tags + pattern = re.compile(b'(.*?)', re.DOTALL) + packets = [] while True: - # Search for the next complete data packet - try: - start_index = self.__tmp_data[tmp_source].index(header) - end_index = self.__tmp_data[tmp_source].index(footer) - except ValueError: + match = pattern.search(self.__tmp_data[tmp_source]) + if not match: break + # Extract the packet data + packet = match.group(1) + #remove headers and footers + packets.append(packet) - # Extract a complete data packet - packets.append(self.__tmp_data[tmp_source][start_index + len(header):end_index]) - # Update the remaining data to exclude the processed packet - self.__tmp_data[tmp_source] = self.__tmp_data[tmp_source][end_index + len(footer):] - - # If the remaining data is longer than the expected packet length, - # there might be an incomplete packet, so log a warning. - if len(self.__tmp_data[tmp_source]) >= byte_length: - print("Warning: Incomplete or malformed packet ------------------------------------") - self.__tmp_data[tmp_source] = b'' + if match.start(0) != 0: + print(f"skipping {match.start(0)} bytes") + # Remove the processed packet from the temporary buffer + self.__tmp_data[tmp_source] = self.__tmp_data[tmp_source][match.end():] return packets + def fs_down_callback(self, data): # copied from listen_upd() if not data: @@ -254,12 +307,13 @@ def sigint_handler(signal, frame): def start_comms(): # start file sync - p.start() - - + # p.start() + # Start two live comm channels - vps_thread = threading.Thread(target=lambda : asyncio.run(telemetry.remote_db_fetch(config.VPS_URL))) - vps_thread.start() - socket_thread = threading.Thread(target=lambda: telemetry.listen_udp(config.UDP_PORT)) + #vps_thread = threading.Thread(target=lambda : asyncio.run(telemetry.remote_db_fetch(config.VPS_URL))) + #vps_thread.start() + #socket_thread = threading.Thread(target=lambda: telemetry.listen_udp(config.UDP_PORT)) + #socket_thread.start() + socket_thread = threading.Thread(target=lambda: telemetry.serial_read()) socket_thread.start() diff --git a/Backend/core/core_api.py b/Backend/core/core_api.py index ce90abee..a397ba5d 100644 --- a/Backend/core/core_api.py +++ b/Backend/core/core_api.py @@ -1,14 +1,18 @@ from fastapi import APIRouter +import serial.tools.list_ports from . import comms +from pydantic import BaseModel + router = APIRouter() @router.get("/single-values") async def single_values(): - if comms.solar_car_connection['udp'] or comms.solar_car_connection['lte']: + if comms.solar_car_connection['udp'] or comms.solar_car_connection['lte'] or comms.solar_car_connection['serial']: latest_data = comms.frontend_data latest_data['solar_car_connection'] = True latest_data['udp_status'] = comms.solar_car_connection['udp'] latest_data['lte_status'] = comms.solar_car_connection['lte'] + latest_data['serial_status'] = comms.solar_car_connection['serial'] latest_data['timestamps'] = f'{latest_data["tstamp_hr"]:02d}:{latest_data["tstamp_mn"]:02d}:' \ f'{latest_data["tstamp_sc"]:02d}.{latest_data["tstamp_ms"]}' format_data = {} @@ -17,3 +21,23 @@ async def single_values(): json_data = {'response': format_data} return json_data return {'response': None} + + +@router.get("/serial-info") +async def list_serial_ports(): + """return currently connected device and all available serial device""" + ports = serial.tools.list_ports.comports() + # Extract the device name from each port object + return {'connected_device': {'device': comms.serial_port['device'], 'baud': comms.serial_port['baud']}, + 'all_devices': [port.device for port in sorted(ports, key=lambda port: port.device)] + } + +class SerialDevice(BaseModel): + device: str + baud: int + +@router.post("/connect-device") +async def dev_conn(serial_device: SerialDevice): + """Connect to serial port, pass in empty device name for disconnect""" + comms.serial_port['device'] = serial_device.device + comms.serial_port['baud'] = serial_device.baud \ No newline at end of file diff --git a/Backend/file_sync b/Backend/file_sync index 14768c06..924590d6 160000 --- a/Backend/file_sync +++ b/Backend/file_sync @@ -1 +1 @@ -Subproject commit 14768c06144f148f230b97dbd3e6b03cb45514df +Subproject commit 924590d67594cbcf00f7d6bcd04c21fe5567d5ae diff --git a/Backend/main.py b/Backend/main.py index a8455534..497f1469 100644 --- a/Backend/main.py +++ b/Backend/main.py @@ -11,6 +11,6 @@ async def startup(): process.start_processes() if __name__ == '__main__': - uvicorn.run(app='main:app', host="0.0.0.0", port=config.HOST_PORT) + uvicorn.run(app='main:app', host="0.0.0.0", port=config.HOST_PORT, log_level='critical') \ No newline at end of file diff --git a/Backend/setup.py b/Backend/setup.py index 2040ce1d..9b553a0e 100644 --- a/Backend/setup.py +++ b/Backend/setup.py @@ -9,5 +9,5 @@ author='Badger Solar Racing Software Team', author_email='', description='', - install_requires=['uvicorn','fastapi','redis', 'requests', 'numpy', 'XlsxWriter', 'pandas', 'aiohttp'] + install_requires=['uvicorn','fastapi','redis', 'requests', 'numpy', 'XlsxWriter', 'pandas', 'aiohttp', 'pyserial'] ) diff --git a/Frontend/src/Components/Communication/Communication.js b/Frontend/src/Components/Communication/Communication.js index 1a7193b0..8ba62744 100644 --- a/Frontend/src/Components/Communication/Communication.js +++ b/Frontend/src/Components/Communication/Communication.js @@ -38,12 +38,8 @@ export default function Communication(props) { - -  Packet Delay: +  Packet Delay: {_getFormattedPacketDelay()} + {switchDataView(dataView4)} + + + { + fetch('/serial-info') + .then((response) => { + if (response.ok) { + return response.json(); + } else { + throw new Error(`Error fetching serial port with code ${response.status}`); + } + }) + .then((body) => { + setSelectedDevice(body['connected_device']['device']); // Set default device + setSelectedBaud(body['connected_device']['baud']); + setAllDevices(body['all_devices']); + }).catch(error => console.error('Fetch error:', error)); + }; + + useInterval(refresh, 3000); + + useEffect(()=> { + fetch("/connect-device", { + method: "POST", + body: JSON.stringify({ + device: selectedDevice, + baud: selectedBaud + }), + headers: { + "Content-type": "application/json" + } + }); + + }, [selectedBaud, selectedDevice]) + + const getSerialPort = () => { + return ( + + ); + }; + + const getBaud = () => { + const defaultBaud = [4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000]; + return ( + + ) + } + + return ( + +

Serial Device:

+ {getSerialPort()} +

Baud:

+ {getBaud()} +
+ ); +}