From 22935a2a62150f438d01bfd589659e099af002f7 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 25 Oct 2025 18:43:00 +0200 Subject: [PATCH 01/32] Update the Changelog for version v1.0.0 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c3bba..fdd421c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ This changelog tries to follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The project uses semantic versioning. +## 1.0.0 - 2025-10-25 + +This is a rebrand of release v.07.0 as v1.0.0. + +A lot of incoming changes and modifications already present on the main branch +make the future code base incompatible to previous releases, such as v0.7.0. +I would like to make the state that is v0.7.0 the official version 1.0.0 of +pyshimmer. That way, it will be easier for users to switch between this first +version and the newer modifications that are going to lead to version 2.0.0. + ## 0.7.0 - 2025-01-18 First release with Changelog From a2ef575861b4c6c83cfc28807cf300fc603b1275 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 25 Oct 2025 19:40:49 +0200 Subject: [PATCH 02/32] Simplify ChannelDataType to use int.from_bytes and int.to_bytes (#30) * Simplify ChannelDataType class by using int.from_bytes and int.to_bytes * Update tests and docstring for ChannelDataType * Update the Changelog --- CHANGELOG.md | 2 + pyshimmer/dev/channels.py | 102 +++++++++---------------------- test/dev/test_device_channels.py | 15 ++++- 3 files changed, 46 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75760f5..85007fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The project uses semantic versioning. - Wrap long lines to 90 characters - Replace types from typing with built-in ones - Raise required Python version to 3.9 since PEP 604 is used in the code +- Update the `ChannelDataType` class to use the `int.from_bytes` and + `int.to_bytes` methods ## 1.0.0 - 2025-10-25 diff --git a/pyshimmer/dev/channels.py b/pyshimmer/dev/channels.py index 963bda9..3f31564 100644 --- a/pyshimmer/dev/channels.py +++ b/pyshimmer/dev/channels.py @@ -15,36 +15,31 @@ # along with this program. If not, see . from __future__ import annotations -import struct from collections.abc import Iterable from enum import Enum, auto, unique +from typing import Literal -from pyshimmer.util import raise_to_next_pow, unpack, flatten_list, bit_is_set +from pyshimmer.util import flatten_list, bit_is_set class ChannelDataType: - """Represents the binary data type and format of a Shimmer data channel - - Every channel that is recorded by a Shimmer device has a specific data type. This - class represents the data type of a single such channel, and is capable of decoding - binary data into the appropriate form. - """ def __init__(self, size: int, signed: bool = True, le: bool = True): + """Represents the binary data type and format of a Shimmer data channel + + Every channel that is recorded by a Shimmer device has a specific data type. This + class represents the data type of a single such channel, and is capable of decoding + binary data into the appropriate form. + + :param size: Length of the data type in Bytes + :param signed: True if the data type is a signed integer + :param le: True if the data type is encoded little endian, False if the + data type is encoded big endian + """ self._size = size self._signed = signed self._le = le - self._valid_size = raise_to_next_pow(self.size) - self._needs_extend = size != self._valid_size - - self._struct_dtypes = { - 1: "B", - 2: "H", - 4: "I", - 8: "Q", - } - @property def little_endian(self) -> bool: return self._le @@ -53,6 +48,13 @@ def little_endian(self) -> bool: def big_endian(self) -> bool: return not self._le + @property + def byte_order(self) -> Literal["little", "big"]: + if self.big_endian: + return "big" + + return "little" + @property def signed(self) -> bool: return self._signed @@ -61,63 +63,19 @@ def signed(self) -> bool: def size(self) -> int: return self._size - def _get_msb(self, val: bytes): - if self.little_endian: - return val[-1] - else: - return val[0] - - def _get_extension_value(self, val: bytes): - msb = self._get_msb(val) - is_negative = (msb >> 7) & 1 == 1 - - if self.signed and is_negative: - return b"\xff" - return b"\x00" - - def _extend_value(self, val: bytes) -> bytes: - ext_value = self._get_extension_value(val) - suffix = ext_value * (self._valid_size - self.size) - - if self.little_endian: - return val + suffix - else: - return suffix + val - - def _truncate_value(self, val: bytes) -> bytes: - if self.little_endian: - return val[: self._size] - else: - return val[self._valid_size - self._size :] - - def _get_struct_format(self) -> str: - stype = self._struct_dtypes[self._valid_size] - if self.signed: - stype = stype.lower() - - if self.little_endian: - prefix = "<" - else: - prefix = ">" - - return prefix + stype - - def decode(self, val_bin: bytes) -> any: - if self._needs_extend: - val_bin = self._extend_value(val_bin) + def decode(self, val_bin: bytes) -> int: + if len(val_bin) != self.size: + raise ValueError( + f"Binary value does not match required size: " + f"{len(val_bin)} != {self.size}" + ) - struct_format = self._get_struct_format() - r_tpl = struct.unpack(struct_format, val_bin) - return unpack(r_tpl) + return int.from_bytes(val_bin, byteorder=self.byte_order, signed=self.signed) def encode(self, val: int) -> bytes: - struct_format = self._get_struct_format() - val_packed = struct.pack(struct_format, val) - - if self._needs_extend: - return self._truncate_value(val_packed) - - return val_packed + return val.to_bytes( + length=self.size, byteorder=self.byte_order, signed=self.signed + ) # @unique causes issues with PyCharm code indexing diff --git a/test/dev/test_device_channels.py b/test/dev/test_device_channels.py index 5b5c110..f0bea02 100644 --- a/test/dev/test_device_channels.py +++ b/test/dev/test_device_channels.py @@ -31,7 +31,7 @@ ) -class DeviceChannelsTest(TestCase): +class EChannelTypeTest(TestCase): def test_channel_enum_uniqueness(self): try: @@ -60,6 +60,16 @@ def test_channel_type_enum_for_id(self): # Timestamp is not public EChannelType.enum_for_id(0x100) + +class ChannelDataTypeTest(TestCase): + + def test_ch_dtype_byte_order(self): + dtype = ChannelDataType(size=4, signed=True, le=True) + assert dtype.byte_order == "little" + + dtype = ChannelDataType(size=4, signed=True, le=False) + assert dtype.byte_order == "big" + def test_channel_data_type_decoding(self): def test_both_endianess(byte_val_le: bytes, expected: int, signed: bool): blen = len(byte_val_le) @@ -119,6 +129,9 @@ def test_both_endianess(val: int, val_len: int, expected: bytes, signed: bool): test_both_endianess(0x12345, 3, b"\x45\x23\x01", signed=False) test_both_endianess(-0x12345, 3, b"\xbb\xdc\xfe", signed=True) + +class ChannelFunctionsTest(TestCase): + def test_get_ch_dtypes(self): channels = [EChannelType.INTERNAL_ADC_A1, EChannelType.GYRO_Y] r = get_ch_dtypes(channels) From 133804662204bef3c79ad2e0792972eb2fad1518 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 25 Oct 2025 18:30:40 +0200 Subject: [PATCH 03/32] Current working state of Shimmer HardwareRevision --- pyshimmer/dev/revision.py | 153 ++++++++++++++++++ pyshimmer/dev/revisions/__init__.py | 0 pyshimmer/dev/revisions/shimmer3.py | 234 ++++++++++++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 pyshimmer/dev/revision.py create mode 100644 pyshimmer/dev/revisions/__init__.py create mode 100644 pyshimmer/dev/revisions/shimmer3.py diff --git a/pyshimmer/dev/revision.py b/pyshimmer/dev/revision.py new file mode 100644 index 0000000..4d8199b --- /dev/null +++ b/pyshimmer/dev/revision.py @@ -0,0 +1,153 @@ +from collections.abc import Iterable +from pyshimmer.dev.channels import EChannelType, ChannelDataType, ESensorGroup + +from abc import ABC, abstractmethod +from typing import overload +import numpy as np + + +class HardwareRevision(ABC): + + @abstractmethod + def sr2dr(self, sr: float) -> int: + """Calculate equivalent device-specific rate for a sample rate in Hz + + Device-specific sample rates are given in absolute clock ticks per unit of time. + This function can be used to calculate such a rate for the Shimmer3. + + :param sr: The sampling rate in Hz + :return: An integer which represents the equivalent device-specific sampling rate + """ + pass + + @abstractmethod + def dr2sr(self, dr: int) -> float: + """Calculate equivalent sampling rate for a given device-specific rate + + Device-specific sample rates are given in absolute clock ticks per unit of time. + This function can be used to calculate a regular sampling rate in Hz from such a + rate. + + :param dr: The absolute device rate as integer + :return: A floating-point number that represents the sampling rate in Hz + """ + pass + + @overload + def sec2ticks(self, t_sec: float) -> int: ... + + @overload + def sec2ticks(self, t_sec: np.ndarray) -> np.ndarray: ... + + @abstractmethod + def sec2ticks(self, t_sec: float | np.ndarray) -> int | np.ndarray: + """Calculate equivalent device clock ticks for a time in seconds + + Args: + t_sec: A time in seconds + Returns: + An integer which represents the equivalent number of clock ticks + """ + pass + + @overload + def ticks2sec(self, t_ticks: int) -> float: ... + + @overload + def ticks2sec(self, t_ticks: np.ndarray) -> np.ndarray: ... + + @abstractmethod + def ticks2sec(self, t_ticks: int | np.ndarray) -> float | np.ndarray: + """Calculate the time in seconds equivalent to a device clock ticks count + + Args: + t_ticks: A clock tick counter for which to calculate the time in seconds + Returns: + A floating point time in seconds that is equivalent to the number of clock ticks + """ + pass + + def get_channel_dtype(self, channel: EChannelType) -> ChannelDataType: + """ + + :param channel: + :return: A list of channel data types with the same order + """ + pass + + @abstractmethod + def get_channel_dtypes( + self, channels: Iterable[EChannelType] + ) -> list[ChannelDataType]: + """Return the channel data types for a set of channels + + :param channels: A list of channels + :return: A list of channel data types with the same order + """ + pass + + @abstractmethod + def get_enabled_channels( + self, sensors: Iterable[ESensorGroup] + ) -> list[EChannelType]: + """Determine the set of data channels for a set of enabled sensors + + There exists a one-to-many mapping between enabled sensors and their corresponding + data channels. This function determines the set of necessary channels for a given + set of enabled sensors. + + :param sensors: A list of sensors that are enabled on a Shimmer + :return: A list of channels in the corresponding order + """ + pass + + @abstractmethod + def sensors2bitfield(self, sensors: Iterable[ESensorGroup]) -> int: + """Convert an iterable of sensors into the corresponding bitfield transmitted to + the Shimmer + + :param sensors: A list of active sensors + :return: A bitfield that conveys the set of active sensors to the Shimmer + """ + pass + + @abstractmethod + def bitfield2sensors(self, bitfield: int) -> list[ESensorGroup]: + """Decode a bitfield returned from the Shimmer to a list of active sensors + + :param bitfield: The bitfield received from the Shimmer encoding the active sensors + :return: The corresponding list of active sensors + """ + pass + + @abstractmethod + def serialize_sensorlist(self, sensors: Iterable[ESensorGroup]) -> bytes: + """Serialize a list of sensors to the three-byte bitfield accepted by the Shimmer + + :param sensors: The list of sensors + :return: A byte string with length 3 that encodes the sensors + """ + pass + + @abstractmethod + def deserialize_sensorlist(self, bitfield_bin: bytes) -> list[ESensorGroup]: + """Deserialize the list of active sensors from the three-byte input received from + the Shimmer + + :param bitfield_bin: The input bitfield as byte string with length 3 + :return: The list of active sensors + """ + pass + + @abstractmethod + def sort_sensors(self, sensors: Iterable[ESensorGroup]) -> list[ESensorGroup]: + """Sorts the sensors in the list according to the sensor order + + This function is useful to determine the order in which sensor data will appear in + a data file by ordering the list of sensors according to their order in the file. + + :param sensors: An unsorted list of sensors + :return: A list with the same sensors as content but sorted according to their + appearance order in the data file + """ + pass diff --git a/pyshimmer/dev/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyshimmer/dev/revisions/shimmer3.py b/pyshimmer/dev/revisions/shimmer3.py new file mode 100644 index 0000000..04b0a01 --- /dev/null +++ b/pyshimmer/dev/revisions/shimmer3.py @@ -0,0 +1,234 @@ +import numpy as np +from typing import Iterable + +from ..channels import EChannelType, ChannelDataType, ESensorGroup +from ..revision import HardwareRevision +from pyshimmer.util import flatten_list + +""" +Assigns each channel type its appropriate data type. +""" +ChDataTypeAssignment: dict[EChannelType, ChannelDataType] = { + EChannelType.ACCEL_LN_X: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_LN_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_LN_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.VBATT: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_X: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_X: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.GYRO_X: ChannelDataType(2, signed=True, le=False), + EChannelType.GYRO_Y: ChannelDataType(2, signed=True, le=False), + EChannelType.GYRO_Z: ChannelDataType(2, signed=True, le=False), + EChannelType.EXTERNAL_ADC_A0: ChannelDataType(2, signed=False, le=True), + EChannelType.EXTERNAL_ADC_A1: ChannelDataType(2, signed=False, le=True), + EChannelType.EXTERNAL_ADC_A2: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A3: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A0: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A1: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A2: ChannelDataType(2, signed=False, le=True), + EChannelType.ACCEL_HG_X: None, + EChannelType.ACCEL_HG_Y: None, + EChannelType.ACCEL_HG_Z: None, + EChannelType.MAG_WR_X: None, + EChannelType.MAG_WR_Y: None, + EChannelType.MAG_WR_Z: None, + EChannelType.TEMPERATURE: ChannelDataType(2, signed=False, le=False), + EChannelType.PRESSURE: ChannelDataType(3, signed=False, le=False), + EChannelType.GSR_RAW: ChannelDataType(2, signed=False, le=True), + EChannelType.EXG1_STATUS: ChannelDataType(1, signed=False, le=True), + EChannelType.EXG1_CH1_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG1_CH2_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG2_STATUS: ChannelDataType(1, signed=False, le=True), + EChannelType.EXG2_CH1_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG2_CH2_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG1_CH1_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG1_CH2_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG2_CH1_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG2_CH2_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.STRAIN_HIGH: ChannelDataType(2, signed=False, le=True), + EChannelType.STRAIN_LOW: ChannelDataType(2, signed=False, le=True), + EChannelType.TIMESTAMP: ChannelDataType(3, signed=False, le=True), +} + +""" +This dictionary contains the mapping from sensor to data channels. Since one sensor can +record on multiple channels, the mapping is one-to-many. +""" +SensorChannelAssignment: dict[ESensorGroup, list[EChannelType]] = { + ESensorGroup.ACCEL_LN: [ + EChannelType.ACCEL_LN_X, + EChannelType.ACCEL_LN_Y, + EChannelType.ACCEL_LN_Z, + ], + ESensorGroup.BATTERY: [EChannelType.VBATT], + ESensorGroup.EXT_CH_A0: [EChannelType.EXTERNAL_ADC_A0], + ESensorGroup.EXT_CH_A1: [EChannelType.EXTERNAL_ADC_A1], + ESensorGroup.EXT_CH_A2: [EChannelType.EXTERNAL_ADC_A2], + ESensorGroup.INT_CH_A0: [EChannelType.INTERNAL_ADC_A0], + ESensorGroup.INT_CH_A1: [EChannelType.INTERNAL_ADC_A1], + ESensorGroup.INT_CH_A2: [EChannelType.INTERNAL_ADC_A2], + ESensorGroup.STRAIN: [EChannelType.STRAIN_HIGH, EChannelType.STRAIN_LOW], + ESensorGroup.INT_CH_A3: [EChannelType.INTERNAL_ADC_A3], + ESensorGroup.GSR: [EChannelType.GSR_RAW], + ESensorGroup.GYRO: [ + EChannelType.GYRO_X, + EChannelType.GYRO_Y, + EChannelType.GYRO_Z, + ], + ESensorGroup.ACCEL_WR: [ + EChannelType.ACCEL_WR_X, + EChannelType.ACCEL_WR_Y, + EChannelType.ACCEL_WR_Z, + ], + ESensorGroup.MAG_REG: [ + EChannelType.MAG_REG_X, + EChannelType.MAG_REG_Y, + EChannelType.MAG_REG_Z, + ], + ESensorGroup.ACCEL_HG: [ + EChannelType.ACCEL_HG_X, + EChannelType.ACCEL_HG_Y, + EChannelType.ACCEL_HG_Z, + ], + ESensorGroup.MAG_WR: [ + EChannelType.MAG_WR_X, + EChannelType.MAG_WR_Y, + EChannelType.MAG_WR_Z, + ], + ESensorGroup.PRESSURE: [EChannelType.TEMPERATURE, EChannelType.PRESSURE], + ESensorGroup.EXG1_24BIT: [ + EChannelType.EXG1_STATUS, + EChannelType.EXG1_CH1_24BIT, + EChannelType.EXG1_CH2_24BIT, + ], + ESensorGroup.EXG1_16BIT: [ + EChannelType.EXG1_STATUS, + EChannelType.EXG1_CH1_16BIT, + EChannelType.EXG1_CH2_16BIT, + ], + ESensorGroup.EXG2_24BIT: [ + EChannelType.EXG2_STATUS, + EChannelType.EXG2_CH1_24BIT, + EChannelType.EXG2_CH2_24BIT, + ], + ESensorGroup.EXG2_16BIT: [ + EChannelType.EXG2_STATUS, + EChannelType.EXG2_CH1_16BIT, + EChannelType.EXG2_CH2_16BIT, + ], + # The MPU9150 Temp sensor is not yet available as a channel in the LogAndStream + # firmware + ESensorGroup.TEMP: [], +} + +""" +The sensors are enabled via a multi-byte bitfield that currently stretches a total of +three bytes. This dictionary contains the bitfield position for every sensor in this +bitfield. +""" +SensorBitAssignments: dict[ESensorGroup, int] = { + ESensorGroup.ACCEL_LN: 0x80 << 0 * 8, + ESensorGroup.GYRO: 0x40 << 0 * 8, + ESensorGroup.MAG_REG: 0x20 << 0 * 8, + ESensorGroup.EXG1_24BIT: 0x10 << 0 * 8, + ESensorGroup.EXG2_24BIT: 0x08 << 0 * 8, + ESensorGroup.GSR: 0x04 << 0 * 8, + ESensorGroup.EXT_CH_A0: 0x02 << 0 * 8, + ESensorGroup.EXT_CH_A1: 0x01 << 0 * 8, + ESensorGroup.STRAIN: 0x80 << 1 * 8, + # No assignment 0x40 << 1 * 8, + ESensorGroup.BATTERY: 0x20 << 1 * 8, + ESensorGroup.ACCEL_WR: 0x10 << 1 * 8, + ESensorGroup.EXT_CH_A2: 0x08 << 1 * 8, + ESensorGroup.INT_CH_A3: 0x04 << 1 * 8, + ESensorGroup.INT_CH_A0: 0x02 << 1 * 8, + ESensorGroup.INT_CH_A1: 0x01 << 1 * 8, + ESensorGroup.INT_CH_A2: 0x80 << 2 * 8, + ESensorGroup.ACCEL_HG: 0x40 << 2 * 8, + ESensorGroup.MAG_WR: 0x20 << 2 * 8, + ESensorGroup.EXG1_16BIT: 0x10 << 2 * 8, + ESensorGroup.EXG2_16BIT: 0x08 << 2 * 8, + ESensorGroup.PRESSURE: 0x04 << 2 * 8, + ESensorGroup.TEMP: 0x02 << 2 * 8, +} + +SensorOrder: dict[ESensorGroup, int] = { + ESensorGroup.ACCEL_LN: 1, + ESensorGroup.BATTERY: 2, + ESensorGroup.EXT_CH_A0: 3, + ESensorGroup.EXT_CH_A1: 4, + ESensorGroup.EXT_CH_A2: 5, + ESensorGroup.INT_CH_A0: 6, + ESensorGroup.INT_CH_A1: 7, + ESensorGroup.INT_CH_A2: 8, + ESensorGroup.STRAIN: 9, + ESensorGroup.INT_CH_A3: 10, + ESensorGroup.GSR: 11, + ESensorGroup.GYRO: 12, + ESensorGroup.ACCEL_WR: 13, + ESensorGroup.MAG_REG: 14, + ESensorGroup.ACCEL_HG: 15, + ESensorGroup.MAG_WR: 16, + ESensorGroup.PRESSURE: 17, + ESensorGroup.EXG1_24BIT: 18, + ESensorGroup.EXG1_16BIT: 19, + ESensorGroup.EXG2_24BIT: 20, + ESensorGroup.EXG2_16BIT: 21, +} + +ENABLED_SENSORS_LEN = 0x03 +SENSOR_DTYPE = ChannelDataType(size=ENABLED_SENSORS_LEN, signed=False, le=True) + + +class Shimmer3Revision(HardwareRevision): + + # Device clock rate in ticks per second + DEV_CLOCK_RATE: float = 32768.0 + + def sr2dr(self, sr: float) -> int: + dr_dec = self.DEV_CLOCK_RATE / sr + return round(dr_dec) + + def dr2sr(self, dr: int) -> float: + return self.DEV_CLOCK_RATE / dr + + def sec2ticks(self, t_sec: float | np.ndarray) -> int | np.ndarray: + return round(t_sec * self.DEV_CLOCK_RATE) + + def ticks2sec(self, t_ticks: int | np.ndarray) -> float | np.ndarray: + return t_ticks / self.DEV_CLOCK_RATE + + def get_channel_dtypes( + self, channels: Iterable[EChannelType] + ) -> list[ChannelDataType]: + dtypes = [ChDataTypeAssignment[ch] for ch in channels] + return dtypes + + def get_enabled_channels( + self, sensors: Iterable[ESensorGroup] + ) -> list[EChannelType]: + channels = [SensorChannelAssignment[e] for e in sensors] + return flatten_list(channels) + + def sensors2bitfield(self, sensors: Iterable[ESensorGroup]) -> int: + bitfield = 0 + for sensor in sensors: + bit_pos = SensorBitAssignments[sensor] + bitfield |= bit_pos + + return bitfield + + def serialize_sensorlist(self, sensors: Iterable[ESensorGroup]) -> bytes: + pass + + def bitfield2sensors(self, bitfield: int) -> list[ESensorGroup]: + pass + + def deserialize_sensorlist(self, bitfield_bin: bytes) -> list[ESensorGroup]: + pass + + def sort_sensors(self, sensors: Iterable[ESensorGroup]) -> list[ESensorGroup]: + pass From fdf3a71a49377bcc654610ac1cd58bc589c8ca7d Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 25 Oct 2025 21:33:23 +0200 Subject: [PATCH 04/32] Implement dedicated HardwareRevision class for Shimmer3 --- pyshimmer/dev/channels.py | 1 + pyshimmer/dev/revision.py | 103 ++++++- pyshimmer/dev/revisions/shimmer3.py | 406 ++++++++++++---------------- test/dev/test_device_channels.py | 15 + 4 files changed, 294 insertions(+), 231 deletions(-) diff --git a/pyshimmer/dev/channels.py b/pyshimmer/dev/channels.py index 3f31564..877d94a 100644 --- a/pyshimmer/dev/channels.py +++ b/pyshimmer/dev/channels.py @@ -482,6 +482,7 @@ class ESensorGroup(Enum): ESensorGroup.EXG1_16BIT: 19, ESensorGroup.EXG2_24BIT: 20, ESensorGroup.EXG2_16BIT: 21, + ESensorGroup.TEMP: 22, } ENABLED_SENSORS_LEN = 0x03 diff --git a/pyshimmer/dev/revision.py b/pyshimmer/dev/revision.py index 4d8199b..53455ce 100644 --- a/pyshimmer/dev/revision.py +++ b/pyshimmer/dev/revision.py @@ -1,10 +1,14 @@ -from collections.abc import Iterable -from pyshimmer.dev.channels import EChannelType, ChannelDataType, ESensorGroup - +import operator from abc import ABC, abstractmethod +from collections.abc import Iterable +from functools import reduce from typing import overload + import numpy as np +from pyshimmer.dev.channels import EChannelType, ChannelDataType, ESensorGroup +from pyshimmer.util import bit_is_set, flatten_list + class HardwareRevision(ABC): @@ -101,6 +105,11 @@ def get_enabled_channels( """ pass + @property + @abstractmethod + def sensorlist_size(self) -> int: + pass + @abstractmethod def sensors2bitfield(self, sensors: Iterable[ESensorGroup]) -> int: """Convert an iterable of sensors into the corresponding bitfield transmitted to @@ -151,3 +160,91 @@ def sort_sensors(self, sensors: Iterable[ESensorGroup]) -> list[ESensorGroup]: appearance order in the data file """ pass + + +class BaseRevision(HardwareRevision): + + def __init__( + self, + dev_clock_rate: float, + sensor_list_dtype: ChannelDataType, + channel_data_types: dict[EChannelType, ChannelDataType], + sensor_channel_assignment: dict[ESensorGroup, list[EChannelType]], + sensor_bit_assignment: dict[ESensorGroup, int], + sensor_order: dict[ESensorGroup, int], + ): + self._dev_clock_rate = dev_clock_rate + self._sensor_list_dtype = sensor_list_dtype + self._channel_data_types = channel_data_types + self._sensor_channel_assignment = sensor_channel_assignment + self._sensor_bit_assignment = sensor_bit_assignment + self._sensor_order = sensor_order + + def sr2dr(self, sr: float) -> int: + dr_dec = self._dev_clock_rate / sr + return round(dr_dec) + + def dr2sr(self, dr: int) -> float: + return self._dev_clock_rate / dr + + @overload + def sec2ticks(self, t_sec: float) -> int: ... + + @overload + def sec2ticks(self, t_sec: np.ndarray) -> np.ndarray: ... + + def sec2ticks(self, t_sec: float | np.ndarray) -> int | np.ndarray: + return round(t_sec * self._dev_clock_rate) + + @overload + def ticks2sec(self, t_ticks: int) -> float: ... + + @overload + def ticks2sec(self, t_ticks: np.ndarray) -> np.ndarray: ... + + def ticks2sec(self, t_ticks: int | np.ndarray) -> float | np.ndarray: + return t_ticks / self._dev_clock_rate + + def get_channel_dtypes( + self, channels: Iterable[EChannelType] + ) -> list[ChannelDataType]: + dtypes = [self._channel_data_types[ch] for ch in channels] + return dtypes + + def get_enabled_channels( + self, sensors: Iterable[ESensorGroup] + ) -> list[EChannelType]: + channels = [self._sensor_channel_assignment[e] for e in sensors] + return flatten_list(channels) + + @property + def sensorlist_size(self) -> int: + return self._sensor_list_dtype.size + + def sensors2bitfield(self, sensors: Iterable[ESensorGroup]) -> int: + bit_values = [1 << self._sensor_bit_assignment[g] for g in sensors] + return reduce(operator.or_, bit_values) + + def bitfield2sensors(self, bitfield: int) -> list[ESensorGroup]: + enabled_sensors = [] + for sensor in ESensorGroup: + bit_mask = 1 << self._sensor_bit_assignment[sensor] + if bit_is_set(bitfield, bit_mask): + enabled_sensors += [sensor] + + return self.sort_sensors(enabled_sensors) + + def serialize_sensorlist(self, sensors: Iterable[ESensorGroup]) -> bytes: + bitfield = self.sensors2bitfield(sensors) + return self._sensor_list_dtype.encode(bitfield) + + def deserialize_sensorlist(self, bitfield_bin: bytes) -> list[ESensorGroup]: + bitfield = self._sensor_list_dtype.decode(bitfield_bin) + return self.bitfield2sensors(bitfield) + + def sort_sensors(self, sensors: Iterable[ESensorGroup]) -> list[ESensorGroup]: + def sort_key_fn(x): + return self._sensor_order[x] + + sensors_sorted = sorted(sensors, key=sort_key_fn) + return sensors_sorted diff --git a/pyshimmer/dev/revisions/shimmer3.py b/pyshimmer/dev/revisions/shimmer3.py index 04b0a01..c10d082 100644 --- a/pyshimmer/dev/revisions/shimmer3.py +++ b/pyshimmer/dev/revisions/shimmer3.py @@ -1,234 +1,184 @@ -import numpy as np -from typing import Iterable - from ..channels import EChannelType, ChannelDataType, ESensorGroup -from ..revision import HardwareRevision -from pyshimmer.util import flatten_list - -""" -Assigns each channel type its appropriate data type. -""" -ChDataTypeAssignment: dict[EChannelType, ChannelDataType] = { - EChannelType.ACCEL_LN_X: ChannelDataType(2, signed=True, le=True), - EChannelType.ACCEL_LN_Y: ChannelDataType(2, signed=True, le=True), - EChannelType.ACCEL_LN_Z: ChannelDataType(2, signed=True, le=True), - EChannelType.VBATT: ChannelDataType(2, signed=True, le=True), - EChannelType.ACCEL_WR_X: ChannelDataType(2, signed=True, le=True), - EChannelType.ACCEL_WR_Y: ChannelDataType(2, signed=True, le=True), - EChannelType.ACCEL_WR_Z: ChannelDataType(2, signed=True, le=True), - EChannelType.MAG_REG_X: ChannelDataType(2, signed=True, le=True), - EChannelType.MAG_REG_Y: ChannelDataType(2, signed=True, le=True), - EChannelType.MAG_REG_Z: ChannelDataType(2, signed=True, le=True), - EChannelType.GYRO_X: ChannelDataType(2, signed=True, le=False), - EChannelType.GYRO_Y: ChannelDataType(2, signed=True, le=False), - EChannelType.GYRO_Z: ChannelDataType(2, signed=True, le=False), - EChannelType.EXTERNAL_ADC_A0: ChannelDataType(2, signed=False, le=True), - EChannelType.EXTERNAL_ADC_A1: ChannelDataType(2, signed=False, le=True), - EChannelType.EXTERNAL_ADC_A2: ChannelDataType(2, signed=False, le=True), - EChannelType.INTERNAL_ADC_A3: ChannelDataType(2, signed=False, le=True), - EChannelType.INTERNAL_ADC_A0: ChannelDataType(2, signed=False, le=True), - EChannelType.INTERNAL_ADC_A1: ChannelDataType(2, signed=False, le=True), - EChannelType.INTERNAL_ADC_A2: ChannelDataType(2, signed=False, le=True), - EChannelType.ACCEL_HG_X: None, - EChannelType.ACCEL_HG_Y: None, - EChannelType.ACCEL_HG_Z: None, - EChannelType.MAG_WR_X: None, - EChannelType.MAG_WR_Y: None, - EChannelType.MAG_WR_Z: None, - EChannelType.TEMPERATURE: ChannelDataType(2, signed=False, le=False), - EChannelType.PRESSURE: ChannelDataType(3, signed=False, le=False), - EChannelType.GSR_RAW: ChannelDataType(2, signed=False, le=True), - EChannelType.EXG1_STATUS: ChannelDataType(1, signed=False, le=True), - EChannelType.EXG1_CH1_24BIT: ChannelDataType(3, signed=True, le=False), - EChannelType.EXG1_CH2_24BIT: ChannelDataType(3, signed=True, le=False), - EChannelType.EXG2_STATUS: ChannelDataType(1, signed=False, le=True), - EChannelType.EXG2_CH1_24BIT: ChannelDataType(3, signed=True, le=False), - EChannelType.EXG2_CH2_24BIT: ChannelDataType(3, signed=True, le=False), - EChannelType.EXG1_CH1_16BIT: ChannelDataType(2, signed=True, le=False), - EChannelType.EXG1_CH2_16BIT: ChannelDataType(2, signed=True, le=False), - EChannelType.EXG2_CH1_16BIT: ChannelDataType(2, signed=True, le=False), - EChannelType.EXG2_CH2_16BIT: ChannelDataType(2, signed=True, le=False), - EChannelType.STRAIN_HIGH: ChannelDataType(2, signed=False, le=True), - EChannelType.STRAIN_LOW: ChannelDataType(2, signed=False, le=True), - EChannelType.TIMESTAMP: ChannelDataType(3, signed=False, le=True), -} - -""" -This dictionary contains the mapping from sensor to data channels. Since one sensor can -record on multiple channels, the mapping is one-to-many. -""" -SensorChannelAssignment: dict[ESensorGroup, list[EChannelType]] = { - ESensorGroup.ACCEL_LN: [ - EChannelType.ACCEL_LN_X, - EChannelType.ACCEL_LN_Y, - EChannelType.ACCEL_LN_Z, - ], - ESensorGroup.BATTERY: [EChannelType.VBATT], - ESensorGroup.EXT_CH_A0: [EChannelType.EXTERNAL_ADC_A0], - ESensorGroup.EXT_CH_A1: [EChannelType.EXTERNAL_ADC_A1], - ESensorGroup.EXT_CH_A2: [EChannelType.EXTERNAL_ADC_A2], - ESensorGroup.INT_CH_A0: [EChannelType.INTERNAL_ADC_A0], - ESensorGroup.INT_CH_A1: [EChannelType.INTERNAL_ADC_A1], - ESensorGroup.INT_CH_A2: [EChannelType.INTERNAL_ADC_A2], - ESensorGroup.STRAIN: [EChannelType.STRAIN_HIGH, EChannelType.STRAIN_LOW], - ESensorGroup.INT_CH_A3: [EChannelType.INTERNAL_ADC_A3], - ESensorGroup.GSR: [EChannelType.GSR_RAW], - ESensorGroup.GYRO: [ - EChannelType.GYRO_X, - EChannelType.GYRO_Y, - EChannelType.GYRO_Z, - ], - ESensorGroup.ACCEL_WR: [ - EChannelType.ACCEL_WR_X, - EChannelType.ACCEL_WR_Y, - EChannelType.ACCEL_WR_Z, - ], - ESensorGroup.MAG_REG: [ - EChannelType.MAG_REG_X, - EChannelType.MAG_REG_Y, - EChannelType.MAG_REG_Z, - ], - ESensorGroup.ACCEL_HG: [ - EChannelType.ACCEL_HG_X, - EChannelType.ACCEL_HG_Y, - EChannelType.ACCEL_HG_Z, - ], - ESensorGroup.MAG_WR: [ - EChannelType.MAG_WR_X, - EChannelType.MAG_WR_Y, - EChannelType.MAG_WR_Z, - ], - ESensorGroup.PRESSURE: [EChannelType.TEMPERATURE, EChannelType.PRESSURE], - ESensorGroup.EXG1_24BIT: [ - EChannelType.EXG1_STATUS, - EChannelType.EXG1_CH1_24BIT, - EChannelType.EXG1_CH2_24BIT, - ], - ESensorGroup.EXG1_16BIT: [ - EChannelType.EXG1_STATUS, - EChannelType.EXG1_CH1_16BIT, - EChannelType.EXG1_CH2_16BIT, - ], - ESensorGroup.EXG2_24BIT: [ - EChannelType.EXG2_STATUS, - EChannelType.EXG2_CH1_24BIT, - EChannelType.EXG2_CH2_24BIT, - ], - ESensorGroup.EXG2_16BIT: [ - EChannelType.EXG2_STATUS, - EChannelType.EXG2_CH1_16BIT, - EChannelType.EXG2_CH2_16BIT, - ], - # The MPU9150 Temp sensor is not yet available as a channel in the LogAndStream - # firmware - ESensorGroup.TEMP: [], -} - -""" -The sensors are enabled via a multi-byte bitfield that currently stretches a total of -three bytes. This dictionary contains the bitfield position for every sensor in this -bitfield. -""" -SensorBitAssignments: dict[ESensorGroup, int] = { - ESensorGroup.ACCEL_LN: 0x80 << 0 * 8, - ESensorGroup.GYRO: 0x40 << 0 * 8, - ESensorGroup.MAG_REG: 0x20 << 0 * 8, - ESensorGroup.EXG1_24BIT: 0x10 << 0 * 8, - ESensorGroup.EXG2_24BIT: 0x08 << 0 * 8, - ESensorGroup.GSR: 0x04 << 0 * 8, - ESensorGroup.EXT_CH_A0: 0x02 << 0 * 8, - ESensorGroup.EXT_CH_A1: 0x01 << 0 * 8, - ESensorGroup.STRAIN: 0x80 << 1 * 8, - # No assignment 0x40 << 1 * 8, - ESensorGroup.BATTERY: 0x20 << 1 * 8, - ESensorGroup.ACCEL_WR: 0x10 << 1 * 8, - ESensorGroup.EXT_CH_A2: 0x08 << 1 * 8, - ESensorGroup.INT_CH_A3: 0x04 << 1 * 8, - ESensorGroup.INT_CH_A0: 0x02 << 1 * 8, - ESensorGroup.INT_CH_A1: 0x01 << 1 * 8, - ESensorGroup.INT_CH_A2: 0x80 << 2 * 8, - ESensorGroup.ACCEL_HG: 0x40 << 2 * 8, - ESensorGroup.MAG_WR: 0x20 << 2 * 8, - ESensorGroup.EXG1_16BIT: 0x10 << 2 * 8, - ESensorGroup.EXG2_16BIT: 0x08 << 2 * 8, - ESensorGroup.PRESSURE: 0x04 << 2 * 8, - ESensorGroup.TEMP: 0x02 << 2 * 8, -} - -SensorOrder: dict[ESensorGroup, int] = { - ESensorGroup.ACCEL_LN: 1, - ESensorGroup.BATTERY: 2, - ESensorGroup.EXT_CH_A0: 3, - ESensorGroup.EXT_CH_A1: 4, - ESensorGroup.EXT_CH_A2: 5, - ESensorGroup.INT_CH_A0: 6, - ESensorGroup.INT_CH_A1: 7, - ESensorGroup.INT_CH_A2: 8, - ESensorGroup.STRAIN: 9, - ESensorGroup.INT_CH_A3: 10, - ESensorGroup.GSR: 11, - ESensorGroup.GYRO: 12, - ESensorGroup.ACCEL_WR: 13, - ESensorGroup.MAG_REG: 14, - ESensorGroup.ACCEL_HG: 15, - ESensorGroup.MAG_WR: 16, - ESensorGroup.PRESSURE: 17, - ESensorGroup.EXG1_24BIT: 18, - ESensorGroup.EXG1_16BIT: 19, - ESensorGroup.EXG2_24BIT: 20, - ESensorGroup.EXG2_16BIT: 21, -} +from ..revision import BaseRevision -ENABLED_SENSORS_LEN = 0x03 -SENSOR_DTYPE = ChannelDataType(size=ENABLED_SENSORS_LEN, signed=False, le=True) - -class Shimmer3Revision(HardwareRevision): +class Shimmer3Revision(BaseRevision): # Device clock rate in ticks per second DEV_CLOCK_RATE: float = 32768.0 - - def sr2dr(self, sr: float) -> int: - dr_dec = self.DEV_CLOCK_RATE / sr - return round(dr_dec) - - def dr2sr(self, dr: int) -> float: - return self.DEV_CLOCK_RATE / dr - - def sec2ticks(self, t_sec: float | np.ndarray) -> int | np.ndarray: - return round(t_sec * self.DEV_CLOCK_RATE) - - def ticks2sec(self, t_ticks: int | np.ndarray) -> float | np.ndarray: - return t_ticks / self.DEV_CLOCK_RATE - - def get_channel_dtypes( - self, channels: Iterable[EChannelType] - ) -> list[ChannelDataType]: - dtypes = [ChDataTypeAssignment[ch] for ch in channels] - return dtypes - - def get_enabled_channels( - self, sensors: Iterable[ESensorGroup] - ) -> list[EChannelType]: - channels = [SensorChannelAssignment[e] for e in sensors] - return flatten_list(channels) - - def sensors2bitfield(self, sensors: Iterable[ESensorGroup]) -> int: - bitfield = 0 - for sensor in sensors: - bit_pos = SensorBitAssignments[sensor] - bitfield |= bit_pos - - return bitfield - - def serialize_sensorlist(self, sensors: Iterable[ESensorGroup]) -> bytes: - pass - - def bitfield2sensors(self, bitfield: int) -> list[ESensorGroup]: - pass - - def deserialize_sensorlist(self, bitfield_bin: bytes) -> list[ESensorGroup]: - pass - - def sort_sensors(self, sensors: Iterable[ESensorGroup]) -> list[ESensorGroup]: - pass + ENABLED_SENSORS_LEN = 0x03 + SENSOR_DTYPE = ChannelDataType(size=ENABLED_SENSORS_LEN, signed=False, le=True) + + CH_DTYPE_ASSIGNMENT: dict[EChannelType, ChannelDataType] = { + EChannelType.ACCEL_LN_X: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_LN_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_LN_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.VBATT: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_X: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_X: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.GYRO_X: ChannelDataType(2, signed=True, le=False), + EChannelType.GYRO_Y: ChannelDataType(2, signed=True, le=False), + EChannelType.GYRO_Z: ChannelDataType(2, signed=True, le=False), + EChannelType.EXTERNAL_ADC_A0: ChannelDataType(2, signed=False, le=True), + EChannelType.EXTERNAL_ADC_A1: ChannelDataType(2, signed=False, le=True), + EChannelType.EXTERNAL_ADC_A2: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A3: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A0: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A1: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A2: ChannelDataType(2, signed=False, le=True), + EChannelType.ACCEL_HG_X: None, + EChannelType.ACCEL_HG_Y: None, + EChannelType.ACCEL_HG_Z: None, + EChannelType.MAG_WR_X: None, + EChannelType.MAG_WR_Y: None, + EChannelType.MAG_WR_Z: None, + EChannelType.TEMPERATURE: ChannelDataType(2, signed=False, le=False), + EChannelType.PRESSURE: ChannelDataType(3, signed=False, le=False), + EChannelType.GSR_RAW: ChannelDataType(2, signed=False, le=True), + EChannelType.EXG1_STATUS: ChannelDataType(1, signed=False, le=True), + EChannelType.EXG1_CH1_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG1_CH2_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG2_STATUS: ChannelDataType(1, signed=False, le=True), + EChannelType.EXG2_CH1_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG2_CH2_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG1_CH1_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG1_CH2_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG2_CH1_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG2_CH2_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.STRAIN_HIGH: ChannelDataType(2, signed=False, le=True), + EChannelType.STRAIN_LOW: ChannelDataType(2, signed=False, le=True), + EChannelType.TIMESTAMP: ChannelDataType(3, signed=False, le=True), + } + + SENSOR_CHANNEL_ASSIGNMENT: dict[ESensorGroup, list[EChannelType]] = { + ESensorGroup.ACCEL_LN: [ + EChannelType.ACCEL_LN_X, + EChannelType.ACCEL_LN_Y, + EChannelType.ACCEL_LN_Z, + ], + ESensorGroup.BATTERY: [EChannelType.VBATT], + ESensorGroup.EXT_CH_A0: [EChannelType.EXTERNAL_ADC_A0], + ESensorGroup.EXT_CH_A1: [EChannelType.EXTERNAL_ADC_A1], + ESensorGroup.EXT_CH_A2: [EChannelType.EXTERNAL_ADC_A2], + ESensorGroup.INT_CH_A0: [EChannelType.INTERNAL_ADC_A0], + ESensorGroup.INT_CH_A1: [EChannelType.INTERNAL_ADC_A1], + ESensorGroup.INT_CH_A2: [EChannelType.INTERNAL_ADC_A2], + ESensorGroup.STRAIN: [EChannelType.STRAIN_HIGH, EChannelType.STRAIN_LOW], + ESensorGroup.INT_CH_A3: [EChannelType.INTERNAL_ADC_A3], + ESensorGroup.GSR: [EChannelType.GSR_RAW], + ESensorGroup.GYRO: [ + EChannelType.GYRO_X, + EChannelType.GYRO_Y, + EChannelType.GYRO_Z, + ], + ESensorGroup.ACCEL_WR: [ + EChannelType.ACCEL_WR_X, + EChannelType.ACCEL_WR_Y, + EChannelType.ACCEL_WR_Z, + ], + ESensorGroup.MAG_REG: [ + EChannelType.MAG_REG_X, + EChannelType.MAG_REG_Y, + EChannelType.MAG_REG_Z, + ], + ESensorGroup.ACCEL_HG: [ + EChannelType.ACCEL_HG_X, + EChannelType.ACCEL_HG_Y, + EChannelType.ACCEL_HG_Z, + ], + ESensorGroup.MAG_WR: [ + EChannelType.MAG_WR_X, + EChannelType.MAG_WR_Y, + EChannelType.MAG_WR_Z, + ], + ESensorGroup.PRESSURE: [EChannelType.TEMPERATURE, EChannelType.PRESSURE], + ESensorGroup.EXG1_24BIT: [ + EChannelType.EXG1_STATUS, + EChannelType.EXG1_CH1_24BIT, + EChannelType.EXG1_CH2_24BIT, + ], + ESensorGroup.EXG1_16BIT: [ + EChannelType.EXG1_STATUS, + EChannelType.EXG1_CH1_16BIT, + EChannelType.EXG1_CH2_16BIT, + ], + ESensorGroup.EXG2_24BIT: [ + EChannelType.EXG2_STATUS, + EChannelType.EXG2_CH1_24BIT, + EChannelType.EXG2_CH2_24BIT, + ], + ESensorGroup.EXG2_16BIT: [ + EChannelType.EXG2_STATUS, + EChannelType.EXG2_CH1_16BIT, + EChannelType.EXG2_CH2_16BIT, + ], + # The MPU9150 Temp sensor is not yet available as a channel in the LogAndStream + # firmware + ESensorGroup.TEMP: [], + } + + SENSOR_BIT_ASSIGNMENT: dict[ESensorGroup, int] = { + ESensorGroup.EXT_CH_A1: 0, + ESensorGroup.EXT_CH_A0: 1, + ESensorGroup.GSR: 2, + ESensorGroup.EXG2_24BIT: 3, + ESensorGroup.EXG1_24BIT: 4, + ESensorGroup.MAG_REG: 5, + ESensorGroup.GYRO: 6, + ESensorGroup.ACCEL_LN: 7, + ESensorGroup.INT_CH_A1: 8, + ESensorGroup.INT_CH_A0: 9, + ESensorGroup.INT_CH_A3: 10, + ESensorGroup.EXT_CH_A2: 11, + ESensorGroup.ACCEL_WR: 12, + ESensorGroup.BATTERY: 13, + # No assignment 14 + ESensorGroup.STRAIN: 15, + # No assignment 16 + ESensorGroup.TEMP: 17, + ESensorGroup.PRESSURE: 18, + ESensorGroup.EXG2_16BIT: 19, + ESensorGroup.EXG1_16BIT: 20, + ESensorGroup.MAG_WR: 21, + ESensorGroup.ACCEL_HG: 22, + ESensorGroup.INT_CH_A2: 23, + } + + SENSOR_ORDER: dict[ESensorGroup, int] = { + ESensorGroup.ACCEL_LN: 1, + ESensorGroup.BATTERY: 2, + ESensorGroup.EXT_CH_A0: 3, + ESensorGroup.EXT_CH_A1: 4, + ESensorGroup.EXT_CH_A2: 5, + ESensorGroup.INT_CH_A0: 6, + ESensorGroup.INT_CH_A1: 7, + ESensorGroup.INT_CH_A2: 8, + ESensorGroup.STRAIN: 9, + ESensorGroup.INT_CH_A3: 10, + ESensorGroup.GSR: 11, + ESensorGroup.GYRO: 12, + ESensorGroup.ACCEL_WR: 13, + ESensorGroup.MAG_REG: 14, + ESensorGroup.ACCEL_HG: 15, + ESensorGroup.MAG_WR: 16, + ESensorGroup.PRESSURE: 17, + ESensorGroup.EXG1_24BIT: 18, + ESensorGroup.EXG1_16BIT: 19, + ESensorGroup.EXG2_24BIT: 20, + ESensorGroup.EXG2_16BIT: 21, + ESensorGroup.TEMP: 22, + } + + def __init__(self): + super().__init__( + self.DEV_CLOCK_RATE, + self.SENSOR_DTYPE, + self.CH_DTYPE_ASSIGNMENT, + self.SENSOR_CHANNEL_ASSIGNMENT, + self.SENSOR_BIT_ASSIGNMENT, + self.SENSOR_ORDER, + ) diff --git a/test/dev/test_device_channels.py b/test/dev/test_device_channels.py index f0bea02..429389c 100644 --- a/test/dev/test_device_channels.py +++ b/test/dev/test_device_channels.py @@ -28,6 +28,8 @@ EChannelType, ESensorGroup, sort_sensors, + sensors2bitfield, + bitfield2sensors, ) @@ -168,6 +170,19 @@ def test_sensor_channel_assignments(self): if sensor not in SensorChannelAssignment: self.fail(f"No channels assigned to sensor type: {sensor}") + def test_sensor_list_to_bitfield(self): + assert sensors2bitfield((ESensorGroup.ACCEL_LN, ESensorGroup.EXT_CH_A1)) == 0x81 + assert sensors2bitfield((ESensorGroup.STRAIN, ESensorGroup.INT_CH_A1)) == 0x8100 + assert sensors2bitfield((ESensorGroup.INT_CH_A2, ESensorGroup.TEMP)) == 0x820000 + + def test_bitfield_to_sensors(self): + assert bitfield2sensors(0x81) == [ESensorGroup.ACCEL_LN, ESensorGroup.EXT_CH_A1] + assert bitfield2sensors(0x8100) == [ESensorGroup.INT_CH_A1, ESensorGroup.STRAIN] + assert bitfield2sensors(0x820000) == [ + ESensorGroup.INT_CH_A2, + ESensorGroup.TEMP, + ] + def test_sensor_bit_assignments_uniqueness(self): for s1 in SensorBitAssignments.keys(): for s2 in SensorBitAssignments.keys(): From 8bd99a0b5d84d6c18d03ed977ecf0ddbc86cc5e8 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Thu, 20 Nov 2025 20:58:41 +0100 Subject: [PATCH 05/32] Move revision module around and add license notices --- pyshimmer/__init__.py | 2 ++ pyshimmer/dev/revisions/__init__.py | 18 ++++++++++++++++++ pyshimmer/dev/{ => revisions}/revision.py | 18 +++++++++++++++++- pyshimmer/dev/revisions/shimmer3.py | 18 +++++++++++++++++- 4 files changed, 54 insertions(+), 2 deletions(-) rename pyshimmer/dev/{ => revisions}/revision.py (91%) diff --git a/pyshimmer/__init__.py b/pyshimmer/__init__.py index 91b208a..891c1f2 100644 --- a/pyshimmer/__init__.py +++ b/pyshimmer/__init__.py @@ -13,12 +13,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . + from .bluetooth.bt_api import ShimmerBluetooth from .bluetooth.bt_commands import DataPacket from .dev.base import DEFAULT_BAUDRATE from .dev.channels import ChannelDataType, EChannelType from .dev.exg import ExGMux, ExGRLDLead, ERLDRef, ExGRegister from .dev.fw_version import EFirmwareType +from .dev.revisions import HardwareRevision, Shimmer3Revision from .reader.binary_reader import ShimmerBinaryReader from .reader.shimmer_reader import ShimmerReader from .uart.dock_api import ShimmerDock diff --git a/pyshimmer/dev/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py index e69de29..315e8d8 100644 --- a/pyshimmer/dev/revisions/__init__.py +++ b/pyshimmer/dev/revisions/__init__.py @@ -0,0 +1,18 @@ +# pyshimmer - API for Shimmer sensor devices +# Copyright (C) 2025 Lukas Magel + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .revision import HardwareRevision +from .shimmer3 import Shimmer3Revision diff --git a/pyshimmer/dev/revision.py b/pyshimmer/dev/revisions/revision.py similarity index 91% rename from pyshimmer/dev/revision.py rename to pyshimmer/dev/revisions/revision.py index 53455ce..539a700 100644 --- a/pyshimmer/dev/revision.py +++ b/pyshimmer/dev/revisions/revision.py @@ -1,3 +1,19 @@ +# pyshimmer - API for Shimmer sensor devices +# Copyright (C) 2025 Lukas Magel + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + import operator from abc import ABC, abstractmethod from collections.abc import Iterable @@ -6,7 +22,7 @@ import numpy as np -from pyshimmer.dev.channels import EChannelType, ChannelDataType, ESensorGroup +from ..channels import EChannelType, ChannelDataType, ESensorGroup from pyshimmer.util import bit_is_set, flatten_list diff --git a/pyshimmer/dev/revisions/shimmer3.py b/pyshimmer/dev/revisions/shimmer3.py index c10d082..025f1ef 100644 --- a/pyshimmer/dev/revisions/shimmer3.py +++ b/pyshimmer/dev/revisions/shimmer3.py @@ -1,5 +1,21 @@ +# pyshimmer - API for Shimmer sensor devices +# Copyright (C) 2025 Lukas Magel + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + from ..channels import EChannelType, ChannelDataType, ESensorGroup -from ..revision import BaseRevision +from .revision import BaseRevision class Shimmer3Revision(BaseRevision): From 14abe1f725f7141dc697e1398687cd4f08168412 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Thu, 20 Nov 2025 21:10:36 +0100 Subject: [PATCH 06/32] Implement test cases for Shimmer3 revision --- pyshimmer/dev/revisions/revision.py | 6 +- test/dev/revision/__init__.py | 15 +++++ test/dev/revision/test_shimmer3_revision.py | 62 +++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 test/dev/revision/__init__.py create mode 100644 test/dev/revision/test_shimmer3_revision.py diff --git a/pyshimmer/dev/revisions/revision.py b/pyshimmer/dev/revisions/revision.py index 539a700..8c11f0a 100644 --- a/pyshimmer/dev/revisions/revision.py +++ b/pyshimmer/dev/revisions/revision.py @@ -210,7 +210,11 @@ def sec2ticks(self, t_sec: float) -> int: ... def sec2ticks(self, t_sec: np.ndarray) -> np.ndarray: ... def sec2ticks(self, t_sec: float | np.ndarray) -> int | np.ndarray: - return round(t_sec * self._dev_clock_rate) + t_ticks = t_sec * self._dev_clock_rate + if isinstance(t_ticks, np.ndarray): + return np.round(t_ticks, decimals=0) + + return round(t_ticks) @overload def ticks2sec(self, t_ticks: int) -> float: ... diff --git a/test/dev/revision/__init__.py b/test/dev/revision/__init__.py new file mode 100644 index 0000000..6e42188 --- /dev/null +++ b/test/dev/revision/__init__.py @@ -0,0 +1,15 @@ +# pyshimmer - API for Shimmer sensor devices +# Copyright (C) 2025 Lukas Magel + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . diff --git a/test/dev/revision/test_shimmer3_revision.py b/test/dev/revision/test_shimmer3_revision.py new file mode 100644 index 0000000..755b34d --- /dev/null +++ b/test/dev/revision/test_shimmer3_revision.py @@ -0,0 +1,62 @@ +# pyshimmer - API for Shimmer sensor devices +# Copyright (C) 2025 Lukas Magel + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +import numpy as np + +from pyshimmer import Shimmer3Revision + + +class TestShimmer3Revision: + + @pytest.fixture + def revision(self) -> Shimmer3Revision: + return Shimmer3Revision() + + def test_sr2dr(self, revision: Shimmer3Revision): + r = revision.sr2dr(1024.0) + assert r == 32 + + r = revision.sr2dr(500.0) + assert r == 66 + + def test_dr2sr(self, revision: Shimmer3Revision): + r = revision.dr2sr(65) + assert r == pytest.approx(504, abs=0.5) + + r = revision.dr2sr(32) + assert r == 1024.0 + + r = revision.dr2sr(64) + assert r == 512.0 + + def test_sec2ticks(self, revision: Shimmer3Revision): + r = revision.sec2ticks(1.0) + assert r == 32768 + + r = revision.sec2ticks(np.array([1.0, 2.0])) + np.testing.assert_array_equal(r, np.array([32768, 65536])) + + def test_ticks2sec(self, revision: Shimmer3Revision): + r = revision.ticks2sec(32768) + assert r == 1.0 + + r = revision.ticks2sec(65536) + assert r == 2.0 + + i = np.array([32768, 65536]) + r = revision.ticks2sec(i) + np.testing.assert_array_equal(r, np.array([1.0, 2.0])) From c8fe2a3473602a4fb6c8e702d429a68a74c29c61 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 24 Nov 2025 20:54:26 +0100 Subject: [PATCH 07/32] Add missing future annotations import --- pyshimmer/dev/revisions/__init__.py | 1 - pyshimmer/dev/revisions/revision.py | 1 + pyshimmer/dev/revisions/shimmer3.py | 3 ++- test/dev/revision/test_shimmer3_revision.py | 3 ++- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pyshimmer/dev/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py index 315e8d8..1cebed6 100644 --- a/pyshimmer/dev/revisions/__init__.py +++ b/pyshimmer/dev/revisions/__init__.py @@ -13,6 +13,5 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . - from .revision import HardwareRevision from .shimmer3 import Shimmer3Revision diff --git a/pyshimmer/dev/revisions/revision.py b/pyshimmer/dev/revisions/revision.py index 8c11f0a..35705b0 100644 --- a/pyshimmer/dev/revisions/revision.py +++ b/pyshimmer/dev/revisions/revision.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from __future__ import annotations import operator from abc import ABC, abstractmethod diff --git a/pyshimmer/dev/revisions/shimmer3.py b/pyshimmer/dev/revisions/shimmer3.py index 025f1ef..3c06b9e 100644 --- a/pyshimmer/dev/revisions/shimmer3.py +++ b/pyshimmer/dev/revisions/shimmer3.py @@ -13,9 +13,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from __future__ import annotations -from ..channels import EChannelType, ChannelDataType, ESensorGroup from .revision import BaseRevision +from ..channels import EChannelType, ChannelDataType, ESensorGroup class Shimmer3Revision(BaseRevision): diff --git a/test/dev/revision/test_shimmer3_revision.py b/test/dev/revision/test_shimmer3_revision.py index 755b34d..e4fbd5e 100644 --- a/test/dev/revision/test_shimmer3_revision.py +++ b/test/dev/revision/test_shimmer3_revision.py @@ -13,9 +13,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from __future__ import annotations -import pytest import numpy as np +import pytest from pyshimmer import Shimmer3Revision From 9e782a8b79c5cbdd12ed9757ad739e6847bc7f8b Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 24 Nov 2025 21:00:53 +0100 Subject: [PATCH 08/32] Remove unused method in HardwareRevision --- pyshimmer/dev/revisions/revision.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pyshimmer/dev/revisions/revision.py b/pyshimmer/dev/revisions/revision.py index 35705b0..0bff2e7 100644 --- a/pyshimmer/dev/revisions/revision.py +++ b/pyshimmer/dev/revisions/revision.py @@ -88,14 +88,6 @@ def ticks2sec(self, t_ticks: int | np.ndarray) -> float | np.ndarray: """ pass - def get_channel_dtype(self, channel: EChannelType) -> ChannelDataType: - """ - - :param channel: - :return: A list of channel data types with the same order - """ - pass - @abstractmethod def get_channel_dtypes( self, channels: Iterable[EChannelType] From 6d6ff577c7270aa476c5fc1530c29e3ecc2b9008 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 24 Nov 2025 21:04:22 +0100 Subject: [PATCH 09/32] Move ESensorGroup unique test to separate group --- test/dev/test_device_channels.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/test/dev/test_device_channels.py b/test/dev/test_device_channels.py index 429389c..ebdf200 100644 --- a/test/dev/test_device_channels.py +++ b/test/dev/test_device_channels.py @@ -63,6 +63,16 @@ def test_channel_type_enum_for_id(self): EChannelType.enum_for_id(0x100) +class ESensorGroupTest: + + def test_sensor_group_uniqueness(self): + try: + # The exception will trigger upon import if the enum values are not unique + from pyshimmer.dev.channels import ESensorGroup + except ValueError as e: + pytest.fail(f"Enum not unique: {e}") + + class ChannelDataTypeTest(TestCase): def test_ch_dtype_byte_order(self): @@ -149,13 +159,6 @@ def test_get_ch_dtypes(self): self.assertEqual(second.little_endian, False) self.assertEqual(second.signed, True) - def test_sensor_group_uniqueness(self): - try: - # The exception will trigger upon import if the enum values are not unique - from pyshimmer.dev.channels import ESensorGroup - except ValueError as e: - self.fail(f"Enum not unique: {e}") - def test_datatype_assignments(self): from pyshimmer.dev.channels import EChannelType From edd00149cb945d10ec0718e55a746084b42303ab Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 24 Nov 2025 21:25:14 +0100 Subject: [PATCH 10/32] Add more tests for HW Revision 3 --- pyshimmer/dev/revisions/revision.py | 3 + test/dev/revision/test_shimmer3_revision.py | 139 +++++++++++++++++++- 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/pyshimmer/dev/revisions/revision.py b/pyshimmer/dev/revisions/revision.py index 0bff2e7..89dfc0a 100644 --- a/pyshimmer/dev/revisions/revision.py +++ b/pyshimmer/dev/revisions/revision.py @@ -235,6 +235,9 @@ def sensorlist_size(self) -> int: return self._sensor_list_dtype.size def sensors2bitfield(self, sensors: Iterable[ESensorGroup]) -> int: + if len(sensors) == 0: + return 0x0 + bit_values = [1 << self._sensor_bit_assignment[g] for g in sensors] return reduce(operator.or_, bit_values) diff --git a/test/dev/revision/test_shimmer3_revision.py b/test/dev/revision/test_shimmer3_revision.py index e4fbd5e..a155a24 100644 --- a/test/dev/revision/test_shimmer3_revision.py +++ b/test/dev/revision/test_shimmer3_revision.py @@ -15,10 +15,13 @@ # along with this program. If not, see . from __future__ import annotations +import itertools + import numpy as np import pytest -from pyshimmer import Shimmer3Revision +from pyshimmer import Shimmer3Revision, EChannelType +from pyshimmer.dev.channels import ESensorGroup class TestShimmer3Revision: @@ -61,3 +64,137 @@ def test_ticks2sec(self, revision: Shimmer3Revision): i = np.array([32768, 65536]) r = revision.ticks2sec(i) np.testing.assert_array_equal(r, np.array([1.0, 2.0])) + + def test_get_channel_dtypes(self, revision: Shimmer3Revision): + r = revision.get_channel_dtypes([]) + assert r == [] + + r = revision.get_channel_dtypes(()) + assert r == [] + + r = revision.get_channel_dtypes( + [EChannelType.INTERNAL_ADC_A0, EChannelType.INTERNAL_ADC_A0] + ) + assert len(r) == 2 + assert r[0] == r[1] + + channels = [EChannelType.INTERNAL_ADC_A1, EChannelType.GYRO_Y] + r = revision.get_channel_dtypes(channels) + + assert len(r) == 2 + first, second = r + + assert first.size == 2 + assert first.little_endian is True + assert first.signed is False + + assert second.size == 2 + assert second.little_endian is False + assert second.signed is True + + def test_channel_dtype_assignment(self, revision: Shimmer3Revision): + for channel in EChannelType: + r = revision.get_channel_dtypes([channel]) + assert len(r) > 0 + + def test_get_enabled_channels(self, revision: Shimmer3Revision): + r = revision.get_enabled_channels([]) + assert r == [] + + r = revision.get_enabled_channels(()) + assert r == [] + + r = revision.get_enabled_channels( + [ESensorGroup.PRESSURE, ESensorGroup.ACCEL_LN] + ) + + assert r == [ + EChannelType.TEMPERATURE, + EChannelType.PRESSURE, + EChannelType.ACCEL_LN_X, + EChannelType.ACCEL_LN_Y, + EChannelType.ACCEL_LN_Z, + ] + + def test_sensor_group_assignment(self, revision: Shimmer3Revision): + for group in ESensorGroup: + r = revision.get_enabled_channels([group]) + + if group != ESensorGroup.TEMP: + assert len(r) > 0 + + def test_sensor_list_to_bitfield(self, revision: Shimmer3Revision): + r = revision.sensors2bitfield((ESensorGroup.ACCEL_LN, ESensorGroup.EXT_CH_A1)) + assert r == 0x81 + + r = revision.sensors2bitfield((ESensorGroup.STRAIN, ESensorGroup.INT_CH_A1)) + assert r == 0x8100 + + r = revision.sensors2bitfield((ESensorGroup.INT_CH_A2, ESensorGroup.TEMP)) + assert r == 0x820000 + + def test_bitfield_to_sensors(self, revision: Shimmer3Revision): + r = revision.bitfield2sensors(0x81) + assert r == [ESensorGroup.ACCEL_LN, ESensorGroup.EXT_CH_A1] + + r = revision.bitfield2sensors(0x8100) + assert r == [ESensorGroup.INT_CH_A1, ESensorGroup.STRAIN] + + r = revision.bitfield2sensors(0x820000) + assert r == [ + ESensorGroup.INT_CH_A2, + ESensorGroup.TEMP, + ] + + def test_sensor_bit_assignment_uniqueness(self, revision: Shimmer3Revision): + for group1, group2 in itertools.product(ESensorGroup, ESensorGroup): + if group1 == group2: + continue + + bitfield1 = revision.sensors2bitfield([group1]) + bitfield2 = revision.sensors2bitfield([group2]) + assert bitfield1 != bitfield2 + + def test_serialize_sensorlist(self, revision: Shimmer3Revision): + r = revision.serialize_sensorlist([]) + assert r == b"\x00\x00\x00" + + r = revision.serialize_sensorlist([ESensorGroup.GSR, ESensorGroup.BATTERY]) + assert r == b"\x04\x20\x00" + + def test_deserialize_sensorlist(self, revision: Shimmer3Revision): + r = revision.deserialize_sensorlist(b"\x00\x00\x00") + assert r == [] + + r = revision.deserialize_sensorlist(b"\x01\x80\x01") + assert r == [ + ESensorGroup.EXT_CH_A1, + ESensorGroup.STRAIN, + ] + + def test_serialize_deserialize(self, revision: Shimmer3Revision): + for group in ESensorGroup: + bitfield = revision.serialize_sensorlist([group]) + group_deserialized = revision.deserialize_sensorlist(bitfield) + assert [group] == group_deserialized + + def test_sort_sensors(self, revision: Shimmer3Revision): + sensors = [ESensorGroup.BATTERY, ESensorGroup.ACCEL_LN] + expected = [ESensorGroup.ACCEL_LN, ESensorGroup.BATTERY] + r = revision.sort_sensors(sensors) + assert r == expected + + sensors = [ + ESensorGroup.EXT_CH_A2, + ESensorGroup.MAG_WR, + ESensorGroup.ACCEL_LN, + ESensorGroup.EXT_CH_A2, + ] + expected = [ + ESensorGroup.ACCEL_LN, + ESensorGroup.EXT_CH_A2, + ESensorGroup.EXT_CH_A2, + ESensorGroup.MAG_WR, + ] + r = revision.sort_sensors(sensors) + assert r == expected From 780b35f7dad0e1d719437724ee4bc8457af89b64 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 6 Dec 2025 19:01:24 +0100 Subject: [PATCH 11/32] Add HardwareRevision to Bluetooth command constructors, reformat docstrings --- pyshimmer/bluetooth/bt_commands.py | 374 +++++++++++++++++++---------- 1 file changed, 242 insertions(+), 132 deletions(-) diff --git a/pyshimmer/bluetooth/bt_commands.py b/pyshimmer/bluetooth/bt_commands.py index 52e8080..df78e16 100644 --- a/pyshimmer/bluetooth/bt_commands.py +++ b/pyshimmer/bluetooth/bt_commands.py @@ -31,6 +31,7 @@ ) from pyshimmer.dev.exg import ExGRegister from pyshimmer.dev.fw_version import HardwareVersion, get_firmware_type +from pyshimmer.dev.revisions import HardwareRevision from pyshimmer.util import ( bit_is_set, resp_code_to_bytes, @@ -40,13 +41,20 @@ class DataPacket: - """Parses data packets received by the Shimmer device - :arg stream_types: List of tuples that contains each data channel contained in the - data packet as well as the corresponding data type decoder - """ + def __init__( + self, + rev: HardwareRevision, + stream_types: list[tuple[EChannelType, ChannelDataType]], + ): + """Parses data packets received by the Shimmer device - def __init__(self, stream_types: list[tuple[EChannelType, ChannelDataType]]): + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param stream_types: List of tuples that contains each data channel contained in the + data packet as well as the corresponding data type decoder + """ + self._rev = rev self._types = stream_types self._values = {} @@ -87,7 +95,14 @@ def receive(self, ser: BluetoothSerial) -> None: class ShimmerCommand(ABC): - """Abstract base class that represents a command sent to the Shimmer""" + + def __init__(self, rev: HardwareRevision): + """Abstract base class that represents a command sent to the Shimmer + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + self._rev = rev @abstractmethod def send(self, ser: BluetoothSerial) -> None: @@ -124,14 +139,17 @@ def receive(self, ser: BluetoothSerial) -> any: class ResponseCommand(ShimmerCommand, ABC): - """Abstract base class for all commands that feature a command response - :arg rcode: The response code of the response. Can be a single int for a - single-byte response code or a tuple of ints or a bytes instance for a - multi-byte response code - """ + def __init__(self, rev: HardwareRevision, rcode: int | bytes | tuple[int, ...]): + """Abstract base class for all commands that feature a command response - def __init__(self, rcode: int | bytes | tuple[int, ...]): + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param rcode: The response code of the response. Can be a single int for a + single-byte response code or a tuple of ints or a bytes instance for a + multi-byte response code + """ + super().__init__(rev) self._rcode = resp_code_to_bytes(rcode) def has_response(self) -> bool: @@ -142,12 +160,15 @@ def get_response_code(self) -> bytes: class OneShotCommand(ShimmerCommand): - """Class for commands that only send a command code and have no response - :arg cmd_code: The command code to send - """ + def __init__(self, rev: HardwareRevision, cmd_code: int): + """Class for commands that only send a command code and have no response - def __init__(self, cmd_code: int): + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param cmd_code: The command code to send + """ + super().__init__(rev) self._code = cmd_code def send(self, ser: BluetoothSerial) -> None: @@ -155,20 +176,23 @@ def send(self, ser: BluetoothSerial) -> None: class GetStringCommand(ResponseCommand): - """Send a command that features a variable-length string as response - - :arg req_code: The command code of the request - :arg resp_code: The response code - :arg encoding: The encoding to use when reading the response string - """ def __init__( self, + rev: HardwareRevision, req_code: int, resp_code: int | bytes | tuple[int], encoding: str = "utf8", ): - super().__init__(resp_code) + """Send a command that features a variable-length string as response + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param req_code: The command code of the request + :param resp_code: The response code + :param encoding: The encoding to use when reading the response string + """ + super().__init__(rev, resp_code) self._req_code = req_code self._encoding = encoding @@ -181,14 +205,23 @@ def receive(self, ser: BluetoothSerial) -> any: class SetStringCommand(ShimmerCommand): - """A command for sending a variable-length string to the device - :arg req_code: The code of the command request - :arg str_data: The data to send as part of the request - :arg encoding: The encoding to use when writing the data to the stream - """ + def __init__( + self, + rev: HardwareRevision, + req_code: int, + str_data: str, + encoding: str = "utf8", + ): + """A command for sending a variable-length string to the device - def __init__(self, req_code: int, str_data: str, encoding: str = "utf8"): + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param req_code: The code of the command request + :param str_data: The data to send as part of the request + :param encoding: The encoding to use when writing the data to the stream + """ + super().__init__(rev) self._req_code = req_code self._str_data = str_data self._encoding = encoding @@ -199,10 +232,14 @@ def send(self, ser: BluetoothSerial) -> None: class GetSamplingRateCommand(ResponseCommand): - """Retrieve the sampling rate in samples per second""" - def __init__(self): - super().__init__(SAMPLING_RATE_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Retrieve the sampling rate in samples per second + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, SAMPLING_RATE_RESPONSE) def send(self, ser: BluetoothSerial) -> None: ser.write_command(GET_SAMPLING_RATE_COMMAND) @@ -215,7 +252,8 @@ def receive(self, ser: BluetoothSerial) -> float: class SetSamplingRateCommand(ShimmerCommand): - def __init__(self, sr: float): + def __init__(self, rev: HardwareRevision, sr: float): + super().__init__(rev) self._sr = sr def send(self, ser: BluetoothSerial) -> None: @@ -224,10 +262,14 @@ def send(self, ser: BluetoothSerial) -> None: class GetBatteryCommand(ResponseCommand): - """Retrieve the battery state""" - def __init__(self, in_percent: bool): - super().__init__(FULL_BATTERY_RESPONSE) + def __init__(self, rev: HardwareRevision, in_percent: bool): + """Retrieve the battery state + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, FULL_BATTERY_RESPONSE) self._in_percent = in_percent def send(self, ser: BluetoothSerial) -> None: @@ -247,12 +289,15 @@ def receive(self, ser: BluetoothSerial) -> any: class GetConfigTimeCommand(ResponseCommand): - """Retrieve the config time that is stored in the Shimmer device - configuration file - """ - def __init__(self): - super().__init__(CONFIGTIME_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Retrieve the config time that is stored in the Shimmer device + configuration file + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, CONFIGTIME_RESPONSE) def send(self, ser: BluetoothSerial) -> None: ser.write_command(GET_CONFIGTIME_COMMAND) @@ -263,13 +308,16 @@ def receive(self, ser: BluetoothSerial) -> any: class SetConfigTimeCommand(ShimmerCommand): - """Set the config time, which will be stored in the Shimmer device configuration - file - :arg time: The integer value to send - """ + def __init__(self, rev: HardwareRevision, time: int): + """Set the config time, which will be stored in the Shimmer device + configuration file - def __init__(self, time: int): + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param time: The integer value to send + """ + super().__init__(rev) self._time = time def send(self, ser: BluetoothSerial) -> None: @@ -280,12 +328,14 @@ def send(self, ser: BluetoothSerial) -> None: class GetRealTimeClockCommand(ResponseCommand): - """ - Get the real-time clock as UNIX Timestamp in seconds - """ - def __init__(self): - super().__init__(RWC_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Get the real-time clock as UNIX Timestamp in seconds + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, RWC_RESPONSE) def send(self, ser: BluetoothSerial) -> None: ser.write_command(GET_RWC_COMMAND) @@ -296,13 +346,16 @@ def receive(self, ser: BluetoothSerial) -> float: class SetRealTimeClockCommand(ShimmerCommand): - """ - Set the real-time clock as UNIX timestamp in seconds - :arg ts_sec: The UNIX timestamp in seconds - """ + def __init__(self, rev: HardwareRevision, ts_sec: float): + """ + Set the real-time clock as UNIX timestamp in seconds - def __init__(self, ts_sec: float): + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param ts_sec: The UNIX timestamp in seconds + """ + super().__init__(rev) self._time = int(ts_sec) def send(self, ser: BluetoothSerial) -> None: @@ -311,7 +364,6 @@ def send(self, ser: BluetoothSerial) -> None: class GetStatusCommand(ResponseCommand): - """Retrieve the current status of the device""" STATUS_DOCKED_BF = 1 << 0 STATUS_SENSING_BF = 1 << 1 @@ -332,8 +384,13 @@ class GetStatusCommand(ResponseCommand): STATUS_RED_LED_BF, ) - def __init__(self): - super().__init__(FULL_STATUS_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Retrieve the current status of the device + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, FULL_STATUS_RESPONSE) def unpack_status_bitfields(self, val: int) -> list[bool]: values = [bit_is_set(val, f) for f in self.STATUS_BITFIELDS] @@ -348,10 +405,14 @@ def receive(self, ser: BluetoothSerial) -> any: class GetFirmwareVersionCommand(ResponseCommand): - """Retrieve the firmware type and version""" - def __init__(self): - super().__init__(FW_VERSION_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Retrieve the firmware type and version + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, FW_VERSION_RESPONSE) def send(self, ser: BluetoothSerial) -> None: ser.write_command(GET_FW_VERSION_COMMAND) @@ -365,22 +426,25 @@ def receive(self, ser: BluetoothSerial) -> any: class GetAllCalibrationCommand(ResponseCommand): - """Returns all the stored calibration values (84 bytes) in the following order: - ESensorGroup.ACCEL_LN (21 bytes) - ESensorGroup.GYRO (21 bytes) - ESensorGroup.MAG (21 bytes) - ESensorGroup.ACCEL_WR (21 bytes) + def __init__(self, rev: HardwareRevision): + """Returns all the stored calibration values (84 bytes) in the following order: - The breakdown of the kinematic (accel x 2, gyro and mag) calibration values is - as follows: - [bytes 0- 5] offset bias values: 3 (x,y,z) 16-bit signed integers (big endian). - [bytes 6-11] sensitivity values: 3 (x,y,z) 16-bit signed integers (big endian). - [bytes 12-20] alignment matrix: 9 values 8-bit signed integers. - """ + ESensorGroup.ACCEL_LN (21 bytes) + ESensorGroup.GYRO (21 bytes) + ESensorGroup.MAG (21 bytes) + ESensorGroup.ACCEL_WR (21 bytes) - def __init__(self): - super().__init__(ALL_CALIBRATION_RESPONSE) + The breakdown of the kinematic (accel x 2, gyro and mag) calibration values is + as follows: + [bytes 0- 5] offset bias values: 3 (x,y,z) 16-bit signed integers (big endian). + [bytes 6-11] sensitivity values: 3 (x,y,z) 16-bit signed integers (big endian). + [bytes 12-20] alignment matrix: 9 values 8-bit signed integers. + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, ALL_CALIBRATION_RESPONSE) self._offset = 0x0 self._rlen = 0x54 # 84 bytes @@ -395,13 +459,15 @@ def receive(self, ser: BluetoothSerial) -> any: class InquiryCommand(ResponseCommand): - """ - Perform an inquiry to determine the sample rate, buffer size, and active data - channels - """ - def __init__(self): - super().__init__(INQUIRY_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Perform an inquiry to determine the sample rate, buffer size, + and active data channels + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, INQUIRY_RESPONSE) @staticmethod def decode_channel_types(ct_bin: bytes) -> list[EChannelType]: @@ -425,30 +491,40 @@ def receive(self, ser: BluetoothSerial) -> any: class StartStreamingCommand(OneShotCommand): - """Start streaming data over the Bluetooth channel""" - def __init__(self): - super().__init__(START_STREAMING_COMMAND) + def __init__(self, rev: HardwareRevision): + """Start streaming data over the Bluetooth channel + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, START_STREAMING_COMMAND) class StopStreamingCommand(OneShotCommand): - """Stop streaming data over the Bluetooth channel""" - def __init__(self): - super().__init__(STOP_STREAMING_COMMAND) + def __init__(self, rev: HardwareRevision): + """Stop streaming data over the Bluetooth channel + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, STOP_STREAMING_COMMAND) class GetEXGRegsCommand(ResponseCommand): - """Retrieve the current state of the ExG chip register - Queries the values of all registers of the specified chip and returns it as an - ExGRegister instance + def __init__(self, rev: HardwareRevision, chip_id: int): + """Retrieve the current state of the ExG chip register - :arg chip_id: The chip id, can be one of [0, 1] - """ + Queries the values of all registers of the specified chip and returns it as an + ExGRegister instance - def __init__(self, chip_id: int): - super().__init__(EXG_REGS_RESPONSE) + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param chip_id: The chip id, can be one of [0, 1] + """ + super().__init__(rev, EXG_REGS_RESPONSE) self._chip = chip_id self._offset = 0x0 @@ -469,14 +545,17 @@ def receive(self, ser: BluetoothSerial) -> any: class SetEXGRegsCommand(ShimmerCommand): - """Set the binary contents of the ExG registers of a chip - :arg chip_id: The id of the chip, can be one of [0, 1] - :arg offset: At which offset to write the data - :arg data: The bytes to write to the registers - """ + def __init__(self, rev: HardwareRevision, chip_id: int, offset: int, data: bytes): + """Set the binary contents of the ExG registers of a chip - def __init__(self, chip_id: int, offset: int, data: bytes): + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param chip_id: The id of the chip, can be one of [0, 1] + :param offset: At which offset to write the data + :param data: The bytes to write to the registers + """ + super().__init__(rev) self._chip = chip_id self._offset = offset self._data = data @@ -488,25 +567,32 @@ def send(self, ser: BluetoothSerial) -> None: class GetExperimentIDCommand(GetStringCommand): - """Retrieve the experiment id""" - def __init__(self): - super().__init__(GET_EXPID_COMMAND, EXPID_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Retrieve the experiment ID + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, GET_EXPID_COMMAND, EXPID_RESPONSE) class SetExperimentIDCommand(SetStringCommand): - """Set the experiment id - :arg exp_id: The experiment id as string - """ + def __init__(self, rev: HardwareRevision, exp_id: str): + """Set the experiment ID - def __init__(self, exp_id: str): - super().__init__(SET_EXPID_COMMAND, exp_id) + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param exp_id: The experiment id as string + """ + super().__init__(rev, SET_EXPID_COMMAND, exp_id) class SetSensorsCommand(ShimmerCommand): - def __init__(self, sensors: Iterable[ESensorGroup]): + def __init__(self, rev: HardwareRevision, sensors: Iterable[ESensorGroup]): + super().__init__(rev) self._sensors = list(sensors) def send(self, ser: BluetoothSerial) -> None: @@ -515,17 +601,25 @@ def send(self, ser: BluetoothSerial) -> None: class GetDeviceNameCommand(GetStringCommand): - """Get the device name""" - def __init__(self): - super().__init__(GET_SHIMMERNAME_COMMAND, SHIMMERNAME_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Get the device name + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, GET_SHIMMERNAME_COMMAND, SHIMMERNAME_RESPONSE) class GetShimmerHardwareVersion(ResponseCommand): - """Get the device hardware version""" - def __init__(self): - super().__init__(SHIMMER_VERSION_RESPONSE) + def __init__(self, rev: HardwareRevision): + """Get the device hardware version + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, SHIMMER_VERSION_RESPONSE) def send(self, ser: BluetoothSerial) -> None: ser.write_command(GET_SHIMMER_VERSION_COMMAND) @@ -536,18 +630,20 @@ def receive(self, ser: BluetoothSerial) -> any: class SetDeviceNameCommand(SetStringCommand): - """Set the device name - :arg dev_name: The new device name as string - """ + def __init__(self, rev: HardwareRevision, dev_name: str): + """Set the device name - def __init__(self, dev_name: str): - super().__init__(SET_SHIMMERNAME_COMMAND, dev_name) + :param rev: The hardware revision of the Shimmer device this command + will be sent to + :param dev_name: The new device name as string + """ + super().__init__(rev, SET_SHIMMERNAME_COMMAND, dev_name) class SetStatusAckCommand(ShimmerCommand): - def __init__(self, enabled: bool): + def __init__(self, rev: HardwareRevision, enabled: bool): """Command to enable/disable the ACK byte before status messages By default, the Shimmer firmware sends an acknowledgment byte before @@ -556,9 +652,12 @@ def __init__(self, enabled: bool): software. This command is used by the Python API to automatically disable the acknowledgment when connecting to a Shimmer. + :param rev: The hardware revision of the Shimmer device this command + will be sent to :param enabled: If set to True, the acknowledgment is sent. If set to False, the acknowledgment is not sent. """ + super().__init__(rev) self._enabled = enabled def send(self, ser: BluetoothSerial) -> None: @@ -566,23 +665,34 @@ def send(self, ser: BluetoothSerial) -> None: class StartLoggingCommand(OneShotCommand): - """Begin logging data to the SD card""" - def __init__(self): - super().__init__(START_LOGGING_COMMAND) + def __init__(self, rev: HardwareRevision): + """Begin logging data to the SD card + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, START_LOGGING_COMMAND) class StopLoggingCommand(OneShotCommand): - """End logging data to the SD card""" - def __init__(self): - super().__init__(STOP_LOGGING_COMMAND) + def __init__(self, rev: HardwareRevision): + """End logging data to the SD card + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, STOP_LOGGING_COMMAND) class DummyCommand(OneShotCommand): - """ - Dummy command that is only acknowledged by the Shimmer but triggers no response - """ - def __init__(self): - super().__init__(DUMMY_COMMAND) + def __init__(self, rev: HardwareRevision): + """Dummy command that is only acknowledged by the Shimmer but + triggers no response + + :param rev: The hardware revision of the Shimmer device this command + will be sent to + """ + super().__init__(rev, DUMMY_COMMAND) From 7e5cdf22da30a699a7de251064fe0a94843a994e Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 6 Dec 2025 19:17:45 +0100 Subject: [PATCH 12/32] Update Bluetooth Command Tests --- test/bluetooth/test_bt_commands.py | 230 +++++++++++++++++------------ 1 file changed, 136 insertions(+), 94 deletions(-) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 57b7df7..53d1093 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from __future__ import annotations -from unittest import TestCase +import pytest from pyshimmer.bluetooth.bt_commands import ( GetShimmerHardwareVersion, @@ -50,10 +50,15 @@ from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ESensorGroup from pyshimmer.dev.fw_version import EFirmwareType, HardwareVersion +from pyshimmer.dev.revisions import Shimmer3Revision, HardwareRevision from pyshimmer.test_util import MockSerial +TEST_REVISIONS = [ + Shimmer3Revision(), +] -class BluetoothCommandsTest(TestCase): + +class TestBluetoothCommands: @staticmethod def create_mock() -> tuple[BluetoothSerial, MockSerial]: @@ -74,182 +79,215 @@ def assert_cmd( cmd.send(serial) actual_req_data = mock.test_get_write_data() - self.assertEqual(actual_req_data, req_data) + assert actual_req_data == req_data if resp_code is None: - self.assertFalse(cmd.has_response()) + assert not cmd.has_response() return None - self.assertTrue(cmd.has_response()) - self.assertEqual(cmd.get_response_code(), resp_code) + assert cmd.has_response() + assert cmd.get_response_code() == resp_code mock.test_put_read_data(resp_data) act_result = cmd.receive(serial) if exp_result is not None: - self.assertEqual(act_result, exp_result) + assert act_result == exp_result return act_result def test_response_command_code_conversion(self): class TestCommand(ResponseCommand): def __init__(self, rcode: int | bytes | tuple[int, ...]): - super().__init__(rcode) + super().__init__(Shimmer3Revision(), rcode) def send(self, ser: BluetoothSerial) -> None: pass cmd = TestCommand(10) - self.assertEqual(cmd.get_response_code(), b"\x0a") + assert cmd.get_response_code() == b"\x0a" cmd = TestCommand(20) - self.assertEqual(cmd.get_response_code(), b"\x14") + assert cmd.get_response_code() == b"\x14" cmd = TestCommand((10,)) - self.assertEqual(cmd.get_response_code(), b"\x0a") + assert cmd.get_response_code() == b"\x0a" cmd = TestCommand((10, 20)) - self.assertEqual(cmd.get_response_code(), b"\x0a\x14") + assert cmd.get_response_code() == b"\x0a\x14" cmd = TestCommand(b"\x10") - self.assertEqual(cmd.get_response_code(), b"\x10") + assert cmd.get_response_code() == b"\x10" cmd = TestCommand(b"\x10\x20") - self.assertEqual(cmd.get_response_code(), b"\x10\x20") + assert cmd.get_response_code() == b"\x10\x20" - def test_get_sampling_rate_command(self): - cmd = GetSamplingRateCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_sampling_rate_command(self, rev): + cmd = GetSamplingRateCommand(rev) self.assert_cmd(cmd, b"\x03", b"\x04", b"\x04\x40\x00", 512.0) - def test_set_sampling_rate_command(self): - cmd = SetSamplingRateCommand(sr=512.0) + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_set_sampling_rate_command(self, rev: HardwareRevision): + cmd = SetSamplingRateCommand(rev, sr=512.0) self.assert_cmd(cmd, b"\x05\x40\x00") - def test_get_battery_state_command(self): - cmd = GetBatteryCommand(in_percent=True) + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_battery_state_command(self, rev: HardwareRevision): + cmd = GetBatteryCommand(rev, in_percent=True) self.assert_cmd(cmd, b"\x95", b"\x8a\x94", b"\x8a\x94\x30\x0b\x80", 100) - cmd = GetBatteryCommand(in_percent=False) + cmd = GetBatteryCommand(rev, in_percent=False) self.assert_cmd( cmd, b"\x95", b"\x8a\x94", b"\x8a\x94\x2e\x0b\x80", 4.168246153846154 ) - def test_set_sensors_command(self): + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_set_sensors_command(self, rev: HardwareRevision): sensors = [ ESensorGroup.GYRO, ESensorGroup.INT_CH_A1, ESensorGroup.PRESSURE, ] - cmd = SetSensorsCommand(sensors) + cmd = SetSensorsCommand(rev, sensors) self.assert_cmd(cmd, b"\x08\x40\x01\x04") - def test_get_config_time_command(self): - cmd = GetConfigTimeCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_config_time_command(self, rev: HardwareRevision): + cmd = GetConfigTimeCommand(rev) self.assert_cmd(cmd, b"\x87", b"\x86", b"\x86\x02\x34\x32", 42) - def test_set_config_time_command(self): - cmd = SetConfigTimeCommand(43) + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_set_config_time_command(self, rev: HardwareRevision): + cmd = SetConfigTimeCommand(rev, 43) self.assert_cmd(cmd, b"\x85\x02\x34\x33") - def test_get_rtc(self): - cmd = GetRealTimeClockCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_rtc(self, rev: HardwareRevision): + cmd = GetRealTimeClockCommand(rev) r = self.assert_cmd( cmd, b"\x91", b"\x90", b"\x90\x1f\xb1\x93\x09\x00\x00\x00\x00" ) - self.assertAlmostEqual(r, 4903.3837585) + assert r == pytest.approx(4903.3837585) - def test_set_rtc(self): - cmd = SetRealTimeClockCommand(10) + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_set_rtc(self, rev: HardwareRevision): + cmd = SetRealTimeClockCommand(rev, 10) self.assert_cmd(cmd, b"\x8f\x00\x00\x05\x00\x00\x00\x00\x00") - def test_get_status_command(self): - cmd = GetStatusCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_status_command(self, rev: HardwareRevision): + cmd = GetStatusCommand(rev) expected_result = [True, False, True, False, False, True, False, False] self.assert_cmd(cmd, b"\x72", b"\x8a\x71", b"\x8a\x71\x25", expected_result) - def test_get_firmware_version_command(self): - cmd = GetFirmwareVersionCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_firmware_version_command(self, rev: HardwareRevision): + cmd = GetFirmwareVersionCommand(rev) fw_type, major, minor, patch = self.assert_cmd( cmd, b"\x2e", b"\x2f", b"\x2f\x03\x00\x00\x00\x0b\x00" ) - self.assertEqual(fw_type, EFirmwareType.LogAndStream) - self.assertEqual(major, 0) - self.assertEqual(minor, 11) - self.assertEqual(patch, 0) - - def test_inquiry_command(self): - cmd = InquiryCommand() + assert fw_type == EFirmwareType.LogAndStream + assert major == 0 + assert minor == 11 + assert patch == 0 + + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_inquiry_command(self, rev: HardwareRevision): + cmd = InquiryCommand(rev) sr, buf_size, ctypes = self.assert_cmd( cmd, b"\x01", b"\x02", b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" ) - self.assertEqual(sr, 512.0) - self.assertEqual(buf_size, 1) - self.assertEqual(ctypes, [EChannelType.INTERNAL_ADC_A1]) + assert sr == 512.0 + assert buf_size == 1 + assert ctypes == [EChannelType.INTERNAL_ADC_A1] - def test_start_streaming_command(self): - cmd = StartStreamingCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_start_streaming_command(self, rev: HardwareRevision): + cmd = StartStreamingCommand(rev) self.assert_cmd(cmd, b"\x07") - def test_stop_streaming_command(self): - cmd = StopStreamingCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_stop_streaming_command(self, rev: HardwareRevision): + cmd = StopStreamingCommand(rev) self.assert_cmd(cmd, b"\x20") - def test_start_logging_command(self): - cmd = StartLoggingCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_start_logging_command(self, rev: HardwareRevision): + cmd = StartLoggingCommand(rev) self.assert_cmd(cmd, b"\x92") - def test_stop_logging_command(self): - cmd = StopLoggingCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_stop_logging_command(self, rev: HardwareRevision): + cmd = StopLoggingCommand(rev) self.assert_cmd(cmd, b"\x93") - def test_get_exg_register_command(self): - cmd = GetEXGRegsCommand(1) + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_exg_register_command(self, rev: HardwareRevision): + cmd = GetEXGRegsCommand(rev, 1) r = self.assert_cmd( cmd, b"\x63\x01\x00\x0a", b"\x62", b"\x62\x0a\x00\x80\x10\x00\x00\x00\x00\x00\x02\x01", ) - self.assertEqual(r.binary, b"\x00\x80\x10\x00\x00\x00\x00\x00\x02\x01") + assert r.binary == b"\x00\x80\x10\x00\x00\x00\x00\x00\x02\x01" - def test_get_exg_reg_fail(self): + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_exg_reg_fail(self, rev: HardwareRevision): serial, mock = self.create_mock() - cmd = GetEXGRegsCommand(1) + cmd = GetEXGRegsCommand(rev, 1) mock.test_put_read_data(b"\x62\x04\x01\x02\x03\x04") - self.assertRaises(ValueError, cmd.receive, serial) - - def test_get_allcalibration_command(self): - cmd = GetAllCalibrationCommand() - r = self.assert_cmd( - cmd, - b"\x2c", - b"\x2d", - b"\x2d\x08\xcd\x08\xcd\x08\xcd\x00\x5c\x00\x5c\x00\x5c\x00\x9c\x00\x9c\x00\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x00\x19\x96\x19\x96\x19\x96\x00\x9c\x00\x9c\x00\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x87\x06\x87\x06\x87\x00\x9c\x00\x64\x00\x00\x00\x00\x9c", + with pytest.raises(ValueError): + cmd.receive(serial) + + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_allcalibration_command(self, rev: HardwareRevision): + response_data = ( + b"\x2d\x08\xcd\x08\xcd\x08\xcd\x00\x5c\x00\x5c\x00\x5c\x00\x9c\x00" + b"\x9c\x00\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x00\x19\x96\x19\x96" + b"\x19\x96\x00\x9c\x00\x9c\x00\x00\x00\x00\x9c\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x06\x87\x06\x87\x06\x87\x00\x9c\x00\x64" + b"\x00\x00\x00\x00\x9c" ) - self.assertEqual( - r.binary, - b"\x08\xcd\x08\xcd\x08\xcd\x00\x5c\x00\x5c\x00\x5c\x00\x9c\x00\x9c\x00\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x00\x19\x96\x19\x96\x19\x96\x00\x9c\x00\x9c\x00\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x87\x06\x87\x06\x87\x00\x9c\x00\x64\x00\x00\x00\x00\x9c", + expected_result = ( + b"\x08\xcd\x08\xcd\x08\xcd\x00\x5c\x00\x5c\x00\x5c\x00\x9c\x00\x9c" + b"\x00\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x00\x19\x96\x19\x96\x19" + b"\x96\x00\x9c\x00\x9c\x00\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x06\x87\x06\x87\x06\x87\x00\x9c\x00\x64\x00" + b"\x00\x00\x00\x9c" ) - def test_set_exg_register_command(self): - cmd = SetEXGRegsCommand(1, 0x02, b"\x10\x00") + cmd = GetAllCalibrationCommand(rev) + r = self.assert_cmd(cmd, b"\x2c", b"\x2d", response_data) + assert r.binary == expected_result + + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_set_exg_register_command(self, rev: HardwareRevision): + cmd = SetEXGRegsCommand(rev, 1, 0x02, b"\x10\x00") self.assert_cmd(cmd, b"\x61\x01\x02\x02\x10\x00") - def test_get_experiment_id_command(self): - cmd = GetExperimentIDCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_experiment_id_command(self, rev: HardwareRevision): + cmd = GetExperimentIDCommand(rev) self.assert_cmd(cmd, b"\x7e", b"\x7d", b"\x7d\x06a_test", "a_test") - def test_set_experiment_id_command(self): - cmd = SetExperimentIDCommand("A_Test") + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_set_experiment_id_command(self, rev: HardwareRevision): + cmd = SetExperimentIDCommand(rev, "A_Test") self.assert_cmd(cmd, b"\x7c\x06A_Test") - def test_get_device_name_command(self): - cmd = GetDeviceNameCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_device_name_command(self, rev: HardwareRevision): + cmd = GetDeviceNameCommand(rev) self.assert_cmd(cmd, b"\x7b", b"\x7a", b"\x7a\x05S_PPG", "S_PPG") - def test_get_hardware_version(self): - cmd = GetShimmerHardwareVersion() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_get_hardware_version(self, rev: HardwareRevision): + cmd = GetShimmerHardwareVersion(rev) self.assert_cmd(cmd, b"\x3f", b"\x25", b"\x25\x00", HardwareVersion.SHIMMER1) self.assert_cmd(cmd, b"\x3f", b"\x25", b"\x25\x01", HardwareVersion.SHIMMER2) self.assert_cmd(cmd, b"\x3f", b"\x25", b"\x25\x02", HardwareVersion.SHIMMER2R) @@ -257,34 +295,38 @@ def test_get_hardware_version(self): self.assert_cmd(cmd, b"\x3f", b"\x25", b"\x25\x0a", HardwareVersion.SHIMMER3R) self.assert_cmd(cmd, b"\x3f", b"\x25", b"\x25\x04", HardwareVersion.UNKNOWN) - def test_set_device_name_command(self): - cmd = SetDeviceNameCommand("S_PPG") + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_set_device_name_command(self, rev: HardwareRevision): + cmd = SetDeviceNameCommand(rev, "S_PPG") self.assert_cmd(cmd, b"\x79\x05S_PPG") - def test_set_status_ack_command(self): - cmd = SetStatusAckCommand(enabled=True) + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_set_status_ack_command(self, rev: HardwareRevision): + cmd = SetStatusAckCommand(rev, enabled=True) self.assert_cmd(cmd, b"\xa3\x01") - cmd = SetStatusAckCommand(enabled=False) + cmd = SetStatusAckCommand(rev, enabled=False) self.assert_cmd(cmd, b"\xa3\x00") - def test_dummy_command(self): - cmd = DummyCommand() + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_dummy_command(self, rev: HardwareRevision): + cmd = DummyCommand(rev) self.assert_cmd(cmd, b"\x96") - def test_data_packet(self): + @pytest.mark.parametrize("rev", TEST_REVISIONS) + def test_data_packet(self, rev: HardwareRevision): serial, mock = self.create_mock() channels = [EChannelType.TIMESTAMP, EChannelType.INTERNAL_ADC_A1] data_types = [ChDataTypeAssignment[c] for c in channels] ch_and_types = list(zip(channels, data_types)) - pkt = DataPacket(ch_and_types) - self.assertEqual(pkt.channels, channels) - self.assertEqual(pkt.channel_types, data_types) + pkt = DataPacket(rev, ch_and_types) + assert pkt.channels == channels + assert pkt.channel_types == data_types mock.test_put_read_data(b"\x00\xde\xd0\xb2\x26\x07") pkt.receive(serial) - self.assertEqual(pkt[EChannelType.TIMESTAMP], 0xB2D0DE) - self.assertEqual(pkt[EChannelType.INTERNAL_ADC_A1], 0x0726) + assert pkt[EChannelType.TIMESTAMP] == 0xB2D0DE + assert pkt[EChannelType.INTERNAL_ADC_A1] == 0x0726 From 7750d54dc375d01672103e0a9191d6e1cf576099 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 14 Dec 2025 20:40:50 +0100 Subject: [PATCH 13/32] Fix some of the tests --- pyshimmer/__init__.py | 7 +++- pyshimmer/dev/revisions/__init__.py | 5 +++ test/bluetooth/test_bluetooth_api.py | 23 ++++++----- test/bluetooth/test_bt_commands.py | 62 +++++++++++++--------------- 4 files changed, 52 insertions(+), 45 deletions(-) diff --git a/pyshimmer/__init__.py b/pyshimmer/__init__.py index 891c1f2..ef24dcd 100644 --- a/pyshimmer/__init__.py +++ b/pyshimmer/__init__.py @@ -20,7 +20,12 @@ from .dev.channels import ChannelDataType, EChannelType from .dev.exg import ExGMux, ExGRLDLead, ERLDRef, ExGRegister from .dev.fw_version import EFirmwareType -from .dev.revisions import HardwareRevision, Shimmer3Revision +from .dev.revisions import ( + HardwareRevision, + Shimmer3Revision, + HW_REVISIONS, + REV_SHIMMER3, +) from .reader.binary_reader import ShimmerBinaryReader from .reader.shimmer_reader import ShimmerReader from .uart.dock_api import ShimmerDock diff --git a/pyshimmer/dev/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py index 1cebed6..63fd553 100644 --- a/pyshimmer/dev/revisions/__init__.py +++ b/pyshimmer/dev/revisions/__init__.py @@ -15,3 +15,8 @@ # along with this program. If not, see . from .revision import HardwareRevision from .shimmer3 import Shimmer3Revision + +REV_SHIMMER3 = Shimmer3Revision() +HW_REVISIONS = [ + REV_SHIMMER3, +] diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index 8aafc42..54b15a8 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -32,6 +32,7 @@ from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType from pyshimmer.dev.fw_version import FirmwareVersion, EFirmwareType, HardwareVersion +from pyshimmer.dev.revisions import REV_SHIMMER3 from pyshimmer.test_util import PTYSerialMockCreator @@ -77,7 +78,7 @@ def cb(_): self._sot.remove_status_callback(cb) def test_enque_command(self): - cmd = GetDeviceNameCommand() + cmd = GetDeviceNameCommand(REV_SHIMMER3) compl, resp = self._sot.queue_command(cmd) self.assertFalse(compl.has_completed()) @@ -99,7 +100,7 @@ def test_enque_command(self): self.assertEqual(resp.get_result(), "S_PPG") def test_enqueue_multibyte(self): - cmd = GetStringCommand(0x10, b"\x0a\x0b") + cmd = GetStringCommand(REV_SHIMMER3, 0x10, b"\x0a\x0b") compl, resp = self._sot.queue_command(cmd) r = self.read_from_master(1) @@ -116,8 +117,8 @@ def test_enqueue_multibyte(self): self.assertEqual(resp.get_result(), "ab") def test_enqueue_multiple_commands(self): - cmd1 = GetDeviceNameCommand() - cmd2 = GetStatusCommand() + cmd1 = GetDeviceNameCommand(REV_SHIMMER3) + cmd2 = GetStatusCommand(REV_SHIMMER3) compl1, resp1 = self._sot.queue_command(cmd1) compl2, resp2 = self._sot.queue_command(cmd2) @@ -145,7 +146,7 @@ def test_enqueue_multiple_commands(self): ) def test_queue_command_no_resp(self): - cmd = SetDeviceNameCommand("S_PPG") + cmd = SetDeviceNameCommand(REV_SHIMMER3, "S_PPG") compl, resp = self._sot.queue_command(cmd) self.assertFalse(compl.has_completed()) @@ -162,7 +163,7 @@ def test_queue_unknown_instream(self): class InStreamCommand(ResponseCommand): def __init__(self): - super().__init__(b"\x8a\x42") + super().__init__(REV_SHIMMER3, b"\x8a\x42") def send(self, ser: BluetoothSerial) -> None: ser.write(b"\x42") @@ -183,7 +184,7 @@ def receive(self, ser: BluetoothSerial) -> any: self.assertTrue(resp.has_result()) def test_get_status_command(self): - cmd = GetStatusCommand() + cmd = GetStatusCommand(REV_SHIMMER3) compl, resp = self._sot.queue_command(cmd) self.assertFalse(compl.has_completed()) @@ -204,7 +205,7 @@ def test_get_status_command(self): ) def test_incorrect_resp_code_fail(self): - cmd = GetDeviceNameCommand() + cmd = GetDeviceNameCommand(REV_SHIMMER3) _ = self._sot.queue_command(cmd) self._master.write(b"\xff\xfe") @@ -269,7 +270,7 @@ def test_get_status_response_update_mixed(self): status_resp: list[list[bool]] = [] self._sot.add_status_callback(status_resp.append) - compl, resp = self._sot.queue_command(GetStatusCommand()) + compl, resp = self._sot.queue_command(GetStatusCommand(REV_SHIMMER3)) r = self.read_from_master(1) self.assertEqual(r, b"\x72") @@ -292,8 +293,8 @@ def test_get_status_response_update_mixed(self): ) def test_clear_queues(self): - compl1, resp1 = self._sot.queue_command(GetDeviceNameCommand()) - compl2, resp2 = self._sot.queue_command(GetDeviceNameCommand()) + compl1, resp1 = self._sot.queue_command(GetDeviceNameCommand(REV_SHIMMER3)) + compl2, resp2 = self._sot.queue_command(GetDeviceNameCommand(REV_SHIMMER3)) self.assertFalse(compl1.has_completed()) self.assertFalse(resp1.has_result()) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 53d1093..40e916c 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -50,13 +50,9 @@ from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ESensorGroup from pyshimmer.dev.fw_version import EFirmwareType, HardwareVersion -from pyshimmer.dev.revisions import Shimmer3Revision, HardwareRevision +from pyshimmer.dev.revisions import HardwareRevision, HW_REVISIONS, REV_SHIMMER3 from pyshimmer.test_util import MockSerial -TEST_REVISIONS = [ - Shimmer3Revision(), -] - class TestBluetoothCommands: @@ -98,7 +94,7 @@ def assert_cmd( def test_response_command_code_conversion(self): class TestCommand(ResponseCommand): def __init__(self, rcode: int | bytes | tuple[int, ...]): - super().__init__(Shimmer3Revision(), rcode) + super().__init__(REV_SHIMMER3, rcode) def send(self, ser: BluetoothSerial) -> None: pass @@ -121,17 +117,17 @@ def send(self, ser: BluetoothSerial) -> None: cmd = TestCommand(b"\x10\x20") assert cmd.get_response_code() == b"\x10\x20" - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_sampling_rate_command(self, rev): cmd = GetSamplingRateCommand(rev) self.assert_cmd(cmd, b"\x03", b"\x04", b"\x04\x40\x00", 512.0) - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_set_sampling_rate_command(self, rev: HardwareRevision): cmd = SetSamplingRateCommand(rev, sr=512.0) self.assert_cmd(cmd, b"\x05\x40\x00") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_battery_state_command(self, rev: HardwareRevision): cmd = GetBatteryCommand(rev, in_percent=True) self.assert_cmd(cmd, b"\x95", b"\x8a\x94", b"\x8a\x94\x30\x0b\x80", 100) @@ -141,7 +137,7 @@ def test_get_battery_state_command(self, rev: HardwareRevision): cmd, b"\x95", b"\x8a\x94", b"\x8a\x94\x2e\x0b\x80", 4.168246153846154 ) - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_set_sensors_command(self, rev: HardwareRevision): sensors = [ ESensorGroup.GYRO, @@ -151,17 +147,17 @@ def test_set_sensors_command(self, rev: HardwareRevision): cmd = SetSensorsCommand(rev, sensors) self.assert_cmd(cmd, b"\x08\x40\x01\x04") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_config_time_command(self, rev: HardwareRevision): cmd = GetConfigTimeCommand(rev) self.assert_cmd(cmd, b"\x87", b"\x86", b"\x86\x02\x34\x32", 42) - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_set_config_time_command(self, rev: HardwareRevision): cmd = SetConfigTimeCommand(rev, 43) self.assert_cmd(cmd, b"\x85\x02\x34\x33") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_rtc(self, rev: HardwareRevision): cmd = GetRealTimeClockCommand(rev) r = self.assert_cmd( @@ -169,18 +165,18 @@ def test_get_rtc(self, rev: HardwareRevision): ) assert r == pytest.approx(4903.3837585) - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_set_rtc(self, rev: HardwareRevision): cmd = SetRealTimeClockCommand(rev, 10) self.assert_cmd(cmd, b"\x8f\x00\x00\x05\x00\x00\x00\x00\x00") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_status_command(self, rev: HardwareRevision): cmd = GetStatusCommand(rev) expected_result = [True, False, True, False, False, True, False, False] self.assert_cmd(cmd, b"\x72", b"\x8a\x71", b"\x8a\x71\x25", expected_result) - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_firmware_version_command(self, rev: HardwareRevision): cmd = GetFirmwareVersionCommand(rev) fw_type, major, minor, patch = self.assert_cmd( @@ -191,7 +187,7 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): assert minor == 11 assert patch == 0 - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_inquiry_command(self, rev: HardwareRevision): cmd = InquiryCommand(rev) sr, buf_size, ctypes = self.assert_cmd( @@ -202,27 +198,27 @@ def test_inquiry_command(self, rev: HardwareRevision): assert buf_size == 1 assert ctypes == [EChannelType.INTERNAL_ADC_A1] - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_start_streaming_command(self, rev: HardwareRevision): cmd = StartStreamingCommand(rev) self.assert_cmd(cmd, b"\x07") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_stop_streaming_command(self, rev: HardwareRevision): cmd = StopStreamingCommand(rev) self.assert_cmd(cmd, b"\x20") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_start_logging_command(self, rev: HardwareRevision): cmd = StartLoggingCommand(rev) self.assert_cmd(cmd, b"\x92") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_stop_logging_command(self, rev: HardwareRevision): cmd = StopLoggingCommand(rev) self.assert_cmd(cmd, b"\x93") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_exg_register_command(self, rev: HardwareRevision): cmd = GetEXGRegsCommand(rev, 1) r = self.assert_cmd( @@ -233,7 +229,7 @@ def test_get_exg_register_command(self, rev: HardwareRevision): ) assert r.binary == b"\x00\x80\x10\x00\x00\x00\x00\x00\x02\x01" - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_exg_reg_fail(self, rev: HardwareRevision): serial, mock = self.create_mock() cmd = GetEXGRegsCommand(rev, 1) @@ -242,7 +238,7 @@ def test_get_exg_reg_fail(self, rev: HardwareRevision): with pytest.raises(ValueError): cmd.receive(serial) - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_allcalibration_command(self, rev: HardwareRevision): response_data = ( b"\x2d\x08\xcd\x08\xcd\x08\xcd\x00\x5c\x00\x5c\x00\x5c\x00\x9c\x00" @@ -265,27 +261,27 @@ def test_get_allcalibration_command(self, rev: HardwareRevision): r = self.assert_cmd(cmd, b"\x2c", b"\x2d", response_data) assert r.binary == expected_result - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_set_exg_register_command(self, rev: HardwareRevision): cmd = SetEXGRegsCommand(rev, 1, 0x02, b"\x10\x00") self.assert_cmd(cmd, b"\x61\x01\x02\x02\x10\x00") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_experiment_id_command(self, rev: HardwareRevision): cmd = GetExperimentIDCommand(rev) self.assert_cmd(cmd, b"\x7e", b"\x7d", b"\x7d\x06a_test", "a_test") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_set_experiment_id_command(self, rev: HardwareRevision): cmd = SetExperimentIDCommand(rev, "A_Test") self.assert_cmd(cmd, b"\x7c\x06A_Test") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_device_name_command(self, rev: HardwareRevision): cmd = GetDeviceNameCommand(rev) self.assert_cmd(cmd, b"\x7b", b"\x7a", b"\x7a\x05S_PPG", "S_PPG") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_get_hardware_version(self, rev: HardwareRevision): cmd = GetShimmerHardwareVersion(rev) self.assert_cmd(cmd, b"\x3f", b"\x25", b"\x25\x00", HardwareVersion.SHIMMER1) @@ -295,12 +291,12 @@ def test_get_hardware_version(self, rev: HardwareRevision): self.assert_cmd(cmd, b"\x3f", b"\x25", b"\x25\x0a", HardwareVersion.SHIMMER3R) self.assert_cmd(cmd, b"\x3f", b"\x25", b"\x25\x04", HardwareVersion.UNKNOWN) - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_set_device_name_command(self, rev: HardwareRevision): cmd = SetDeviceNameCommand(rev, "S_PPG") self.assert_cmd(cmd, b"\x79\x05S_PPG") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_set_status_ack_command(self, rev: HardwareRevision): cmd = SetStatusAckCommand(rev, enabled=True) self.assert_cmd(cmd, b"\xa3\x01") @@ -308,12 +304,12 @@ def test_set_status_ack_command(self, rev: HardwareRevision): cmd = SetStatusAckCommand(rev, enabled=False) self.assert_cmd(cmd, b"\xa3\x00") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_dummy_command(self, rev: HardwareRevision): cmd = DummyCommand(rev) self.assert_cmd(cmd, b"\x96") - @pytest.mark.parametrize("rev", TEST_REVISIONS) + @pytest.mark.parametrize("rev", HW_REVISIONS) def test_data_packet(self, rev: HardwareRevision): serial, mock = self.create_mock() From bac291baf7ca53639f05266e3453330f706c67d3 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 10:48:59 +0100 Subject: [PATCH 14/32] Move HardwareVersion into separate module --- pyshimmer/__init__.py | 1 + pyshimmer/bluetooth/bt_api.py | 2 +- pyshimmer/bluetooth/bt_commands.py | 4 ++-- pyshimmer/dev/fw_version.py | 22 +--------------------- pyshimmer/dev/revisions/__init__.py | 1 + pyshimmer/dev/revisions/hw_version.py | 23 +++++++++++++++++++++++ test/bluetooth/test_bluetooth_api.py | 4 ++-- test/bluetooth/test_bt_commands.py | 10 ++++++++-- 8 files changed, 39 insertions(+), 28 deletions(-) create mode 100644 pyshimmer/dev/revisions/hw_version.py diff --git a/pyshimmer/__init__.py b/pyshimmer/__init__.py index ef24dcd..33a769f 100644 --- a/pyshimmer/__init__.py +++ b/pyshimmer/__init__.py @@ -25,6 +25,7 @@ Shimmer3Revision, HW_REVISIONS, REV_SHIMMER3, + HardwareVersion, ) from .reader.binary_reader import ShimmerBinaryReader from .reader.shimmer_reader import ShimmerReader diff --git a/pyshimmer/bluetooth/bt_api.py b/pyshimmer/bluetooth/bt_api.py index 719fea0..2383c3d 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -69,8 +69,8 @@ EFirmwareType, FirmwareVersion, FirmwareCapabilities, - HardwareVersion, ) +from pyshimmer.dev.revisions import HardwareVersion from pyshimmer.serial_base import ReadAbort from pyshimmer.util import fmt_hex, PeekQueue diff --git a/pyshimmer/bluetooth/bt_commands.py b/pyshimmer/bluetooth/bt_commands.py index df78e16..2e1c92f 100644 --- a/pyshimmer/bluetooth/bt_commands.py +++ b/pyshimmer/bluetooth/bt_commands.py @@ -30,8 +30,8 @@ serialize_sensorlist, ) from pyshimmer.dev.exg import ExGRegister -from pyshimmer.dev.fw_version import HardwareVersion, get_firmware_type -from pyshimmer.dev.revisions import HardwareRevision +from pyshimmer.dev.fw_version import get_firmware_type +from pyshimmer.dev.revisions import HardwareRevision, HardwareVersion from pyshimmer.util import ( bit_is_set, resp_code_to_bytes, diff --git a/pyshimmer/dev/fw_version.py b/pyshimmer/dev/fw_version.py index 9098290..bc4fd5b 100644 --- a/pyshimmer/dev/fw_version.py +++ b/pyshimmer/dev/fw_version.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from __future__ import annotations -from enum import Enum, IntEnum, auto +from enum import Enum, auto def ensure_firmware_version(func): @@ -103,23 +103,3 @@ def get_firmware_type(f_type: int) -> EFirmwareType: raise ValueError(f"Unknown firmware type: 0x{f_type:x}") return FirmwareTypeValueAssignment[f_type] - - -class HardwareVersion(IntEnum): - """Represents the supported Shimmer device hardware versions""" - - SHIMMER1 = 0 - SHIMMER2 = 1 - SHIMMER2R = 2 - SHIMMER3 = 3 - SHIMMER3R = 10 - UNKNOWN = -1 - - @classmethod - def from_int(cls, value: int) -> HardwareVersion: - """Converts an Integer to the corresponding HardwareVersion enum - - :param value: Integer representing device hardware version - :return: Corresponding HardwareVersion enum member, or UNKNOWN if unrecognised - """ - return cls._value2member_map_.get(value, cls.UNKNOWN) diff --git a/pyshimmer/dev/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py index 63fd553..9c20e5f 100644 --- a/pyshimmer/dev/revisions/__init__.py +++ b/pyshimmer/dev/revisions/__init__.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from .revision import HardwareRevision from .shimmer3 import Shimmer3Revision +from .hw_version import HardwareVersion REV_SHIMMER3 = Shimmer3Revision() HW_REVISIONS = [ diff --git a/pyshimmer/dev/revisions/hw_version.py b/pyshimmer/dev/revisions/hw_version.py new file mode 100644 index 0000000..4d597df --- /dev/null +++ b/pyshimmer/dev/revisions/hw_version.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from enum import IntEnum + + +class HardwareVersion(IntEnum): + """Represents the supported Shimmer device hardware versions""" + + SHIMMER1 = 0 + SHIMMER2 = 1 + SHIMMER2R = 2 + SHIMMER3 = 3 + SHIMMER3R = 10 + UNKNOWN = -1 + + @classmethod + def from_int(cls, value: int) -> HardwareVersion: + """Converts an Integer to the corresponding HardwareVersion enum + + :param value: Integer representing device hardware version + :return: Corresponding HardwareVersion enum member, or UNKNOWN if unrecognised + """ + return cls._value2member_map_.get(value, cls.UNKNOWN) diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index 54b15a8..2a8c3c3 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -31,8 +31,8 @@ ) from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType -from pyshimmer.dev.fw_version import FirmwareVersion, EFirmwareType, HardwareVersion -from pyshimmer.dev.revisions import REV_SHIMMER3 +from pyshimmer.dev.fw_version import FirmwareVersion, EFirmwareType +from pyshimmer.dev.revisions import HardwareVersion, REV_SHIMMER3 from pyshimmer.test_util import PTYSerialMockCreator diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 40e916c..70fc101 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -49,8 +49,14 @@ ) from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ESensorGroup -from pyshimmer.dev.fw_version import EFirmwareType, HardwareVersion -from pyshimmer.dev.revisions import HardwareRevision, HW_REVISIONS, REV_SHIMMER3 +from pyshimmer.dev.fw_version import EFirmwareType + +from pyshimmer.dev.revisions import ( + HardwareVersion, + HardwareRevision, + HW_REVISIONS, + REV_SHIMMER3, +) from pyshimmer.test_util import MockSerial From 8c1e5746e68ea54e82b5a8032f0e3e75e116de7b Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 11:01:25 +0100 Subject: [PATCH 15/32] Link the HardwareRevision to the HardwareVersion enum --- pyshimmer/dev/revisions/__init__.py | 6 ++-- pyshimmer/dev/revisions/hw_version.py | 44 ++++++++++++++++++++++----- pyshimmer/dev/revisions/shimmer3.py | 3 ++ test/dev/revision/test_hw_version.py | 16 ++++++++++ 4 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 test/dev/revision/test_hw_version.py diff --git a/pyshimmer/dev/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py index 9c20e5f..77c85ce 100644 --- a/pyshimmer/dev/revisions/__init__.py +++ b/pyshimmer/dev/revisions/__init__.py @@ -11,13 +11,13 @@ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. +from .hw_version import HardwareVersion + # You should have received a copy of the GNU General Public License # along with this program. If not, see . from .revision import HardwareRevision -from .shimmer3 import Shimmer3Revision -from .hw_version import HardwareVersion +from .shimmer3 import Shimmer3Revision, REV_SHIMMER3 -REV_SHIMMER3 = Shimmer3Revision() HW_REVISIONS = [ REV_SHIMMER3, ] diff --git a/pyshimmer/dev/revisions/hw_version.py b/pyshimmer/dev/revisions/hw_version.py index 4d597df..da926d5 100644 --- a/pyshimmer/dev/revisions/hw_version.py +++ b/pyshimmer/dev/revisions/hw_version.py @@ -2,16 +2,31 @@ from enum import IntEnum +from .revision import HardwareRevision +from .shimmer3 import REV_SHIMMER3 + class HardwareVersion(IntEnum): - """Represents the supported Shimmer device hardware versions""" + """Represents the supported Shimmer device hardware version / revision + + This enum links between the value returned by the Shimmer device + and the revision class. + """ - SHIMMER1 = 0 - SHIMMER2 = 1 - SHIMMER2R = 2 - SHIMMER3 = 3 - SHIMMER3R = 10 - UNKNOWN = -1 + SHIMMER1 = (0, None) + SHIMMER2 = (1, None) + SHIMMER2R = (2, None) + SHIMMER3 = (3, REV_SHIMMER3) + SHIMMER3R = (10, None) + UNKNOWN = (-1, None) + + def __new__(cls, version: int, revision: HardwareRevision | None): + # Strips the revision argument from the tuple and only assigns the + # version ID as enum value + obj = int.__new__(cls) + obj._value_ = version + obj._revision = revision + return obj @classmethod def from_int(cls, value: int) -> HardwareVersion: @@ -21,3 +36,18 @@ def from_int(cls, value: int) -> HardwareVersion: :return: Corresponding HardwareVersion enum member, or UNKNOWN if unrecognised """ return cls._value2member_map_.get(value, cls.UNKNOWN) + + @property + def revision(self) -> HardwareRevision | None: + return self._revision + + def get_revision(self) -> HardwareRevision: + """Provides a fail-early way of retrieving the revision + + :return: A revision if one is available. Otherwise, it throws a + ValueError. + """ + if self._revision is None: + raise ValueError(f"Hardware version {self.value} does not have revision") + + return self._revision diff --git a/pyshimmer/dev/revisions/shimmer3.py b/pyshimmer/dev/revisions/shimmer3.py index 3c06b9e..d0d9b23 100644 --- a/pyshimmer/dev/revisions/shimmer3.py +++ b/pyshimmer/dev/revisions/shimmer3.py @@ -199,3 +199,6 @@ def __init__(self): self.SENSOR_BIT_ASSIGNMENT, self.SENSOR_ORDER, ) + + +REV_SHIMMER3 = Shimmer3Revision() diff --git a/test/dev/revision/test_hw_version.py b/test/dev/revision/test_hw_version.py new file mode 100644 index 0000000..102ce6c --- /dev/null +++ b/test/dev/revision/test_hw_version.py @@ -0,0 +1,16 @@ +import pytest + +from pyshimmer import HardwareVersion + + +class TestHardwareVersion: + + def test_revision_access(self): + ver_shimmer3 = HardwareVersion.SHIMMER3 + assert ver_shimmer3.revision is not None + assert ver_shimmer3.revision is ver_shimmer3.get_revision() + + ver_shimmer2 = HardwareVersion.SHIMMER2 + assert ver_shimmer2.revision is None + with pytest.raises(ValueError): + ver_shimmer2.get_revision() From fd6712887f795fb9b42d6cc101399b09187da65a Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 11:18:25 +0100 Subject: [PATCH 16/32] Update the Bluetooth API to use the new revision class --- pyshimmer/bluetooth/bt_api.py | 160 ++++++++++++++++++++++++---------- 1 file changed, 116 insertions(+), 44 deletions(-) diff --git a/pyshimmer/bluetooth/bt_api.py b/pyshimmer/bluetooth/bt_api.py index 2383c3d..9311303 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Sequence from queue import Queue, Empty from threading import Event, Thread @@ -70,7 +70,7 @@ FirmwareVersion, FirmwareCapabilities, ) -from pyshimmer.dev.revisions import HardwareVersion +from pyshimmer.dev.revisions import HardwareVersion, HardwareRevision, REV_SHIMMER3 from pyshimmer.serial_base import ReadAbort from pyshimmer.util import fmt_hex, PeekQueue @@ -127,10 +127,16 @@ class BluetoothRequestHandler: acts as a base layer that operates synchronously and allows for easier testing. :arg serial: The serial interface to use + :arg revision: The hardware revision of the Shimmer device. There is a + chicken-egg problem that we can only know the revision after contacting + the device, for which we need this class. The revision can bootstrapped + with a sensible default and updated later with the appropriate + property setter. """ - def __init__(self, serial: BluetoothSerial): + def __init__(self, serial: BluetoothSerial, revision: HardwareRevision): self._serial = serial + self._rev = revision self._ack_queue = Queue() self._resp_queue = PeekQueue() @@ -139,15 +145,37 @@ def __init__(self, serial: BluetoothSerial): self._stream_cbs = [] self._status_cbs = [] - def set_stream_types( - self, types: list[tuple[EChannelType, ChannelDataType]] + @property + def hardware_revision(self) -> HardwareRevision: + """ + The hardware revision of the Shimmer device that we are currently + talking to. + """ + return self._rev + + @hardware_revision.setter + def hardware_revision(self, v: HardwareRevision) -> None: + """Update the hardware revision of the Shimmer device that we + are currently talking to. This can be necessary because we might not + know precisely what device we are talking to during class instantiation. + """ + self._rev = v + + @property + def stream_types(self) -> Sequence[tuple[EChannelType, ChannelDataType]]: + """The data types expected to be sent by the device in a DataPacket""" + return self._stream_types + + @stream_types.setter + def stream_types( + self, types: Iterable[tuple[EChannelType, ChannelDataType]] ) -> None: """Set the channel types that are streamed as part of the data packets :param types: A List of tuples, each containing a channel type and its corresponding data type """ - self._stream_types = types + self._stream_types = list(types) def add_stream_callback(self, cb: Callable[[DataPacket], None]) -> None: """Add a stream callback which is called when a new data packet arrives @@ -191,7 +219,7 @@ def _process_ack(self): compl_obj.set_completed() def _process_data_packet(self): - packet = DataPacket(self._stream_types) + packet = DataPacket(self._rev, self._stream_types) packet.receive(self._serial) for cb in self._stream_cbs: @@ -223,7 +251,7 @@ def _process_status_response(self): def _process_status_update(self): # Called if the status response was not triggered by a command but sent by the # Shimmer as the result of an event - status_cmd = GetStatusCommand() + status_cmd = GetStatusCommand(self._rev) r = status_cmd.receive(self._serial) for cb in self._status_cbs: @@ -310,7 +338,12 @@ def clear_queues(self) -> None: class ShimmerBluetooth: - def __init__(self, serial: Serial, disable_status_ack: bool = True): + def __init__( + self, + serial: Serial, + revision: HardwareRevision = None, + disable_status_ack: bool = True, + ): """API for communicating with the Shimmer via Bluetooth This class implements support for talking to the Shimmer LogAndStream firmware @@ -321,6 +354,10 @@ def __init__(self, serial: Serial, disable_status_ack: bool = True): :param serial: The serial channel that encapsulates the rfcomm Bluetooth connection to the Shimmer + :param revision: Manually set the revision of the Shimmer device that we are + communicating with. Normally, the revision is automatically determined + during initialization. You can manually set one here if there is an + issue with automatic revision selection. :param disable_status_ack: Starting with LogAndStream firmware version 0.15.4, the vanilla firmware supports disabling the acknowledgment byte before status messages. This removes the need for running a custom firmware version @@ -330,7 +367,14 @@ def __init__(self, serial: Serial, disable_status_ack: bool = True): want this or if it causes trouble with your firmware version. """ self._serial = BluetoothSerial(serial) - self._bluetooth = BluetoothRequestHandler(self._serial) + + self._revision = revision + # If no specific revision is provided, we simply assume that + # we are talking to a Shimmer3. During initialization, we query + # the actual revision and update it. + self._bluetooth = BluetoothRequestHandler( + self._serial, revision=revision if revision is not None else REV_SHIMMER3 + ) self._thread = Thread(target=self._run_readloop, daemon=True) @@ -370,20 +414,28 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, exc_traceback): self.shutdown() - def _set_fw_capabilities(self) -> None: - fw_type, fw_ver = self.get_firmware_version() - self._fw_caps = FirmwareCapabilities(fw_type, fw_ver) - self._hw_version = self.get_device_hardware_version() - def initialize(self) -> None: """Initialize the Bluetooth connection This method must be invoked before sending commands to the Shimmer. It queries - the Shimmer version, optionally disables the status acknowledgment and - starts the read loop. + the Shimmer version, optionally disables the status acknowledgment and + starts the read loop. """ + + # Start the thread to enable communication with the device self._thread.start() - self._set_fw_capabilities() + + fw_type, fw_ver = self.get_firmware_version() + self._fw_caps = FirmwareCapabilities(fw_type, fw_ver) + self._hw_version = self.get_device_hardware_version() + + if self._hw_version.revision is None: + raise ValueError( + f"Hardware version {self._hw_version} is not" f"supported." + ) + else: + # Update the request handler with the actually used revision + self._bluetooth.hardware_revision = self._hw_version.revision if self.capabilities.supports_ack_disable and self._disable_ack: self.set_status_ack(enabled=False) @@ -452,14 +504,14 @@ def get_sampling_rate(self) -> float: :return: The sampling rate as floating point value in samples per second """ - return self._process_and_wait(GetSamplingRateCommand()) + return self._process_and_wait(GetSamplingRateCommand(self._hw_version.revision)) def set_sampling_rate(self, sr: float) -> None: """Set the active sampling rate for the device :param sr: The sampling rate in Hertz """ - self._process_and_wait(SetSamplingRateCommand(sr)) + self._process_and_wait(SetSamplingRateCommand(self._hw_version.revision, sr)) def get_battery_state(self, in_percent: bool) -> float: """Retrieve the battery state of the device @@ -468,14 +520,16 @@ def get_battery_state(self, in_percent: bool) -> float: battery state in Volt :return: The battery state in percent / Volt """ - return self._process_and_wait(GetBatteryCommand(in_percent)) + return self._process_and_wait( + GetBatteryCommand(self._hw_version.revision, in_percent) + ) def get_config_time(self) -> int: """Get the config time from the device as configured in the configuration file :return: The config time as integer """ - return self._process_and_wait(GetConfigTimeCommand()) + return self._process_and_wait(GetConfigTimeCommand(self._hw_version.revision)) def set_config_time(self, time: int) -> None: """Set the config time of the device @@ -483,7 +537,7 @@ def set_config_time(self, time: int) -> None: :arg time: The configuration time that will be set in the configuration of the Shimmer """ - self._process_and_wait(SetConfigTimeCommand(time)) + self._process_and_wait(SetConfigTimeCommand(self._hw_version.revision, time)) def set_sensors(self, sensors: Iterable[ESensorGroup]) -> None: """Set the active sensors for sampling @@ -493,14 +547,16 @@ def set_sensors(self, sensors: Iterable[ESensorGroup]) -> None: :param sensors: A list of sensors to activate """ - self._process_and_wait(SetSensorsCommand(sensors)) + self._process_and_wait(SetSensorsCommand(self._hw_version.revision, sensors)) def get_rtc(self) -> float: """Retrieve the current value of the onboard real-time clock :return: The current time of the device in seconds as UNIX timestamp """ - return self._process_and_wait(GetRealTimeClockCommand()) + return self._process_and_wait( + GetRealTimeClockCommand(self._hw_version.revision) + ) def set_rtc(self, time_sec: float) -> None: """Set the value of the onboard real-time clock @@ -510,7 +566,9 @@ def set_rtc(self, time_sec: float) -> None: :param time_sec: The UNIX timestamp in seconds """ - self._process_and_wait(SetRealTimeClockCommand(time_sec)) + self._process_and_wait( + SetRealTimeClockCommand(self._hw_version.revision, time_sec) + ) def get_status(self) -> list[bool]: """Get the status of the device @@ -519,7 +577,7 @@ def get_status(self) -> list[bool]: dev_docked, dev_sensing, rtc_set, dev_logging, dev_streaming, sd_card_present, sd_error, status_red_led """ - return self._process_and_wait(GetStatusCommand()) + return self._process_and_wait(GetStatusCommand(self._hw_version.revision)) def get_firmware_version(self) -> tuple[EFirmwareType, FirmwareVersion]: """Get the version of the running firmware @@ -527,7 +585,9 @@ def get_firmware_version(self) -> tuple[EFirmwareType, FirmwareVersion]: :return: The firmware type as enum, i.e. SDLog or LogAndStream and the numeric firmware version """ - fw_type, major, minor, rel = self._process_and_wait(GetFirmwareVersionCommand()) + fw_type, major, minor, rel = self._process_and_wait( + GetFirmwareVersionCommand(self._hw_version.revision) + ) fw_version = FirmwareVersion(major, minor, rel) return fw_type, fw_version @@ -542,14 +602,18 @@ def get_exg_register(self, chip_id: int) -> ExGRegister: :return: An ExGRegister object that presents the register contents in an easily processable manner """ - return self._process_and_wait(GetEXGRegsCommand(chip_id)) + return self._process_and_wait( + GetEXGRegsCommand(self._hw_version.revision, chip_id) + ) def get_all_calibration(self) -> AllCalibration: """Gets all calibration data from sensor :return: An AllCalibration object that presents the calibration contents in an easily processable manner """ - return self._process_and_wait(GetAllCalibrationCommand()) + return self._process_and_wait( + GetAllCalibrationCommand(self._hw_version.revision) + ) def set_exg_register(self, chip_id: int, offset: int, data: bytes) -> None: """Configure part of the memory of the ExG registers @@ -558,42 +622,50 @@ def set_exg_register(self, chip_id: int, offset: int, data: bytes) -> None: :param offset: The offset at which to write the data bytes :param data: The data bytes to write """ - self._process_and_wait(SetEXGRegsCommand(chip_id, offset, data)) + self._process_and_wait( + SetEXGRegsCommand(self._hw_version.revision, chip_id, offset, data) + ) def get_device_name(self) -> str: """Retrieve the device name :return: The device name as string """ - return self._process_and_wait(GetDeviceNameCommand()) + return self._process_and_wait(GetDeviceNameCommand(self._hw_version.revision)) def set_device_name(self, dev_name: str) -> None: """Set the device name :param dev_name: The device name to set """ - self._process_and_wait(SetDeviceNameCommand(dev_name)) + self._process_and_wait( + SetDeviceNameCommand(self._hw_version.revision, dev_name) + ) def get_device_hardware_version(self) -> HardwareVersion: """Retrieve the device hardware version :return: The device hardware version as string """ - return self._process_and_wait(GetShimmerHardwareVersion()) + return self._process_and_wait( + GetShimmerHardwareVersion(self._hw_version.revision) + ) def get_experiment_id(self) -> str: """Retrieve the experiment id as string :return: The experiment ID as string """ - return self._process_and_wait(GetExperimentIDCommand()) + return self._process_and_wait(GetExperimentIDCommand(self._hw_version.revision)) def set_experiment_id(self, exp_id: str) -> None: """Set the experiment ID for the device :param exp_id: The id to set for the device """ - self._process_and_wait(SetExperimentIDCommand(exp_id)) + self._process_and_wait( + SetExperimentIDCommand(self._hw_version.revision, exp_id) + ) def get_inquiry(self) -> tuple[float, int, list[EChannelType]]: """Perform inquiry command @@ -604,7 +676,7 @@ def get_inquiry(self) -> tuple[float, int, list[EChannelType]]: - The active data channels of the device as list, does not include the TIMESTAMP channel """ - return self._process_and_wait(InquiryCommand()) + return self._process_and_wait(InquiryCommand(self._hw_version.revision)) def get_data_types(self): """Get the active data channels of the device @@ -624,9 +696,9 @@ def start_streaming(self) -> None: ctypes = self.get_data_types() stream_types = [(t, ChDataTypeAssignment[t]) for t in ctypes] - self._bluetooth.set_stream_types(stream_types) + self._bluetooth.stream_types = stream_types - self._process_and_wait(StartStreamingCommand()) + self._process_and_wait(StartStreamingCommand(self._hw_version.revision)) def stop_streaming(self) -> None: """Stop streaming data @@ -635,22 +707,22 @@ def stop_streaming(self) -> None: already been received and are in the input buffer. """ - self._process_and_wait(StopStreamingCommand()) + self._process_and_wait(StopStreamingCommand(self._hw_version.revision)) def start_logging(self) -> None: """Start logging data to the SD card of the device""" - self._process_and_wait(StartLoggingCommand()) + self._process_and_wait(StartLoggingCommand(self._hw_version.revision)) def stop_logging(self) -> None: """Stop logging data to the SD card of the device""" - self._process_and_wait(StopLoggingCommand()) + self._process_and_wait(StopLoggingCommand(self._hw_version.revision)) def send_ping(self) -> None: """Send a ping command to the device The command can be used to test the connection. It does not return anything. """ - self._process_and_wait(DummyCommand()) + self._process_and_wait(DummyCommand(self._hw_version.revision)) def set_status_ack(self, enabled: bool) -> None: """Send a command to enable or disable the status acknowledgment @@ -665,4 +737,4 @@ def set_status_ack(self, enabled: bool) -> None: sending the status ack. In this state, the firmware is compatible to the Python API. """ - self._process_and_wait(SetStatusAckCommand(enabled)) + self._process_and_wait(SetStatusAckCommand(self._hw_version.revision, enabled)) From ff4fdebb541dc8b471494624f15290c64db8f7cb Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 12:27:16 +0100 Subject: [PATCH 17/32] Update revision handling in ShimmerBluetooth --- pyshimmer/bluetooth/bt_api.py | 98 ++++++++++++++++------------------- 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/pyshimmer/bluetooth/bt_api.py b/pyshimmer/bluetooth/bt_api.py index 9311303..b77507e 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -368,13 +368,16 @@ def __init__( """ self._serial = BluetoothSerial(serial) - self._revision = revision + if revision is None: + self._revision_is_custom = False + self._revision = REV_SHIMMER3 + else: + self._revision_is_custom = True + self._revision = revision # If no specific revision is provided, we simply assume that # we are talking to a Shimmer3. During initialization, we query # the actual revision and update it. - self._bluetooth = BluetoothRequestHandler( - self._serial, revision=revision if revision is not None else REV_SHIMMER3 - ) + self._bluetooth = BluetoothRequestHandler(self._serial, revision=self._revision) self._thread = Thread(target=self._run_readloop, daemon=True) @@ -429,13 +432,18 @@ def initialize(self) -> None: self._fw_caps = FirmwareCapabilities(fw_type, fw_ver) self._hw_version = self.get_device_hardware_version() - if self._hw_version.revision is None: - raise ValueError( - f"Hardware version {self._hw_version} is not" f"supported." - ) - else: - # Update the request handler with the actually used revision - self._bluetooth.hardware_revision = self._hw_version.revision + # If the revision was set manually, we don't want to automatically + # update it + if not self._revision_is_custom: + if self._hw_version.revision is None: + raise ValueError( + f"Hardware version {self._hw_version} is not" f"supported." + ) + else: + # Update the request handler with the actually used revision + self._revision = self._bluetooth.hardware_revision = ( + self._hw_version.revision + ) if self.capabilities.supports_ack_disable and self._disable_ack: self.set_status_ack(enabled=False) @@ -504,14 +512,14 @@ def get_sampling_rate(self) -> float: :return: The sampling rate as floating point value in samples per second """ - return self._process_and_wait(GetSamplingRateCommand(self._hw_version.revision)) + return self._process_and_wait(GetSamplingRateCommand(self._revision)) def set_sampling_rate(self, sr: float) -> None: """Set the active sampling rate for the device :param sr: The sampling rate in Hertz """ - self._process_and_wait(SetSamplingRateCommand(self._hw_version.revision, sr)) + self._process_and_wait(SetSamplingRateCommand(self._revision, sr)) def get_battery_state(self, in_percent: bool) -> float: """Retrieve the battery state of the device @@ -520,16 +528,14 @@ def get_battery_state(self, in_percent: bool) -> float: battery state in Volt :return: The battery state in percent / Volt """ - return self._process_and_wait( - GetBatteryCommand(self._hw_version.revision, in_percent) - ) + return self._process_and_wait(GetBatteryCommand(self._revision, in_percent)) def get_config_time(self) -> int: """Get the config time from the device as configured in the configuration file :return: The config time as integer """ - return self._process_and_wait(GetConfigTimeCommand(self._hw_version.revision)) + return self._process_and_wait(GetConfigTimeCommand(self._revision)) def set_config_time(self, time: int) -> None: """Set the config time of the device @@ -537,7 +543,7 @@ def set_config_time(self, time: int) -> None: :arg time: The configuration time that will be set in the configuration of the Shimmer """ - self._process_and_wait(SetConfigTimeCommand(self._hw_version.revision, time)) + self._process_and_wait(SetConfigTimeCommand(self._revision, time)) def set_sensors(self, sensors: Iterable[ESensorGroup]) -> None: """Set the active sensors for sampling @@ -547,16 +553,14 @@ def set_sensors(self, sensors: Iterable[ESensorGroup]) -> None: :param sensors: A list of sensors to activate """ - self._process_and_wait(SetSensorsCommand(self._hw_version.revision, sensors)) + self._process_and_wait(SetSensorsCommand(self._revision, sensors)) def get_rtc(self) -> float: """Retrieve the current value of the onboard real-time clock :return: The current time of the device in seconds as UNIX timestamp """ - return self._process_and_wait( - GetRealTimeClockCommand(self._hw_version.revision) - ) + return self._process_and_wait(GetRealTimeClockCommand(self._revision)) def set_rtc(self, time_sec: float) -> None: """Set the value of the onboard real-time clock @@ -566,9 +570,7 @@ def set_rtc(self, time_sec: float) -> None: :param time_sec: The UNIX timestamp in seconds """ - self._process_and_wait( - SetRealTimeClockCommand(self._hw_version.revision, time_sec) - ) + self._process_and_wait(SetRealTimeClockCommand(self._revision, time_sec)) def get_status(self) -> list[bool]: """Get the status of the device @@ -577,7 +579,7 @@ def get_status(self) -> list[bool]: dev_docked, dev_sensing, rtc_set, dev_logging, dev_streaming, sd_card_present, sd_error, status_red_led """ - return self._process_and_wait(GetStatusCommand(self._hw_version.revision)) + return self._process_and_wait(GetStatusCommand(self._revision)) def get_firmware_version(self) -> tuple[EFirmwareType, FirmwareVersion]: """Get the version of the running firmware @@ -586,7 +588,7 @@ def get_firmware_version(self) -> tuple[EFirmwareType, FirmwareVersion]: and the numeric firmware version """ fw_type, major, minor, rel = self._process_and_wait( - GetFirmwareVersionCommand(self._hw_version.revision) + GetFirmwareVersionCommand(self._revision) ) fw_version = FirmwareVersion(major, minor, rel) @@ -602,18 +604,14 @@ def get_exg_register(self, chip_id: int) -> ExGRegister: :return: An ExGRegister object that presents the register contents in an easily processable manner """ - return self._process_and_wait( - GetEXGRegsCommand(self._hw_version.revision, chip_id) - ) + return self._process_and_wait(GetEXGRegsCommand(self._revision, chip_id)) def get_all_calibration(self) -> AllCalibration: """Gets all calibration data from sensor :return: An AllCalibration object that presents the calibration contents in an easily processable manner """ - return self._process_and_wait( - GetAllCalibrationCommand(self._hw_version.revision) - ) + return self._process_and_wait(GetAllCalibrationCommand(self._revision)) def set_exg_register(self, chip_id: int, offset: int, data: bytes) -> None: """Configure part of the memory of the ExG registers @@ -622,50 +620,42 @@ def set_exg_register(self, chip_id: int, offset: int, data: bytes) -> None: :param offset: The offset at which to write the data bytes :param data: The data bytes to write """ - self._process_and_wait( - SetEXGRegsCommand(self._hw_version.revision, chip_id, offset, data) - ) + self._process_and_wait(SetEXGRegsCommand(self._revision, chip_id, offset, data)) def get_device_name(self) -> str: """Retrieve the device name :return: The device name as string """ - return self._process_and_wait(GetDeviceNameCommand(self._hw_version.revision)) + return self._process_and_wait(GetDeviceNameCommand(self._revision)) def set_device_name(self, dev_name: str) -> None: """Set the device name :param dev_name: The device name to set """ - self._process_and_wait( - SetDeviceNameCommand(self._hw_version.revision, dev_name) - ) + self._process_and_wait(SetDeviceNameCommand(self._revision, dev_name)) def get_device_hardware_version(self) -> HardwareVersion: """Retrieve the device hardware version :return: The device hardware version as string """ - return self._process_and_wait( - GetShimmerHardwareVersion(self._hw_version.revision) - ) + return self._process_and_wait(GetShimmerHardwareVersion(self._revision)) def get_experiment_id(self) -> str: """Retrieve the experiment id as string :return: The experiment ID as string """ - return self._process_and_wait(GetExperimentIDCommand(self._hw_version.revision)) + return self._process_and_wait(GetExperimentIDCommand(self._revision)) def set_experiment_id(self, exp_id: str) -> None: """Set the experiment ID for the device :param exp_id: The id to set for the device """ - self._process_and_wait( - SetExperimentIDCommand(self._hw_version.revision, exp_id) - ) + self._process_and_wait(SetExperimentIDCommand(self._revision, exp_id)) def get_inquiry(self) -> tuple[float, int, list[EChannelType]]: """Perform inquiry command @@ -676,7 +666,7 @@ def get_inquiry(self) -> tuple[float, int, list[EChannelType]]: - The active data channels of the device as list, does not include the TIMESTAMP channel """ - return self._process_and_wait(InquiryCommand(self._hw_version.revision)) + return self._process_and_wait(InquiryCommand(self._revision)) def get_data_types(self): """Get the active data channels of the device @@ -698,7 +688,7 @@ def start_streaming(self) -> None: stream_types = [(t, ChDataTypeAssignment[t]) for t in ctypes] self._bluetooth.stream_types = stream_types - self._process_and_wait(StartStreamingCommand(self._hw_version.revision)) + self._process_and_wait(StartStreamingCommand(self._revision)) def stop_streaming(self) -> None: """Stop streaming data @@ -707,22 +697,22 @@ def stop_streaming(self) -> None: already been received and are in the input buffer. """ - self._process_and_wait(StopStreamingCommand(self._hw_version.revision)) + self._process_and_wait(StopStreamingCommand(self._revision)) def start_logging(self) -> None: """Start logging data to the SD card of the device""" - self._process_and_wait(StartLoggingCommand(self._hw_version.revision)) + self._process_and_wait(StartLoggingCommand(self._revision)) def stop_logging(self) -> None: """Stop logging data to the SD card of the device""" - self._process_and_wait(StopLoggingCommand(self._hw_version.revision)) + self._process_and_wait(StopLoggingCommand(self._revision)) def send_ping(self) -> None: """Send a ping command to the device The command can be used to test the connection. It does not return anything. """ - self._process_and_wait(DummyCommand(self._hw_version.revision)) + self._process_and_wait(DummyCommand(self._revision)) def set_status_ack(self, enabled: bool) -> None: """Send a command to enable or disable the status acknowledgment @@ -737,4 +727,4 @@ def set_status_ack(self, enabled: bool) -> None: sending the status ack. In this state, the firmware is compatible to the Python API. """ - self._process_and_wait(SetStatusAckCommand(self._hw_version.revision, enabled)) + self._process_and_wait(SetStatusAckCommand(self._revision, enabled)) From 013da766a6d9dc6cea7930aadc305700c9c15ef2 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 12:28:27 +0100 Subject: [PATCH 18/32] Update the BluetoothRequestHandler tests --- pyshimmer/test_util.py | 30 +- test/bluetooth/test_bluetooth_api.py | 528 +++++++++++++++------------ 2 files changed, 322 insertions(+), 236 deletions(-) diff --git a/pyshimmer/test_util.py b/pyshimmer/test_util.py index 22d88a7..cc5cb7e 100644 --- a/pyshimmer/test_util.py +++ b/pyshimmer/test_util.py @@ -84,10 +84,10 @@ def test_get_write_data(self) -> bytes: class PTYSerialMockCreator: def __init__(self): - self._master_fobj = None - self._slave_fobj = None + self.master_fobj = None + self.slave_fobj = None - self._slave_serial = None + self.slave_serial = None @staticmethod def _create_fobj(fd: int) -> BinaryIO: @@ -100,17 +100,27 @@ def _create_fobj(fd: int) -> BinaryIO: def create_mock(self) -> tuple[Serial, BinaryIO]: master_fd, slave_fd = pty.openpty() - self._master_fobj = self._create_fobj(master_fd) - self._slave_fobj = self._create_fobj(slave_fd) + self.master_fobj = self._create_fobj(master_fd) + self.slave_fobj = self._create_fobj(slave_fd) # Serial Baud rate is ignored by the driver and can be set to any value slave_path = os.ttyname(slave_fd) - self._slave_serial = Serial(slave_path, 115200) + self.slave_serial = Serial(slave_path, 115200) - return self._slave_serial, self._master_fobj + return self.slave_serial, self.master_fobj def close(self): - self._slave_serial.close() + self.slave_serial.close() - self._master_fobj.close() - self._slave_fobj.close() + self.master_fobj.close() + self.slave_fobj.close() + + def read_from_master(self, n: int) -> bytes: + result = bytes() + while len(result) < n: + result += self.master_fobj.read(n - len(result)) + + return result + + def write_to_master(self, data: bytes) -> None: + self.master_fobj.write(data) diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index 2a8c3c3..6bd4dfb 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -20,6 +20,8 @@ from typing import BinaryIO from unittest import TestCase +import pytest + from pyshimmer.bluetooth.bt_api import BluetoothRequestHandler, ShimmerBluetooth from pyshimmer.bluetooth.bt_commands import ( GetDeviceNameCommand, @@ -32,138 +34,174 @@ from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType from pyshimmer.dev.fw_version import FirmwareVersion, EFirmwareType -from pyshimmer.dev.revisions import HardwareVersion, REV_SHIMMER3 +from pyshimmer.dev.revisions import ( + HardwareVersion, + HardwareRevision, + HW_REVISIONS, +) from pyshimmer.test_util import PTYSerialMockCreator -class BluetoothRequestHandlerTest(TestCase): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._mock_creator: PTYSerialMockCreator | None = None - self._sot: BluetoothRequestHandler | None = None +class TestBluetoothRequestHandler: - self._master: BinaryIO | None = None + @pytest.fixture + def mock_creator(self) -> PTYSerialMockCreator: + mock_creator = PTYSerialMockCreator() + mock_creator.create_mock() - def setUp(self) -> None: - self._mock_creator = PTYSerialMockCreator() - serial, self._master = self._mock_creator.create_mock() + yield mock_creator - bt_serial = BluetoothSerial(serial) - self._sot = BluetoothRequestHandler(bt_serial) + mock_creator.close() - def read_from_master(self, n: int) -> bytes: - result = bytes() - while len(result) < n: - result += self._master.read(n - len(result)) + @pytest.fixture(params=HW_REVISIONS) + def revision(self, request) -> HardwareRevision: + return request.param - return result + @pytest.fixture + def mock_serial(self, mock_creator: PTYSerialMockCreator) -> BluetoothSerial: + bt_serial = BluetoothSerial(mock_creator.slave_serial) + return bt_serial - def tearDown(self) -> None: - self._mock_creator.close() + @pytest.fixture + def sot( + self, mock_serial: BluetoothSerial, revision: HardwareRevision + ) -> BluetoothRequestHandler: + handler = BluetoothRequestHandler(mock_serial, revision) + return handler - def test_add_remove_stream_cb(self): + def test_add_remove_stream_cb(self, sot: BluetoothRequestHandler): def cb(_): pass - self._sot.add_stream_callback(cb) - self._sot.remove_stream_callback(cb) + sot.add_stream_callback(cb) + sot.remove_stream_callback(cb) - def test_add_remove_status_cb(self): + def test_add_remove_status_cb(self, sot: BluetoothRequestHandler): def cb(_): pass - self._sot.add_status_callback(cb) - self._sot.remove_status_callback(cb) - - def test_enque_command(self): - cmd = GetDeviceNameCommand(REV_SHIMMER3) - compl, resp = self._sot.queue_command(cmd) - - self.assertFalse(compl.has_completed()) - self.assertFalse(resp.has_result()) - - r = self.read_from_master(1) - self.assertEqual(r, b"\x7b") - - self._master.write(b"\xff") - self._sot.process_single_input_event() - - self.assertTrue(compl.has_completed()) - self.assertFalse(resp.has_result()) - - self._master.write(b"\x7a\x05\x53\x5f\x50\x50\x47") - self._sot.process_single_input_event() - - self.assertTrue(resp.has_result()) - self.assertEqual(resp.get_result(), "S_PPG") - - def test_enqueue_multibyte(self): - cmd = GetStringCommand(REV_SHIMMER3, 0x10, b"\x0a\x0b") - compl, resp = self._sot.queue_command(cmd) - - r = self.read_from_master(1) - self.assertEqual(r, b"\x10") - - self._master.write(b"\xff") - self._sot.process_single_input_event() - - self._master.write(b"\x0a\x0b\x02ab") - self._sot.process_single_input_event() - - self.assertTrue(compl.has_completed()) - self.assertTrue(resp.has_result()) - self.assertEqual(resp.get_result(), "ab") - - def test_enqueue_multiple_commands(self): - cmd1 = GetDeviceNameCommand(REV_SHIMMER3) - cmd2 = GetStatusCommand(REV_SHIMMER3) - - compl1, resp1 = self._sot.queue_command(cmd1) - compl2, resp2 = self._sot.queue_command(cmd2) - - r = self.read_from_master(2) - self.assertEqual(r, b"\x7b\x72") - - self._master.write(b"\xff\x7a\x05\x53\x5f\x50\x50\x47") - self._master.write(b"\xff\x8a\x71\x21") - - self._sot.process_single_input_event() - self.assertTrue(compl1.has_completed()) - - self._sot.process_single_input_event() - self.assertTrue(resp1.has_result()) - self.assertEqual(resp1.get_result(), "S_PPG") - - self._sot.process_single_input_event() - self.assertTrue(compl2.has_completed()) - - self._sot.process_single_input_event() - self.assertTrue(resp2.has_result()) - self.assertEqual( - resp2.get_result(), [True, False, False, False, False, True, False, False] - ) - - def test_queue_command_no_resp(self): - cmd = SetDeviceNameCommand(REV_SHIMMER3, "S_PPG") - compl, resp = self._sot.queue_command(cmd) - - self.assertFalse(compl.has_completed()) - self.assertEqual(resp, None) - - r = self.read_from_master(7) - self.assertEqual(r, b"\x79\x05S_PPG") - - self._master.write(b"\xff") - self._sot.process_single_input_event() - self.assertTrue(compl.has_completed()) - - def test_queue_unknown_instream(self): + sot.add_status_callback(cb) + sot.remove_status_callback(cb) + + def test_enque_command( + self, + mock_creator: PTYSerialMockCreator, + sot: BluetoothRequestHandler, + revision: HardwareRevision, + ): + cmd = GetDeviceNameCommand(revision) + compl, resp = sot.queue_command(cmd) + + assert compl.has_completed() is False + assert resp.has_result() is False + + r = mock_creator.read_from_master(1) + assert r == b"\x7b" + + mock_creator.write_to_master(b"\xff") + sot.process_single_input_event() + + assert compl.has_completed() is True + assert resp.has_result() is False + + mock_creator.write_to_master(b"\x7a\x05\x53\x5f\x50\x50\x47") + sot.process_single_input_event() + + assert resp.has_result() is True + assert resp.get_result() == "S_PPG" + + def test_enqueue_multibyte( + self, + mock_creator: PTYSerialMockCreator, + revision: HardwareRevision, + sot: BluetoothRequestHandler, + ): + cmd = GetStringCommand(revision, 0x10, b"\x0a\x0b") + compl, resp = sot.queue_command(cmd) + + r = mock_creator.read_from_master(1) + assert r == b"\x10" + + mock_creator.write_to_master(b"\xff") + sot.process_single_input_event() + + mock_creator.write_to_master(b"\x0a\x0b\x02ab") + sot.process_single_input_event() + + assert compl.has_completed() is True + assert resp.has_result() is True + assert resp.get_result() == "ab" + + def test_enqueue_multiple_commands( + self, + mock_creator: PTYSerialMockCreator, + revision: HardwareRevision, + sot: BluetoothRequestHandler, + ): + cmd1 = GetDeviceNameCommand(revision) + cmd2 = GetStatusCommand(revision) + + compl1, resp1 = sot.queue_command(cmd1) + compl2, resp2 = sot.queue_command(cmd2) + + r = mock_creator.read_from_master(2) + assert r == b"\x7b\x72" + + mock_creator.write_to_master(b"\xff\x7a\x05\x53\x5f\x50\x50\x47") + mock_creator.write_to_master(b"\xff\x8a\x71\x21") + + sot.process_single_input_event() + assert compl1.has_completed() is True + + sot.process_single_input_event() + assert resp1.has_result() is True + assert resp1.get_result() == "S_PPG" + + sot.process_single_input_event() + assert compl2.has_completed() is True + + sot.process_single_input_event() + assert resp2.has_result() is True + assert resp2.get_result() == [ + True, + False, + False, + False, + False, + True, + False, + False, + ] + + def test_queue_command_no_resp( + self, + mock_creator: PTYSerialMockCreator, + revision: HardwareRevision, + sot: BluetoothRequestHandler, + ): + cmd = SetDeviceNameCommand(revision, "S_PPG") + compl, resp = sot.queue_command(cmd) + + assert compl.has_completed() is False + assert resp is None + + r = mock_creator.read_from_master(7) + assert r == b"\x79\x05S_PPG" + + mock_creator.write_to_master(b"\xff") + sot.process_single_input_event() + assert compl.has_completed() is True + + def test_queue_unknown_instream( + self, + mock_creator: PTYSerialMockCreator, + revision: HardwareRevision, + sot: BluetoothRequestHandler, + ): class InStreamCommand(ResponseCommand): def __init__(self): - super().__init__(REV_SHIMMER3, b"\x8a\x42") + super().__init__(revision, b"\x8a\x42") def send(self, ser: BluetoothSerial) -> None: ser.write(b"\x42") @@ -171,153 +209,191 @@ def send(self, ser: BluetoothSerial) -> None: def receive(self, ser: BluetoothSerial) -> any: return ser.read_response(b"\x8a\x42") - compl, resp = self._sot.queue_command(InStreamCommand()) - - r = self.read_from_master(1) - self.assertEqual(r, b"\x42") - - self._master.write(b"\xff\x8a\x42") - self._sot.process_single_input_event() - self.assertTrue(compl.has_completed()) - - self._sot.process_single_input_event() - self.assertTrue(resp.has_result()) - - def test_get_status_command(self): - cmd = GetStatusCommand(REV_SHIMMER3) - compl, resp = self._sot.queue_command(cmd) - - self.assertFalse(compl.has_completed()) - self.assertFalse(resp.has_result()) - - r = self.read_from_master(1) - self.assertEqual(r, b"\x72") - - self._master.write(b"\xff\x8a\x71\x21") - self._sot.process_single_input_event() - self.assertTrue(compl.has_completed()) - self.assertFalse(resp.has_result()) - - self._sot.process_single_input_event() - self.assertTrue(resp.has_result()) - self.assertEqual( - resp.get_result(), [True, False, False, False, False, True, False, False] - ) - - def test_incorrect_resp_code_fail(self): - cmd = GetDeviceNameCommand(REV_SHIMMER3) - _ = self._sot.queue_command(cmd) - - self._master.write(b"\xff\xfe") - self._sot.process_single_input_event() - self.assertRaises(ValueError, self._sot.process_single_input_event) - - def test_data_packet(self): + compl, resp = sot.queue_command(InStreamCommand()) + + r = mock_creator.read_from_master(1) + assert r == b"\x42" + + mock_creator.write_to_master(b"\xff\x8a\x42") + sot.process_single_input_event() + assert compl.has_completed() is True + + sot.process_single_input_event() + assert resp.has_result() is True + + def test_get_status_command( + self, + mock_creator: PTYSerialMockCreator, + revision: HardwareRevision, + sot: BluetoothRequestHandler, + ): + + cmd = GetStatusCommand(revision) + compl, resp = sot.queue_command(cmd) + + assert compl.has_completed() is False + assert resp.has_result() is False + + r = mock_creator.read_from_master(1) + assert r == b"\x72" + + mock_creator.write_to_master(b"\xff\x8a\x71\x21") + sot.process_single_input_event() + assert compl.has_completed() is True + assert resp.has_result() is False + + sot.process_single_input_event() + assert resp.has_result() is True + assert resp.get_result() == [ + True, + False, + False, + False, + False, + True, + False, + False, + ] + + def test_incorrect_resp_code_fail( + self, + mock_creator: PTYSerialMockCreator, + revision: HardwareRevision, + sot: BluetoothRequestHandler, + ): + + cmd = GetDeviceNameCommand(revision) + _ = sot.queue_command(cmd) + + mock_creator.write_to_master(b"\xff\xfe") + sot.process_single_input_event() + + with pytest.raises(ValueError): + sot.process_single_input_event() + + def test_data_packet( + self, + mock_creator: PTYSerialMockCreator, + sot: BluetoothRequestHandler, + ): results: list[DataPacket] = [] data_pkt_1 = b"\x00\xde\xd0\xb2\x26\x07" data_pkt_2 = b"\x00\x1e\xd1\xb2\xfc\x06" ch_types = [EChannelType.TIMESTAMP, EChannelType.INTERNAL_ADC_A1] - self._sot.set_stream_types([(c, ChDataTypeAssignment[c]) for c in ch_types]) - self._sot.add_stream_callback(results.append) + sot.stream_types = [(c, ChDataTypeAssignment[c]) for c in ch_types] + sot.add_stream_callback(results.append) - self._master.write(data_pkt_1) - self._master.write(data_pkt_2) + mock_creator.write_to_master(data_pkt_1) + mock_creator.write_to_master(data_pkt_2) - self._sot.process_single_input_event() - self.assertEqual(len(results), 1) + sot.process_single_input_event() + assert len(results) == 1 pkt = results[0] - self.assertEqual(pkt.channels, ch_types) - self.assertEqual(pkt[EChannelType.TIMESTAMP], 0xB2D0DE) - self.assertEqual(pkt[EChannelType.INTERNAL_ADC_A1], 0x0726) + assert pkt.channels == ch_types + assert pkt[EChannelType.TIMESTAMP] == 0xB2D0DE + assert pkt[EChannelType.INTERNAL_ADC_A1] == 0x0726 - self._sot.process_single_input_event() - self.assertEqual(len(results), 2) + sot.process_single_input_event() + assert len(results) == 2 pkt = results[1] - self.assertEqual(pkt.channels, ch_types) - self.assertEqual(pkt[EChannelType.TIMESTAMP], 0xB2D11E) - self.assertEqual(pkt[EChannelType.INTERNAL_ADC_A1], 0x06FC) + assert pkt.channels == ch_types + assert pkt[EChannelType.TIMESTAMP] == 0xB2D11E + assert pkt[EChannelType.INTERNAL_ADC_A1] == 0x06FC - def test_get_status_response(self): + def test_get_status_response( + self, mock_creator: PTYSerialMockCreator, sot: BluetoothRequestHandler + ): status_resp: list[list[bool]] = [] stat_pkt_1 = b"\x8a\x71\x20" stat_pkt_2 = b"\x8a\x71\x21" - self._sot.add_status_callback(status_resp.append) + sot.add_status_callback(status_resp.append) - self._master.write(stat_pkt_1) - self._master.write(stat_pkt_2) + mock_creator.write_to_master(stat_pkt_1) + mock_creator.write_to_master(stat_pkt_2) - self._sot.process_single_input_event() - self.assertEqual(len(status_resp), 1) - self.assertEqual( - status_resp[0], [False, False, False, False, False, True, False, False] - ) + sot.process_single_input_event() + assert len(status_resp) == 1 + assert status_resp[0] == [False, False, False, False, False, True, False, False] - self._sot.process_single_input_event() - self.assertEqual(len(status_resp), 2) - self.assertEqual( - status_resp[1], [True, False, False, False, False, True, False, False] - ) + sot.process_single_input_event() + assert len(status_resp) == 2 + assert status_resp[1] == [True, False, False, False, False, True, False, False] - def test_get_status_response_update_mixed(self): + def test_get_status_response_update_mixed( + self, + mock_creator: PTYSerialMockCreator, + revision: HardwareRevision, + sot: BluetoothRequestHandler, + ): stat_pkt_1 = b"\x8a\x71\x20" stat_pkt_2 = b"\x8a\x71\x21" status_resp: list[list[bool]] = [] - self._sot.add_status_callback(status_resp.append) - - compl, resp = self._sot.queue_command(GetStatusCommand(REV_SHIMMER3)) - r = self.read_from_master(1) - self.assertEqual(r, b"\x72") - - self._master.write(b"\xff" + stat_pkt_1) - self._master.write(stat_pkt_2) - - self._sot.process_single_input_event() - self.assertTrue(compl.has_completed()) - - self._sot.process_single_input_event() - self.assertTrue(resp.has_result()) - self.assertEqual( - resp.get_result(), [False, False, False, False, False, True, False, False] - ) - - self._sot.process_single_input_event() - self.assertEqual(len(status_resp), 1) - self.assertEqual( - status_resp[0], [True, False, False, False, False, True, False, False] - ) - - def test_clear_queues(self): - compl1, resp1 = self._sot.queue_command(GetDeviceNameCommand(REV_SHIMMER3)) - compl2, resp2 = self._sot.queue_command(GetDeviceNameCommand(REV_SHIMMER3)) - - self.assertFalse(compl1.has_completed()) - self.assertFalse(resp1.has_result()) + sot.add_status_callback(status_resp.append) + + compl, resp = sot.queue_command(GetStatusCommand(revision)) + r = mock_creator.read_from_master(1) + assert r == b"\x72" + + mock_creator.write_to_master(b"\xff" + stat_pkt_1) + mock_creator.write_to_master(stat_pkt_2) + + sot.process_single_input_event() + assert compl.has_completed() is True + + sot.process_single_input_event() + assert resp.has_result() is True + assert resp.get_result() == [ + False, + False, + False, + False, + False, + True, + False, + False, + ] + + sot.process_single_input_event() + assert len(status_resp) == 1 + assert status_resp[0] == [True, False, False, False, False, True, False, False] + + def test_clear_queues( + self, + mock_creator: PTYSerialMockCreator, + revision: HardwareRevision, + sot: BluetoothRequestHandler, + ): + compl1, resp1 = sot.queue_command(GetDeviceNameCommand(revision)) + compl2, resp2 = sot.queue_command(GetDeviceNameCommand(revision)) + + assert compl1.has_completed() is False + assert resp1.has_result() is False # Ensure that the first command has been passed into the response queue - self._master.write(b"\xff") - self._sot.process_single_input_event() + mock_creator.write_to_master(b"\xff") + sot.process_single_input_event() - self.assertTrue(compl1.has_completed()) - self.assertFalse(resp1.has_result()) + assert compl1.has_completed() is True + assert resp1.has_result() is False - self.assertFalse(compl2.has_completed()) - self.assertFalse(resp2.has_result()) + assert compl2.has_completed() is False + assert resp2.has_result() is False - self._sot.clear_queues() + sot.clear_queues() - self.assertTrue(compl1.has_completed()) - self.assertTrue(resp1.has_result()) - self.assertEqual(resp1.get_result(), None) + assert compl1.has_completed() is True + assert resp1.has_result() is True + assert resp1.get_result() is None - self.assertTrue(compl2.has_completed()) - self.assertTrue(resp2.has_result()) - self.assertEqual(resp2.get_result(), None) + assert compl2.has_completed() is True + assert resp2.has_result() is True + assert resp2.get_result() is None class ShimmerBluetoothIntegrationTest(TestCase): From dd2c3db1280ef48b0660482382f9a027c3e1392e Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 13:40:20 +0100 Subject: [PATCH 19/32] Fix HardwareVersion initialization --- pyshimmer/dev/revisions/hw_version.py | 2 +- test/dev/revision/test_hw_version.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyshimmer/dev/revisions/hw_version.py b/pyshimmer/dev/revisions/hw_version.py index da926d5..c838d6f 100644 --- a/pyshimmer/dev/revisions/hw_version.py +++ b/pyshimmer/dev/revisions/hw_version.py @@ -23,7 +23,7 @@ class HardwareVersion(IntEnum): def __new__(cls, version: int, revision: HardwareRevision | None): # Strips the revision argument from the tuple and only assigns the # version ID as enum value - obj = int.__new__(cls) + obj = int.__new__(cls, version) obj._value_ = version obj._revision = revision return obj diff --git a/test/dev/revision/test_hw_version.py b/test/dev/revision/test_hw_version.py index 102ce6c..b10dfdc 100644 --- a/test/dev/revision/test_hw_version.py +++ b/test/dev/revision/test_hw_version.py @@ -5,6 +5,10 @@ class TestHardwareVersion: + def test_version(self): + assert int(HardwareVersion.SHIMMER3) == 3 + assert int(HardwareVersion.SHIMMER3R) == 10 + def test_revision_access(self): ver_shimmer3 = HardwareVersion.SHIMMER3 assert ver_shimmer3.revision is not None From 89314c3b476c7be373ac24b7a15007de5bf33b1e Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 15:16:36 +0100 Subject: [PATCH 20/32] Update Bluetooth tests --- pyshimmer/bluetooth/bt_api.py | 48 +++- pyshimmer/dev/revisions/__init__.py | 4 +- test/bluetooth/test_bluetooth_api.py | 330 +++++++++++++++++---------- 3 files changed, 260 insertions(+), 122 deletions(-) diff --git a/pyshimmer/bluetooth/bt_api.py b/pyshimmer/bluetooth/bt_api.py index b77507e..28fc9b0 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -384,6 +384,7 @@ def __init__( self._initialized = False self._disable_ack = disable_status_ack + self._fw_type: EFirmwareType | None = None self._fw_version: FirmwareVersion | None = None self._fw_caps: FirmwareCapabilities | None = None self._hw_version: HardwareVersion | None = None @@ -399,6 +400,28 @@ def initialized(self) -> bool: """ return self._initialized + @property + def firmware_type(self) -> EFirmwareType | None: + """Return the firmware type being run on the device + + This property shall only be accessed after invoking initialize(). + + :return: An EFirmwareType instance or None if the connection has + not been initialized. + """ + return self._fw_type + + @property + def firmware_version(self) -> FirmwareVersion | None: + """Return the firmware version of the device + + This property shall only be accessed after invoking initialize(). + + :return: A FirmwareVersion instance or None if the connection has + not been initialized. + """ + return self._fw_version + @property def capabilities(self) -> FirmwareCapabilities: """Return the capabilities of the device firmware @@ -410,6 +433,27 @@ def capabilities(self) -> FirmwareCapabilities: """ return self._fw_caps + @property + def hardware_version(self) -> HardwareVersion | None: + """Return the hardware version of the device + + This property shall only be accessed after invoking initialize(). + + :return: A HardwareVersion instance or None if the connection has + not been initialized. + """ + return self._hw_version + + @property + def hardware_revision(self) -> HardwareRevision: + """Return the hardware revision instance being used for communication + + :return: A valid hardware revision instance - it does not necessarily need + to match the hardware version if a custom revision was provided to the + constructor. + """ + return self._revision + def __enter__(self): self.initialize() return self @@ -428,8 +472,8 @@ def initialize(self) -> None: # Start the thread to enable communication with the device self._thread.start() - fw_type, fw_ver = self.get_firmware_version() - self._fw_caps = FirmwareCapabilities(fw_type, fw_ver) + self._fw_type, self._fw_version = self.get_firmware_version() + self._fw_caps = FirmwareCapabilities(self._fw_type, self._fw_version) self._hw_version = self.get_device_hardware_version() # If the revision was set manually, we don't want to automatically diff --git a/pyshimmer/dev/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py index 77c85ce..33eafab 100644 --- a/pyshimmer/dev/revisions/__init__.py +++ b/pyshimmer/dev/revisions/__init__.py @@ -11,10 +11,10 @@ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. -from .hw_version import HardwareVersion - # You should have received a copy of the GNU General Public License # along with this program. If not, see . + +from .hw_version import HardwareVersion from .revision import HardwareRevision from .shimmer3 import Shimmer3Revision, REV_SHIMMER3 diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index 6bd4dfb..d22ad55 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -18,10 +18,10 @@ from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, Future from typing import BinaryIO -from unittest import TestCase import pytest + from pyshimmer.bluetooth.bt_api import BluetoothRequestHandler, ShimmerBluetooth from pyshimmer.bluetooth.bt_commands import ( GetDeviceNameCommand, @@ -32,12 +32,14 @@ ResponseCommand, ) from pyshimmer.bluetooth.bt_serial import BluetoothSerial -from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType +from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ChannelDataType from pyshimmer.dev.fw_version import FirmwareVersion, EFirmwareType from pyshimmer.dev.revisions import ( HardwareVersion, HardwareRevision, HW_REVISIONS, + REV_SHIMMER3, + Shimmer3Revision, ) from pyshimmer.test_util import PTYSerialMockCreator @@ -69,6 +71,22 @@ def sot( handler = BluetoothRequestHandler(mock_serial, revision) return handler + def test_stream_types(self, sot: BluetoothRequestHandler): + assert sot.stream_types == [] + + sot.stream_types = [ + (EChannelType.TIMESTAMP, ChannelDataType(4, signed=False, le=True)) + ] + assert len(sot.stream_types) == 1 + assert sot.stream_types[0][0] == EChannelType.TIMESTAMP + + def test_revision(self, sot: BluetoothRequestHandler, revision: HardwareRevision): + assert sot.hardware_revision is revision + new_revision = Shimmer3Revision() + + sot.hardware_revision = new_revision + assert sot.hardware_revision is new_revision + def test_add_remove_stream_cb(self, sot: BluetoothRequestHandler): def cb(_): pass @@ -396,24 +414,19 @@ def test_clear_queues( assert resp2.get_result() is None -class ShimmerBluetoothIntegrationTest(TestCase): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) +class IntegrationTestHelper: - self._executor = ThreadPoolExecutor(max_workers=1) + def __init__(self): + self.executor = ThreadPoolExecutor(max_workers=1) + self.mock_creator = PTYSerialMockCreator() + self.sot: ShimmerBluetooth | None = None - self._mock_creator: PTYSerialMockCreator | None = None - self._sot: ShimmerBluetooth | None = None - - self._master: BinaryIO | None = None - - def _submit_handler_fn( + def submit_handler_fn( self, fn: Callable[[BinaryIO, ShimmerBluetooth], any] ) -> Future: - return self._executor.submit(fn, self._master, self._sot) + return self.executor.submit(fn, self.mock_creator.master_fobj, self.sot) - def _submit_req_resp_handler(self, req_len: int, resp: bytes) -> Future: + def submit_req_resp_handler(self, req_len: int, resp: bytes) -> Future: def master_fn(master: BinaryIO, _) -> bytes: req = bytes() while len(req) < req_len: @@ -422,172 +435,253 @@ def master_fn(master: BinaryIO, _) -> bytes: master.write(resp) return req - return self._submit_handler_fn(master_fn) + return self.submit_handler_fn(master_fn) + + def queue_initialization_data( + self, version: HardwareVersion | int + ) -> tuple[Future, Future]: + # The Bluetooth API automatically requests the firmware version upon + # initialization. We must prepare a proper response beforehand. + req_future_fw = self.submit_req_resp_handler( + req_len=1, resp=b"\xff\x2f\x03\x00\x00\x00\x0b\x00" + ) + + hw_version_bin = version.to_bytes(length=1) + req_future_hw = self.submit_req_resp_handler( + req_len=1, resp=b"\xff\x25" + hw_version_bin + ) + return req_future_fw, req_future_hw - def do_setup(self, initialize: bool = True, **kwargs) -> None: - self._mock_creator = PTYSerialMockCreator() - serial, self._master = self._mock_creator.create_mock() + def execute_sot_initialization( + self, version: HardwareVersion | int = HardwareVersion.SHIMMER3 + ) -> None: - self._sot = ShimmerBluetooth(serial, **kwargs) + req_future_fw, req_future_hw = self.queue_initialization_data(version) - if initialize: - # The Bluetooth API automatically requests the firmware version upon - # initialization. We must prepare a proper response beforehand. - req_future_fw = self._submit_req_resp_handler( - req_len=1, resp=b"\xff\x2f\x03\x00\x00\x00\x0b\x00" - ) - req_future_hw = self._submit_req_resp_handler( - req_len=1, resp=b"\xff\x25\x03" - ) - self._sot.initialize() + self.sot.initialize() - # Check that it properly asked for the firmware version - result = req_future_fw.result() - assert result == b"\x2e" - result = req_future_hw.result() - assert result == b"\x3f" + # Check that it properly asked for the firmware version + result = req_future_fw.result() + assert result == b"\x2e" + result = req_future_hw.result() + assert result == b"\x3f" - def tearDown(self) -> None: - self._sot.shutdown() - self._mock_creator.close() + def setup( + self, + run_sot_initialize: bool = True, + hw_version: HardwareVersion | int = HardwareVersion.SHIMMER3, + **bt_kwargs, + ): + self.mock_creator.create_mock() - def test_context_manager(self): - self.do_setup(initialize=False) + self.sot = ShimmerBluetooth(self.mock_creator.slave_serial, **bt_kwargs) - # The Bluetooth API automatically requests the firmware version upon - # initialization. We must prepare a proper response beforehand. - req_future_fw = self._submit_req_resp_handler( + if run_sot_initialize: + self.execute_sot_initialization(hw_version) + + def teardown(self): + self.sot.shutdown() + self.mock_creator.close() + self.executor.shutdown(cancel_futures=True) + + +class TestShimmerBluetoothIntegration: + + @pytest.fixture + def helper(self) -> IntegrationTestHelper: + helper = IntegrationTestHelper() + + yield helper + + helper.teardown() + + @pytest.fixture(params=[HardwareVersion.SHIMMER3]) + def hw_version(self, request) -> HardwareVersion: + return request.param + + def test_properties(self, helper: IntegrationTestHelper): + helper.setup(run_sot_initialize=True) + + assert helper.sot.hardware_revision == REV_SHIMMER3 + assert helper.sot.hardware_version == HardwareVersion.SHIMMER3 + assert helper.sot.firmware_type == EFirmwareType.LogAndStream + assert helper.sot.firmware_version == FirmwareVersion(0, 11, 0) + + def test_custom_revision(self, helper: IntegrationTestHelper): + custom_revision = Shimmer3Revision() + + helper.setup( + run_sot_initialize=True, + hw_version=HardwareVersion.SHIMMER3, + # Set a custom revision + revision=custom_revision, + ) + + assert helper.sot.hardware_version == HardwareVersion.SHIMMER3 + assert helper.sot.hardware_revision == custom_revision + + def test_error_if_unsupported_version(self, helper: IntegrationTestHelper): + helper.setup(run_sot_initialize=False) + + helper.queue_initialization_data(version=HardwareVersion.SHIMMER2) + + with pytest.raises(ValueError): + helper.sot.initialize() + + def test_context_manager(self, helper: IntegrationTestHelper): + helper.setup(run_sot_initialize=False) + + req_future_fw = helper.submit_req_resp_handler( req_len=1, resp=b"\xff\x2f\x03\x00\x00\x00\x0b\x00" ) - req_future_hw = self._submit_req_resp_handler(req_len=1, resp=b"\xff\x25\x03") - with self._sot: + req_future_hw = helper.submit_req_resp_handler(req_len=1, resp=b"\xff\x25\x03") + with helper.sot: # We check that the API properly asked for the firmware version req_data_fw = req_future_fw.result() - self.assertEqual(req_data_fw, b"\x2e") + assert req_data_fw == b"\x2e" req_data_hw = req_future_hw.result() - self.assertEqual(req_data_hw, b"\x3f") + assert req_data_hw == b"\x3f" # It should now be in an initialized state - self.assertTrue(self._sot.initialized) + assert helper.sot.initialized is True + + def test_version_and_capabilities( + self, helper: IntegrationTestHelper, hw_version: HardwareVersion + ): + helper.setup(run_sot_initialize=True, hw_version=hw_version) - def test_version_and_capabilities(self): - self.do_setup(initialize=True) + assert helper.sot.initialized is True + assert helper.sot.capabilities is not None - self.assertTrue(self._sot.initialized) - self.assertIsNotNone(self._sot.capabilities) - self.assertEqual(self._sot.capabilities.fw_type, EFirmwareType.LogAndStream) - self.assertEqual(self._sot.capabilities.version, FirmwareVersion(0, 11, 0)) + assert helper.sot.capabilities.fw_type == EFirmwareType.LogAndStream + assert helper.sot.capabilities.version == FirmwareVersion(0, 11, 0) - def test_get_sampling_rate(self): - self.do_setup() + def test_get_sampling_rate( + self, helper: IntegrationTestHelper, hw_version: HardwareVersion + ): + helper.setup(run_sot_initialize=True, hw_version=hw_version) - ftr = self._submit_req_resp_handler(1, b"\xff\x04\x40\x00") - r = self._sot.get_sampling_rate() + ftr = helper.submit_req_resp_handler(1, b"\xff\x04\x40\x00") + r = helper.sot.get_sampling_rate() - self.assertEqual(ftr.result(), b"\x03") - self.assertEqual(r, 512.0) + assert ftr.result() == b"\x03" + assert r == 512.0 - def test_get_data_types(self): - self.do_setup() + def test_get_data_types( + self, helper: IntegrationTestHelper, hw_version: HardwareVersion + ): + helper.setup(run_sot_initialize=True, hw_version=hw_version) - ftr = self._submit_req_resp_handler( + ftr = helper.submit_req_resp_handler( 1, b"\xff\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" ) - r = self._sot.get_data_types() + r = helper.sot.get_data_types() - self.assertEqual(ftr.result(), b"\x01") - self.assertEqual(r, [EChannelType.TIMESTAMP, EChannelType.INTERNAL_ADC_A1]) + assert ftr.result() == b"\x01" + assert r == [EChannelType.TIMESTAMP, EChannelType.INTERNAL_ADC_A1] - def test_streaming(self): - self.do_setup() + def test_streaming( + self, helper: IntegrationTestHelper, hw_version: HardwareVersion + ): + helper.setup(run_sot_initialize=True, hw_version=hw_version) pkts = [] def pkt_handler(new_pkt: DataPacket) -> None: pkts.append(new_pkt) - inquiry_ftr = self._submit_req_resp_handler( + inquiry_ftr = helper.submit_req_resp_handler( 1, b"\xff\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" ) - start_streaming_ftr = self._submit_req_resp_handler(1, b"\xff") - self._submit_req_resp_handler(0, b"\x00\x25\x13\xf4\x4a\x07") - stop_streaming_ftr = self._submit_req_resp_handler(1, b"\xff") + start_streaming_ftr = helper.submit_req_resp_handler(1, b"\xff") + helper.submit_req_resp_handler(0, b"\x00\x25\x13\xf4\x4a\x07") + stop_streaming_ftr = helper.submit_req_resp_handler(1, b"\xff") - self._sot.add_stream_callback(pkt_handler) - self._sot.start_streaming() + helper.sot.add_stream_callback(pkt_handler) + helper.sot.start_streaming() - self.assertEqual(inquiry_ftr.result(), b"\x01") - self.assertEqual(start_streaming_ftr.result(), b"\x07") + assert inquiry_ftr.result() == b"\x01" + assert start_streaming_ftr.result() == b"\x07" - self._sot.stop_streaming() - self.assertEqual(stop_streaming_ftr.result(), b"\x20") + helper.sot.stop_streaming() + assert stop_streaming_ftr.result() == b"\x20" - self.assertEqual(len(pkts), 1) + assert len(pkts) == 1 pkt = pkts[0] - self.assertEqual(pkt[EChannelType.TIMESTAMP], 15995685) - self.assertEqual(pkt[EChannelType.INTERNAL_ADC_A1], 1866) + assert pkt[EChannelType.TIMESTAMP] == 15995685 + assert pkt[EChannelType.INTERNAL_ADC_A1] == 1866 - def test_status_update(self): - self.do_setup() + def test_status_update( + self, helper: IntegrationTestHelper, hw_version: HardwareVersion + ): + helper.setup(run_sot_initialize=True, hw_version=hw_version) pkts = [] def status_handler(new_pkt: list[bool]) -> None: pkts.append(new_pkt) - self._sot.add_status_callback(status_handler) + helper.sot.add_status_callback(status_handler) - self._submit_req_resp_handler(1, b"\x8a\x71\x20\xff\x7a\x03ABC") - r = self._sot.get_device_name() - self.assertEqual(r, "ABC") + helper.submit_req_resp_handler(1, b"\x8a\x71\x20\xff\x7a\x03ABC") + r = helper.sot.get_device_name() + assert r == "ABC" - self.assertEqual(len(pkts), 1) + assert len(pkts) == 1 pkt = pkts[0] - self.assertEqual(pkt, [False, False, False, False, False, True, False, False]) + assert pkt == [False, False, False, False, False, True, False, False] - def test_get_firmware_version(self): - self.do_setup() + def test_get_firmware_version( + self, helper: IntegrationTestHelper, hw_version: HardwareVersion + ): + helper.setup(run_sot_initialize=True, hw_version=hw_version) - self._submit_req_resp_handler(1, b"\xff\x2f\x03\x00\x01\x00\x02\x03") - fwtype, fwver = self._sot.get_firmware_version() + helper.submit_req_resp_handler(1, b"\xff\x2f\x03\x00\x01\x00\x02\x03") + fwtype, fwver = helper.sot.get_firmware_version() - self.assertEqual(fwtype, EFirmwareType.LogAndStream) - self.assertEqual(fwver, FirmwareVersion(1, 2, 3)) + assert fwtype == EFirmwareType.LogAndStream + assert fwver == FirmwareVersion(1, 2, 3) - def test_get_hardware_version(self): - self.do_setup() + def test_get_hardware_version(self, helper: IntegrationTestHelper): + helper.setup() - self._submit_req_resp_handler(1, b"\xff\x25\x03") - hw_version = self._sot.get_device_hardware_version() - self.assertEqual(hw_version, HardwareVersion.SHIMMER3) + helper.submit_req_resp_handler(1, b"\xff\x25\x03") + hw_version = helper.sot.get_device_hardware_version() + assert hw_version == HardwareVersion.SHIMMER3 - self._submit_req_resp_handler(1, b"\xff\x25\x0a") - hw_version = self._sot.get_device_hardware_version() - self.assertEqual(hw_version, HardwareVersion.SHIMMER3R) + helper.submit_req_resp_handler(1, b"\xff\x25\x0a") + hw_version = helper.sot.get_device_hardware_version() + assert hw_version == HardwareVersion.SHIMMER3R - self._submit_req_resp_handler(1, b"\xff\x25\x04") - hw_version = self._sot.get_device_hardware_version() - self.assertEqual(hw_version, HardwareVersion.UNKNOWN) + helper.submit_req_resp_handler(1, b"\xff\x25\x04") + hw_version = helper.sot.get_device_hardware_version() + assert hw_version == HardwareVersion.UNKNOWN - def test_status_ack_disable(self): - self.do_setup(initialize=False) + def test_status_ack_disable( + self, helper: IntegrationTestHelper, hw_version: HardwareVersion + ): + helper.setup(run_sot_initialize=False, hw_version=hw_version) # Queue response for version command - self._submit_req_resp_handler(1, b"\xff\x2f\x03\x00\x00\x00\x0f\x04") - self._submit_req_resp_handler(1, b"\xff\x25\x03") + helper.submit_req_resp_handler(1, b"\xff\x2f\x03\x00\x00\x00\x0f\x04") + helper.submit_req_resp_handler(1, b"\xff\x25\x03") # Queue response for disabling the status acknowledgment - req_future = self._submit_req_resp_handler(2, b"\xff") + req_future = helper.submit_req_resp_handler(2, b"\xff") - self._sot.initialize() + helper.sot.initialize() req_data = req_future.result() - self.assertEqual(req_data, b"\xa3\x00") + assert req_data == b"\xa3\x00" - def test_status_ack_not_disable(self): - self.do_setup(initialize=False, disable_status_ack=False) + def test_status_ack_not_disable( + self, helper: IntegrationTestHelper, hw_version: HardwareVersion + ): + helper.setup( + run_sot_initialize=False, hw_version=hw_version, disable_status_ack=False + ) # Queue response for version command - self._submit_req_resp_handler(1, b"\xff\x2f\x03\x00\x00\x00\x0f\x04") - self._submit_req_resp_handler(1, b"\xff\x25\x03") - self._sot.initialize() + helper.submit_req_resp_handler(1, b"\xff\x2f\x03\x00\x00\x00\x0f\x04") + helper.submit_req_resp_handler(1, b"\xff\x25\x03") + helper.sot.initialize() From 491f58c81104051dc855efa9605912f9e9ad09d1 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 15:18:39 +0100 Subject: [PATCH 21/32] Rewrite Bluetooth commands to use the revision class --- pyshimmer/bluetooth/bt_commands.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pyshimmer/bluetooth/bt_commands.py b/pyshimmer/bluetooth/bt_commands.py index 2e1c92f..043f54f 100644 --- a/pyshimmer/bluetooth/bt_commands.py +++ b/pyshimmer/bluetooth/bt_commands.py @@ -21,13 +21,11 @@ from pyshimmer.bluetooth.bt_const import * from pyshimmer.bluetooth.bt_serial import BluetoothSerial -from pyshimmer.dev.base import dr2sr, sr2dr, sec2ticks, ticks2sec from pyshimmer.dev.calibration import AllCalibration from pyshimmer.dev.channels import ( ChannelDataType, EChannelType, ESensorGroup, - serialize_sensorlist, ) from pyshimmer.dev.exg import ExGRegister from pyshimmer.dev.fw_version import get_firmware_type @@ -246,7 +244,7 @@ def send(self, ser: BluetoothSerial) -> None: def receive(self, ser: BluetoothSerial) -> float: sr_clock = ser.read_response(SAMPLING_RATE_RESPONSE, arg_format=" None: - dr = sr2dr(self._sr) + dr = self._rev.sr2dr(self._sr) ser.write_command(SET_SAMPLING_RATE_COMMAND, " None: def receive(self, ser: BluetoothSerial) -> float: t_ticks = ser.read_response(RWC_RESPONSE, arg_format=" None: - t_ticks = sec2ticks(self._time) + t_ticks = self._rev.sec2ticks(self._time) ser.write_command(SET_RWC_COMMAND, " any: ) channel_conf = ser.read(n_ch) - sr = dr2sr(sr_val) + sr = self._rev.dr2sr(sr_val) ctypes = self.decode_channel_types(channel_conf) return sr, buf_size, ctypes @@ -596,7 +594,7 @@ def __init__(self, rev: HardwareRevision, sensors: Iterable[ESensorGroup]): self._sensors = list(sensors) def send(self, ser: BluetoothSerial) -> None: - bitfield_bin = serialize_sensorlist(self._sensors) + bitfield_bin = self._rev.serialize_sensorlist(self._sensors) ser.write_command(SET_SENSORS_COMMAND, "<3s", bitfield_bin) From 0df5ffd81f3b3855de917b8ac21e4de8e97f7f26 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 15:20:16 +0100 Subject: [PATCH 22/32] Rename EFirmwareType to FirmwareType --- pyshimmer/__init__.py | 2 +- pyshimmer/bluetooth/bt_api.py | 8 ++++---- pyshimmer/dev/fw_version.py | 25 +++++++++++++------------ pyshimmer/uart/dock_api.py | 6 +++--- test/bluetooth/test_bluetooth_api.py | 8 ++++---- test/bluetooth/test_bt_commands.py | 4 ++-- test/dev/test_device_fw_version.py | 12 ++++++------ test/uart/test_dock_api.py | 4 ++-- 8 files changed, 35 insertions(+), 34 deletions(-) diff --git a/pyshimmer/__init__.py b/pyshimmer/__init__.py index 33a769f..17c76bb 100644 --- a/pyshimmer/__init__.py +++ b/pyshimmer/__init__.py @@ -19,7 +19,7 @@ from .dev.base import DEFAULT_BAUDRATE from .dev.channels import ChannelDataType, EChannelType from .dev.exg import ExGMux, ExGRLDLead, ERLDRef, ExGRegister -from .dev.fw_version import EFirmwareType +from .dev.fw_version import FirmwareType from .dev.revisions import ( HardwareRevision, Shimmer3Revision, diff --git a/pyshimmer/bluetooth/bt_api.py b/pyshimmer/bluetooth/bt_api.py index 28fc9b0..781274b 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -66,7 +66,7 @@ ) from pyshimmer.dev.exg import ExGRegister from pyshimmer.dev.fw_version import ( - EFirmwareType, + FirmwareType, FirmwareVersion, FirmwareCapabilities, ) @@ -384,7 +384,7 @@ def __init__( self._initialized = False self._disable_ack = disable_status_ack - self._fw_type: EFirmwareType | None = None + self._fw_type: FirmwareType | None = None self._fw_version: FirmwareVersion | None = None self._fw_caps: FirmwareCapabilities | None = None self._hw_version: HardwareVersion | None = None @@ -401,7 +401,7 @@ def initialized(self) -> bool: return self._initialized @property - def firmware_type(self) -> EFirmwareType | None: + def firmware_type(self) -> FirmwareType | None: """Return the firmware type being run on the device This property shall only be accessed after invoking initialize(). @@ -625,7 +625,7 @@ def get_status(self) -> list[bool]: """ return self._process_and_wait(GetStatusCommand(self._revision)) - def get_firmware_version(self) -> tuple[EFirmwareType, FirmwareVersion]: + def get_firmware_version(self) -> tuple[FirmwareType, FirmwareVersion]: """Get the version of the running firmware :return: The firmware type as enum, i.e. SDLog or LogAndStream diff --git a/pyshimmer/dev/fw_version.py b/pyshimmer/dev/fw_version.py index bc4fd5b..c104a20 100644 --- a/pyshimmer/dev/fw_version.py +++ b/pyshimmer/dev/fw_version.py @@ -28,10 +28,7 @@ def wrapper(self, other): return wrapper -class EFirmwareType(Enum): - BtStream = auto() - SDLog = auto() - LogAndStream = auto() + class FirmwareVersion: @@ -71,12 +68,12 @@ def __le__(self, other: FirmwareVersion) -> bool: class FirmwareCapabilities: - def __init__(self, fw_type: EFirmwareType, version: FirmwareVersion): + def __init__(self, fw_type: FirmwareType, version: FirmwareVersion): self._fw_type = fw_type self._version = version @property - def fw_type(self) -> EFirmwareType: + def fw_type(self) -> FirmwareType: return self._fw_type @property @@ -86,19 +83,23 @@ def version(self) -> FirmwareVersion: @property def supports_ack_disable(self) -> bool: return ( - self._fw_type == EFirmwareType.LogAndStream - and self._version >= FirmwareVersion(major=0, minor=15, rel=4) + self._fw_type == FirmwareType.LogAndStream + and self._version >= FirmwareVersion(major=0, minor=15, rel=4) ) +class FirmwareType(Enum): + BtStream = auto() + SDLog = auto() + LogAndStream = auto() FirmwareTypeValueAssignment = { - 0x01: EFirmwareType.BtStream, - 0x02: EFirmwareType.SDLog, - 0x03: EFirmwareType.LogAndStream, + 0x01: FirmwareType.BtStream, + 0x02: FirmwareType.SDLog, + 0x03: FirmwareType.LogAndStream, } -def get_firmware_type(f_type: int) -> EFirmwareType: +def get_firmware_type(f_type: int) -> FirmwareType: if f_type not in FirmwareTypeValueAssignment: raise ValueError(f"Unknown firmware type: 0x{f_type:x}") diff --git a/pyshimmer/uart/dock_api.py b/pyshimmer/uart/dock_api.py index 271bad2..b26890a 100644 --- a/pyshimmer/uart/dock_api.py +++ b/pyshimmer/uart/dock_api.py @@ -21,7 +21,7 @@ from pyshimmer.dev.base import sec2ticks, ticks2sec from pyshimmer.dev.exg import ExGRegister -from pyshimmer.dev.fw_version import get_firmware_type, EFirmwareType +from pyshimmer.dev.fw_version import get_firmware_type, FirmwareType from pyshimmer.uart.dock_const import * from pyshimmer.uart.dock_serial import DockSerial from pyshimmer.util import unpack @@ -181,7 +181,7 @@ def get_config_rtc(self) -> float: ) return ticks2sec(ticks) - def get_firmware_version(self) -> tuple[int, EFirmwareType, int, int, int]: + def get_firmware_version(self) -> tuple[int, FirmwareType, int, int, int]: """Retrieve the firmware version of the device :return: A tuple containing the following values: @@ -198,7 +198,7 @@ def get_firmware_version(self) -> tuple[int, EFirmwareType, int, int, int]: fw_type = get_firmware_type(fw_type_bin) return hw_ver, fw_type, major, minor, rel - def get_firmware_type(self) -> EFirmwareType: + def get_firmware_type(self) -> FirmwareType: """Retrieve the active firmware type :return: The firmware type: LogAndStream or SDLog diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index d22ad55..1fad5f5 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -33,7 +33,7 @@ ) from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ChannelDataType -from pyshimmer.dev.fw_version import FirmwareVersion, EFirmwareType +from pyshimmer.dev.fw_version import FirmwareVersion, FirmwareType from pyshimmer.dev.revisions import ( HardwareVersion, HardwareRevision, @@ -504,7 +504,7 @@ def test_properties(self, helper: IntegrationTestHelper): assert helper.sot.hardware_revision == REV_SHIMMER3 assert helper.sot.hardware_version == HardwareVersion.SHIMMER3 - assert helper.sot.firmware_type == EFirmwareType.LogAndStream + assert helper.sot.firmware_type == FirmwareType.LogAndStream assert helper.sot.firmware_version == FirmwareVersion(0, 11, 0) def test_custom_revision(self, helper: IntegrationTestHelper): @@ -553,7 +553,7 @@ def test_version_and_capabilities( assert helper.sot.initialized is True assert helper.sot.capabilities is not None - assert helper.sot.capabilities.fw_type == EFirmwareType.LogAndStream + assert helper.sot.capabilities.fw_type == FirmwareType.LogAndStream assert helper.sot.capabilities.version == FirmwareVersion(0, 11, 0) def test_get_sampling_rate( @@ -641,7 +641,7 @@ def test_get_firmware_version( helper.submit_req_resp_handler(1, b"\xff\x2f\x03\x00\x01\x00\x02\x03") fwtype, fwver = helper.sot.get_firmware_version() - assert fwtype == EFirmwareType.LogAndStream + assert fwtype == FirmwareType.LogAndStream assert fwver == FirmwareVersion(1, 2, 3) def test_get_hardware_version(self, helper: IntegrationTestHelper): diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 70fc101..bd033e7 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -49,7 +49,7 @@ ) from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ESensorGroup -from pyshimmer.dev.fw_version import EFirmwareType +from pyshimmer.dev.fw_version import FirmwareType from pyshimmer.dev.revisions import ( HardwareVersion, @@ -188,7 +188,7 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): fw_type, major, minor, patch = self.assert_cmd( cmd, b"\x2e", b"\x2f", b"\x2f\x03\x00\x00\x00\x0b\x00" ) - assert fw_type == EFirmwareType.LogAndStream + assert fw_type == FirmwareType.LogAndStream assert major == 0 assert minor == 11 assert patch == 0 diff --git a/test/dev/test_device_fw_version.py b/test/dev/test_device_fw_version.py index 0e68ddf..8d8fc6b 100644 --- a/test/dev/test_device_fw_version.py +++ b/test/dev/test_device_fw_version.py @@ -20,7 +20,7 @@ from pyshimmer.dev.fw_version import ( FirmwareVersion, get_firmware_type, - EFirmwareType, + FirmwareType, FirmwareCapabilities, ) @@ -29,11 +29,11 @@ class DeviceFirmwareVersionTest(TestCase): def test_get_firmware_type(self): r = get_firmware_type(0x01) - self.assertEqual(r, EFirmwareType.BtStream) + self.assertEqual(r, FirmwareType.BtStream) r = get_firmware_type(0x02) - self.assertEqual(r, EFirmwareType.SDLog) + self.assertEqual(r, FirmwareType.SDLog) r = get_firmware_type(0x03) - self.assertEqual(r, EFirmwareType.LogAndStream) + self.assertEqual(r, FirmwareType.LogAndStream) self.assertRaises(ValueError, get_firmware_type, 0xFF) @@ -42,11 +42,11 @@ class FirmwareCapabilitiesTest(TestCase): def test_capabilities(self): cap = FirmwareCapabilities( - EFirmwareType.LogAndStream, version=FirmwareVersion(1, 2, 3) + FirmwareType.LogAndStream, version=FirmwareVersion(1, 2, 3) ) self.assertTrue(cap.supports_ack_disable) self.assertEqual(cap.version, FirmwareVersion(1, 2, 3)) - self.assertEqual(cap.fw_type, EFirmwareType.LogAndStream) + self.assertEqual(cap.fw_type, FirmwareType.LogAndStream) class FirmwareVersionTest(TestCase): diff --git a/test/uart/test_dock_api.py b/test/uart/test_dock_api.py index a2a7188..ddfb485 100644 --- a/test/uart/test_dock_api.py +++ b/test/uart/test_dock_api.py @@ -2,7 +2,7 @@ from unittest import TestCase -from pyshimmer import EFirmwareType, ShimmerDock +from pyshimmer import FirmwareType, ShimmerDock from pyshimmer.test_util import MockSerial @@ -88,7 +88,7 @@ def test_get_firmware_version(self): self.assertEqual(mock.test_get_write_data(), b"\x24\x03\x02\x01\x03\xca\xdc") self.assertEqual(hw_ver, 3) - self.assertEqual(fw_type, EFirmwareType.LogAndStream) + self.assertEqual(fw_type, FirmwareType.LogAndStream) self.assertEqual(major, 0) self.assertEqual(minor, 11) self.assertEqual(patch, 0) From 6c05c5f36575bcfdb453e3c3c9b5e39cc8af3836 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 15:26:23 +0100 Subject: [PATCH 23/32] Integrate get_firmware_type into FirmwareType enum --- pyshimmer/bluetooth/bt_commands.py | 4 ++-- pyshimmer/dev/fw_version.py | 35 +++++++++++++----------------- pyshimmer/uart/dock_api.py | 4 ++-- test/dev/test_device_fw_version.py | 20 ++++++++--------- 4 files changed, 29 insertions(+), 34 deletions(-) diff --git a/pyshimmer/bluetooth/bt_commands.py b/pyshimmer/bluetooth/bt_commands.py index 043f54f..1a4c387 100644 --- a/pyshimmer/bluetooth/bt_commands.py +++ b/pyshimmer/bluetooth/bt_commands.py @@ -28,7 +28,7 @@ ESensorGroup, ) from pyshimmer.dev.exg import ExGRegister -from pyshimmer.dev.fw_version import get_firmware_type +from pyshimmer.dev.fw_version import FirmwareType from pyshimmer.dev.revisions import HardwareRevision, HardwareVersion from pyshimmer.util import ( bit_is_set, @@ -419,7 +419,7 @@ def receive(self, ser: BluetoothSerial) -> any: fw_type_bin, major, minor, rel = ser.read_response( FW_VERSION_RESPONSE, arg_format=". from __future__ import annotations -from enum import Enum, auto +from enum import IntEnum def ensure_firmware_version(func): @@ -28,9 +28,6 @@ def wrapper(self, other): return wrapper - - - class FirmwareVersion: def __init__(self, major: int, minor: int, rel: int): @@ -83,24 +80,22 @@ def version(self) -> FirmwareVersion: @property def supports_ack_disable(self) -> bool: return ( - self._fw_type == FirmwareType.LogAndStream - and self._version >= FirmwareVersion(major=0, minor=15, rel=4) + self._fw_type == FirmwareType.LogAndStream + and self._version >= FirmwareVersion(major=0, minor=15, rel=4) ) -class FirmwareType(Enum): - BtStream = auto() - SDLog = auto() - LogAndStream = auto() -FirmwareTypeValueAssignment = { - 0x01: FirmwareType.BtStream, - 0x02: FirmwareType.SDLog, - 0x03: FirmwareType.LogAndStream, -} +class FirmwareType(IntEnum): + BtStream = 0x01 + SDLog = 0x02 + LogAndStream = 0x03 + Unknown = -1 + @classmethod + def from_int(cls, value: int) -> FirmwareType: + """Converts an Integer to the corresponding FirmwareType enum -def get_firmware_type(f_type: int) -> FirmwareType: - if f_type not in FirmwareTypeValueAssignment: - raise ValueError(f"Unknown firmware type: 0x{f_type:x}") - - return FirmwareTypeValueAssignment[f_type] + :param value: Integer representing firmware type + :return: Corresponding FirmwareType enum member, or Unknown if unrecognised + """ + return cls._value2member_map_.get(value, cls.Unknown) diff --git a/pyshimmer/uart/dock_api.py b/pyshimmer/uart/dock_api.py index b26890a..392e5bf 100644 --- a/pyshimmer/uart/dock_api.py +++ b/pyshimmer/uart/dock_api.py @@ -21,7 +21,7 @@ from pyshimmer.dev.base import sec2ticks, ticks2sec from pyshimmer.dev.exg import ExGRegister -from pyshimmer.dev.fw_version import get_firmware_type, FirmwareType +from pyshimmer.dev.fw_version import FirmwareType from pyshimmer.uart.dock_const import * from pyshimmer.uart.dock_serial import DockSerial from pyshimmer.util import unpack @@ -195,7 +195,7 @@ def get_firmware_version(self) -> tuple[int, FirmwareType, int, int, int]: hw_ver, fw_type_bin, major, minor, rel = self._read_response_wformat_verify( UART_COMP_SHIMMER, UART_PROP_VER, " FirmwareType: diff --git a/test/dev/test_device_fw_version.py b/test/dev/test_device_fw_version.py index 8d8fc6b..4dded5c 100644 --- a/test/dev/test_device_fw_version.py +++ b/test/dev/test_device_fw_version.py @@ -19,23 +19,23 @@ from pyshimmer.dev.fw_version import ( FirmwareVersion, - get_firmware_type, FirmwareType, FirmwareCapabilities, ) -class DeviceFirmwareVersionTest(TestCase): +class TestFirmwareType: - def test_get_firmware_type(self): - r = get_firmware_type(0x01) - self.assertEqual(r, FirmwareType.BtStream) - r = get_firmware_type(0x02) - self.assertEqual(r, FirmwareType.SDLog) - r = get_firmware_type(0x03) - self.assertEqual(r, FirmwareType.LogAndStream) + def tet_firmware_type(self): + r = FirmwareType.from_int(0x01) + assert r == FirmwareType.BtStream + r = FirmwareType.from_int(0x02) + assert r == FirmwareType.SDLog + r = FirmwareType.from_int(0x03) + assert r == FirmwareType.LogAndStream - self.assertRaises(ValueError, get_firmware_type, 0xFF) + r = FirmwareType.from_int(100) + assert r == FirmwareType.Unknown class FirmwareCapabilitiesTest(TestCase): From 38d66274b2dd2b30c15f223657bab534b06703c0 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Mon, 22 Dec 2025 15:32:04 +0100 Subject: [PATCH 24/32] Add missin byteorder flag for Python 3.9 --- test/bluetooth/test_bluetooth_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index 1fad5f5..cd97aa9 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -446,7 +446,7 @@ def queue_initialization_data( req_len=1, resp=b"\xff\x2f\x03\x00\x00\x00\x0b\x00" ) - hw_version_bin = version.to_bytes(length=1) + hw_version_bin = version.to_bytes(length=1, byteorder="big") req_future_hw = self.submit_req_resp_handler( req_len=1, resp=b"\xff\x25" + hw_version_bin ) From 53aa8fe6e149f64fce49c65da7e59fc8bbe0ab10 Mon Sep 17 00:00:00 2001 From: JongChern Date: Wed, 24 Dec 2025 15:02:26 +0800 Subject: [PATCH 25/32] add 3r support --- pyshimmer/__init__.py | 2 + pyshimmer/bluetooth/bt_api.py | 2 +- pyshimmer/bluetooth/bt_commands.py | 11 +- pyshimmer/dev/revisions/__init__.py | 2 + pyshimmer/dev/revisions/hw_version.py | 3 +- pyshimmer/dev/revisions/shimmer3r.py | 204 ++++++++++++++++++++++++++ 6 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 pyshimmer/dev/revisions/shimmer3r.py diff --git a/pyshimmer/__init__.py b/pyshimmer/__init__.py index 17c76bb..ac5860e 100644 --- a/pyshimmer/__init__.py +++ b/pyshimmer/__init__.py @@ -26,6 +26,8 @@ HW_REVISIONS, REV_SHIMMER3, HardwareVersion, + Shimmer3RRevision, + REV_SHIMMER3R ) from .reader.binary_reader import ShimmerBinaryReader from .reader.shimmer_reader import ShimmerReader diff --git a/pyshimmer/bluetooth/bt_api.py b/pyshimmer/bluetooth/bt_api.py index 781274b..952d6e9 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -70,7 +70,7 @@ FirmwareVersion, FirmwareCapabilities, ) -from pyshimmer.dev.revisions import HardwareVersion, HardwareRevision, REV_SHIMMER3 +from pyshimmer.dev.revisions import HardwareVersion, HardwareRevision, REV_SHIMMER3, REV_SHIMMER3R from pyshimmer.serial_base import ReadAbort from pyshimmer.util import fmt_hex, PeekQueue diff --git a/pyshimmer/bluetooth/bt_commands.py b/pyshimmer/bluetooth/bt_commands.py index 1a4c387..3551ad5 100644 --- a/pyshimmer/bluetooth/bt_commands.py +++ b/pyshimmer/bluetooth/bt_commands.py @@ -18,7 +18,8 @@ import struct from abc import ABC, abstractmethod from collections.abc import Iterable - +from pyshimmer.dev.revisions.shimmer3 import REV_SHIMMER3 +from pyshimmer.dev.revisions.shimmer3r import REV_SHIMMER3R from pyshimmer.bluetooth.bt_const import * from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.calibration import AllCalibration @@ -476,9 +477,13 @@ def decode_channel_types(ct_bin: bytes) -> list[EChannelType]: def send(self, ser: BluetoothSerial) -> None: ser.write_command(INQUIRY_COMMAND) - def receive(self, ser: BluetoothSerial) -> any: + def receive(self, ser: BluetoothSerial) -> any: + if self._rev is REV_SHIMMER3: + arg_format = ". +from __future__ import annotations + +from .revision import BaseRevision +from ..channels import EChannelType, ChannelDataType, ESensorGroup + + +class Shimmer3RRevision(BaseRevision): + + # Device clock rate in ticks per second + DEV_CLOCK_RATE: float = 32768.0 + ENABLED_SENSORS_LEN = 0x03 + SENSOR_DTYPE = ChannelDataType(size=ENABLED_SENSORS_LEN, signed=False, le=True) + + CH_DTYPE_ASSIGNMENT: dict[EChannelType, ChannelDataType] = { + EChannelType.ACCEL_LN_X: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_LN_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_LN_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.VBATT: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_X: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.ACCEL_WR_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_X: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.MAG_REG_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.GYRO_X: ChannelDataType(2, signed=True, le=True), + EChannelType.GYRO_Y: ChannelDataType(2, signed=True, le=True), + EChannelType.GYRO_Z: ChannelDataType(2, signed=True, le=True), + EChannelType.EXTERNAL_ADC_A0: ChannelDataType(2, signed=False, le=True), + EChannelType.EXTERNAL_ADC_A1: ChannelDataType(2, signed=False, le=True), + EChannelType.EXTERNAL_ADC_A2: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A3: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A0: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A1: ChannelDataType(2, signed=False, le=True), + EChannelType.INTERNAL_ADC_A2: ChannelDataType(2, signed=False, le=True), + EChannelType.ACCEL_HG_X: None, + EChannelType.ACCEL_HG_Y: None, + EChannelType.ACCEL_HG_Z: None, + EChannelType.MAG_WR_X: None, + EChannelType.MAG_WR_Y: None, + EChannelType.MAG_WR_Z: None, + EChannelType.TEMPERATURE: ChannelDataType(2, signed=False, le=False), + EChannelType.PRESSURE: ChannelDataType(3, signed=False, le=False), + EChannelType.GSR_RAW: ChannelDataType(2, signed=False, le=True), + EChannelType.EXG1_STATUS: ChannelDataType(1, signed=False, le=True), + EChannelType.EXG1_CH1_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG1_CH2_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG2_STATUS: ChannelDataType(1, signed=False, le=True), + EChannelType.EXG2_CH1_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG2_CH2_24BIT: ChannelDataType(3, signed=True, le=False), + EChannelType.EXG1_CH1_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG1_CH2_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG2_CH1_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.EXG2_CH2_16BIT: ChannelDataType(2, signed=True, le=False), + EChannelType.STRAIN_HIGH: ChannelDataType(2, signed=False, le=True), + EChannelType.STRAIN_LOW: ChannelDataType(2, signed=False, le=True), + EChannelType.TIMESTAMP: ChannelDataType(3, signed=False, le=True), + } + + SENSOR_CHANNEL_ASSIGNMENT: dict[ESensorGroup, list[EChannelType]] = { + ESensorGroup.ACCEL_LN: [ + EChannelType.ACCEL_LN_X, + EChannelType.ACCEL_LN_Y, + EChannelType.ACCEL_LN_Z, + ], + ESensorGroup.BATTERY: [EChannelType.VBATT], + ESensorGroup.EXT_CH_A0: [EChannelType.EXTERNAL_ADC_A0], + ESensorGroup.EXT_CH_A1: [EChannelType.EXTERNAL_ADC_A1], + ESensorGroup.EXT_CH_A2: [EChannelType.EXTERNAL_ADC_A2], + ESensorGroup.INT_CH_A0: [EChannelType.INTERNAL_ADC_A0], + ESensorGroup.INT_CH_A1: [EChannelType.INTERNAL_ADC_A1], + ESensorGroup.INT_CH_A2: [EChannelType.INTERNAL_ADC_A2], + ESensorGroup.STRAIN: [EChannelType.STRAIN_HIGH, EChannelType.STRAIN_LOW], + ESensorGroup.INT_CH_A3: [EChannelType.INTERNAL_ADC_A3], + ESensorGroup.GSR: [EChannelType.GSR_RAW], + ESensorGroup.GYRO: [ + EChannelType.GYRO_X, + EChannelType.GYRO_Y, + EChannelType.GYRO_Z, + ], + ESensorGroup.ACCEL_WR: [ + EChannelType.ACCEL_WR_X, + EChannelType.ACCEL_WR_Y, + EChannelType.ACCEL_WR_Z, + ], + ESensorGroup.MAG_REG: [ + EChannelType.MAG_REG_X, + EChannelType.MAG_REG_Y, + EChannelType.MAG_REG_Z, + ], + ESensorGroup.ACCEL_HG: [ + EChannelType.ACCEL_HG_X, + EChannelType.ACCEL_HG_Y, + EChannelType.ACCEL_HG_Z, + ], + ESensorGroup.MAG_WR: [ + EChannelType.MAG_WR_X, + EChannelType.MAG_WR_Y, + EChannelType.MAG_WR_Z, + ], + ESensorGroup.PRESSURE: [EChannelType.TEMPERATURE, EChannelType.PRESSURE], + ESensorGroup.EXG1_24BIT: [ + EChannelType.EXG1_STATUS, + EChannelType.EXG1_CH1_24BIT, + EChannelType.EXG1_CH2_24BIT, + ], + ESensorGroup.EXG1_16BIT: [ + EChannelType.EXG1_STATUS, + EChannelType.EXG1_CH1_16BIT, + EChannelType.EXG1_CH2_16BIT, + ], + ESensorGroup.EXG2_24BIT: [ + EChannelType.EXG2_STATUS, + EChannelType.EXG2_CH1_24BIT, + EChannelType.EXG2_CH2_24BIT, + ], + ESensorGroup.EXG2_16BIT: [ + EChannelType.EXG2_STATUS, + EChannelType.EXG2_CH1_16BIT, + EChannelType.EXG2_CH2_16BIT, + ], + # The MPU9150 Temp sensor is not yet available as a channel in the LogAndStream + # firmware + ESensorGroup.TEMP: [], + } + + SENSOR_BIT_ASSIGNMENT: dict[ESensorGroup, int] = { + ESensorGroup.EXT_CH_A1: 0, + ESensorGroup.EXT_CH_A0: 1, + ESensorGroup.GSR: 2, + ESensorGroup.EXG2_24BIT: 3, + ESensorGroup.EXG1_24BIT: 4, + ESensorGroup.MAG_REG: 5, + ESensorGroup.GYRO: 6, + ESensorGroup.ACCEL_LN: 7, + ESensorGroup.INT_CH_A1: 8, + ESensorGroup.INT_CH_A0: 9, + ESensorGroup.INT_CH_A3: 10, + ESensorGroup.EXT_CH_A2: 11, + ESensorGroup.ACCEL_WR: 12, + ESensorGroup.BATTERY: 13, + # No assignment 14 + ESensorGroup.STRAIN: 15, + # No assignment 16 + ESensorGroup.TEMP: 17, + ESensorGroup.PRESSURE: 18, + ESensorGroup.EXG2_16BIT: 19, + ESensorGroup.EXG1_16BIT: 20, + ESensorGroup.MAG_WR: 21, + ESensorGroup.ACCEL_HG: 22, + ESensorGroup.INT_CH_A2: 23, + } + + SENSOR_ORDER: dict[ESensorGroup, int] = { + ESensorGroup.ACCEL_LN: 1, + ESensorGroup.BATTERY: 2, + ESensorGroup.EXT_CH_A0: 3, + ESensorGroup.EXT_CH_A1: 4, + ESensorGroup.EXT_CH_A2: 5, + ESensorGroup.INT_CH_A0: 6, + ESensorGroup.INT_CH_A1: 7, + ESensorGroup.INT_CH_A2: 8, + ESensorGroup.STRAIN: 9, + ESensorGroup.INT_CH_A3: 10, + ESensorGroup.GSR: 11, + ESensorGroup.GYRO: 12, + ESensorGroup.ACCEL_WR: 13, + ESensorGroup.MAG_REG: 14, + ESensorGroup.ACCEL_HG: 15, + ESensorGroup.MAG_WR: 16, + ESensorGroup.PRESSURE: 17, + ESensorGroup.EXG1_24BIT: 18, + ESensorGroup.EXG1_16BIT: 19, + ESensorGroup.EXG2_24BIT: 20, + ESensorGroup.EXG2_16BIT: 21, + ESensorGroup.TEMP: 22, + } + + def __init__(self): + super().__init__( + self.DEV_CLOCK_RATE, + self.SENSOR_DTYPE, + self.CH_DTYPE_ASSIGNMENT, + self.SENSOR_CHANNEL_ASSIGNMENT, + self.SENSOR_BIT_ASSIGNMENT, + self.SENSOR_ORDER, + ) + + +REV_SHIMMER3R = Shimmer3RRevision() From a4e666d4474e779ce1fe90bdab548f2dd32656e7 Mon Sep 17 00:00:00 2001 From: JongChern Date: Wed, 24 Dec 2025 15:51:07 +0800 Subject: [PATCH 26/32] Update test_bt_commands.py --- test/bluetooth/test_bt_commands.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index bd033e7..4b5ebaa 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -56,6 +56,7 @@ HardwareRevision, HW_REVISIONS, REV_SHIMMER3, + REV_SHIMMER3R ) from pyshimmer.test_util import MockSerial @@ -193,11 +194,14 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): assert minor == 11 assert patch == 0 - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev,payload,exp_sr,exp_buf,exp_ctypes", [ + (REV_SHIMMER3, b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), + (REV_SHIMMER3R, b"\x02\x40\x00\x00\x01\t\x00\x00\x00\x01\x01", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), + ]) def test_inquiry_command(self, rev: HardwareRevision): cmd = InquiryCommand(rev) sr, buf_size, ctypes = self.assert_cmd( - cmd, b"\x01", b"\x02", b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" + cmd, b"\x01", b"\x02", payload ) assert sr == 512.0 From ff0c02404b4944e677d63ce714f28420ffc976e8 Mon Sep 17 00:00:00 2001 From: JongChern Date: Wed, 24 Dec 2025 15:56:22 +0800 Subject: [PATCH 27/32] Update test_bt_commands.py --- test/bluetooth/test_bt_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 4b5ebaa..a5ebcfa 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -198,7 +198,7 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): (REV_SHIMMER3, b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), (REV_SHIMMER3R, b"\x02\x40\x00\x00\x01\t\x00\x00\x00\x01\x01", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), ]) - def test_inquiry_command(self, rev: HardwareRevision): + def test_inquiry_command(self, rev: HardwareRevision, payload): cmd = InquiryCommand(rev) sr, buf_size, ctypes = self.assert_cmd( cmd, b"\x01", b"\x02", payload From 10a0635dc021d7b4d78a74a6065c0ef087339a2a Mon Sep 17 00:00:00 2001 From: JongChern Date: Wed, 24 Dec 2025 16:01:22 +0800 Subject: [PATCH 28/32] Update test_bt_commands.py --- test/bluetooth/test_bt_commands.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index a5ebcfa..d44a4d2 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -198,15 +198,15 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): (REV_SHIMMER3, b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), (REV_SHIMMER3R, b"\x02\x40\x00\x00\x01\t\x00\x00\x00\x01\x01", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), ]) - def test_inquiry_command(self, rev: HardwareRevision, payload): + def test_inquiry_command(self, rev: HardwareRevision, payload, exp_sr, exp_buf, exp_ctypes): cmd = InquiryCommand(rev) sr, buf_size, ctypes = self.assert_cmd( cmd, b"\x01", b"\x02", payload ) - assert sr == 512.0 - assert buf_size == 1 - assert ctypes == [EChannelType.INTERNAL_ADC_A1] + assert sr == exp_sr + assert buf_size == exp_buf + assert ctypes == exp_ctypes @pytest.mark.parametrize("rev", HW_REVISIONS) def test_start_streaming_command(self, rev: HardwareRevision): From ced3b3475a28996a6ea4ea3a2ebce90de6bae962 Mon Sep 17 00:00:00 2001 From: JongChern Date: Wed, 24 Dec 2025 16:12:17 +0800 Subject: [PATCH 29/32] Update test_bt_commands.py --- test/bluetooth/test_bt_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index d44a4d2..c1b6cef 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -196,7 +196,7 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): @pytest.mark.parametrize("rev,payload,exp_sr,exp_buf,exp_ctypes", [ (REV_SHIMMER3, b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), - (REV_SHIMMER3R, b"\x02\x40\x00\x00\x01\t\x00\x00\x00\x01\x01", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), + (REV_SHIMMER3R, b"\x02\x40\x00\x00\x01\t\x00\x00\x00\x01\x01\0x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), ]) def test_inquiry_command(self, rev: HardwareRevision, payload, exp_sr, exp_buf, exp_ctypes): cmd = InquiryCommand(rev) From 2194a7f379df38e559d66b51705102f3cd071bb9 Mon Sep 17 00:00:00 2001 From: JongChern Date: Wed, 24 Dec 2025 16:27:21 +0800 Subject: [PATCH 30/32] Update test_bt_commands.py --- test/bluetooth/test_bt_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index c1b6cef..7a742cc 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -196,7 +196,7 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): @pytest.mark.parametrize("rev,payload,exp_sr,exp_buf,exp_ctypes", [ (REV_SHIMMER3, b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), - (REV_SHIMMER3R, b"\x02\x40\x00\x00\x01\t\x00\x00\x00\x01\x01\0x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), + (REV_SHIMMER3R, b"\x02\x40\x00\x00\x01\t\x00\x00\x00\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), ]) def test_inquiry_command(self, rev: HardwareRevision, payload, exp_sr, exp_buf, exp_ctypes): cmd = InquiryCommand(rev) From 9f3da5949ca439724782e92ddcc764ad1ffcdeb5 Mon Sep 17 00:00:00 2001 From: JongChern Date: Wed, 24 Dec 2025 16:36:07 +0800 Subject: [PATCH 31/32] Update test_bt_commands.py --- test/bluetooth/test_bt_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 7a742cc..2f192bd 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -196,7 +196,7 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): @pytest.mark.parametrize("rev,payload,exp_sr,exp_buf,exp_ctypes", [ (REV_SHIMMER3, b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), - (REV_SHIMMER3R, b"\x02\x40\x00\x00\x01\t\x00\x00\x00\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), + (REV_SHIMMER3R, b"\x02\x40\x00\x02\x00\x01\x09\x00\x00\0x00\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), ]) def test_inquiry_command(self, rev: HardwareRevision, payload, exp_sr, exp_buf, exp_ctypes): cmd = InquiryCommand(rev) From 744aaf4d19bab9e3779d323b2c4f5505817b80d8 Mon Sep 17 00:00:00 2001 From: JongChern Date: Wed, 24 Dec 2025 16:39:59 +0800 Subject: [PATCH 32/32] Update test_bt_commands.py --- test/bluetooth/test_bt_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 2f192bd..a1dc1f0 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -196,7 +196,7 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): @pytest.mark.parametrize("rev,payload,exp_sr,exp_buf,exp_ctypes", [ (REV_SHIMMER3, b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), - (REV_SHIMMER3R, b"\x02\x40\x00\x02\x00\x01\x09\x00\x00\0x00\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), + (REV_SHIMMER3R, b"\x02\x40\x00\x02\x00\x01\x09\x00\x00\x00\x01\x01\x12", 512.0, 1, [EChannelType.INTERNAL_ADC_A1]), ]) def test_inquiry_command(self, rev: HardwareRevision, payload, exp_sr, exp_buf, exp_ctypes): cmd = InquiryCommand(rev)