diff --git a/.github/workflows/build-validation.yml b/.github/workflows/build-validation.yml index 81c844a..6a1ba32 100644 --- a/.github/workflows/build-validation.yml +++ b/.github/workflows/build-validation.yml @@ -15,13 +15,13 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.x" - - name: Install pypa/build/pylint + - name: Install pypa/build/pylint/requirements.txt run: >- python3 -m pip install build - pyserial pylint + -r src/science_mode_4/requirements.txt --user - name: Build a binary wheel and a source tarball run: python3 -m build diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index f3d341c..b89260d 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -23,6 +23,7 @@ jobs: python3 -m pip install build + -r src/science_mode_4/requirements.txt --user - name: Build a binary wheel and a source tarball run: python3 -m build diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index bc89587..6dfa141 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -20,6 +20,7 @@ jobs: python3 -m pip install build + -r src/science_mode_4/requirements.txt --user - name: Build a binary wheel and a source tarball run: python3 -m build diff --git a/.vscode/launch.json b/.vscode/launch.json index 266e7b4..c269084 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,8 +8,8 @@ "name": "Python Debugger: Current File", "type": "debugpy", "request": "launch", - // "program": "__main__.py", - "module": "examples.dyscom.example_dyscom_write_csv", + "program": "__main__.py", + // "module": "examples.dyscom.example_dyscom_write_csv", "justMyCode": false, // "args": ["COM3"], "console": "integratedTerminal" diff --git a/README.md b/README.md index 1b5c4d1..12f4f96 100644 --- a/README.md +++ b/README.md @@ -11,29 +11,31 @@ Python 3.11 or higher # Library ## Installation - - Install science_mode_4 library inclusive dependencies via pip - `pip install science_mode_4` - https://pypi.org/project/science-mode-4/ ## Dependencies - - PySerial - https://pypi.org/project/pyserial/ - `pip install pyserial` -- PyUSB - currently not used +- PyUSB - https://pypi.org/project/pyusb/ - - `pip install pyusb` + - `pip install pyusb` - On Windows - - Download libusb from https://libusb.info/ - - Copy libusb-XX.dll into environment root folder (besides python.exe) - - Under Windows there are driver issues - - Code is currently commented out and not usable + - Install libusb-package to get _libusb-XX.dll_ + - https://pypi.org/project/libusb-package/ + - `pip install libusb-package` + - Under Windows there may be driver issues + - See https://github.com/libusb/libusb/wiki/Windows#How_to_use_libusb_on_Windows + - Use Zadig to change driver for _STM32 Virtual ComPort_ to _libusb-XX.dll_ and reinstall driver ## Build library - Only necessary, if you made changes to the library or install a version from a branch -- Install dependencies +- Install build dependencies - `python -m pip install --upgrade build` +- Install other library dependencies + - `pip install -r src/science_mode_4/requirements.txt` - Optional run linter - `pip install pylint` - `pylint .\src\science_mode_4\` @@ -87,6 +89,13 @@ Python 3.11 or higher - https://pypi.org/project/matplotlib/ - `pip install matplotlib` - Fastplotlib with glfw backend + - https://pypi.org/project/fastplotlib/ - `pip install -U fastplotlib` - `pip install -U glfw` +# Changes + +## 0.0.11 +- Implemented UsbConnection class + - Alternative for SerialPortConnection, both share the same base class Connection + - Added _PyUSB_ and _libusb-package_ as dependencies \ No newline at end of file diff --git a/__main__.py b/__main__.py index e9d8441..6791a42 100644 --- a/__main__.py +++ b/__main__.py @@ -1,30 +1,104 @@ """Test program how to use library without installing the library, DO NOT USE THIS FILE, USE EXAMPLES INSTEAD""" +from timeit import default_timer as timer + +import logging import sys import asyncio +from science_mode_4.device_i24 import DeviceI24 from science_mode_4.device_p24 import DeviceP24 +from science_mode_4.dyscom.ads129x.ads129x_config_register_1 import Ads129xOutputDataRate, Ads129xPowerMode +from science_mode_4.dyscom.dyscom_get_operation_mode import PacketDyscomGetAckOperationMode +from science_mode_4.dyscom.dyscom_send_live_data import PacketDyscomSendLiveData +from science_mode_4.dyscom.dyscom_types import DyscomFilterType, DyscomGetType, DyscomInitParams, DyscomPowerModulePowerType, DyscomPowerModuleType, DyscomSignalType +from science_mode_4.protocol.commands import Commands +from science_mode_4.protocol.types import ResultAndError +from science_mode_4.utils import logger from science_mode_4.utils.serial_port_connection import SerialPortConnection +from science_mode_4.utils.usb_connection import UsbConnection async def main() -> int: """Main function""" - devices = SerialPortConnection.list_science_mode_device_ports() - connection = SerialPortConnection(devices[0].device) - # devices = UsbConnection.list_science_mode_devices() - # connection = UsbConnection(devices[0]) + logger().disabled = True + + logger().setLevel(logging.DEBUG) + # devices = SerialPortConnection.list_science_mode_device_ports() + # connection = SerialPortConnection(devices[0].device) + devices = UsbConnection.list_science_mode_devices() + connection = UsbConnection(devices[0]) # connection = NullConnection() connection.open() - device = DeviceP24(connection) + device = DeviceI24(connection) await device.initialize() - general = device.get_layer_general() - print(f"Device id: {general.device_id}") - print(f"Firmware version: {general.firmware_version}") - print(f"Science mode version: {general.science_mode_version}") + # get dyscom layer to call dyscom level commands + dyscom = device.get_layer_dyscom() + + # call enable measurement power module for measurement + await dyscom.power_module(DyscomPowerModuleType.MEASUREMENT, DyscomPowerModulePowerType.SWITCH_ON) + # call init with 4k sample rate and enable signal types + init_params = DyscomInitParams() + init_params.filter = DyscomFilterType.PREDEFINED_FILTER_2 + init_params.signal_type = [DyscomSignalType.BI, DyscomSignalType.EMG_1,\ + DyscomSignalType.EMG_2, DyscomSignalType.BREATHING, DyscomSignalType.TEMPERATURE] + init_params.register_map_ads129x.config_register_1.output_data_rate = Ads129xOutputDataRate.HR_MODE_4_KSPS__LP_MODE_2_KSPS + init_params.register_map_ads129x.config_register_1.power_mode = Ads129xPowerMode.HIGH_RESOLUTION + await dyscom.init(init_params) + + # start dyscom measurement + await dyscom.start() + + start_time = timer() + total_count = 0 + + # loop for some time + for x in range(1000): + # check operation mode from time to time, this function is not waiting for response + # so we have to handle it by ourself later + if x % 100 == 0: + dyscom.send_get_operation_mode() + + live_data_counter = 0 + while True: + # process all available packages + ack = dyscom.packet_buffer.get_packet_from_buffer(live_data_counter == 0) + if ack: + # because there are multiple get commands, we need to additionally check kind, + # which is always associated DyscomGetType + if ack.command == Commands.DL_GET_ACK and ack.kind == DyscomGetType.OPERATION_MODE: + om_ack: PacketDyscomGetAckOperationMode = ack + print(f"Operation mode {om_ack.operation_mode.name}") + # check if measurement is still active + if om_ack.result_error != ResultAndError.NO_ERROR: + break + elif ack.command == Commands.DL_SEND_LIVE_DATA: + live_data_counter += 1 + total_count += 1 + + sld: PacketDyscomSendLiveData = ack + if sld.status_error: + print(f"SendLiveData status error {sld.samples}") + break + + else: + # print(f"Live data acknowledges per iteration {live_data_counter}") + break + + # await asyncio.sleep(0.01) + + # print stats + end_time = timer() + print(f"Samples: {total_count}, duration: {end_time - start_time}, sample rate: {total_count / (end_time - start_time)}") + + # stop measurement + await dyscom.stop() + # turn power module off + await dyscom.power_module(DyscomPowerModuleType.MEASUREMENT, DyscomPowerModulePowerType.SWITCH_OFF) connection.close() diff --git a/pyproject.toml b/pyproject.toml index 0692517..494fa83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "science_mode_4" -version = "0.0.10" +version = "0.0.11" authors = [ { name="Marc Hofmann", email="marc-hofmann@gmx.de" }, ] @@ -20,7 +20,9 @@ license-files = [ "LICENSE" ] dependencies = [ - "pyserial >= 3.5", + "pyserial", + "pyusb", + "libusb-package" ] [project.urls] diff --git a/src/science_mode_4/protocol/protocol.py b/src/science_mode_4/protocol/protocol.py index b60540d..b6cc72b 100644 --- a/src/science_mode_4/protocol/protocol.py +++ b/src/science_mode_4/protocol/protocol.py @@ -48,7 +48,6 @@ def packet_to_bytes(packet: Packet) -> bytes: bb.append_byte(Protocol.STOP_BYTE) logger().debug("Build package, %s", packet) - logger().debug("Outgoing data, %s", bb) result = bb.get_bytes() return bytes(result) diff --git a/src/science_mode_4/requirements.txt b/src/science_mode_4/requirements.txt new file mode 100644 index 0000000..b4da6b7 --- /dev/null +++ b/src/science_mode_4/requirements.txt @@ -0,0 +1,3 @@ +pyserial +pyusb +libusb-package \ No newline at end of file diff --git a/src/science_mode_4/utils/connection.py b/src/science_mode_4/utils/connection.py index 958034b..2ff7dc9 100644 --- a/src/science_mode_4/utils/connection.py +++ b/src/science_mode_4/utils/connection.py @@ -2,6 +2,8 @@ from abc import ABC, abstractmethod +from .logger import logger + class Connection(ABC): """Abstract base class for connection""" @@ -22,16 +24,23 @@ def is_open(self) -> bool: """Checks if connection is open""" - @abstractmethod def write(self, data: bytes): """Write data to connection""" + logger().debug("Outgoing data, length: %d, bytes: %s", len(data), data.hex(" ").upper()) - @abstractmethod def read(self) -> bytes: """Read all data from connection""" + result = self._read_intern() + logger().debug("Incoming data, length: %d, bytes: %s", len(result), result.hex(" ").upper()) + return result @abstractmethod def clear_buffer(self): """Clear buffer from connection""" + + + @abstractmethod + def _read_intern(self): + """Read all data from connection""" diff --git a/src/science_mode_4/utils/serial_port_connection.py b/src/science_mode_4/utils/serial_port_connection.py index 3f75cc2..81643ea 100644 --- a/src/science_mode_4/utils/serial_port_connection.py +++ b/src/science_mode_4/utils/serial_port_connection.py @@ -1,11 +1,11 @@ """Provides a class for a serial connection""" +import os import serial import serial.tools.list_ports import serial.tools.list_ports_common from .connection import Connection -from .logger import logger class SerialPortConnection(Connection): @@ -35,7 +35,9 @@ def __init__(self, port: str): def open(self): self._ser.open() - self._ser.set_buffer_size(4096*128) + + if os.name == "nt": + self._ser.set_buffer_size(4096*128) def close(self): @@ -47,16 +49,17 @@ def is_open(self) -> bool: def write(self, data: bytes): + super().write(data) self._ser.write(data) - def read(self) -> bytes: + def clear_buffer(self): + self._ser.reset_input_buffer() + + + def _read_intern(self) -> bytes: result = [] if self._ser.in_waiting > 0: result = self._ser.read_all() - logger().debug("Incoming data, length: %d, bytes: %s", len(result), result.hex(" ").upper()) - return bytes(result) - - def clear_buffer(self): - self._ser.reset_input_buffer() + return bytes(result) diff --git a/src/science_mode_4/utils/usb_connection.py b/src/science_mode_4/utils/usb_connection.py index 09b5c33..3691904 100644 --- a/src/science_mode_4/utils/usb_connection.py +++ b/src/science_mode_4/utils/usb_connection.py @@ -1,7 +1,8 @@ """Provides a class for a usb connection""" -# import usb.core -# import usb.util +import libusb_package +import usb.core +import usb.util from .connection import Connection class UsbConnection(Connection): @@ -9,64 +10,86 @@ class UsbConnection(Connection): IMPORTANT: work in progress (there are driver issues under windows)""" - # @staticmethod - # def list_devices() -> list[usb.core.Device]: - # """Returns all USB devices""" - # return list(usb.core.find(find_all=True)) + @staticmethod + def list_devices() -> list[usb.core.Device]: + """Returns all USB devices""" + # return list(usb.core.find(find_all=True)) + return libusb_package.find(find_all=True) - # @staticmethod - # def list_science_mode_devices() -> list[usb.core.Device]: - # """Returns all potential science mode USB devices""" - # devices = UsbConnection.list_devices() - # # science mode devices (P24/I24) have an STM32 mcu and these are - # # default values for USB CDC devices - # filtered_devices = list(filter(lambda x: x.idVendor == 0x0483 and x.idProduct == 0x5740 and - # x.bDeviceClass == 0x02, devices)) - # return filtered_devices + @staticmethod + def list_science_mode_devices() -> list[usb.core.Device]: + """Returns all potential science mode USB devices""" + devices = UsbConnection.list_devices() + # science mode devices (P24/I24) have an STM32 mcu and these are + # default values for USB CDC devices + filtered_devices = list(filter(lambda x: x.idVendor == 0x0483 and x.idProduct == 0x5740 and + x.bDeviceClass == 0x02, devices)) + return filtered_devices - # def __init__(self, device: usb.core.Device): - # self._device = device - # self._out_endpoint = None - # self._in_endpoint = None - # self._is_open = False + def __init__(self, device: usb.core.Device): + self._device = device + self._out_endpoint: usb.core.Endpoint = None + self._in_endpoint: usb.core.Endpoint = None + self._in_endpoint_buffer_size = 128 * 64 + self._is_open = False - # def open(self): - # # P24/I24 have only one configuration - # self._device.set_configuration() - # # get an endpoint instance - # cfg = self._device.get_active_configuration() - # intf = cfg[(0,0)] + def open(self): + # P24/I24 have only one configuration + self._device.set_configuration() + # get an endpoint instance + cfg = self._device.get_active_configuration() + # Interface 0: CDC Communication + # Interface 1: CDC Data + interface: usb.core.Interface = cfg[(1,0)] - # # match the first OUT endpoint - # self._out_endpoint = usb.util.find_descriptor( - # intf, - # custom_match = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT) - # # match the first IN endpoint - # self._in_endpoint = usb.util.find_descriptor( - # intf, - # custom_match = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN) + # match the first OUT endpoint + self._out_endpoint = usb.util.find_descriptor( + interface, + custom_match = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT) + # match the first IN endpoint + self._in_endpoint = usb.util.find_descriptor( + interface, + custom_match = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN) - # self._is_open = True + self._is_open = True - # def close(self): - # self._is_open = False + def close(self): + self._is_open = False - # def is_open(self) -> bool: - # return self._is_open + def is_open(self) -> bool: + return self._is_open - # def write(self, data: bytes): - # self._out_endpoint.write(data) + def write(self, data: bytes): + super().write(data) + self._out_endpoint.write(data) - # def read(self) -> bytes: - # return self._in_endpoint.read() + def clear_buffer(self): + # there is no way to clear the buffer from in endpoint, so + # read all data and discard it + self._read_intern() - # def clear_buffer(self): - # pass + def _read_intern(self) -> bytes: + data = bytes() + try: + while True: + # Read up to endpoint's max packet size (64 bytes in this case) + tmp = self._in_endpoint.read(self._in_endpoint_buffer_size) + data += bytes(tmp) + if len(tmp) < self._in_endpoint_buffer_size: + break + except usb.core.USBError as e: + if e.errno == 10060: + # Timeout error (no data available) + pass + else: + raise + + return data