Restructure for better usability

This commit is contained in:
2025-06-10 17:15:39 +02:00
parent af4b697e01
commit e423add6a0
23 changed files with 0 additions and 0 deletions

127
lib/com.py Normal file
View File

@@ -0,0 +1,127 @@
from abc import ABC, abstractmethod
from typing import Optional
import serial
import struct
import serial.tools.list_ports
class ComSuperClass(ABC):
def __init__(self, baudrate: Optional[int] = 19200, filters: Optional[list[str]] = None) -> None:
self._serial: Optional[serial.Serial] = None
self._filters = filters if filters != None else [ 'USB-Serial Controller', 'Prolific USB-Serial Controller' ]
self._port_override = ''
self._baudrate = baudrate if baudrate != None else 19200
self._err = None
def set_port_override(self, override: str) -> None:
"""Set the port override, to disable port search"""
self._port_override = override
def get_error(self) -> serial.SerialException | None:
return self._err
@abstractmethod
def get_comport(self) -> str:
pass
@abstractmethod
def connect(self) -> bool:
pass
@abstractmethod
def close(self) -> None:
pass
@abstractmethod
def receive(self, byte_count: int) -> bytes:
pass
@abstractmethod
def send(self, msg: str) -> None:
pass
@abstractmethod
def send_float(self, msg: float) -> None:
pass
class Com(ComSuperClass):
def _connection_check(self) -> bool:
if self._serial == None:
return self._open()
if self._serial != None:
if not self._serial.is_open:
self._serial.open()
return True
else:
return False
def get_comport(self) -> str:
"""Find the comport the microcontroller has attached to"""
if self._port_override != '':
return self._port_override
# Catch all errors and simply return an empty string if search unsuccessful
try:
# Get an array of all used comports
ports = [comport.device for comport in serial.tools.list_ports.comports()]
# Filter for specific controller
for comport in ports:
for filter in self._filters:
if filter in comport:
return comport
except Exception as e:
self._err = e
return ""
def _open(self) -> bool:
comport = self.get_comport()
# Comport search returns empty string if search unsuccessful
if comport == '':
try:
self._serial = serial.Serial(comport, self._baudrate, timeout=5)
except serial.SerialException as e:
self._err = e
return False
return True
else:
return False
def connect(self) -> bool:
"""Try to find a comport and connect to the microcontroller. Returns the success as a boolean"""
return self._connection_check()
def close(self) -> None:
"""Close the serial connection, if possible"""
if self._serial != None:
try:
self._serial.close()
except:
pass
def receive(self, byte_count: int) -> bytes:
"""Recieve bytes from microcontroller over serial. Returns bytes. Might want to decode using functions from lib.tools"""
self._connection_check()
if self._serial != None:
return self._serial.read(byte_count)
else:
raise Exception('ERR_CONNECTING')
def send(self, msg: str) -> None:
"""Send a string over serial connection. Will open a connection if none is available"""
self._connection_check()
if self._serial != None:
self._serial.write(msg.encode())
else:
raise Exception('ERR_CONNECTING')
def send_float(self, msg: float) -> None:
"""Send a float number over serial connection"""
self._connection_check()
if self._serial != None:
self._serial.write(bytearray(struct.pack('>f', msg))[0:3])
else:
raise Exception('ERR_CONNECTING')

18
lib/decoder.py Normal file
View File

@@ -0,0 +1,18 @@
import struct
class Decoder:
def decode_ascii(self, value: bytes) -> str:
try:
return value.decode()
except:
return 'Error'
def decode_float(self, value: bytes) -> float:
return struct.unpack('>f', bytes.fromhex(str(value, 'ascii') + '00'))[0]
def decode_float_long(self, value: bytes) -> float:
return struct.unpack('>f', bytes.fromhex(str(value, 'ascii') + '0000'))[0]
def decode_int(self, value: bytes) -> int:
# return int.from_bytes(value, 'big')
return int(value, base=16)

130
lib/instructions.py Normal file
View File

@@ -0,0 +1,130 @@
from lib.com import ComSuperClass
import lib.decoder
import time
# TODO: Load filters (for comport search)
decoder = lib.decoder.Decoder()
# Class that supports sending instructions to the microcontroller,
# as well as hooking to data stream according to protocol
class Instructions:
def __init__(self, com: ComSuperClass) -> None:
self._com = com
# Set a port override (to use a specific COM port)
def set_port_override(self, override: str) -> None:
self._com.set_port_override(override)
# Helper method to hook to the data stream according to protocol.
# You can specify the sequence that the program listens to to sync up,
# as an array of strings, that should each be of length one and only contain
# ascii characters
def hook(self, instruction: str, sequence: list[str]) -> bool:
# Add protection: If we cannot establish connection, refuse to run
if not self._com.connect():
return False
# Send instruction to microcontroller to start hooking process
self._com.send(instruction)
# Record start time to respond to timeout
start = time.time()
# The pointer below points to the element in the array which is the next expected character to be received
pointer = 0
# Simply the length of the sequence, since it is both cheaper and cleaner to calculate it once
sequence_max = len(sequence)
# Only run for a limited amount of time
while time.time() - start < 5:
# If the decoded ascii character is equal to the next expected character, move pointer right by one
# If not, jump back to start
if decoder.decode_ascii(self._com.receive(1)) == sequence[pointer]:
pointer += 1
else:
pointer = 0
# If the pointer has reached the end of the sequence, return True, as now the hook was successful
if pointer == sequence_max:
return True
# If we time out, which is the only way in which this code can be reached, return False
return False
# Used to hook to the main data stream, as that hooking mechanism is differen
def hook_main(self) -> bool:
# Record start time to respond to timeout
start = time.time()
# Wait to find a CR character (enter)
char = decoder.decode_ascii(self._com.receive(1))
while char != "\n":
if time.time() - start > 3:
return False
char = decoder.decode_ascii(self._com.receive(1))
# Store the position in the hooking process
state = 0
distance = 0
while time.time() - start < 5 and state < 3:
char = decoder.decode_ascii(self._com.receive(1))
if char == " ":
if distance == 4:
state += 1
distance = 0
else:
if distance > 4:
state = 0
distance = 0
else:
distance += 1
self._com.receive(5)
return state == 3
# Private helper method to transmit data using the necessary protocols
def _change_data(
self,
instruction: str,
readback: list[str],
data: list[float],
readback_length: int,
) -> None:
# Hook to stream
if self.hook(instruction, readback):
# Transmit data
while len(data) > 0:
# If we received data back, we can send more data, i.e. from this we know
# the controller has received the data
# If not, we close the connection and create an exception
if self._com.receive(readback_length) != "":
self._com.send_float(data.pop(0))
else:
self._com.close()
raise Exception(
"Failed to transmit data. No response from controller"
)
self._com.close()
else:
self._com.close()
raise ConnectionError(
"Failed to hook to controller data stream. No fitting response received"
)
# Abstraction of the _change_data method specifically designed to change the entire config
def change_config(self, new_config: list[float]) -> None:
try:
self._change_data("PR", ["\n", "P", "R", "\n"], new_config, 3)
except Exception as e:
raise e
# Abstraction of the _change_data method specifically designed to change only the configured temperature
def change_temperature(self, temperatures: list[float]) -> None:
try:
self._change_data("PT", ["\n", "P", "T", "\n"], temperatures, 3)
except Exception as e:
raise e

148
lib/test/com.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Library to be used in standalone mode (without microcontroller, for testing functionality)
It simulates the behviour of an actual microcontroller being connected
"""
from typing import Optional
import queue
import random
import time
import struct
from lib.com import ComSuperClass
# This file contains a Com class that can be used to test the functionality
# even without a microcontroller. It is not documented in a particularly
# beginner-friendly way, nor is the code written with beginner-friendliness
# in mind. It is the most complicated piece of code of the entire application
# All double __ prefixed properties and methods are not available in the actual one
instruction_lut: dict[str, list[str]] = {
"PR": ["\n", "P", "R", "\n"],
"PT": ["\n", "P", "T", "\n"],
"RD": ["\n", "R", "D", "\n"],
"NM": ["\n", "N", "M", "\n"],
"FM": ["\n", "F", "M", "\n"],
}
class SimulationError(Exception):
pass
class Com(ComSuperClass):
def __init__(
self, baudrate: int = 19200, filters: Optional[list[str]] = None
) -> None:
# Calling the constructor of the super class to assign defaults
print("\n\nWARNING: Using testing library for communication!\n\n")
super().__init__(baudrate, filters)
# Initialize queue with values to be sent on call of recieve
self.__simulated_data: queue.Queue[bytes] = queue.Queue()
self.__simulated_data_remaining = 0
# Initially, we are in normal mode (which leads to slower data intervals)
self.__mode = "NM"
def set_port_override(self, override: str) -> None:
"""Set the port override, to disable port search"""
self._port_override = override
def get_comport(self) -> str:
return "test" if self._port_override != "" else self._port_override
def connect(self) -> bool:
# Randomly return false in 1 in 20 ish cases
if random.randint(0, 20) == 1:
print("Simulating error to connect")
return False
return True
def close(self) -> None:
pass
def receive(self, byte_count: int) -> bytes:
data = []
# If queue is too short, refill it
if self.__simulated_data_remaining < byte_count:
self.__fill_queue()
for _ in range(byte_count):
if self.__mode == "NM":
time.sleep(0.001)
try:
data.append(self.__simulated_data.get_nowait())
self.__simulated_data_remaining -= 1
except Exception as e:
print("ERROR: Simulation could not continue")
raise SimulationError(
"Simulation encountered an error with the simulation queue. The error encountered: \n"
+ str(e)
)
return b''.join(data)
def send(self, msg: str) -> None:
# Using LUT to reference
readback = instruction_lut.get(msg)
if readback != None:
for i in range(len(readback)):
self.__simulated_data.put(bytes(readback[i], "ascii"))
if msg == "RD":
# Handle ReadData readback
# self.__simulated_data.put(ord(""))
pass
def send_float(self, msg: float) -> None:
# Encode float as 8 bytes (64 bit)
ba = struct.pack("d", msg)
for byte in ba:
self.__simulated_data.put(byte.to_bytes())
def __fill_queue_alternative(self):
for _ in range(4):
for _ in range(4):
self.__simulated_data.put(random.randbytes(1))
self.__simulated_data.put(bytes(" ", "ascii"))
for _ in range(6):
self.__simulated_data.put(random.randbytes(1))
self.__simulated_data.put(bytes(" ", "ascii"))
for _ in range(3):
for _ in range(4):
self.__simulated_data.put(random.randbytes(1))
self.__simulated_data.put(bytes(" ", "ascii"))
for _ in range(4):
self.__simulated_data.put(random.randbytes(1))
self.__simulated_data.put(bytes("\n", "ascii"))
self.__simulated_data_remaining = 68
def __fill_queue(self):
for _ in range(4):
self.__add_integer_as_hex(self.__generate_random_int(200))
self.__simulated_data.put(bytes(" ", "ascii"))
self.__add_float_as_hex(self.__generate_random_float(50))
self.__simulated_data.put(bytes(" ", "ascii"))
self.__simulated_data_remaining += 2
for _ in range(3):
self.__add_integer_as_hex(self.__generate_random_int(65535))
self.__simulated_data.put(bytes(" ", "ascii"))
self.__add_integer_as_hex(self.__generate_random_int(65535))
self.__simulated_data.put(bytes("\n", "ascii"))
self.__simulated_data_remaining += 4
print("Length:", self.__simulated_data_remaining)
def __generate_random_int(self, max: int) -> int:
return random.randint(0, max)
def __generate_random_float(self, max: int) -> float:
return random.random() * max
def __add_character_as_hex(self, data: str):
pass
def __add_integer_as_hex(self, data: int):
pass
def __add_float_as_hex(self, data: float):
pass