├── setup.cfg ├── update_live_pypi.bat ├── pySerialTransfer ├── __init__.py ├── CRC.py └── pySerialTransfer.py ├── create_live_pypi.bat ├── create_test_pypi.bat ├── examples ├── datum │ ├── Arduino │ │ ├── tx_datum │ │ │ └── tx_datum.ino │ │ └── rx_datum │ │ │ └── rx_datum.ino │ └── Python │ │ ├── tx_datum.py │ │ └── rx_datum.py ├── data │ ├── Arduino │ │ ├── rx_data │ │ │ └── rx_data.ino │ │ └── tx_data │ │ │ └── tx_data.ino │ └── Python │ │ ├── tx_data.py │ │ └── rx_data.py └── file │ ├── Arduino │ ├── rx_file │ │ └── rx_file.ino │ └── tx_file │ │ └── tx_file.ino │ └── Python │ ├── rx_file.py │ └── tx_file.py ├── LICENSE ├── setup.py ├── .gitignore ├── tests ├── test_crc.py └── test_py_serial_transfer.py └── README.md /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /update_live_pypi.bat: -------------------------------------------------------------------------------- 1 | python setup.py sdist 2 | twine upload dist/* 3 | PAUSE 4 | -------------------------------------------------------------------------------- /pySerialTransfer/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from . import * 4 | 5 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 6 | -------------------------------------------------------------------------------- /create_live_pypi.bat: -------------------------------------------------------------------------------- 1 | python -m pip install --user --upgrade setuptools wheel 2 | python setup.py sdist bdist_wheel 3 | python -m pip install --user --upgrade twine 4 | python -m twine upload dist/* 5 | PAUSE -------------------------------------------------------------------------------- /create_test_pypi.bat: -------------------------------------------------------------------------------- 1 | python -m pip install --user --upgrade setuptools wheel 2 | python setup.py sdist bdist_wheel 3 | python -m pip install --user --upgrade twine 4 | python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/* 5 | PAUSE -------------------------------------------------------------------------------- /examples/datum/Arduino/tx_datum/tx_datum.ino: -------------------------------------------------------------------------------- 1 | #include "SerialTransfer.h" 2 | 3 | 4 | SerialTransfer myTransfer; 5 | 6 | double y; 7 | 8 | 9 | void setup() 10 | { 11 | Serial.begin(115200); 12 | myTransfer.begin(Serial); 13 | 14 | y = 4.5; 15 | } 16 | 17 | 18 | void loop() 19 | { 20 | myTransfer.sendDatum(y); 21 | delay(500); 22 | } 23 | -------------------------------------------------------------------------------- /examples/datum/Arduino/rx_datum/rx_datum.ino: -------------------------------------------------------------------------------- 1 | #include "SerialTransfer.h" 2 | 3 | 4 | SerialTransfer myTransfer; 5 | 6 | double y; 7 | 8 | 9 | void setup() 10 | { 11 | Serial.begin(115200); 12 | myTransfer.begin(Serial); 13 | } 14 | 15 | 16 | void loop() 17 | { 18 | if(myTransfer.available()) 19 | { 20 | myTransfer.rxObj(y); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/datum/Python/tx_datum.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from pySerialTransfer import pySerialTransfer as txfer 3 | 4 | 5 | y = 4.5 6 | 7 | 8 | if __name__ == '__main__': 9 | try: 10 | link = txfer.SerialTransfer('COM11') 11 | 12 | link.open() 13 | sleep(5) 14 | 15 | while True: 16 | sendSize = link.tx_obj(y) 17 | link.send(sendSize) 18 | 19 | except KeyboardInterrupt: 20 | link.close() -------------------------------------------------------------------------------- /examples/data/Arduino/rx_data/rx_data.ino: -------------------------------------------------------------------------------- 1 | #include "SerialTransfer.h" 2 | 3 | 4 | SerialTransfer myTransfer; 5 | 6 | struct __attribute__((packed)) STRUCT { 7 | char z; 8 | double y; 9 | } testStruct; 10 | 11 | char arr[6]; 12 | 13 | 14 | void setup() 15 | { 16 | Serial.begin(115200); 17 | myTransfer.begin(Serial); 18 | } 19 | 20 | 21 | void loop() 22 | { 23 | if(myTransfer.available()) 24 | { 25 | // use this variable to keep track of how many 26 | // bytes we've processed from the receive buffer 27 | uint16_t recSize = 0; 28 | 29 | recSize = myTransfer.rxObj(testStruct, recSize); 30 | recSize = myTransfer.rxObj(arr, recSize); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/data/Python/tx_data.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from pySerialTransfer import pySerialTransfer as txfer 3 | 4 | 5 | class struct: 6 | z = '$' 7 | y = 4.5 8 | 9 | 10 | arr = 'hello' 11 | 12 | 13 | if __name__ == '__main__': 14 | try: 15 | testStruct = struct 16 | link = txfer.SerialTransfer('COM11') 17 | 18 | link.open() 19 | sleep(5) 20 | 21 | while True: 22 | sendSize = 0 23 | 24 | sendSize = link.tx_obj(testStruct.z, start_pos=sendSize) 25 | sendSize = link.tx_obj(testStruct.y, start_pos=sendSize) 26 | sendSize = link.tx_obj(arr, start_pos=sendSize) 27 | 28 | link.send(sendSize) 29 | 30 | except KeyboardInterrupt: 31 | link.close() -------------------------------------------------------------------------------- /examples/file/Arduino/rx_file/rx_file.ino: -------------------------------------------------------------------------------- 1 | #include "SerialTransfer.h" 2 | 3 | 4 | SerialTransfer myTransfer; 5 | 6 | const int fileSize = 2000; 7 | char file[fileSize]; 8 | uint16_t fileIndex = 0; 9 | char fileName[10]; 10 | 11 | 12 | void setup() 13 | { 14 | Serial.begin(115200); 15 | 16 | myTransfer.begin(Serial); 17 | } 18 | 19 | 20 | void loop() 21 | { 22 | if (myTransfer.available()) 23 | { 24 | if (!myTransfer.currentPacketID()) 25 | { 26 | myTransfer.rxObj(fileName); 27 | } 28 | else if (myTransfer.currentPacketID() == 1) 29 | { 30 | myTransfer.rxObj(fileIndex); 31 | 32 | for(uint8_t i=sizeof(fileIndex); i=8.1.1', 23 | 'pytest-cov>=5.0.0', 24 | 'pytest-mock>=3.14.0', 25 | ], 26 | }, 27 | ) 28 | -------------------------------------------------------------------------------- /examples/file/Python/rx_file.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from pySerialTransfer import pySerialTransfer as txfer 3 | from pySerialTransfer.pySerialTransfer import Status 4 | 5 | file = '' 6 | fileName = '' 7 | 8 | 9 | if __name__ == '__main__': 10 | try: 11 | link = txfer.SerialTransfer('COM11') 12 | 13 | link.open() 14 | sleep(5) 15 | 16 | while True: 17 | if link.available(): 18 | if not link.id_byte: 19 | file = '' 20 | fileName = link.rx_obj(str, obj_byte_size=8) 21 | 22 | print('\n\n\nFile Name: {}\n'.format(fileName)) 23 | 24 | else: 25 | nextContents = link.rx_obj(str, start_pos=2, obj_byte_size=link.bytes_read - 2) 26 | file += nextContents 27 | 28 | print(nextContents, end='') 29 | 30 | elif link.status.value <= 0: 31 | if link.status == Status.CRC_ERROR: 32 | print('ERROR: CRC_ERROR') 33 | elif link.status == Status.PAYLOAD_ERROR: 34 | print('ERROR: PAYLOAD_ERROR') 35 | elif link.status == Status.STOP_BYTE_ERROR: 36 | print('ERROR: STOP_BYTE_ERROR') 37 | else: 38 | print('ERROR: {}'.format(link.status.name)) 39 | 40 | except KeyboardInterrupt: 41 | link.close() -------------------------------------------------------------------------------- /examples/data/Python/rx_data.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from pySerialTransfer import pySerialTransfer as txfer 3 | from pySerialTransfer.pySerialTransfer import Status 4 | 5 | 6 | class Struct: 7 | z = '' 8 | y = 0.0 9 | 10 | 11 | arr = '' 12 | 13 | 14 | if __name__ == '__main__': 15 | try: 16 | testStruct = Struct 17 | link = txfer.SerialTransfer('COM11') 18 | 19 | link.open() 20 | sleep(5) 21 | 22 | while True: 23 | if link.available(): 24 | recSize = 0 25 | 26 | testStruct.z = link.rx_obj(obj_type='c', start_pos=recSize) 27 | recSize += txfer.STRUCT_FORMAT_LENGTHS['c'] 28 | 29 | testStruct.y = link.rx_obj(obj_type='f', start_pos=recSize) 30 | recSize += txfer.STRUCT_FORMAT_LENGTHS['f'] 31 | 32 | arr = link.rx_obj(obj_type=str, 33 | start_pos=recSize, 34 | obj_byte_size=5) 35 | recSize += len(arr) 36 | 37 | print('{}{} | {}'.format(testStruct.z, testStruct.y, arr)) 38 | 39 | elif link.status.value <= 0: 40 | if link.status == Status.CRC_ERROR: 41 | print('ERROR: CRC_ERROR') 42 | elif link.status == Status.PAYLOAD_ERROR: 43 | print('ERROR: PAYLOAD_ERROR') 44 | elif link.status == Status.STOP_BYTE_ERROR: 45 | print('ERROR: STOP_BYTE_ERROR') 46 | else: 47 | print('ERROR: {}'.format(link.status.name)) 48 | 49 | 50 | except KeyboardInterrupt: 51 | link.close() 52 | -------------------------------------------------------------------------------- /pySerialTransfer/CRC.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from functools import lru_cache 3 | 4 | 5 | class CRC: 6 | def __init__(self, polynomial=0x9B, crc_len=8): 7 | self.poly = polynomial & 0xFF 8 | self.crc_len = crc_len 9 | self.table_len = pow(2, crc_len) 10 | 11 | @lru_cache(2 ^ 16) 12 | def calculate_checksum(self, index: int): 13 | """Calculate the checksum for a given index. 14 | An LRU cached version of the CRC calculation function, with an upper bound on the cache size of 2^16 15 | """ 16 | if index > self.table_len: 17 | raise ValueError('Index out of range') 18 | curr = index 19 | for j in range(8): 20 | if (curr & 0x80) != 0: 21 | curr = ((curr << 1) & 0xFF) ^ self.poly 22 | else: 23 | curr <<= 1 24 | return curr 25 | 26 | def print_table(self): 27 | for i in range(self.table_len): 28 | sys.stdout.write(hex(self.calculate_checksum(i)).upper().replace('X', 'x')) 29 | 30 | if (i + 1) % 16: 31 | sys.stdout.write(' ') 32 | else: 33 | sys.stdout.write('\n') 34 | 35 | def calculate(self, arr, dist=None): 36 | crc = 0 37 | 38 | try: 39 | if dist: 40 | indicies = dist 41 | else: 42 | indicies = len(arr) 43 | 44 | for i in range(indicies): 45 | try: 46 | nex_el = int(arr[i]) 47 | except ValueError: 48 | nex_el = ord(arr[i]) 49 | 50 | crc = self.calculate_checksum(crc ^ nex_el) 51 | 52 | except TypeError: 53 | crc = self.calculate_checksum(arr) 54 | 55 | return crc 56 | 57 | 58 | if __name__ == '__main__': 59 | crc_instance = CRC() 60 | print(crc_instance.print_table()) 61 | print(' ') 62 | print(hex(crc_instance.calculate(0x31)).upper().replace('X', 'x')) 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ -------------------------------------------------------------------------------- /examples/file/Arduino/tx_file/tx_file.ino: -------------------------------------------------------------------------------- 1 | #include "SerialTransfer.h" 2 | 3 | 4 | SerialTransfer myTransfer; 5 | 6 | const int fileSize = 2000; 7 | char file[fileSize] = "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc, quis gravida magna mi a libero. Fusce vulputate eleifend sapien. Vestibulum purus quam, scelerisque ut, mollis sed, nonummy id, metus. Nullam accumsan lorem in dui. Cras ultricies mi eu turpis hendrerit fringilla. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; In ac dui quis mi consectetuer lacinia. Nam pretium turpis et arcu. Duis arcu tortor, suscipit eget, imperdiet nec, imperdiet iaculis, ipsum. Sed aliquam ultrices mauris. Integer ante arcu, accumsan a, consectetuer eget, posuere ut, mauris. Praesent adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus. Vestib"; 8 | char fileName[8] = "test.txt"; 9 | 10 | 11 | void setup() 12 | { 13 | Serial.begin(115200); 14 | 15 | myTransfer.begin(Serial); 16 | } 17 | 18 | 19 | void loop() 20 | { 21 | myTransfer.sendDatum(fileName); // Send filename 22 | 23 | uint16_t numPackets = fileSize / (MAX_PACKET_SIZE - 2); // Reserve two bytes for current file index 24 | 25 | if (fileSize % MAX_PACKET_SIZE) // Add an extra transmission if needed 26 | numPackets++; 27 | 28 | for (uint16_t i=0; i fileSize) // Determine data length for the last packet if file length is not an exact multiple of MAX_PACKET_SIZE-2 34 | dataLen = fileSize - fileIndex; 35 | 36 | uint8_t sendSize = myTransfer.txObj(fileIndex); // Stuff the current file index 37 | sendSize = myTransfer.txObj(file[fileIndex], sendSize, dataLen); // Stuff the current file data 38 | 39 | myTransfer.sendData(sendSize, 1); // Send the current file index and data 40 | delay(1000); 41 | } 42 | delay(10000); 43 | } 44 | -------------------------------------------------------------------------------- /examples/file/Python/tx_file.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from pySerialTransfer import pySerialTransfer as txfer 3 | 4 | 5 | file = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc, quis gravida magna mi a libero. Fusce vulputate eleifend sapien. Vestibulum purus quam, scelerisque ut, mollis sed, nonummy id, metus. Nullam accumsan lorem in dui. Cras ultricies mi eu turpis hendrerit fringilla. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; In ac dui quis mi consectetuer lacinia. Nam pretium turpis et arcu. Duis arcu tortor, suscipit eget, imperdiet nec, imperdiet iaculis, ipsum. Sed aliquam ultrices mauris. Integer ante arcu, accumsan a, consectetuer eget, posuere ut, mauris. Praesent adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus. Vestib' 6 | fileSize = len(file) 7 | fileName = 'test.txt' 8 | 9 | 10 | if __name__ == '__main__': 11 | try: 12 | link = txfer.SerialTransfer('COM11') 13 | 14 | link.open() 15 | sleep(5) 16 | 17 | while True: 18 | link.send(link.tx_obj(fileName)) 19 | 20 | numPackets = int(fileSize / (txfer.MAX_PACKET_SIZE - 2)) 21 | 22 | if numPackets % txfer.MAX_PACKET_SIZE: 23 | numPackets += 1 24 | 25 | 26 | 27 | for i in range(numPackets): 28 | fileIndex = i * txfer.MAX_PACKET_SIZE 29 | dataLen = txfer.MAX_PACKET_SIZE - 2 30 | 31 | if (fileIndex + (txfer.MAX_PACKET_SIZE - 2)) > fileSize: 32 | dataLen = fileSize - fileIndex 33 | 34 | dataStr = file[fileIndex:fileIndex+dataLen] 35 | 36 | sendSize = link.tx_obj(fileIndex, val_type_override='h') 37 | sendSize = link.tx_obj(dataStr, start_pos=sendSize) 38 | link.send(sendSize) 39 | 40 | sleep(1) 41 | 42 | sleep(10) 43 | 44 | except KeyboardInterrupt: 45 | link.close() 46 | -------------------------------------------------------------------------------- /tests/test_crc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pySerialTransfer.CRC import CRC 4 | from io import StringIO 5 | import sys 6 | 7 | 8 | def test_crc_init(): 9 | """Test the initialization of the CRC class.""" 10 | crc = CRC() 11 | assert crc.poly == 0x9B 12 | assert crc.crc_len == 8 13 | assert crc.table_len == 256 14 | 15 | 16 | def test_crc_poly(): 17 | """Test the initialization of the CRC class with a custom polynomial.""" 18 | polynomial = 0x8C 19 | crc = CRC(polynomial) 20 | assert crc.poly == polynomial & 0xFF 21 | assert crc.crc_len == 8 22 | assert crc.table_len == 256 23 | 24 | 25 | # Note: The CRC class has no upper limit on the crc_len parameter, but attempting to use a value greater than 32 hangs 26 | # the test. The CRC class should be updated to handle this case. 27 | @pytest.mark.parametrize('crc_len', [4, 8, 16, 32, 128, 256]) 28 | def test_custom_positive_crc_len(crc_len): 29 | """Test the initialization of the CRC class with a custom crc length.""" 30 | expected_table_len = pow(2, crc_len) 31 | crc = CRC(crc_len=crc_len) 32 | assert crc.table_len == expected_table_len 33 | 34 | 35 | def test_crc_calculate(): 36 | """Test the calculate method of the CRC class returns an integer.""" 37 | crc = CRC() 38 | result = crc.calculate([0x31]) 39 | assert isinstance(result, int) 40 | 41 | 42 | def test_calculate_with_int_list_no_dist(): 43 | crc_instance = CRC() 44 | arr = [0x31, 0x32, 0x33, 0x34, 0x35] 45 | expected_output = 218 46 | result = crc_instance.calculate(arr) 47 | assert result == expected_output 48 | 49 | 50 | def test_calculate_with_int_list_with_dist(): 51 | crc_instance = CRC() 52 | arr = [0x31, 0x32, 0x33, 0x34, 0x35] 53 | dist = 3 54 | expected_output = 209 55 | result = crc_instance.calculate(arr, dist) 56 | assert result == expected_output 57 | 58 | 59 | def test_calculate_with_char_list_no_dist(): 60 | crc_instance = CRC() 61 | arr = ["1", "2", "3", "4", "5"] 62 | expected_output = 128 63 | result = crc_instance.calculate(arr) 64 | assert result == expected_output 65 | 66 | 67 | def test_calculate_with_char_list_with_dist(): 68 | crc_instance = CRC() 69 | arr = ["1", "2", "3", "4", "5"] 70 | dist = 3 71 | expected_output = 68 72 | result = crc_instance.calculate(arr, dist) 73 | assert result == expected_output 74 | 75 | 76 | def test_calculate_with_int_no_dist(): 77 | crc_instance = CRC() 78 | arr = 0x31 79 | expected_output = 205 80 | result = crc_instance.calculate(arr) 81 | assert result == expected_output 82 | 83 | 84 | def test_calculate_with_non_int_no_dist(): 85 | crc_instance = CRC() 86 | arr = ["a", "b", "c", "d", "e"] 87 | expected_output = 52 88 | result = crc_instance.calculate(arr) 89 | assert result == expected_output 90 | 91 | 92 | def test_calculate_with_non_int_with_dist(): 93 | crc_instance = CRC() 94 | arr = ["a", "b", "c", "d", "e"] 95 | dist = 3 96 | expected_output = 245 97 | result = crc_instance.calculate(arr, dist) 98 | assert result == expected_output 99 | 100 | 101 | # TODO: Handle this case in the calculate method 102 | @pytest.mark.xfail(reason="not currently handled in the calculate method") 103 | def test_calculate_with_dist_greater_than_list_length(): 104 | crc_instance = CRC() 105 | arr = [0x31, 0x32, 0x33, 0x34, 0x35] 106 | dist = 10 107 | expected_output = 218 108 | result = crc_instance.calculate(arr, dist) 109 | assert result == expected_output 110 | 111 | 112 | def test_print_table(): 113 | """Test the print_table method of the CRC class.""" 114 | # Create an instance of CRC 115 | crc_instance = CRC() 116 | 117 | # Redirect stdout to a buffer 118 | stdout = sys.stdout 119 | sys.stdout = StringIO() 120 | 121 | # Call the method 122 | crc_instance.print_table() 123 | 124 | # Get the output and restore stdout 125 | output = sys.stdout.getvalue() 126 | sys.stdout = stdout 127 | 128 | # Prepare the expected output for the default length of 8 129 | expected_output = """ 130 | 0x0 0x9B 0xAD 0x36 0xC1 0x5A 0x6C 0xF7 0x19 0x82 0xB4 0x2F 0xD8 0x43 0x75 0xEE 131 | 0x32 0xA9 0x9F 0x4 0xF3 0x68 0x5E 0xC5 0x2B 0xB0 0x86 0x1D 0xEA 0x71 0x47 0xDC 132 | 0x64 0xFF 0xC9 0x52 0xA5 0x3E 0x8 0x93 0x7D 0xE6 0xD0 0x4B 0xBC 0x27 0x11 0x8A 133 | 0x56 0xCD 0xFB 0x60 0x97 0xC 0x3A 0xA1 0x4F 0xD4 0xE2 0x79 0x8E 0x15 0x23 0xB8 134 | 0xC8 0x53 0x65 0xFE 0x9 0x92 0xA4 0x3F 0xD1 0x4A 0x7C 0xE7 0x10 0x8B 0xBD 0x26 135 | 0xFA 0x61 0x57 0xCC 0x3B 0xA0 0x96 0xD 0xE3 0x78 0x4E 0xD5 0x22 0xB9 0x8F 0x14 136 | 0xAC 0x37 0x1 0x9A 0x6D 0xF6 0xC0 0x5B 0xB5 0x2E 0x18 0x83 0x74 0xEF 0xD9 0x42 137 | 0x9E 0x5 0x33 0xA8 0x5F 0xC4 0xF2 0x69 0x87 0x1C 0x2A 0xB1 0x46 0xDD 0xEB 0x70 138 | 0xB 0x90 0xA6 0x3D 0xCA 0x51 0x67 0xFC 0x12 0x89 0xBF 0x24 0xD3 0x48 0x7E 0xE5 139 | 0x39 0xA2 0x94 0xF 0xF8 0x63 0x55 0xCE 0x20 0xBB 0x8D 0x16 0xE1 0x7A 0x4C 0xD7 140 | 0x6F 0xF4 0xC2 0x59 0xAE 0x35 0x3 0x98 0x76 0xED 0xDB 0x40 0xB7 0x2C 0x1A 0x81 141 | 0x5D 0xC6 0xF0 0x6B 0x9C 0x7 0x31 0xAA 0x44 0xDF 0xE9 0x72 0x85 0x1E 0x28 0xB3 142 | 0xC3 0x58 0x6E 0xF5 0x2 0x99 0xAF 0x34 0xDA 0x41 0x77 0xEC 0x1B 0x80 0xB6 0x2D 143 | 0xF1 0x6A 0x5C 0xC7 0x30 0xAB 0x9D 0x6 0xE8 0x73 0x45 0xDE 0x29 0xB2 0x84 0x1F 144 | 0xA7 0x3C 0xA 0x91 0x66 0xFD 0xCB 0x50 0xBE 0x25 0x13 0x88 0x7F 0xE4 0xD2 0x49 145 | 0x95 0xE 0x38 0xA3 0x54 0xCF 0xF9 0x62 0x8C 0x17 0x21 0xBA 0x4D 0xD6 0xE0 0x7B 146 | """.lstrip() 147 | 148 | # Assert that the output matches the expected output 149 | assert output == expected_output 150 | 151 | 152 | def test_calculate_with_empty_list(): 153 | """Test that the calculate method returns 0 when an empty list is passed.""" 154 | crc_instance = CRC() 155 | arr = [] 156 | result = crc_instance.calculate(arr) 157 | assert result == 0 158 | 159 | 160 | # TODO: Handle this case in the calculate method 161 | @pytest.mark.xfail(reason="not currently handled in the calculate method") 162 | def test_calculate_with_negative_dist(): 163 | """Test that the calculate method raises a ValueError when the dist parameter is negative.""" 164 | crc_instance = CRC() 165 | arr = [0x31, 0x32, 0x33, 0x34, 0x35] 166 | dist = -1 167 | with pytest.raises(ValueError): 168 | crc_instance.calculate(arr, dist) 169 | 170 | 171 | def test_calculate_with_string_input(): 172 | """Test that the calculate method can handle a string input.""" 173 | crc_instance = CRC() 174 | arr = "abc" 175 | result = crc_instance.calculate(arr) 176 | assert result == 245 177 | 178 | 179 | def test_calculate_with_list_of_mixed_types(): 180 | """Test that the calculate method can handle a list of mixed types.""" 181 | crc_instance = CRC() 182 | arr = [0x31, "a", 0x33, "b", 0x35] 183 | result = crc_instance.calculate(arr) 184 | assert result == 254 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pySerialTransfer 2 | [![GitHub version](https://badge.fury.io/gh/PowerBroker2%2FpySerialTransfer.svg)](https://badge.fury.io/gh/PowerBroker2%2FpySerialTransfer) [![PyPI version](https://badge.fury.io/py/pySerialTransfer.svg)](https://badge.fury.io/py/pySerialTransfer)

3 | Python package to transfer data in a fast, reliable, and packetized form. 4 | 5 | If using this package to communicate with Arduinos, see https://github.com/PowerBroker2/SerialTransfer for the corresponding and compatible library (also available through the Arduino IDE's Libraries Manager). 6 | 7 | # To Install 8 | ``` 9 | pip install pySerialTransfer 10 | ``` 11 | 12 | # Example Python Script 13 | ```python 14 | import time 15 | from pySerialTransfer import pySerialTransfer as txfer 16 | 17 | 18 | if __name__ == '__main__': 19 | try: 20 | link = txfer.SerialTransfer('COM17') 21 | 22 | link.open() 23 | time.sleep(2) # allow some time for the Arduino to completely reset 24 | 25 | while True: 26 | send_size = 0 27 | 28 | ################################################################### 29 | # Send a list 30 | ################################################################### 31 | list_ = [1, 3] 32 | list_size = link.tx_obj(list_) 33 | send_size += list_size 34 | 35 | ################################################################### 36 | # Send a string 37 | ################################################################### 38 | str_ = 'hello' 39 | str_size = link.tx_obj(str_, send_size) - send_size 40 | send_size += str_size 41 | 42 | ################################################################### 43 | # Send a float 44 | ################################################################### 45 | float_ = 5.234 46 | float_size = link.tx_obj(float_, send_size) - send_size 47 | send_size += float_size 48 | 49 | ################################################################### 50 | # Transmit all the data to send in a single packet 51 | ################################################################### 52 | link.send(send_size) 53 | 54 | ################################################################### 55 | # Wait for a response and report any errors while receiving packets 56 | ################################################################### 57 | while not link.available(): 58 | # A negative value for status indicates an error 59 | if link.status.value < 0: 60 | if link.status == txfer.Status.CRC_ERROR: 61 | print('ERROR: CRC_ERROR') 62 | elif link.status == txfer.Status.PAYLOAD_ERROR: 63 | print('ERROR: PAYLOAD_ERROR') 64 | elif link.status == txfer.Status.STOP_BYTE_ERROR: 65 | print('ERROR: STOP_BYTE_ERROR') 66 | else: 67 | print('ERROR: {}'.format(link.status.name)) 68 | 69 | ################################################################### 70 | # Parse response list 71 | ################################################################### 72 | rec_list_ = link.rx_obj(obj_type=type(list_), 73 | obj_byte_size=list_size, 74 | list_format='i') 75 | 76 | ################################################################### 77 | # Parse response string 78 | ################################################################### 79 | rec_str_ = link.rx_obj(obj_type=type(str_), 80 | obj_byte_size=str_size, 81 | start_pos=list_size) 82 | 83 | ################################################################### 84 | # Parse response float 85 | ################################################################### 86 | rec_float_ = link.rx_obj(obj_type=type(float_), 87 | obj_byte_size=float_size, 88 | start_pos=(list_size + str_size)) 89 | 90 | ################################################################### 91 | # Display the received data 92 | ################################################################### 93 | print('SENT: {} {} {}'.format(list_, str_, float_)) 94 | print('RCVD: {} {} {}'.format(rec_list_, rec_str_, rec_float_)) 95 | print(' ') 96 | 97 | except KeyboardInterrupt: 98 | try: 99 | link.close() 100 | except: 101 | pass 102 | 103 | except: 104 | import traceback 105 | traceback.print_exc() 106 | 107 | try: 108 | link.close() 109 | except: 110 | pass 111 | ``` 112 | 113 | # Example Arduino Sketch 114 | ```C++ 115 | #include "SerialTransfer.h" 116 | 117 | 118 | SerialTransfer myTransfer; 119 | 120 | 121 | void setup() 122 | { 123 | Serial.begin(115200); 124 | myTransfer.begin(Serial); 125 | } 126 | 127 | 128 | void loop() 129 | { 130 | if(myTransfer.available()) 131 | { 132 | // send all received data back to Python 133 | for(uint16_t i=0; i < myTransfer.bytesRead; i++) 134 | myTransfer.packet.txBuff[i] = myTransfer.packet.rxBuff[i]; 135 | 136 | myTransfer.sendData(myTransfer.bytesRead); 137 | } 138 | } 139 | ``` 140 | 141 | # Example Python Script with Callback Functionality 142 | Note that you can specify many callbacks, but only one per packet ID 143 | ```Python 144 | import time 145 | from pySerialTransfer import pySerialTransfer as txfer 146 | 147 | 148 | def hi(): 149 | ''' 150 | Callback function that will automatically be called by link.tick() whenever 151 | a packet with ID of 0 is successfully parsed. 152 | ''' 153 | 154 | print("hi") 155 | 156 | ''' 157 | list of callback functions to be called during tick. The index of the function 158 | reference within this list must correspond to the packet ID. For instance, if 159 | you want to call the function hi() when you parse a packet with an ID of 0, you 160 | would write the callback list with "hi" being in the 0th place of the list: 161 | ''' 162 | callback_list = [ hi ] 163 | 164 | 165 | if __name__ == '__main__': 166 | try: 167 | link = txfer.SerialTransfer('COM17') 168 | 169 | link.set_callbacks(callback_list) 170 | link.open() 171 | time.sleep(2) # allow some time for the Arduino to completely reset 172 | 173 | while True: 174 | link.tick() 175 | 176 | except KeyboardInterrupt: 177 | link.close() 178 | 179 | except: 180 | import traceback 181 | traceback.print_exc() 182 | 183 | link.close() 184 | ``` 185 | -------------------------------------------------------------------------------- /tests/test_py_serial_transfer.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock, PropertyMock 2 | 3 | import pytest 4 | import serial 5 | 6 | from pySerialTransfer.pySerialTransfer import ( 7 | InvalidCallbackList, 8 | InvalidSerialPort, 9 | SerialTransfer, 10 | State, 11 | BYTE_FORMATS, 12 | MAX_PACKET_SIZE, 13 | START_BYTE, 14 | ) 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def mock_serial(): 19 | with patch('serial.Serial') as mock: 20 | mock.return_value.is_open = False 21 | yield mock 22 | 23 | 24 | @pytest.fixture(autouse=True) 25 | def mock_comports(): 26 | with patch('serial.tools.list_ports.comports') as mock: 27 | mock.return_value = [MagicMock(device='COM3')] 28 | yield mock 29 | 30 | 31 | def make_incoming_byte_stream(incoming_byte_values: list[int], connection: MagicMock) -> list[bytes]: 32 | """Create a list of bytes objects from a list of byte values, set the in_waiting property of the connection mock, and 33 | set the read side effect of the connection mock to return the list of bytes objects. 34 | Return the list of bytes objects.""" 35 | incoming_bytes = [bytes([b]) for b in incoming_byte_values] 36 | type(connection).in_waiting = PropertyMock(side_effect=[True] * len(incoming_bytes) + [False]) 37 | connection.read.side_effect = incoming_bytes 38 | return incoming_bytes 39 | 40 | 41 | def test_port_is_required(): 42 | """Test that the SerialTransfer class raises a TypeError when no port is passed""" 43 | with pytest.raises(TypeError): 44 | SerialTransfer() 45 | 46 | 47 | def test_init_defaults(): 48 | """Basic test for SerialTransfer class initialization defaults""" 49 | st = SerialTransfer('COM3') 50 | assert st.port_name == 'COM3' 51 | assert st.debug is True 52 | assert st.byte_format == BYTE_FORMATS['little-endian'] 53 | assert st.connection.port == 'COM3' 54 | assert st.connection.baudrate == 115200 55 | assert st.connection.timeout == 0.05 56 | assert st.connection.write_timeout is None 57 | assert st.state == State.FIND_START_BYTE 58 | 59 | 60 | def test_raises_exception_on_invalid_port(): 61 | """Test that the SerialTransfer class raises an InvalidSerialPort exception when an invalid port is passed.""" 62 | with pytest.raises(InvalidSerialPort): 63 | SerialTransfer('NOT_A_REAL_PORT') 64 | 65 | 66 | def test_port_restriction_can_be_bypassed(mock_comports): 67 | """Test that the SerialTransfer class can be initialized with an invalid port if the port restriction is bypassed""" 68 | st = SerialTransfer(port='NOT_A_REAL_PORT', restrict_ports=False) 69 | assert mock_comports.call_count == 0 70 | assert st.port_name == 'NOT_A_REAL_PORT' 71 | 72 | 73 | @pytest.mark.parametrize('port, baud, timeout, write_timeout', [ 74 | ('COM3', 9600, 0.1, 0.1), 75 | ('COM4', 115200, 0.05, 0.05), 76 | ('COM5', 57600, 0.01, 0.01), 77 | ('COM6', 38400, 0.1, 0.1), 78 | ('COM7', 19200, 0.05, 0.05), 79 | ]) 80 | def test_serial_params_can_be_overridden(mock_comports, port, baud, timeout, write_timeout): 81 | """Test that the SerialTransfer class can be initialized with custom serial parameters""" 82 | mock_comports.return_value = [MagicMock(device=port)] 83 | st = SerialTransfer(port=port, baud=baud, timeout=timeout, write_timeout=write_timeout) 84 | assert st.port_name == port 85 | assert st.connection.baudrate == baud 86 | assert st.connection.timeout == timeout 87 | assert st.connection.write_timeout == write_timeout 88 | 89 | 90 | def test_open_returns_true_on_success(mock_serial): 91 | """Test that the open method returns True when the serial connection is successfully opened""" 92 | st = SerialTransfer('COM3') 93 | st.connection.open.return_value = True 94 | result = st.open() 95 | 96 | assert result is True 97 | 98 | 99 | def test_open_returns_false_on_serial_exception(mock_serial): 100 | """Test that the open method returns False when the serial connection raises an exception""" 101 | st = SerialTransfer('COM3') 102 | st.connection.open.side_effect = serial.SerialException 103 | result = st.open() 104 | 105 | assert result is False 106 | 107 | 108 | def test_open_on_open_connection(mock_serial): 109 | """Test that the open method does not call the connection.open method if the connection is already open""" 110 | st = SerialTransfer('COM3') 111 | st.connection.is_open = True 112 | result = st.open() 113 | 114 | assert st.connection.open.call_count == 0 115 | 116 | 117 | def test_close_closes_connection(mock_serial): 118 | """Test that the close method calls the connection.close method""" 119 | st = SerialTransfer('COM3') 120 | st.connection.is_open = True 121 | st.close() 122 | 123 | assert st.connection.close.call_count == 1 124 | 125 | 126 | @pytest.mark.parametrize('tx_buff, payload_length, expected_overhead_byte', [ 127 | ([START_BYTE, 0x01, 0x02, 0x03, 0x04], 5, 0x00), # found in 1st byte 128 | ([0x01, START_BYTE, 0x03, 0x04, 0x05], 5, 0x01), # found in 2nd byte 129 | ([0x02, 0x03, START_BYTE, 0x05, 0x06], 5, 0x02), # found in 3rd byte 130 | ([0x03, 0x04, 0x05, START_BYTE, 0x07], 5, 0x03), # found in 4th byte 131 | ([0x04, 0x05, 0x06, 0x07, START_BYTE], 5, 0x04), # found in 5th byte 132 | ([0x05, 0x06, 0x07, 0x08, 0x09], 5, 0xFF), # not found in payload 133 | ([0x06, 0x07, 0x08, 0x09, START_BYTE], 4, 0xFF), # not present within the payload length 134 | ]) 135 | def test_calc_overhead_basic(tx_buff, payload_length, expected_overhead_byte): 136 | """Test that the calc_overhead method sets the overhead property to the byte position in the payload of the first 137 | payload byte equal to the START_BYTE value""" 138 | st = SerialTransfer('COM3') 139 | st.tx_buff = tx_buff 140 | st.calc_overhead(payload_length) 141 | 142 | assert st.overhead_byte == expected_overhead_byte 143 | 144 | 145 | @pytest.mark.parametrize('tx_buff, payload_length, expected_position', [ 146 | ([START_BYTE, START_BYTE, START_BYTE, START_BYTE, START_BYTE], 5, 4), # all bytes are START_BYTE, last byte pos is payload length -1 147 | ([START_BYTE, START_BYTE, START_BYTE, 0x01, 0x01], 5, 2), # first 3 bytes are START_BYTE, last byte pos is 2 148 | ([START_BYTE, START_BYTE, START_BYTE, 0x01, START_BYTE], 4, 2), # trailing START_BYTE is ignored as it is not part of the payload 149 | ([START_BYTE, START_BYTE, START_BYTE, START_BYTE, START_BYTE], MAX_PACKET_SIZE + 1, -1), # special case: payload length exceeds MAX_PACKET_SIZE, return -1 150 | 151 | ]) 152 | def test_find_last(tx_buff, payload_length, expected_position): 153 | """Test that the find_last method returns the index of the last occurrence of the START_BYTE value in the tx_buff""" 154 | st = SerialTransfer('COM3') 155 | st.tx_buff = tx_buff 156 | result = st.find_last(payload_length) 157 | 158 | assert result == expected_position 159 | 160 | 161 | def test_stuff_packet(): 162 | # Create an instance of SerialTransfer 163 | st = SerialTransfer('COM3') 164 | 165 | # Set up a specific tx_buff 166 | st.tx_buff = [START_BYTE if i % 2 == 0 else i for i in range(MAX_PACKET_SIZE)] 167 | 168 | # Call stuff_packet with a specific payload length 169 | st.stuff_packet(MAX_PACKET_SIZE) 170 | 171 | # Assert that tx_buff has been modified as expected 172 | expected_tx_buff = [2, 1, 2, 3, 2, 5, 2, 7, 2, 9, 2, 11, 2, 13, 2, 15, 2, 17, 2, 19, 2, 21, 2, 23, 2, 25, 2, 27, 2, 29, 2, 31, 2, 33, 2, 35, 2, 37, 2, 39, 2, 41, 2, 43, 2, 45, 2, 47, 2, 49, 2, 51, 2, 53, 2, 55, 2, 57, 2, 59, 2, 61, 2, 63, 2, 65, 2, 67, 2, 69, 2, 71, 2, 73, 2, 75, 2, 77, 2, 79, 2, 81, 2, 83, 2, 85, 2, 87, 2, 89, 2, 91, 2, 93, 2, 95, 2, 97, 2, 99, 2, 101, 2, 103, 2, 105, 2, 107, 2, 109, 2, 111, 2, 113, 2, 115, 2, 117, 2, 119, 2, 121, 2, 123, 2, 125, 2, 127, 2, 129, 2, 131, 2, 133, 2, 135, 2, 137, 2, 139, 2, 141, 2, 143, 2, 145, 2, 147, 2, 149, 2, 151, 2, 153, 2, 155, 2, 157, 2, 159, 2, 161, 2, 163, 2, 165, 2, 167, 2, 169, 2, 171, 2, 173, 2, 175, 2, 177, 2, 179, 2, 181, 2, 183, 2, 185, 2, 187, 2, 189, 2, 191, 2, 193, 2, 195, 2, 197, 2, 199, 2, 201, 2, 203, 2, 205, 2, 207, 2, 209, 2, 211, 2, 213, 2, 215, 2, 217, 2, 219, 2, 221, 2, 223, 2, 225, 2, 227, 2, 229, 2, 231, 2, 233, 2, 235, 2, 237, 2, 239, 2, 241, 2, 243, 2, 245, 2, 247, 2, 249, 2, 251, 0, 253] 173 | assert st.tx_buff == expected_tx_buff 174 | 175 | 176 | def test_stuff_packet_pay_length_exceeds_max_packet_size(): 177 | """Test that the stuff_packet method does not modify the tx_buff when the payload length exceeds MAX_PACKET_SIZE""" 178 | # Create an instance of SerialTransfer 179 | st = SerialTransfer('COM3') 180 | 181 | # Set up a specific tx_buff 182 | start_tx_buff = [START_BYTE if i % 2 == 0 else i for i in range(MAX_PACKET_SIZE)] 183 | st.tx_buff = start_tx_buff.copy() 184 | 185 | # Call stuff_packet with a payload length that exceeds MAX_PACKET_SIZE 186 | st.stuff_packet(MAX_PACKET_SIZE + 1) 187 | 188 | # Assert that tx_buff has been modified as expected 189 | assert st.tx_buff == start_tx_buff 190 | 191 | 192 | def test_unpack_packet(): 193 | # Create an instance of SerialTransfer 194 | st = SerialTransfer('COM3') 195 | 196 | # Set up a specific rx_buff 197 | st.rx_buff = [2, 1, 2, 3, 2, 5, 2, 7, 2, 9, 2, 11, 2, 13, 2, 15, 2, 17, 2, 19, 2, 21, 2, 23, 2, 25, 2, 27, 2, 29, 2, 31, 2, 33, 2, 35, 2, 37, 2, 39, 2, 41, 2, 43, 2, 45, 2, 47, 2, 49, 2, 51, 2, 53, 2, 55, 2, 57, 2, 59, 2, 61, 2, 63, 2, 65, 2, 67, 2, 69, 2, 71, 2, 73, 2, 75, 2, 77, 2, 79, 2, 81, 2, 83, 2, 85, 2, 87, 2, 89, 2, 91, 2, 93, 2, 95, 2, 97, 2, 99, 2, 101, 2, 103, 2, 105, 2, 107, 2, 109, 2, 111, 2, 113, 2, 115, 2, 117, 2, 119, 2, 121, 2, 123, 2, 125, 2, 127, 2, 129, 2, 131, 2, 133, 2, 135, 2, 137, 2, 139, 2, 141, 2, 143, 2, 145, 2, 147, 2, 149, 2, 151, 2, 153, 2, 155, 2, 157, 2, 159, 2, 161, 2, 163, 2, 165, 2, 167, 2, 169, 2, 171, 2, 173, 2, 175, 2, 177, 2, 179, 2, 181, 2, 183, 2, 185, 2, 187, 2, 189, 2, 191, 2, 193, 2, 195, 2, 197, 2, 199, 2, 201, 2, 203, 2, 205, 2, 207, 2, 209, 2, 211, 2, 213, 2, 215, 2, 217, 2, 219, 2, 221, 2, 223, 2, 225, 2, 227, 2, 229, 2, 231, 2, 233, 2, 235, 2, 237, 2, 239, 2, 241, 2, 243, 2, 245, 2, 247, 2, 249, 2, 251, 0, 253] 198 | 199 | # Call unpack_packet 200 | st.unpack_packet() 201 | 202 | # Assert that rx_payload has been modified as expected 203 | expected_rx_payload = st.tx_buff = [START_BYTE if i % 2 == 0 else i for i in range(MAX_PACKET_SIZE)] 204 | assert st.rx_buff == expected_rx_payload 205 | 206 | 207 | def test_set_callbacks(): 208 | """Test that the set_callbacks method sets the callback property to the passed callbacks""" 209 | # Create an instance of SerialTransfer 210 | st = SerialTransfer('COM3') 211 | 212 | def callback_1(): 213 | pass 214 | 215 | def callback_2(): 216 | pass 217 | 218 | # Set up a specific callbacks list 219 | callbacks = [callback_1, callback_2] 220 | 221 | # Call set_callbacks 222 | st.set_callbacks(callbacks) 223 | 224 | # Assert that the callbacks property has been set as expected 225 | assert st.callbacks == callbacks 226 | 227 | 228 | def test_set_callbacks_raises_on_invalid_callback_types(): 229 | """Test that the set_callbacks method raises an InvalidCallbackList when the callbacks parameter is not a list""" 230 | # Create an instance of SerialTransfer 231 | st = SerialTransfer('COM3') 232 | 233 | # Set up callbacks with a non-list value 234 | callbacks = 'foo' 235 | 236 | # Call set_callbacks 237 | with pytest.raises(InvalidCallbackList): 238 | st.set_callbacks(callbacks) # type: ignore 239 | 240 | 241 | def test_set_callbacks_raises_on_non_callable_callbacks(): 242 | """Test that the set_callbacks method raises a TypeError when the callbacks parameter is not a list""" 243 | # Create an instance of SerialTransfer 244 | st = SerialTransfer('COM3') 245 | 246 | # Set up a specific callbacks dictionary 247 | callbacks = ['foo', 'bar'] 248 | 249 | # Call set_callbacks 250 | with pytest.raises(InvalidCallbackList): 251 | st.set_callbacks(callbacks) # type: ignore 252 | 253 | 254 | @pytest.mark.parametrize("val, start_pos, byte_format, val_type_override, expected", [ 255 | ("test", 0, '', '', 4), 256 | ({"key": "value"}, 0, '', '', 16), 257 | (1.23, 0, '', '', 4), 258 | (123, 0, '', '', 4), 259 | (True, 0, '', '', 1), 260 | (['a', 'b', 'c'], 0, '', '', 3), 261 | ("test", 0, '>', '', 4), 262 | (123, 0, '', 'h', 2), 263 | pytest.param( 264 | '11', 0, '', 'c', 1, 265 | marks=pytest.mark.xfail( 266 | reason="tx_obj does not handle gracefully handle exceptions when 'c' type is manually declared" 267 | ) 268 | ), 269 | ]) 270 | def test_tx_obj_known_types(val, start_pos, byte_format, val_type_override, expected): 271 | """Test that the tx_obj method returns the expected number of bytes for known types""" 272 | st = SerialTransfer('COM3') 273 | result = st.tx_obj(val, start_pos, byte_format, val_type_override) 274 | assert result == expected 275 | 276 | 277 | def test_tx_obj_unhandled_type(): 278 | """Test that the tx_obj returns None when an unhandled type is passed""" 279 | st = SerialTransfer('COM3') 280 | return_value = st.tx_obj(object, 0, '', '') 281 | assert return_value is None 282 | 283 | 284 | def test_tx_obj_val_type_override(): 285 | """Test that the tx_obj method uses the val_type_override parameter when it is passed""" 286 | st = SerialTransfer('COM3') 287 | result = st.tx_obj(123, 0, '', 'h') 288 | assert result == 2 289 | 290 | 291 | @pytest.mark.parametrize("rx_bytes, obj_type, start_pos, byte_format, expected", [ 292 | ([116, 101, 115, 116], str, 0, '', "test"), 293 | ([123, 34, 107, 101, 121, 34, 58, 32, 34, 118, 97, 108, 117, 101, 34, 125], dict, 0, '', {"key": "value"}), 294 | ([164, 112, 157, 63], float, 0, '', 1.23), 295 | ([123, 0, 0, 0], int, 0, '', 123), 296 | ([1, 0, 0, 0], bool, 0, '', True), 297 | ([116, 101, 115, 116], str, 0, '>', "test"), 298 | ]) 299 | def test_rx_obj_known_types(rx_bytes, obj_type, start_pos, byte_format, expected): 300 | """Test that the rx_obj method returns the expected value for known types""" 301 | st = SerialTransfer('COM3') 302 | st.rx_buff = rx_bytes + [' '] * (MAX_PACKET_SIZE - len(rx_bytes)) # First set the rx_buff 303 | result = st.rx_obj(obj_type, start_pos=start_pos, obj_byte_size=len(rx_bytes), byte_format=byte_format) # Then receive it 304 | if isinstance(result, float): 305 | assert pytest.approx(result, 0.01) == expected 306 | else: 307 | assert result == expected 308 | 309 | 310 | def test_rx_obj_unhandled_type(): 311 | """Test that the rx_obj returns None when an unhandled type is passed""" 312 | st = SerialTransfer('COM3') 313 | return_value = st.rx_obj(object) 314 | assert return_value is None 315 | 316 | 317 | def test_send(): 318 | # Create an instance of SerialTransfer 319 | st = SerialTransfer('COM3') 320 | 321 | # Mock the write method of the serial object 322 | st.connection.write = MagicMock() 323 | 324 | # Define the message to be sent 325 | message = [1, 2, 3, 4, 5] 326 | message_len = len(message) 327 | message_crc = 0x80 328 | 329 | # Add the message to the tx_buff 330 | st.tx_buff[:message_len] = message 331 | 332 | # Call the send method 333 | st.send(message_len) 334 | 335 | # Check that the write method was called with the correct argument 336 | st.connection.write.assert_called_once() 337 | 338 | # Get the actual value that was written 339 | actual_value = st.connection.write.call_args[0][0] 340 | 341 | # The expected value is the message wrapped in a bytearray, with additional bytes for the packet structure 342 | expected_value = bytearray([0x7E, 0, 0xFF, message_len] + message + [message_crc, 0x81]) 343 | 344 | # Assert that the actual value matches the expected value 345 | assert actual_value == expected_value 346 | 347 | 348 | def test_available_with_no_data(): 349 | st = SerialTransfer('COM3') 350 | incoming_byte_values = [] 351 | make_incoming_byte_stream(incoming_byte_values=incoming_byte_values, connection=st.connection) 352 | assert st.available() == 0 353 | 354 | 355 | def test_available_with_new_data(): 356 | st = SerialTransfer('COM3') 357 | incoming_byte_values = [0x7E, 0, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0xC8, 0x81] 358 | make_incoming_byte_stream(incoming_byte_values=incoming_byte_values, connection=st.connection) 359 | assert st.available() == 4 360 | 361 | 362 | def test_available_with_crc_error(): 363 | st = SerialTransfer('COM3') 364 | incoming_byte_values = [0x7E, 0x00, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0x81, 0x81] 365 | make_incoming_byte_stream(incoming_byte_values=incoming_byte_values, connection=st.connection) 366 | assert st.available() == 0 367 | 368 | 369 | def test_available_with_stop_byte_error(): 370 | st = SerialTransfer('COM3') 371 | incoming_byte_values = [0x7E, 0, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0x80, 0x7E] 372 | make_incoming_byte_stream(incoming_byte_values=incoming_byte_values, connection=st.connection) 373 | assert st.available() == 0 374 | 375 | 376 | def test_tick_with_valid_data(): 377 | """Test that the tick method returns True when valid data is received.""" 378 | st = SerialTransfer('COM3') 379 | incoming_byte_values = [0x7E, 0, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0xC8, 0x81] 380 | make_incoming_byte_stream(incoming_byte_values=incoming_byte_values, connection=st.connection) 381 | result = st.tick() 382 | assert result is True 383 | 384 | 385 | def test_tick_with_valid_data_and_callback(): 386 | """Test that the tick method calls the callback function when valid data is received.""" 387 | callback = MagicMock() 388 | st = SerialTransfer('COM3') 389 | st.set_callbacks([callback]) 390 | incoming_byte_values = [0x7E, 0, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0xC8, 0x81] 391 | make_incoming_byte_stream(incoming_byte_values=incoming_byte_values, connection=st.connection) 392 | result = st.tick() 393 | assert result is True 394 | callback.assert_called_once() 395 | 396 | 397 | def test_set_callbacks_with_non_callable_items(): 398 | """Test that set_callbacks raises an exception when non callable callbacks are passed, and that the callbacks 399 | property is not modified.""" 400 | st = SerialTransfer('COM3') 401 | original_callbacks = st.callbacks 402 | 403 | def i_am_callable(): 404 | pass 405 | 406 | with pytest.raises(InvalidCallbackList): 407 | st.set_callbacks(["i'm not callable", i_am_callable]) 408 | assert st.callbacks == original_callbacks 409 | 410 | 411 | def test_set_callbacks_with_non_iterable(): 412 | """Test that set_callbacks raises an exception when non list|tuple callbacks are passed, and that the callbacks 413 | property is not modified.""" 414 | st = SerialTransfer('COM3') 415 | original_callbacks = st.callbacks 416 | 417 | with pytest.raises(InvalidCallbackList): 418 | st.set_callbacks(True) # type: ignore 419 | assert st.callbacks == original_callbacks 420 | 421 | 422 | @pytest.mark.parametrize('incoming_byte_values, expected_print_str', [ 423 | ([0x7E, 0, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0xFF, 0x81], 'CRC_ERROR'), 424 | ([0x7E, 0, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0xC8, 0x7E], 'STOP_BYTE_ERROR'), 425 | ([0x7E, 0, 0xFF, 0xFF, 0x01, 0x02, 0x03, 0x04, 0xC8, 0x81], 'PAYLOAD_ERROR'), 426 | ]) 427 | def test_tick_with_invalid_data(caplog, incoming_byte_values, expected_print_str): 428 | """Test that the tick method returns False when invalid data is received.""" 429 | st = SerialTransfer('COM3') 430 | make_incoming_byte_stream(incoming_byte_values=incoming_byte_values, connection=st.connection) 431 | result = st.tick() 432 | assert result is False 433 | assert len(caplog.records) == 1 434 | assert caplog.records[0].message == f"{expected_print_str}" 435 | assert caplog.records[0].levelname == 'ERROR' 436 | 437 | 438 | @pytest.mark.parametrize('incoming_byte_values, expected_print_str', [ 439 | ([0x7E, 0, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0xFF, 0x81], 'CRC_ERROR'), 440 | ([0x7E, 0, 0xFF, 0x04, 0x01, 0x02, 0x03, 0x04, 0xC8, 0x7E], 'STOP_BYTE_ERROR'), 441 | ([0x7E, 0, 0xFF, 0xFF, 0x01, 0x02, 0x03, 0x04, 0xC8, 0x81], 'PAYLOAD_ERROR'), 442 | ]) 443 | def test_tick_with_invalid_data_debug_false(caplog, incoming_byte_values, expected_print_str): 444 | """Test that the tick method does not print when presented with invalid data and debug is False.""" 445 | st = SerialTransfer('COM3', debug=False) 446 | make_incoming_byte_stream(incoming_byte_values=incoming_byte_values, connection=st.connection) 447 | result = st.tick() 448 | assert result is False 449 | assert len(caplog.records) == 0 450 | 451 | -------------------------------------------------------------------------------- /pySerialTransfer/pySerialTransfer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import json 4 | import struct 5 | from enum import Enum 6 | from typing import Union 7 | 8 | import serial 9 | import serial.tools.list_ports 10 | from array import array 11 | from .CRC import CRC 12 | 13 | 14 | class InvalidSerialPort(Exception): 15 | pass 16 | 17 | 18 | class InvalidCallbackList(Exception): 19 | pass 20 | 21 | 22 | class Status(Enum): 23 | CONTINUE = 3 24 | NEW_DATA = 2 25 | NO_DATA = 1 26 | CRC_ERROR = 0 27 | PAYLOAD_ERROR = -1 28 | STOP_BYTE_ERROR = -2 29 | 30 | 31 | START_BYTE = 0x7E 32 | STOP_BYTE = 0x81 33 | 34 | MAX_PACKET_SIZE = 0xFE 35 | 36 | BYTE_FORMATS = {'native': '@', 37 | 'native_standard': '=', 38 | 'little-endian': '<', 39 | 'big-endian': '>', 40 | 'network': '!'} 41 | 42 | STRUCT_FORMAT_LENGTHS = {'c': 1, 43 | 'b': 1, 44 | 'B': 1, 45 | '?': 1, 46 | 'h': 2, 47 | 'H': 2, 48 | 'i': 4, 49 | 'I': 4, 50 | 'l': 4, 51 | 'L': 4, 52 | 'q': 8, 53 | 'Q': 8, 54 | 'e': 2, 55 | 'f': 4, 56 | 'd': 8} 57 | 58 | ARRAY_FORMAT_LENGTHS = {'b': 1, 59 | 'B': 1, 60 | 'u': 2, 61 | 'h': 2, 62 | 'H': 2, 63 | 'i': 2, 64 | 'I': 2, 65 | 'l': 4, 66 | 'q': 8, 67 | 'Q': 8, 68 | 'f': 4, 69 | 'd': 8} 70 | 71 | 72 | class State(Enum): 73 | FIND_START_BYTE = 0 74 | FIND_ID_BYTE = 1 75 | FIND_OVERHEAD_BYTE = 2 76 | FIND_PAYLOAD_LEN = 3 77 | FIND_PAYLOAD = 4 78 | FIND_CRC = 5 79 | FIND_END_BYTE = 6 80 | 81 | 82 | def constrain(val, min_, max_): 83 | if val < min_: 84 | return min_ 85 | elif val > max_: 86 | return max_ 87 | return val 88 | 89 | 90 | def serial_ports(): 91 | return [p.device for p in serial.tools.list_ports.comports(include_links=True)] 92 | 93 | 94 | class SerialTransfer: 95 | def __init__(self, port, baud=115200, restrict_ports=True, debug=True, byte_format=BYTE_FORMATS['little-endian'], timeout=0.05, write_timeout=None): 96 | ''' 97 | Description: 98 | ------------ 99 | Initialize transfer class and connect to the specified USB device 100 | 101 | :param port: int or str - port the USB device is connected to 102 | :param baud: int - baud (bits per sec) the device is configured for 103 | :param restrict_ports: bool - only allow port selection from auto 104 | detected list 105 | :param byte_format: str - format for values packed/unpacked via the 106 | struct package as defined by 107 | https://docs.python.org/3/library/struct.html#struct-format-strings 108 | :param timeout: float - timeout (in s) to set on pySerial for maximum wait for a read from the OS 109 | default 50ms marries up with DEFAULT_TIMEOUT in SerialTransfer 110 | :param write_timeout: float - timeout (in s) to set on pySerial for maximum wait for a write operation to the serial port 111 | default None causes no write timeouts to be raised 112 | :return: void 113 | ''' 114 | 115 | self.bytes_to_rec = 0 116 | self.pay_index = 0 117 | self.rec_overhead_byte = 0 118 | self.tx_buff = [' '] * MAX_PACKET_SIZE 119 | self.rx_buff = [' '] * MAX_PACKET_SIZE 120 | 121 | self.debug = debug 122 | self.id_byte = 0 123 | self.bytes_read = 0 124 | self.status = 0 125 | self.overhead_byte = 0xFF 126 | self.callbacks = [] 127 | self.byte_format = byte_format 128 | 129 | self.state = State.FIND_START_BYTE 130 | 131 | if restrict_ports: 132 | self.port_name = None 133 | for p in serial_ports(): 134 | if p == port or os.path.split(p)[-1] == port: 135 | self.port_name = p 136 | break 137 | 138 | if self.port_name is None: 139 | raise InvalidSerialPort('Invalid serial port specified.\ 140 | Valid options are {ports}, but {port} was provided'.format( 141 | **{'ports': serial_ports(), 'port': port})) 142 | else: 143 | self.port_name = port 144 | 145 | self.crc = CRC() 146 | self.connection = serial.Serial() 147 | self.connection.port = self.port_name 148 | self.connection.baudrate = baud 149 | self.connection.timeout = timeout 150 | self.connection.write_timeout = write_timeout 151 | 152 | def open(self): 153 | ''' 154 | Description: 155 | ------------ 156 | Open serial port and connect to device if possible 157 | 158 | :return: bool - True if successful, else False 159 | ''' 160 | 161 | if not self.connection.is_open: 162 | try: 163 | self.connection.open() 164 | return True 165 | except serial.SerialException as e: 166 | logging.exception(e) 167 | return False 168 | return True 169 | 170 | def set_callbacks(self, callbacks: Union[list[callable], tuple[callable]]): 171 | ''' 172 | Description: 173 | ------------ 174 | Specify a sequence of callback functions to be automatically called by 175 | self.tick() when a new packet is fully parsed. The ID of the parsed 176 | packet is then used to determine which callback needs to be called. 177 | 178 | :return: void 179 | ''' 180 | 181 | if not isinstance(callbacks, (list, tuple)): 182 | raise InvalidCallbackList('Parameter "callbacks" is not of type "list" or "tuple"') 183 | 184 | if not all([callable(cb) for cb in callbacks]): 185 | raise InvalidCallbackList('One or more elements in "callbacks" are not callable') 186 | 187 | self.callbacks = callbacks 188 | 189 | def close(self): 190 | ''' 191 | Description: 192 | ------------ 193 | Close serial port 194 | 195 | :return: void 196 | ''' 197 | if self.connection.is_open: 198 | self.connection.close() 199 | 200 | def tx_obj(self, val, start_pos=0, byte_format='', val_type_override=''): 201 | ''' 202 | Description: 203 | ----------- 204 | Insert an arbitrary variable's value into the TX buffer starting at the 205 | specified index 206 | 207 | :param val: n/a - value to be inserted into TX buffer 208 | :param start_pos: int - index of TX buffer where the first byte 209 | of the value is to be stored in 210 | :param byte_format: str - byte order, size and alignment according to 211 | https://docs.python.org/3/library/struct.html#struct-format-strings 212 | :param val_type_override: str - manually specify format according to 213 | https://docs.python.org/3/library/struct.html#format-characters 214 | 215 | :return: int - index of the last byte of the value in the TX buffer + 1, 216 | None if operation failed 217 | ''' 218 | 219 | if val_type_override: 220 | format_str = val_type_override 221 | 222 | else: 223 | if isinstance(val, str): 224 | val = val.encode() 225 | format_str = '%ds' % len(val) 226 | 227 | elif isinstance(val, dict): 228 | val = json.dumps(val).encode() 229 | format_str = '%ds' % len(val) 230 | 231 | elif isinstance(val, float): 232 | format_str = 'f' 233 | 234 | elif isinstance(val, bool): 235 | format_str = '?' 236 | 237 | elif isinstance(val, int): 238 | format_str = 'i' 239 | 240 | elif isinstance(val, list): 241 | for el in val: 242 | start_pos = self.tx_obj(el, start_pos) 243 | 244 | return start_pos 245 | 246 | else: 247 | return None 248 | 249 | if byte_format: 250 | val_bytes = struct.pack(byte_format + format_str, val) 251 | 252 | else: 253 | if format_str == 'c': 254 | val_bytes = struct.pack(self.byte_format + format_str, bytes(str(val), "utf-8")) 255 | else: 256 | val_bytes = struct.pack(self.byte_format + format_str, val) 257 | 258 | return self.tx_struct_obj(val_bytes, start_pos) 259 | 260 | def tx_struct_obj(self, val_bytes, start_pos=0): 261 | ''' 262 | Description: 263 | ----------- 264 | Insert a byte array into the TX buffer starting at the 265 | specified index 266 | 267 | :param val_bytes: bytearray - value to be inserted into TX buffer 268 | :param start_pos: int - index of TX buffer where the first byte 269 | of the value is to be stored in 270 | :return: int - index of the last byte of the value in the TX buffer + 1, 271 | None if operation failed 272 | ''' 273 | 274 | for index in range(len(val_bytes)): 275 | self.tx_buff[index + start_pos] = val_bytes[index] 276 | 277 | return start_pos + len(val_bytes) 278 | 279 | def rx_obj(self, obj_type, start_pos=0, obj_byte_size=0, list_format=None, byte_format=''): 280 | ''' 281 | Description: 282 | ------------ 283 | Extract an arbitrary variable's value from the RX buffer starting at 284 | the specified index. If object_type is list, it is assumed that the 285 | list to be extracted has homogeneous element types where the common 286 | element type can neither be list, dict, nor string longer than a 287 | single char 288 | 289 | :param obj_type: type or str - type of object to extract from the 290 | RX buffer or format string as 291 | defined by https://docs.python.org/3/library/struct.html#format-characters 292 | :param start_pos: int - index of TX buffer where the first byte 293 | of the value is to be stored in 294 | :param obj_byte_size: int - number of bytes making up extracted object 295 | :param list_format: char - array.array format char to represent the 296 | common list element type as defined by 297 | https://docs.python.org/3/library/array.html#module-array 298 | :param byte_format: str - byte order, size and alignment according to 299 | https://docs.python.org/3/library/struct.html#struct-format-strings 300 | 301 | :return unpacked_response: obj - object extracted from the RX buffer, 302 | None if operation failed 303 | ''' 304 | 305 | if (obj_type == str) or (obj_type == dict): 306 | buff = bytes(self.rx_buff[start_pos:(start_pos + obj_byte_size)]) 307 | format_str = '%ds' % len(buff) 308 | 309 | elif obj_type == float: 310 | format_str = 'f' 311 | buff = bytes(self.rx_buff[start_pos:(start_pos + STRUCT_FORMAT_LENGTHS[format_str])]) 312 | 313 | elif obj_type == int: 314 | format_str = 'i' 315 | buff = bytes(self.rx_buff[start_pos:(start_pos + STRUCT_FORMAT_LENGTHS[format_str])]) 316 | 317 | elif obj_type == bool: 318 | format_str = '?' 319 | buff = bytes(self.rx_buff[start_pos:(start_pos + STRUCT_FORMAT_LENGTHS[format_str])]) 320 | 321 | elif obj_type == list: 322 | buff = bytes(self.rx_buff[start_pos:(start_pos + obj_byte_size)]) 323 | 324 | if list_format: 325 | arr = array(list_format, buff) 326 | return arr.tolist() 327 | 328 | else: 329 | return None 330 | 331 | elif isinstance(obj_type, str): 332 | buff = bytes(self.rx_buff[start_pos:(start_pos + STRUCT_FORMAT_LENGTHS[obj_type])]) 333 | format_str = obj_type 334 | 335 | else: 336 | return None 337 | 338 | if byte_format: 339 | unpacked_response = struct.unpack(byte_format + format_str, buff)[0] 340 | 341 | else: 342 | unpacked_response = struct.unpack(self.byte_format + format_str, buff)[0] 343 | 344 | if (obj_type == str) or (obj_type == dict): 345 | # remove any trailing bytes of value 0 from data 346 | if 0 in unpacked_response: 347 | unpacked_response = unpacked_response[:unpacked_response.index(0)] 348 | 349 | unpacked_response = unpacked_response.decode('utf-8') 350 | 351 | if obj_type == dict: 352 | unpacked_response = json.loads(unpacked_response) 353 | 354 | return unpacked_response 355 | 356 | def calc_overhead(self, pay_len): 357 | ''' 358 | Description: 359 | ------------ 360 | Calculates the COBS (Consistent Overhead Stuffing) Overhead 361 | byte and stores it in the class's overhead_byte variable. This 362 | variable holds the byte position (within the payload) of the 363 | first payload byte equal to that of START_BYTE 364 | 365 | :param pay_len: int - number of bytes in the payload 366 | 367 | :return: void 368 | ''' 369 | 370 | self.overhead_byte = 0xFF 371 | 372 | for i in range(pay_len): 373 | if self.tx_buff[i] == START_BYTE: 374 | self.overhead_byte = i 375 | break 376 | 377 | def find_last(self, pay_len): 378 | ''' 379 | Description: 380 | ------------ 381 | Finds last instance of the value START_BYTE within the given 382 | packet array 383 | 384 | :param pay_len: int - number of bytes in the payload 385 | 386 | :return: int - location of the last instance of the value START_BYTE 387 | within the given packet array 388 | ''' 389 | 390 | if pay_len <= MAX_PACKET_SIZE: 391 | for i in range(pay_len - 1, -1, -1): 392 | if self.tx_buff[i] == START_BYTE: 393 | return i 394 | return -1 395 | 396 | def stuff_packet(self, pay_len): 397 | ''' 398 | Description: 399 | ------------ 400 | Enforces the COBS (Consistent Overhead Stuffing) ruleset across 401 | all bytes in the packet against the value of START_BYTE 402 | 403 | :param pay_len: int - number of bytes in the payload 404 | 405 | :return: void 406 | ''' 407 | 408 | ref_byte = self.find_last(pay_len) 409 | 410 | if (not ref_byte == -1) and (ref_byte <= MAX_PACKET_SIZE): 411 | for i in range(pay_len - 1, -1, -1): 412 | if self.tx_buff[i] == START_BYTE: 413 | self.tx_buff[i] = ref_byte - i 414 | ref_byte = i 415 | 416 | def send(self, message_len, packet_id=0): 417 | ''' 418 | Description: 419 | ------------ 420 | Send a specified number of bytes in packetized form 421 | 422 | :param message_len: int - number of bytes from the tx_buff to send as 423 | payload in the packet 424 | :param packet_id: int - ID of the packet to send 425 | 426 | :return: bool - whether or not the operation was successful 427 | ''' 428 | 429 | stack = [] 430 | message_len = constrain(message_len, 0, MAX_PACKET_SIZE) 431 | 432 | try: 433 | self.calc_overhead(message_len) 434 | self.stuff_packet(message_len) 435 | found_checksum = self.crc.calculate(self.tx_buff, message_len) 436 | 437 | stack.append(START_BYTE) 438 | stack.append(packet_id) 439 | stack.append(self.overhead_byte) 440 | stack.append(message_len) 441 | 442 | for i in range(message_len): 443 | if isinstance(self.tx_buff[i], str): 444 | val = ord(self.tx_buff[i]) 445 | else: 446 | val = int(self.tx_buff[i]) 447 | 448 | stack.append(val) 449 | 450 | stack.append(found_checksum) 451 | stack.append(STOP_BYTE) 452 | 453 | stack = bytearray(stack) 454 | 455 | if self.open(): 456 | self.connection.write(stack) 457 | 458 | return True 459 | 460 | except: 461 | import traceback 462 | traceback.print_exc() 463 | 464 | return False 465 | 466 | def unpack_packet(self): 467 | ''' 468 | Description: 469 | ------------ 470 | Unpacks all COBS-stuffed bytes within the array 471 | 472 | :return: void 473 | ''' 474 | 475 | test_index = self.rec_overhead_byte 476 | 477 | if test_index <= MAX_PACKET_SIZE: 478 | while self.rx_buff[test_index]: 479 | delta = self.rx_buff[test_index] 480 | self.rx_buff[test_index] = START_BYTE 481 | test_index += delta 482 | 483 | self.rx_buff[test_index] = START_BYTE 484 | 485 | def available(self): 486 | ''' 487 | Description: 488 | ------------ 489 | Parses incoming serial data, analyzes packet contents, 490 | and reports errors/successful packet reception 491 | 492 | :return self.bytes_read: int - number of bytes read from the received 493 | packet 494 | ''' 495 | 496 | if self.open(): 497 | if self.connection.in_waiting: 498 | while self.connection.in_waiting: 499 | rec_char = int.from_bytes(self.connection.read(), 500 | byteorder='big') 501 | 502 | if self.state == State.FIND_START_BYTE: 503 | if rec_char == START_BYTE: 504 | self.state = State.FIND_ID_BYTE 505 | 506 | elif self.state == State.FIND_ID_BYTE: 507 | self.id_byte = rec_char 508 | self.state = State.FIND_OVERHEAD_BYTE 509 | 510 | elif self.state == State.FIND_OVERHEAD_BYTE: 511 | self.rec_overhead_byte = rec_char 512 | self.state = State.FIND_PAYLOAD_LEN 513 | 514 | elif self.state == State.FIND_PAYLOAD_LEN: 515 | if rec_char > 0 and rec_char <= MAX_PACKET_SIZE: 516 | self.bytes_to_rec = rec_char 517 | self.pay_index = 0 518 | self.state = State.FIND_PAYLOAD 519 | else: 520 | self.bytes_read = 0 521 | self.state = State.FIND_START_BYTE 522 | self.status = Status.PAYLOAD_ERROR 523 | return self.bytes_read 524 | 525 | elif self.state == State.FIND_PAYLOAD: 526 | if self.pay_index < self.bytes_to_rec: 527 | self.rx_buff[self.pay_index] = rec_char 528 | self.pay_index += 1 529 | 530 | # Try to receive as many more bytes as we can, but we might not get all of them 531 | # if there is a timeout from the OS 532 | if self.pay_index != self.bytes_to_rec: 533 | more_bytes = list(self.connection.read(self.bytes_to_rec - self.pay_index)) 534 | next_index = self.pay_index + len(more_bytes) 535 | 536 | self.rx_buff[self.pay_index:next_index] = more_bytes 537 | self.pay_index = next_index 538 | 539 | if self.pay_index == self.bytes_to_rec: 540 | self.state = State.FIND_CRC 541 | 542 | elif self.state == State.FIND_CRC: 543 | found_checksum = self.crc.calculate( 544 | self.rx_buff, self.bytes_to_rec) 545 | 546 | if found_checksum == rec_char: 547 | self.state = State.FIND_END_BYTE 548 | else: 549 | self.bytes_read = 0 550 | self.state = State.FIND_START_BYTE 551 | self.status = Status.CRC_ERROR 552 | return self.bytes_read 553 | 554 | elif self.state == State.FIND_END_BYTE: 555 | self.state = State.FIND_START_BYTE 556 | 557 | if rec_char == STOP_BYTE: 558 | self.unpack_packet() 559 | self.bytes_read = self.bytes_to_rec 560 | self.status = Status.NEW_DATA 561 | return self.bytes_read 562 | 563 | self.bytes_read = 0 564 | self.status = Status.STOP_BYTE_ERROR 565 | return self.bytes_read 566 | 567 | else: 568 | logging.error('Undefined state: {}'.format(self.state)) 569 | 570 | self.bytes_read = 0 571 | self.state = State.FIND_START_BYTE 572 | return self.bytes_read 573 | else: 574 | self.bytes_read = 0 575 | self.status = Status.NO_DATA 576 | return self.bytes_read 577 | 578 | self.bytes_read = 0 579 | self.status = Status.CONTINUE 580 | return self.bytes_read 581 | 582 | def tick(self): 583 | ''' 584 | Description: 585 | ------------ 586 | Automatically parse all incoming packets, print debug statements if 587 | necessary (if enabled), and call the callback function that corresponds 588 | to the parsed packet's ID (if such a callback exists for that packet 589 | ID) 590 | 591 | :return: void 592 | ''' 593 | 594 | if self.available(): 595 | if self.id_byte < len(self.callbacks): 596 | self.callbacks[self.id_byte]() 597 | elif self.debug: 598 | logging.error('No callback available for packet ID {}'.format(self.id_byte)) 599 | 600 | return True 601 | 602 | elif self.debug and self.status in [Status.CRC_ERROR, Status.PAYLOAD_ERROR, Status.STOP_BYTE_ERROR]: 603 | if self.status == Status.CRC_ERROR: 604 | err_str = 'CRC_ERROR' 605 | elif self.status == Status.PAYLOAD_ERROR: 606 | err_str = 'PAYLOAD_ERROR' 607 | elif self.status == Status.STOP_BYTE_ERROR: 608 | err_str = 'STOP_BYTE_ERROR' 609 | else: 610 | err_str = str(self.status) 611 | 612 | logging.error('{}'.format(err_str)) 613 | 614 | return False 615 | --------------------------------------------------------------------------------