From 133804662204bef3c79ad2e0792972eb2fad1518 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 25 Oct 2025 18:30:40 +0200 Subject: [PATCH 01/43] 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 02/43] 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 03/43] 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 04/43] 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 05/43] 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 06/43] 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 07/43] 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 08/43] 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 09/43] 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 10/43] 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 11/43] 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 12/43] 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 13/43] 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 14/43] 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 15/43] 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 16/43] 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 17/43] 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 18/43] 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 19/43] 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 20/43] 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 21/43] 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 22/43] 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 1bb9ee30b6ca3277fdcb0524999fef419acd69f2 Mon Sep 17 00:00:00 2001 From: JongChern Date: Thu, 25 Dec 2025 21:45:52 +0800 Subject: [PATCH 23/43] Shimmer3R (#36) * add 3r support * Update test_bt_commands.py * Update test_bt_commands.py * Update test_bt_commands.py * Update test_bt_commands.py * Update test_bt_commands.py * Update test_bt_commands.py * Update test_bt_commands.py --- 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 ++++++++++++++++++++++++++ test/bluetooth/test_bt_commands.py | 16 +- 7 files changed, 229 insertions(+), 11 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() diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index bd033e7..a1dc1f0 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,16 +194,19 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): assert minor == 11 assert patch == 0 - @pytest.mark.parametrize("rev", HW_REVISIONS) - def test_inquiry_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\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) 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 - 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 e8875f400e6d66a218e404935f3708e773296c04 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Thu, 25 Dec 2025 16:05:54 +0100 Subject: [PATCH 24/43] Reformat code and optimize imports --- pyshimmer/__init__.py | 2 +- pyshimmer/bluetooth/bt_api.py | 6 +++++- pyshimmer/bluetooth/bt_commands.py | 9 +++++---- pyshimmer/dev/revisions/__init__.py | 2 +- pyshimmer/dev/revisions/revision.py | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/pyshimmer/__init__.py b/pyshimmer/__init__.py index ac5860e..7ab6707 100644 --- a/pyshimmer/__init__.py +++ b/pyshimmer/__init__.py @@ -27,7 +27,7 @@ REV_SHIMMER3, HardwareVersion, Shimmer3RRevision, - REV_SHIMMER3R + 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 952d6e9..4137721 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -70,7 +70,11 @@ FirmwareVersion, FirmwareCapabilities, ) -from pyshimmer.dev.revisions import HardwareVersion, HardwareRevision, REV_SHIMMER3, REV_SHIMMER3R +from pyshimmer.dev.revisions import ( + HardwareVersion, + HardwareRevision, + REV_SHIMMER3, +) 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 3551ad5..10a4d6e 100644 --- a/pyshimmer/bluetooth/bt_commands.py +++ b/pyshimmer/bluetooth/bt_commands.py @@ -18,8 +18,7 @@ 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 @@ -31,6 +30,8 @@ from pyshimmer.dev.exg import ExGRegister from pyshimmer.dev.fw_version import FirmwareType from pyshimmer.dev.revisions import HardwareRevision, HardwareVersion +from pyshimmer.dev.revisions.shimmer3 import REV_SHIMMER3 +from pyshimmer.dev.revisions.shimmer3r import REV_SHIMMER3R from pyshimmer.util import ( bit_is_set, resp_code_to_bytes, @@ -477,11 +478,11 @@ 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 = " Date: Thu, 25 Dec 2025 16:15:50 +0100 Subject: [PATCH 25/43] Refactor tests --- pyshimmer/bluetooth/bt_commands.py | 11 +++++------ test/bluetooth/test_bt_commands.py | 31 +++++++++++++++++++----------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/pyshimmer/bluetooth/bt_commands.py b/pyshimmer/bluetooth/bt_commands.py index 10a4d6e..d552643 100644 --- a/pyshimmer/bluetooth/bt_commands.py +++ b/pyshimmer/bluetooth/bt_commands.py @@ -29,9 +29,7 @@ ) from pyshimmer.dev.exg import ExGRegister from pyshimmer.dev.fw_version import FirmwareType -from pyshimmer.dev.revisions import HardwareRevision, HardwareVersion -from pyshimmer.dev.revisions.shimmer3 import REV_SHIMMER3 -from pyshimmer.dev.revisions.shimmer3r import REV_SHIMMER3R +from pyshimmer.dev.revisions import HardwareRevision, HardwareVersion, Shimmer3RRevision from pyshimmer.util import ( bit_is_set, resp_code_to_bytes, @@ -479,10 +477,11 @@ def send(self, ser: BluetoothSerial) -> None: ser.write_command(INQUIRY_COMMAND) def receive(self, ser: BluetoothSerial) -> any: - if self._rev is REV_SHIMMER3: - arg_format = " Date: Thu, 25 Dec 2025 17:13:14 +0100 Subject: [PATCH 26/43] Fix tests for new revision --- test/bluetooth/test_bluetooth_api.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index cd97aa9..1bd7894 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -495,7 +495,7 @@ def helper(self) -> IntegrationTestHelper: helper.teardown() - @pytest.fixture(params=[HardwareVersion.SHIMMER3]) + @pytest.fixture(params=[HardwareVersion.SHIMMER3, HardwareVersion.SHIMMER3R]) def hw_version(self, request) -> HardwareVersion: return request.param @@ -572,9 +572,12 @@ def test_get_data_types( ): helper.setup(run_sot_initialize=True, hw_version=hw_version) - ftr = helper.submit_req_resp_handler( - 1, b"\xff\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" - ) + if hw_version == HardwareVersion.SHIMMER3R: + response = b"\xff\x02\x40\x00\x02\x00\x01\x09\x00\x00\x00\x01\x01\x12" + else: + response = b"\xff\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" + + ftr = helper.submit_req_resp_handler(1, response) r = helper.sot.get_data_types() assert ftr.result() == b"\x01" @@ -590,9 +593,12 @@ def test_streaming( def pkt_handler(new_pkt: DataPacket) -> None: pkts.append(new_pkt) - inquiry_ftr = helper.submit_req_resp_handler( - 1, b"\xff\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" - ) + if hw_version == HardwareVersion.SHIMMER3R: + response = b"\xff\x02\x40\x00\x02\x00\x01\x09\x00\x00\x00\x01\x01\x12" + else: + response = b"\xff\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" + + inquiry_ftr = helper.submit_req_resp_handler(1, response) 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") From 57c7e1b8783d5a7266f3c824029f87db97e16c87 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Thu, 25 Dec 2025 17:21:13 +0100 Subject: [PATCH 27/43] Introduce dedicated Inquiry commands for different HW revisions --- pyshimmer/bluetooth/bt_api.py | 10 ++++++++-- pyshimmer/bluetooth/bt_commands.py | 26 ++++++++++++++++++-------- test/bluetooth/test_bt_commands.py | 7 ++++--- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/pyshimmer/bluetooth/bt_api.py b/pyshimmer/bluetooth/bt_api.py index 4137721..f851239 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -31,7 +31,8 @@ SetRealTimeClockCommand, GetStatusCommand, GetFirmwareVersionCommand, - InquiryCommand, + Shimmer3InquiryCommand, + Shimmer3RInquiryCommand, StartStreamingCommand, StopStreamingCommand, DataPacket, @@ -714,7 +715,12 @@ 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._revision)) + if self._hw_version == HardwareVersion.SHIMMER3R: + cmd = Shimmer3RInquiryCommand(self._revision) + else: + cmd = Shimmer3InquiryCommand(self._revision) + + return self._process_and_wait(cmd) def get_data_types(self): """Get the active data channels of the device diff --git a/pyshimmer/bluetooth/bt_commands.py b/pyshimmer/bluetooth/bt_commands.py index d552643..f13deec 100644 --- a/pyshimmer/bluetooth/bt_commands.py +++ b/pyshimmer/bluetooth/bt_commands.py @@ -29,7 +29,7 @@ ) from pyshimmer.dev.exg import ExGRegister from pyshimmer.dev.fw_version import FirmwareType -from pyshimmer.dev.revisions import HardwareRevision, HardwareVersion, Shimmer3RRevision +from pyshimmer.dev.revisions import HardwareRevision, HardwareVersion from pyshimmer.util import ( bit_is_set, resp_code_to_bytes, @@ -458,14 +458,17 @@ def receive(self, ser: BluetoothSerial) -> any: class InquiryCommand(ResponseCommand): - def __init__(self, rev: HardwareRevision): + def __init__(self, rev: HardwareRevision, response_format: str): """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 + :param response_format: The struct format to use for unpacking the + response """ super().__init__(rev, INQUIRY_RESPONSE) + self._response_format = response_format @staticmethod def decode_channel_types(ct_bin: bytes) -> list[EChannelType]: @@ -477,13 +480,8 @@ def send(self, ser: BluetoothSerial) -> None: ser.write_command(INQUIRY_COMMAND) def receive(self, ser: BluetoothSerial) -> any: - if isinstance(self._rev, Shimmer3RRevision): - arg_format = " any: return sr, buf_size, ctypes +class Shimmer3InquiryCommand(InquiryCommand): + + def __init__(self, rev: HardwareRevision): + super().__init__(rev, response_format=" Date: Sat, 7 Feb 2026 17:29:14 +0100 Subject: [PATCH 28/43] Reverse coupling from HardwareVersion to HardwareRevision --- pyshimmer/dev/revisions/hw_version.py | 39 +++++---------------------- pyshimmer/dev/revisions/revision.py | 11 ++++++++ pyshimmer/dev/revisions/shimmer3.py | 2 ++ pyshimmer/dev/revisions/shimmer3r.py | 2 ++ test/dev/revision/test_hw_version.py | 15 +++++------ 5 files changed, 27 insertions(+), 42 deletions(-) diff --git a/pyshimmer/dev/revisions/hw_version.py b/pyshimmer/dev/revisions/hw_version.py index a68c33f..380a490 100644 --- a/pyshimmer/dev/revisions/hw_version.py +++ b/pyshimmer/dev/revisions/hw_version.py @@ -2,10 +2,6 @@ from enum import IntEnum -from .revision import HardwareRevision -from .shimmer3 import REV_SHIMMER3 -from .shimmer3r import REV_SHIMMER3R - class HardwareVersion(IntEnum): """Represents the supported Shimmer device hardware version / revision @@ -14,20 +10,12 @@ class HardwareVersion(IntEnum): and the revision class. """ - SHIMMER1 = (0, None) - SHIMMER2 = (1, None) - SHIMMER2R = (2, None) - SHIMMER3 = (3, REV_SHIMMER3) - SHIMMER3R = (10, REV_SHIMMER3R) - 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, version) - obj._value_ = version - obj._revision = revision - return obj + SHIMMER1 = 0 + SHIMMER2 = 1 + SHIMMER2R = 2 + SHIMMER3 = 3 + SHIMMER3R = 10 + UNKNOWN = -1 @classmethod def from_int(cls, value: int) -> HardwareVersion: @@ -37,18 +25,3 @@ 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/revision.py b/pyshimmer/dev/revisions/revision.py index a2b24a7..0fd963d 100644 --- a/pyshimmer/dev/revisions/revision.py +++ b/pyshimmer/dev/revisions/revision.py @@ -25,10 +25,18 @@ from pyshimmer.util import bit_is_set, flatten_list from ..channels import EChannelType, ChannelDataType, ESensorGroup +from .hw_version import HardwareVersion class HardwareRevision(ABC): + def __init__(self, hw_version: HardwareVersion): + self._hw_version = hw_version + + @property + def hardware_version(self) -> HardwareVersion: + return self._hw_version + @abstractmethod def sr2dr(self, sr: float) -> int: """Calculate equivalent device-specific rate for a sample rate in Hz @@ -175,6 +183,7 @@ class BaseRevision(HardwareRevision): def __init__( self, + hw_version: HardwareVersion, dev_clock_rate: float, sensor_list_dtype: ChannelDataType, channel_data_types: dict[EChannelType, ChannelDataType], @@ -182,6 +191,8 @@ def __init__( sensor_bit_assignment: dict[ESensorGroup, int], sensor_order: dict[ESensorGroup, int], ): + super().__init__(hw_version) + self._dev_clock_rate = dev_clock_rate self._sensor_list_dtype = sensor_list_dtype self._channel_data_types = channel_data_types diff --git a/pyshimmer/dev/revisions/shimmer3.py b/pyshimmer/dev/revisions/shimmer3.py index d0d9b23..b56b7c2 100644 --- a/pyshimmer/dev/revisions/shimmer3.py +++ b/pyshimmer/dev/revisions/shimmer3.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from __future__ import annotations +from .hw_version import HardwareVersion from .revision import BaseRevision from ..channels import EChannelType, ChannelDataType, ESensorGroup @@ -192,6 +193,7 @@ class Shimmer3Revision(BaseRevision): def __init__(self): super().__init__( + HardwareVersion.SHIMMER3, self.DEV_CLOCK_RATE, self.SENSOR_DTYPE, self.CH_DTYPE_ASSIGNMENT, diff --git a/pyshimmer/dev/revisions/shimmer3r.py b/pyshimmer/dev/revisions/shimmer3r.py index 00bb557..8885a10 100644 --- a/pyshimmer/dev/revisions/shimmer3r.py +++ b/pyshimmer/dev/revisions/shimmer3r.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from __future__ import annotations +from .hw_version import HardwareVersion from .revision import BaseRevision from ..channels import EChannelType, ChannelDataType, ESensorGroup @@ -192,6 +193,7 @@ class Shimmer3RRevision(BaseRevision): def __init__(self): super().__init__( + HardwareVersion.SHIMMER3R, self.DEV_CLOCK_RATE, self.SENSOR_DTYPE, self.CH_DTYPE_ASSIGNMENT, diff --git a/test/dev/revision/test_hw_version.py b/test/dev/revision/test_hw_version.py index b10dfdc..5fdea86 100644 --- a/test/dev/revision/test_hw_version.py +++ b/test/dev/revision/test_hw_version.py @@ -9,12 +9,9 @@ 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 - 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() + def test_from_int(self): + assert HardwareVersion.from_int(0) == HardwareVersion.SHIMMER1 + assert HardwareVersion.from_int(1) == HardwareVersion.SHIMMER2 + assert HardwareVersion.from_int(2) == HardwareVersion.SHIMMER2R + assert HardwareVersion.from_int(3) == HardwareVersion.SHIMMER3 + assert HardwareVersion.from_int(10) == HardwareVersion.SHIMMER3R From 2f8c1ff5070d637a58d3aedd31bebc5cb7bcdd5a Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 7 Feb 2026 18:22:33 +0100 Subject: [PATCH 29/43] Implement RevisionRegistry class to look up revisions for HW versions --- pyshimmer/__init__.py | 4 +- pyshimmer/bluetooth/bt_api.py | 21 +++----- pyshimmer/dev/revisions/__init__.py | 10 ++-- pyshimmer/dev/revisions/registry.py | 26 ++++++++++ pyshimmer/dev/revisions/shimmer3.py | 3 -- pyshimmer/dev/revisions/shimmer3r.py | 3 -- test/bluetooth/test_bluetooth_api.py | 8 ++-- test/bluetooth/test_bt_commands.py | 72 ++++++++++++++-------------- test/dev/revision/test_registry.py | 31 ++++++++++++ 9 files changed, 106 insertions(+), 72 deletions(-) create mode 100644 pyshimmer/dev/revisions/registry.py create mode 100644 test/dev/revision/test_registry.py diff --git a/pyshimmer/__init__.py b/pyshimmer/__init__.py index 7ab6707..10f4077 100644 --- a/pyshimmer/__init__.py +++ b/pyshimmer/__init__.py @@ -23,11 +23,9 @@ from .dev.revisions import ( HardwareRevision, Shimmer3Revision, - HW_REVISIONS, - REV_SHIMMER3, + RevisionRegistry, 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 f851239..429fd55 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -71,11 +71,7 @@ FirmwareVersion, FirmwareCapabilities, ) -from pyshimmer.dev.revisions import ( - HardwareVersion, - HardwareRevision, - REV_SHIMMER3, -) +from pyshimmer.dev.revisions import HardwareVersion, HardwareRevision, RevisionRegistry from pyshimmer.serial_base import ReadAbort from pyshimmer.util import fmt_hex, PeekQueue @@ -375,7 +371,7 @@ def __init__( if revision is None: self._revision_is_custom = False - self._revision = REV_SHIMMER3 + self._revision = RevisionRegistry.get_revision(HardwareVersion.SHIMMER3) else: self._revision_is_custom = True self._revision = revision @@ -484,15 +480,10 @@ def initialize(self) -> None: # 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 - ) + # Update the request handler with the actually used revision + self._revision = self._bluetooth.hardware_revision = ( + RevisionRegistry.get_revision(self._hw_version) + ) if self.capabilities.supports_ack_disable and self._disable_ack: self.set_status_ack(enabled=False) diff --git a/pyshimmer/dev/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py index e7a335d..a3963b7 100644 --- a/pyshimmer/dev/revisions/__init__.py +++ b/pyshimmer/dev/revisions/__init__.py @@ -16,10 +16,6 @@ from .hw_version import HardwareVersion from .revision import HardwareRevision -from .shimmer3 import Shimmer3Revision, REV_SHIMMER3 -from .shimmer3r import Shimmer3RRevision, REV_SHIMMER3R - -HW_REVISIONS = [ - REV_SHIMMER3, - REV_SHIMMER3R, -] +from .shimmer3 import Shimmer3Revision +from .shimmer3r import Shimmer3RRevision +from .registry import RevisionRegistry diff --git a/pyshimmer/dev/revisions/registry.py b/pyshimmer/dev/revisions/registry.py new file mode 100644 index 0000000..b372124 --- /dev/null +++ b/pyshimmer/dev/revisions/registry.py @@ -0,0 +1,26 @@ +from .hw_version import HardwareVersion +from .revision import HardwareRevision +from .shimmer3 import Shimmer3Revision +from .shimmer3r import Shimmer3RRevision + + +class RevisionRegistry: + + _MAP = { + HardwareVersion.SHIMMER3: Shimmer3Revision(), + HardwareVersion.SHIMMER3R: Shimmer3RRevision(), + } + + ALL_REVISIONS = tuple(_MAP.values()) + + @classmethod + def find_revision(cls, version: HardwareVersion) -> HardwareRevision | None: + return cls._MAP.get(version, None) + + @classmethod + def get_revision(cls, version: HardwareVersion) -> HardwareRevision: + rev = cls.find_revision(version) + if rev is None: + raise ValueError(f"No revision exists for hardware version {version.name}") + + return rev diff --git a/pyshimmer/dev/revisions/shimmer3.py b/pyshimmer/dev/revisions/shimmer3.py index b56b7c2..f30348e 100644 --- a/pyshimmer/dev/revisions/shimmer3.py +++ b/pyshimmer/dev/revisions/shimmer3.py @@ -201,6 +201,3 @@ def __init__(self): self.SENSOR_BIT_ASSIGNMENT, self.SENSOR_ORDER, ) - - -REV_SHIMMER3 = Shimmer3Revision() diff --git a/pyshimmer/dev/revisions/shimmer3r.py b/pyshimmer/dev/revisions/shimmer3r.py index 8885a10..80647e1 100644 --- a/pyshimmer/dev/revisions/shimmer3r.py +++ b/pyshimmer/dev/revisions/shimmer3r.py @@ -201,6 +201,3 @@ def __init__(self): self.SENSOR_BIT_ASSIGNMENT, self.SENSOR_ORDER, ) - - -REV_SHIMMER3R = Shimmer3RRevision() diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index 1bd7894..1890b27 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -21,7 +21,6 @@ import pytest - from pyshimmer.bluetooth.bt_api import BluetoothRequestHandler, ShimmerBluetooth from pyshimmer.bluetooth.bt_commands import ( GetDeviceNameCommand, @@ -37,9 +36,8 @@ from pyshimmer.dev.revisions import ( HardwareVersion, HardwareRevision, - HW_REVISIONS, - REV_SHIMMER3, Shimmer3Revision, + RevisionRegistry, ) from pyshimmer.test_util import PTYSerialMockCreator @@ -55,7 +53,7 @@ def mock_creator(self) -> PTYSerialMockCreator: mock_creator.close() - @pytest.fixture(params=HW_REVISIONS) + @pytest.fixture(params=RevisionRegistry.ALL_REVISIONS) def revision(self, request) -> HardwareRevision: return request.param @@ -502,7 +500,7 @@ def hw_version(self, request) -> HardwareVersion: def test_properties(self, helper: IntegrationTestHelper): helper.setup(run_sot_initialize=True) - assert helper.sot.hardware_revision == REV_SHIMMER3 + assert isinstance(helper.sot.hardware_revision, Shimmer3Revision) assert helper.sot.hardware_version == HardwareVersion.SHIMMER3 assert helper.sot.firmware_type == FirmwareType.LogAndStream assert helper.sot.firmware_version == FirmwareVersion(0, 11, 0) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 34237b2..1cd43a1 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -52,13 +52,7 @@ from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ESensorGroup from pyshimmer.dev.fw_version import FirmwareType -from pyshimmer.dev.revisions import ( - HardwareVersion, - HardwareRevision, - HW_REVISIONS, - REV_SHIMMER3, - REV_SHIMMER3R, -) +from pyshimmer.dev.revisions import HardwareVersion, HardwareRevision, RevisionRegistry from pyshimmer.test_util import MockSerial @@ -102,7 +96,9 @@ def assert_cmd( def test_response_command_code_conversion(self): class TestCommand(ResponseCommand): def __init__(self, rcode: int | bytes | tuple[int, ...]): - super().__init__(REV_SHIMMER3, rcode) + super().__init__( + RevisionRegistry.get_revision(HardwareVersion.SHIMMER3), rcode + ) def send(self, ser: BluetoothSerial) -> None: pass @@ -125,17 +121,17 @@ def send(self, ser: BluetoothSerial) -> None: cmd = TestCommand(b"\x10\x20") assert cmd.get_response_code() == b"\x10\x20" - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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) @@ -145,7 +141,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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_set_sensors_command(self, rev: HardwareRevision): sensors = [ ESensorGroup.GYRO, @@ -155,17 +151,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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_get_rtc(self, rev: HardwareRevision): cmd = GetRealTimeClockCommand(rev) r = self.assert_cmd( @@ -173,18 +169,18 @@ def test_get_rtc(self, rev: HardwareRevision): ) assert r == pytest.approx(4903.3837585) - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_get_firmware_version_command(self, rev: HardwareRevision): cmd = GetFirmwareVersionCommand(rev) fw_type, major, minor, patch = self.assert_cmd( @@ -196,7 +192,9 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): assert patch == 0 def test_inquiry_command_shimmer3(self): - cmd = Shimmer3InquiryCommand(REV_SHIMMER3) + cmd = Shimmer3InquiryCommand( + RevisionRegistry.get_revision(HardwareVersion.SHIMMER3) + ) sr, buf_size, ctypes = self.assert_cmd( cmd, b"\x01", b"\x02", b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" ) @@ -206,7 +204,9 @@ def test_inquiry_command_shimmer3(self): assert ctypes == [EChannelType.INTERNAL_ADC_A1] def test_inquiry_command_shimmer3r(self): - cmd = Shimmer3RInquiryCommand(REV_SHIMMER3R) + cmd = Shimmer3RInquiryCommand( + RevisionRegistry.get_revision(HardwareVersion.SHIMMER3R) + ) sr, buf_size, ctypes = self.assert_cmd( cmd, b"\x01", @@ -218,27 +218,27 @@ def test_inquiry_command_shimmer3r(self): assert buf_size == 1 assert ctypes == [EChannelType.INTERNAL_ADC_A1] - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_start_streaming_command(self, rev: HardwareRevision): cmd = StartStreamingCommand(rev) self.assert_cmd(cmd, b"\x07") - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_stop_streaming_command(self, rev: HardwareRevision): cmd = StopStreamingCommand(rev) self.assert_cmd(cmd, b"\x20") - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_start_logging_command(self, rev: HardwareRevision): cmd = StartLoggingCommand(rev) self.assert_cmd(cmd, b"\x92") - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_stop_logging_command(self, rev: HardwareRevision): cmd = StopLoggingCommand(rev) self.assert_cmd(cmd, b"\x93") - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_get_exg_register_command(self, rev: HardwareRevision): cmd = GetEXGRegsCommand(rev, 1) r = self.assert_cmd( @@ -249,7 +249,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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_get_exg_reg_fail(self, rev: HardwareRevision): serial, mock = self.create_mock() cmd = GetEXGRegsCommand(rev, 1) @@ -258,7 +258,7 @@ def test_get_exg_reg_fail(self, rev: HardwareRevision): with pytest.raises(ValueError): cmd.receive(serial) - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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" @@ -281,27 +281,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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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) @@ -311,12 +311,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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_set_status_ack_command(self, rev: HardwareRevision): cmd = SetStatusAckCommand(rev, enabled=True) self.assert_cmd(cmd, b"\xa3\x01") @@ -324,12 +324,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", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_dummy_command(self, rev: HardwareRevision): cmd = DummyCommand(rev) self.assert_cmd(cmd, b"\x96") - @pytest.mark.parametrize("rev", HW_REVISIONS) + @pytest.mark.parametrize("rev", RevisionRegistry.ALL_REVISIONS) def test_data_packet(self, rev: HardwareRevision): serial, mock = self.create_mock() diff --git a/test/dev/revision/test_registry.py b/test/dev/revision/test_registry.py new file mode 100644 index 0000000..2a66ad4 --- /dev/null +++ b/test/dev/revision/test_registry.py @@ -0,0 +1,31 @@ +import pytest + +from pyshimmer import ( + RevisionRegistry, + HardwareVersion, + Shimmer3Revision, + Shimmer3RRevision, +) + + +class TestRegistry: + + def test_find_revision(self): + rev = RevisionRegistry.find_revision(HardwareVersion.SHIMMER3) + assert isinstance(rev, Shimmer3Revision) + assert rev.hardware_version == HardwareVersion.SHIMMER3 + + rev = RevisionRegistry.find_revision(HardwareVersion.SHIMMER3R) + assert isinstance(rev, Shimmer3RRevision) + assert rev.hardware_version == HardwareVersion.SHIMMER3R + + rev = RevisionRegistry.find_revision(HardwareVersion.SHIMMER2) + assert rev is None + + def test_get_revision(self): + rev = RevisionRegistry.find_revision(HardwareVersion.SHIMMER3) + assert isinstance(rev, Shimmer3Revision) + assert rev.hardware_version == HardwareVersion.SHIMMER3 + + with pytest.raises(ValueError): + RevisionRegistry.get_revision(HardwareVersion.SHIMMER1) From f20d326e2ba98e0e8d7c6a41d799cad525c6ee59 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 7 Feb 2026 18:41:03 +0100 Subject: [PATCH 30/43] Introduce HardwareRevision class into ShimmerBinaryReader --- pyshimmer/reader/binary_reader.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/pyshimmer/reader/binary_reader.py b/pyshimmer/reader/binary_reader.py index 1db54ce..4ef4bfb 100644 --- a/pyshimmer/reader/binary_reader.py +++ b/pyshimmer/reader/binary_reader.py @@ -22,12 +22,9 @@ from pyshimmer.dev.channels import ( ESensorGroup, - get_ch_dtypes, - get_enabled_channels, EChannelType, - ENABLED_SENSORS_LEN, - deserialize_sensors, ) +from pyshimmer.dev.revisions import RevisionRegistry, HardwareVersion from pyshimmer.dev.exg import ExGRegister from pyshimmer.util import FileIOBase, unpack, bit_is_set from .reader_const import ( @@ -55,6 +52,7 @@ class ShimmerBinaryReader(FileIOBase): def __init__(self, fp: BinaryIO): super().__init__(fp) + self._revision = RevisionRegistry.get_revision(HardwareVersion.SHIMMER3) self._sensors = [] self._channels = [] self._sr = 0 @@ -64,9 +62,8 @@ def __init__(self, fp: BinaryIO): self._read_header() - @staticmethod - def get_data_channels(sensors): - channels = get_enabled_channels(sensors) + def get_data_channels(self, sensors): + channels = self._revision.get_enabled_channels(sensors) channels_with_ts = [EChannelType.TIMESTAMP] + channels return channels_with_ts @@ -74,7 +71,7 @@ def _read_header(self) -> None: self._sr = self._read_sample_rate() self._sensors = self._read_enabled_sensors() self._channels = self.get_data_channels(self._sensors) - self._channel_dtypes = get_ch_dtypes(self._channels) + self._channel_dtypes = self._revision.get_channel_dtypes(self._channels) self._rtc_diff = self._read_rtc_clock_diff() self._start_ts = self._read_start_time() self._trial_config = self._read_trial_config() @@ -88,8 +85,8 @@ def _read_sample_rate(self) -> int: def _read_enabled_sensors(self) -> list[ESensorGroup]: self._seek(ENABLED_SENSORS_OFFSET) - sensor_bitfield = self._read(ENABLED_SENSORS_LEN) - enabled_sensors = deserialize_sensors(sensor_bitfield) + sensor_bitfield = self._read(self._revision.sensorlist_size) + enabled_sensors = self._revision.deserialize_sensorlist(sensor_bitfield) return enabled_sensors From 3e76b552f5427c2ef8b5586ab8a7dd5197976ff4 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 7 Feb 2026 18:52:34 +0100 Subject: [PATCH 31/43] Make BinaryReader hw revision available as property --- pyshimmer/reader/binary_reader.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyshimmer/reader/binary_reader.py b/pyshimmer/reader/binary_reader.py index 4ef4bfb..8fdf50d 100644 --- a/pyshimmer/reader/binary_reader.py +++ b/pyshimmer/reader/binary_reader.py @@ -24,8 +24,8 @@ ESensorGroup, EChannelType, ) -from pyshimmer.dev.revisions import RevisionRegistry, HardwareVersion from pyshimmer.dev.exg import ExGRegister +from pyshimmer.dev.revisions import RevisionRegistry, HardwareVersion, HardwareRevision from pyshimmer.util import FileIOBase, unpack, bit_is_set from .reader_const import ( RTC_CLOCK_DIFF_OFFSET, @@ -237,6 +237,10 @@ def get_triaxcal_params( offset, gain, alignment = self._read_triaxcal_params(offset) return offset * sc_offset, gain * sc_gain, alignment * sc_alignment + @property + def hardware_revision(self) -> HardwareRevision: + return self._revision + @property def sample_rate(self) -> int: return self._sr From a50a62384e7c8cccf92ab461dc8e7ada63339f67 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 7 Feb 2026 19:05:55 +0100 Subject: [PATCH 32/43] Introduce HW revision into ShimmerReader, add unwrapping fn to Revision --- pyshimmer/dev/revisions/revision.py | 34 ++++++++++++++++++++- pyshimmer/reader/shimmer_reader.py | 13 ++++---- test/dev/revision/test_shimmer3_revision.py | 13 ++++++++ test/reader/test_shimmer_reader.py | 18 +++++++++-- test/reader/test_util.py | 16 +--------- 5 files changed, 68 insertions(+), 26 deletions(-) diff --git a/pyshimmer/dev/revisions/revision.py b/pyshimmer/dev/revisions/revision.py index 0fd963d..323ed41 100644 --- a/pyshimmer/dev/revisions/revision.py +++ b/pyshimmer/dev/revisions/revision.py @@ -23,7 +23,7 @@ import numpy as np -from pyshimmer.util import bit_is_set, flatten_list +from pyshimmer.util import bit_is_set, flatten_list, unwrap from ..channels import EChannelType, ChannelDataType, ESensorGroup from .hw_version import HardwareVersion @@ -96,6 +96,15 @@ def ticks2sec(self, t_ticks: int | np.ndarray) -> float | np.ndarray: """ pass + @abstractmethod + def get_channel_dtype(self, channel: EChannelType) -> ChannelDataType: + """Return the channel data type for a single channel + + :param channel: The channel for which to return the data type + :return: The channel data type + """ + pass + @abstractmethod def get_channel_dtypes( self, channels: Iterable[EChannelType] @@ -178,6 +187,20 @@ def sort_sensors(self, sensors: Iterable[ESensorGroup]) -> list[ESensorGroup]: """ pass + @abstractmethod + def unwrap_device_timestamps(self, timestamps: np.ndarray) -> np.ndarray: + """Unwrap the device timestamps + + The timestamps recorded by the Shimmer devices have a size limitation. + If they reach their upper limit, they overflow and restart counting + at zero. This function detects these wrap-arounds in the dataset and + undoes them. + + :param timestamps: A 1D array of timestamps which need to be unwrapped + :return: An unwrapped version of the 1D input array with the same length + """ + pass + class BaseRevision(HardwareRevision): @@ -229,6 +252,9 @@ 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_dtype(self, channel: EChannelType) -> ChannelDataType: + return self._channel_data_types[channel] + def get_channel_dtypes( self, channels: Iterable[EChannelType] ) -> list[ChannelDataType]: @@ -275,3 +301,9 @@ def sort_key_fn(x): sensors_sorted = sorted(sensors, key=sort_key_fn) return sensors_sorted + + def unwrap_device_timestamps(self, timestamps: np.ndarray) -> np.ndarray: + ts_dtype = self.get_channel_dtype(EChannelType.TIMESTAMP) + uint_max = 2 ** (8 * ts_dtype.size) + + return unwrap(timestamps, uint_max) diff --git a/pyshimmer/reader/shimmer_reader.py b/pyshimmer/reader/shimmer_reader.py index 01ec761..61a5f33 100644 --- a/pyshimmer/reader/shimmer_reader.py +++ b/pyshimmer/reader/shimmer_reader.py @@ -26,6 +26,7 @@ get_enabled_channels, EChannelType, ) +from pyshimmer.dev.revisions import HardwareRevision from pyshimmer.dev.exg import is_exg_ch, get_exg_ch, ExGRegister from pyshimmer.reader.binary_reader import ShimmerBinaryReader from pyshimmer.reader.reader_const import ( @@ -33,12 +34,6 @@ EXG_ADC_OFFSET, TRIAXCAL_SENSORS, ) -from pyshimmer.util import unwrap - - -def unwrap_device_timestamps(ts_dev: np.ndarray) -> np.ndarray: - ts_dtype = ChDataTypeAssignment[EChannelType.TIMESTAMP] - return unwrap(ts_dev, 2 ** (8 * ts_dtype.size)) def fit_linear_1d(xp, fp, x): @@ -212,7 +207,7 @@ def load_file_data(self): samples, sync_offsets = self._bin_reader.read_data() ts_raw = samples.pop(EChannelType.TIMESTAMP) - ts_unwrapped = unwrap_device_timestamps(ts_raw) + ts_unwrapped = self.hardware_revision.unwrap_device_timestamps(ts_raw) ts_sane = self._apply_clock_offsets(ts_unwrapped) if self._sync and self._bin_reader.has_sync: @@ -234,6 +229,10 @@ def __getitem__(self, item: EChannelType) -> np.ndarray: return self._ch_samples[item] + @property + def hardware_revision(self) -> HardwareRevision: + return self._bin_reader.hardware_revision + @property def timestamp(self) -> np.ndarray: return self._ts diff --git a/test/dev/revision/test_shimmer3_revision.py b/test/dev/revision/test_shimmer3_revision.py index a155a24..e9cdbd2 100644 --- a/test/dev/revision/test_shimmer3_revision.py +++ b/test/dev/revision/test_shimmer3_revision.py @@ -198,3 +198,16 @@ def test_sort_sensors(self, revision: Shimmer3Revision): ] r = revision.sort_sensors(sensors) assert r == expected + + def test_unwrap_device_timestamps(self, revision: Shimmer3Revision): + ts_wrapped = np.array([0, 1, 2, 2**24 - 1, 0, 2**24]) + expected = np.array([0, 1, 2, 2**24 - 1, 2**24, 2 * 2**24]) + actual = revision.unwrap_device_timestamps(ts_wrapped) + np.testing.assert_equal(actual, expected) + + ts_wrapped = np.array([0, 10, 20, 30, 5, 15, 25, 35]) + expected = np.array( + [0, 10, 20, 30, 5 + 2**24, 15 + 2**24, 25 + 2**24, 35 + 2**24] + ) + actual = revision.unwrap_device_timestamps(ts_wrapped) + np.testing.assert_equal(actual, expected) diff --git a/test/reader/test_shimmer_reader.py b/test/reader/test_shimmer_reader.py index e9b71ef..aa792a8 100644 --- a/test/reader/test_shimmer_reader.py +++ b/test/reader/test_shimmer_reader.py @@ -21,10 +21,10 @@ import numpy as np import pandas as pd -from pyshimmer import EChannelType, ExGRegister from pyshimmer.dev.base import ticks2sec -from pyshimmer.dev.channels import ESensorGroup, get_enabled_channels -from pyshimmer.dev.exg import get_exg_ch +from pyshimmer.dev.channels import ESensorGroup, EChannelType, get_enabled_channels +from pyshimmer.dev.exg import ExGRegister, get_exg_ch +from pyshimmer.dev.revisions import RevisionRegistry, HardwareVersion from pyshimmer.reader.binary_reader import ShimmerBinaryReader from pyshimmer.reader.shimmer_reader import ( ShimmerReader, @@ -59,6 +59,9 @@ def test_reader_timestep_interpolation(self): type(m_br).has_sync = PropertyMock(return_value=False) type(m_br).has_global_clock = PropertyMock(return_value=False) type(m_br).start_timestamp = PropertyMock(return_value=0) + type(m_br).hardware_revision = RevisionRegistry.get_revision( + HardwareVersion.SHIMMER3 + ) reader = ShimmerReader(bin_reader=m_br) reader.load_file_data() @@ -85,6 +88,9 @@ def test_timestamp_unwrapping(self): type(m_br).sample_rate = PropertyMock(return_value=sr) type(m_br).has_global_clock = PropertyMock(return_value=False) type(m_br).start_timestamp = PropertyMock(return_value=0) + type(m_br).hardware_revision = RevisionRegistry.get_revision( + HardwareVersion.SHIMMER3 + ) samples = { EChannelType.VBATT: vbatt, @@ -120,6 +126,9 @@ def test_timestamp_synchronization(self): type(m_br).has_sync = PropertyMock(return_value=True) type(m_br).has_global_clock = PropertyMock(return_value=False) type(m_br).start_timestamp = PropertyMock(return_value=0) + type(m_br).hardware_revision = RevisionRegistry.get_revision( + HardwareVersion.SHIMMER3 + ) reader = ShimmerReader(bin_reader=m_br) reader.load_file_data() @@ -236,6 +245,9 @@ def test_post_process_exg_signal(self): type(m_br).start_timestamp = PropertyMock(return_value=0) type(m_br).exg_reg1 = PropertyMock(return_value=exg_reg1) type(m_br).exg_reg2 = PropertyMock(return_value=exg_reg2) + type(m_br).hardware_revision = RevisionRegistry.get_revision( + HardwareVersion.SHIMMER3 + ) reader = ShimmerReader(bin_reader=m_br, post_process=False) reader.load_file_data() diff --git a/test/reader/test_util.py b/test/reader/test_util.py index d6b457b..b9278c1 100644 --- a/test/reader/test_util.py +++ b/test/reader/test_util.py @@ -19,25 +19,11 @@ import numpy as np -from pyshimmer.reader.shimmer_reader import unwrap_device_timestamps, fit_linear_1d +from pyshimmer.reader.shimmer_reader import fit_linear_1d -# noinspection PyMethodMayBeStatic class UtilTest(TestCase): - def test_unwrap_device_timestamps(self): - ts_wrapped = np.array([0, 1, 2, 2**24 - 1, 0, 2**24]) - expected = np.array([0, 1, 2, 2**24 - 1, 2**24, 2 * 2**24]) - actual = unwrap_device_timestamps(ts_wrapped) - np.testing.assert_equal(actual, expected) - - ts_wrapped = np.array([0, 10, 20, 30, 5, 15, 25, 35]) - expected = np.array( - [0, 10, 20, 30, 5 + 2**24, 15 + 2**24, 25 + 2**24, 35 + 2**24] - ) - actual = unwrap_device_timestamps(ts_wrapped) - np.testing.assert_equal(actual, expected) - def test_fit_linear_1d(self): x = np.array([0, 1]) y = np.array([0, 10]) From 14afc235f6ae26dc0c50fac21b8eb7f47d59bbf6 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 7 Feb 2026 19:09:17 +0100 Subject: [PATCH 33/43] Introduce revision class for ShimmerReader classes --- pyshimmer/reader/shimmer_reader.py | 12 ++++-------- test/reader/test_shimmer_reader.py | 3 +++ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pyshimmer/reader/shimmer_reader.py b/pyshimmer/reader/shimmer_reader.py index 61a5f33..3fdcc32 100644 --- a/pyshimmer/reader/shimmer_reader.py +++ b/pyshimmer/reader/shimmer_reader.py @@ -21,13 +21,9 @@ import numpy as np from pyshimmer.dev.base import ticks2sec, dr2sr -from pyshimmer.dev.channels import ( - ChDataTypeAssignment, - get_enabled_channels, - EChannelType, -) -from pyshimmer.dev.revisions import HardwareRevision +from pyshimmer.dev.channels import EChannelType from pyshimmer.dev.exg import is_exg_ch, get_exg_ch, ExGRegister +from pyshimmer.dev.revisions import HardwareRevision from pyshimmer.reader.binary_reader import ShimmerBinaryReader from pyshimmer.reader.reader_const import ( EXG_ADC_REF_VOLT, @@ -92,7 +88,7 @@ def process_channel( exg_reg = reader.get_exg_reg(chip_id) gain = exg_reg.get_ch_gain(ch_id) - ch_dtype = ChDataTypeAssignment[ch_type] + ch_dtype = reader.hardware_revision.get_channel_dtype(ch_type) resolution = 8 * ch_dtype.size sensitivity = EXG_ADC_REF_VOLT / (2 ** (resolution - 1) - 1) @@ -122,7 +118,7 @@ def process( active_sensors = [s for s in reader.enabled_sensors if s in TRIAXCAL_SENSORS] for sensor in active_sensors: - sensor_channels = get_enabled_channels([sensor]) + sensor_channels = reader.hardware_revision.get_enabled_channels([sensor]) channel_data = np.stack([channels[c] for c in sensor_channels]) o, g, a = reader.get_triaxcal_params(sensor) diff --git a/test/reader/test_shimmer_reader.py b/test/reader/test_shimmer_reader.py index aa792a8..3d9d130 100644 --- a/test/reader/test_shimmer_reader.py +++ b/test/reader/test_shimmer_reader.py @@ -402,6 +402,9 @@ def test_triaxcal_processor(self): type(mock_reader).enabled_sensors = PropertyMock( return_value=list(params.keys()) ) + type(mock_reader).hardware_revision = RevisionRegistry.get_revision( + HardwareVersion.SHIMMER3 + ) proc = TriAxCalProcessor() actual_dict = proc.process(data_dict, mock_reader) From f0c789c25b71c77f1e47e49177fb8957a76b5ac5 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sat, 7 Feb 2026 19:17:29 +0100 Subject: [PATCH 34/43] Add licence and annotations import to new module --- pyshimmer/dev/revisions/registry.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pyshimmer/dev/revisions/registry.py b/pyshimmer/dev/revisions/registry.py index b372124..cf9f9ce 100644 --- a/pyshimmer/dev/revisions/registry.py +++ b/pyshimmer/dev/revisions/registry.py @@ -1,3 +1,20 @@ +# pyshimmer - API for Shimmer sensor devices +# Copyright (C) 2026 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 __future__ import annotations + from .hw_version import HardwareVersion from .revision import HardwareRevision from .shimmer3 import Shimmer3Revision From 48d0166e2a366a5fa8164ae85963d9b998b1b8f1 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 8 Feb 2026 14:41:52 +0100 Subject: [PATCH 35/43] Introduce class-level variables to Registry for individual revisions --- pyshimmer/dev/revisions/registry.py | 7 +++++-- test/bluetooth/test_bt_commands.py | 12 +++--------- test/reader/test_shimmer_reader.py | 20 +++++--------------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/pyshimmer/dev/revisions/registry.py b/pyshimmer/dev/revisions/registry.py index cf9f9ce..5fd354d 100644 --- a/pyshimmer/dev/revisions/registry.py +++ b/pyshimmer/dev/revisions/registry.py @@ -23,9 +23,12 @@ class RevisionRegistry: + REV_SHIMMER3 = Shimmer3Revision() + REV_SHIMMER3R = Shimmer3RRevision() + _MAP = { - HardwareVersion.SHIMMER3: Shimmer3Revision(), - HardwareVersion.SHIMMER3R: Shimmer3RRevision(), + HardwareVersion.SHIMMER3: REV_SHIMMER3, + HardwareVersion.SHIMMER3R: REV_SHIMMER3R, } ALL_REVISIONS = tuple(_MAP.values()) diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 1cd43a1..543d4fb 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -96,9 +96,7 @@ def assert_cmd( def test_response_command_code_conversion(self): class TestCommand(ResponseCommand): def __init__(self, rcode: int | bytes | tuple[int, ...]): - super().__init__( - RevisionRegistry.get_revision(HardwareVersion.SHIMMER3), rcode - ) + super().__init__(RevisionRegistry.REV_SHIMMER3, rcode) def send(self, ser: BluetoothSerial) -> None: pass @@ -192,9 +190,7 @@ def test_get_firmware_version_command(self, rev: HardwareRevision): assert patch == 0 def test_inquiry_command_shimmer3(self): - cmd = Shimmer3InquiryCommand( - RevisionRegistry.get_revision(HardwareVersion.SHIMMER3) - ) + cmd = Shimmer3InquiryCommand(RevisionRegistry.REV_SHIMMER3) sr, buf_size, ctypes = self.assert_cmd( cmd, b"\x01", b"\x02", b"\x02\x40\x00\x01\xff\x01\x09\x01\x01\x12" ) @@ -204,9 +200,7 @@ def test_inquiry_command_shimmer3(self): assert ctypes == [EChannelType.INTERNAL_ADC_A1] def test_inquiry_command_shimmer3r(self): - cmd = Shimmer3RInquiryCommand( - RevisionRegistry.get_revision(HardwareVersion.SHIMMER3R) - ) + cmd = Shimmer3RInquiryCommand(RevisionRegistry.REV_SHIMMER3R) sr, buf_size, ctypes = self.assert_cmd( cmd, b"\x01", diff --git a/test/reader/test_shimmer_reader.py b/test/reader/test_shimmer_reader.py index 3d9d130..659ec2c 100644 --- a/test/reader/test_shimmer_reader.py +++ b/test/reader/test_shimmer_reader.py @@ -59,9 +59,7 @@ def test_reader_timestep_interpolation(self): type(m_br).has_sync = PropertyMock(return_value=False) type(m_br).has_global_clock = PropertyMock(return_value=False) type(m_br).start_timestamp = PropertyMock(return_value=0) - type(m_br).hardware_revision = RevisionRegistry.get_revision( - HardwareVersion.SHIMMER3 - ) + type(m_br).hardware_revision = RevisionRegistry.REV_SHIMMER3 reader = ShimmerReader(bin_reader=m_br) reader.load_file_data() @@ -88,9 +86,7 @@ def test_timestamp_unwrapping(self): type(m_br).sample_rate = PropertyMock(return_value=sr) type(m_br).has_global_clock = PropertyMock(return_value=False) type(m_br).start_timestamp = PropertyMock(return_value=0) - type(m_br).hardware_revision = RevisionRegistry.get_revision( - HardwareVersion.SHIMMER3 - ) + type(m_br).hardware_revision = RevisionRegistry.REV_SHIMMER3 samples = { EChannelType.VBATT: vbatt, @@ -126,9 +122,7 @@ def test_timestamp_synchronization(self): type(m_br).has_sync = PropertyMock(return_value=True) type(m_br).has_global_clock = PropertyMock(return_value=False) type(m_br).start_timestamp = PropertyMock(return_value=0) - type(m_br).hardware_revision = RevisionRegistry.get_revision( - HardwareVersion.SHIMMER3 - ) + type(m_br).hardware_revision = RevisionRegistry.REV_SHIMMER3 reader = ShimmerReader(bin_reader=m_br) reader.load_file_data() @@ -245,9 +239,7 @@ def test_post_process_exg_signal(self): type(m_br).start_timestamp = PropertyMock(return_value=0) type(m_br).exg_reg1 = PropertyMock(return_value=exg_reg1) type(m_br).exg_reg2 = PropertyMock(return_value=exg_reg2) - type(m_br).hardware_revision = RevisionRegistry.get_revision( - HardwareVersion.SHIMMER3 - ) + type(m_br).hardware_revision = RevisionRegistry.REV_SHIMMER3 reader = ShimmerReader(bin_reader=m_br, post_process=False) reader.load_file_data() @@ -402,9 +394,7 @@ def test_triaxcal_processor(self): type(mock_reader).enabled_sensors = PropertyMock( return_value=list(params.keys()) ) - type(mock_reader).hardware_revision = RevisionRegistry.get_revision( - HardwareVersion.SHIMMER3 - ) + type(mock_reader).hardware_revision = RevisionRegistry.REV_SHIMMER3 proc = TriAxCalProcessor() actual_dict = proc.process(data_dict, mock_reader) From 80ce3fba0d6fff69e014aace324be79114b3f7f9 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 8 Feb 2026 14:43:54 +0100 Subject: [PATCH 36/43] Add missing import for ESensorGroup to pyshimmer __init__ --- pyshimmer/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyshimmer/__init__.py b/pyshimmer/__init__.py index 10f4077..8e7012d 100644 --- a/pyshimmer/__init__.py +++ b/pyshimmer/__init__.py @@ -17,7 +17,7 @@ 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.channels import ChannelDataType, EChannelType, ESensorGroup from .dev.exg import ExGMux, ExGRLDLead, ERLDRef, ExGRegister from .dev.fw_version import FirmwareType from .dev.revisions import ( From d8115f17654c8267ec34159e75e4d1329ab6047b Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 8 Feb 2026 14:52:15 +0100 Subject: [PATCH 37/43] Remove uses of GLOBAL variables and functions in tests --- test/bluetooth/test_bluetooth_api.py | 7 ++++--- test/reader/test_binary_reader.py | 13 +++++++++---- test/reader/test_shimmer_reader.py | 16 +++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/test/bluetooth/test_bluetooth_api.py b/test/bluetooth/test_bluetooth_api.py index 1890b27..3594bca 100644 --- a/test/bluetooth/test_bluetooth_api.py +++ b/test/bluetooth/test_bluetooth_api.py @@ -21,6 +21,7 @@ import pytest +from pyshimmer import EChannelType, ChannelDataType from pyshimmer.bluetooth.bt_api import BluetoothRequestHandler, ShimmerBluetooth from pyshimmer.bluetooth.bt_commands import ( GetDeviceNameCommand, @@ -31,13 +32,12 @@ ResponseCommand, ) from pyshimmer.bluetooth.bt_serial import BluetoothSerial -from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ChannelDataType from pyshimmer.dev.fw_version import FirmwareVersion, FirmwareType from pyshimmer.dev.revisions import ( HardwareVersion, HardwareRevision, - Shimmer3Revision, RevisionRegistry, + Shimmer3Revision, ) from pyshimmer.test_util import PTYSerialMockCreator @@ -291,6 +291,7 @@ def test_data_packet( self, mock_creator: PTYSerialMockCreator, sot: BluetoothRequestHandler, + revision: HardwareRevision, ): results: list[DataPacket] = [] @@ -298,7 +299,7 @@ def test_data_packet( data_pkt_2 = b"\x00\x1e\xd1\xb2\xfc\x06" ch_types = [EChannelType.TIMESTAMP, EChannelType.INTERNAL_ADC_A1] - sot.stream_types = [(c, ChDataTypeAssignment[c]) for c in ch_types] + sot.stream_types = [(c, revision.get_channel_dtype(c)) for c in ch_types] sot.add_stream_callback(results.append) mock_creator.write_to_master(data_pkt_1) diff --git a/test/reader/test_binary_reader.py b/test/reader/test_binary_reader.py index d792ebb..3151187 100644 --- a/test/reader/test_binary_reader.py +++ b/test/reader/test_binary_reader.py @@ -19,8 +19,7 @@ import numpy as np -from pyshimmer import EChannelType, ExGRegister -from pyshimmer.dev.channels import ESensorGroup, get_ch_dtypes +from pyshimmer import EChannelType, ExGRegister, RevisionRegistry, ESensorGroup from pyshimmer.reader.shimmer_reader import ShimmerBinaryReader from .reader_test_util import ( get_binary_sample_fpath, @@ -52,7 +51,10 @@ def test_parsing_wo_sync(self): EChannelType.INTERNAL_ADC_A1, ] - sample_size = sum([dt.size for dt in get_ch_dtypes(exp_channels)]) + revision = RevisionRegistry.REV_SHIMMER3 + sample_size = sum( + [dt.size for dt in revision.get_channel_dtypes(exp_channels)] + ) samples_per_block = int(512 / sample_size) block_size = samples_per_block * sample_size @@ -94,7 +96,10 @@ def test_parsing_w_sync(self): exp_exg_reg1 = ExGRegister(b"\x00\x80\x10\x00\x00\x00\x00\x00\x02\x01") exp_exg_reg2 = ExGRegister(b"\x00\x80\x10\x00\x00\x00\x00\x00\x02\x01") - sample_size = sum([dt.size for dt in get_ch_dtypes(exp_channels)]) + revision = RevisionRegistry.REV_SHIMMER3 + sample_size = sum( + [dt.size for dt in revision.get_channel_dtypes(exp_channels)] + ) samples_per_block = int((512 - 9) / sample_size) block_size = samples_per_block * sample_size + 9 diff --git a/test/reader/test_shimmer_reader.py b/test/reader/test_shimmer_reader.py index 659ec2c..4583b59 100644 --- a/test/reader/test_shimmer_reader.py +++ b/test/reader/test_shimmer_reader.py @@ -21,10 +21,9 @@ import numpy as np import pandas as pd -from pyshimmer.dev.base import ticks2sec -from pyshimmer.dev.channels import ESensorGroup, EChannelType, get_enabled_channels +from pyshimmer.dev.channels import ESensorGroup, EChannelType from pyshimmer.dev.exg import ExGRegister, get_exg_ch -from pyshimmer.dev.revisions import RevisionRegistry, HardwareVersion +from pyshimmer.dev.revisions import RevisionRegistry from pyshimmer.reader.binary_reader import ShimmerBinaryReader from pyshimmer.reader.shimmer_reader import ( ShimmerReader, @@ -39,13 +38,16 @@ get_triaxcal_sample, ) +TEST_REVISION = RevisionRegistry.REV_SHIMMER3 + class ShimmerReaderTest(TestCase): def test_reader_timestep_interpolation(self): + sr = 5 ts_dev = np.array([0, 5, 10, 15, 21, 25, 29, 35]) - ts = ticks2sec(ts_dev) + ts = TEST_REVISION.ticks2sec(ts_dev) vbatt = np.array([93, 85, 78, 74, 71, 68, 65, 64]) samples = { EChannelType.TIMESTAMP: ts_dev, @@ -77,7 +79,7 @@ def test_timestamp_unwrapping(self): ts_dev = np.arange(0, 4 * (2**24), sr) ts_dev_wrapped = ts_dev % 2**24 - ts = ticks2sec(ts_dev) + ts = TEST_REVISION.ticks2sec(ts_dev) vbatt = np.random.randint(0, 100 + 1, len(ts_dev)) m_br = Mock(spec=ShimmerBinaryReader) @@ -128,7 +130,7 @@ def test_timestamp_synchronization(self): reader.load_file_data() ts_sync_dev = ts - np.linspace(1, 0, len(ts)) - exp_ts = ticks2sec(ts_sync_dev) + exp_ts = TEST_REVISION.ticks2sec(ts_sync_dev) act_ts = reader.timestamp self.assertEqual(len(exp_ts), len(act_ts)) @@ -384,7 +386,7 @@ def test_triaxcal_processor(self): o, g, a = np.array([1, 2, 3]), np.diag([4, 5, 6]), np.diag([7, 8, 9]) params = {ESensorGroup.ACCEL_LN: (o, g, a)} - ch_types = get_enabled_channels(list(params.keys())) + ch_types = TEST_REVISION.get_enabled_channels(list(params.keys())) data_arr = np.random.randn(3, 100) data_dict = {c: data_arr[i] for i, c in enumerate(ch_types)} From 471c17f1c54e1dabe8c29c41069270940c976702 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 8 Feb 2026 14:55:14 +0100 Subject: [PATCH 38/43] Remove uses of global channel functions and attributes from Dock API --- pyshimmer/uart/dock_api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pyshimmer/uart/dock_api.py b/pyshimmer/uart/dock_api.py index 392e5bf..c10c4b9 100644 --- a/pyshimmer/uart/dock_api.py +++ b/pyshimmer/uart/dock_api.py @@ -19,7 +19,7 @@ from serial import Serial -from pyshimmer.dev.base import sec2ticks, ticks2sec +from pyshimmer.dev.revisions import RevisionRegistry from pyshimmer.dev.exg import ExGRegister from pyshimmer.dev.fw_version import FirmwareType from pyshimmer.uart.dock_const import * @@ -35,6 +35,7 @@ class ShimmerDock: """ def __init__(self, ser: Serial, flush_before_req=True): + self._revision = RevisionRegistry.REV_SHIMMER3 self._serial = DockSerial(ser) self._flush_before_req = flush_before_req @@ -145,7 +146,7 @@ def set_rtc(self, ts_sec: float) -> None: :param ts_sec: The UNIX timestamp in seconds """ - ticks = sec2ticks(ts_sec) + ticks = self._revision.sec2ticks(ts_sec) self._write_packet_wformat( UART_SET, UART_COMP_SHIMMER, UART_PROP_RWC_CFG_TIME, " float: ticks = self._read_response_wformat_verify( UART_COMP_SHIMMER, UART_PROP_CURR_LOCAL_TIME, " float: """Get the value that was last set for the real-time clock @@ -179,7 +180,7 @@ def get_config_rtc(self) -> float: ticks = self._read_response_wformat_verify( UART_COMP_SHIMMER, UART_PROP_RWC_CFG_TIME, " tuple[int, FirmwareType, int, int, int]: """Retrieve the firmware version of the device From 1de4bd1ba3c4bea5acc6db391c3f3cda7e50fefc Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 8 Feb 2026 15:08:06 +0100 Subject: [PATCH 39/43] Migrate DockAPI test to pytest --- test/uart/test_dock_api.py | 149 +++++++++++++++++-------------------- 1 file changed, 68 insertions(+), 81 deletions(-) diff --git a/test/uart/test_dock_api.py b/test/uart/test_dock_api.py index ddfb485..14a58d5 100644 --- a/test/uart/test_dock_api.py +++ b/test/uart/test_dock_api.py @@ -1,132 +1,120 @@ from __future__ import annotations -from unittest import TestCase +import pytest from pyshimmer import FirmwareType, ShimmerDock from pyshimmer.test_util import MockSerial -class DockAPITest(TestCase): +class TestDockAPI: - @staticmethod - def create_sot(flush: bool = False) -> tuple[ShimmerDock, MockSerial]: + @pytest.fixture() + def sot_and_mock(self) -> tuple[ShimmerDock, MockSerial]: mock = MockSerial() # noinspection PyTypeChecker - dock = ShimmerDock(mock, flush_before_req=flush) + dock = ShimmerDock(mock, flush_before_req=False) return dock, mock - def test_context_manager(self): - dock, mock = self.create_sot() + @pytest.fixture() + def sot(self, sot_and_mock: tuple[ShimmerDock, MockSerial]) -> ShimmerDock: + return sot_and_mock[0] - self.assertFalse(mock.test_closed) + @pytest.fixture() + def mock(self, sot_and_mock: tuple[ShimmerDock, MockSerial]) -> MockSerial: + return sot_and_mock[1] - with dock: - pass + def test_context_manager(self, sot: ShimmerDock, mock: MockSerial): + + assert not mock.test_closed - self.assertTrue(mock.test_closed) + with sot: + pass - def test_unknown_start_char(self): - dock, mock = self.create_sot() + assert mock.test_closed + def test_unknown_start_char(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x25") - self.assertRaises(IOError, dock.get_firmware_version) - - def test_bad_arg_response(self): - dock, mock = self.create_sot() + with pytest.raises(IOError): + sot.get_firmware_version() + def test_bad_arg_response(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x24\xfd") - self.assertRaises(IOError, dock.get_firmware_version) - - def test_bad_cmd_response(self): - dock, mock = self.create_sot() + with pytest.raises(IOError): + sot.get_firmware_version() + def test_bad_cmd_response(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x24\xfc") - self.assertRaises(IOError, dock.get_firmware_version) - - def test_bad_crc_response(self): - dock, mock = self.create_sot() + with pytest.raises(IOError): + sot.get_firmware_version() + def test_bad_crc_response(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x24\xfe") - self.assertRaises(IOError, dock.get_firmware_version) - - def test_unexpected_cmd_response(self): - dock, mock = self.create_sot() + with pytest.raises(IOError): + sot.get_firmware_version() + def test_unexpected_cmd_response(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x24\x03") - self.assertRaises(IOError, dock.get_firmware_version) - - def test_unexpected_component(self): - dock, mock = self.create_sot() + with pytest.raises(IOError): + sot.get_firmware_version() + def test_unexpected_component(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x24\x02\x02\x02\x00\x98z") - self.assertRaises(IOError, dock.get_firmware_version) - - def test_unexpected_property(self): - dock, mock = self.create_sot() + with pytest.raises(IOError): + sot.get_firmware_version() + def test_unexpected_property(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x24\x02\x02\x01\x02\xaaE") - self.assertRaises(IOError, dock.get_firmware_version) - - def test_get_mac_address(self): - dock, mock = self.create_sot() + with pytest.raises(IOError): + sot.get_firmware_version() + def test_get_mac_address(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x24\x02\x08\x01\x02\x01\x02\x03\x04\x05\x06N\x87") - r = dock.get_mac_address() + r = sot.get_mac_address() - self.assertEqual(r, (0x01, 0x02, 0x03, 0x04, 0x05, 0x06)) - self.assertEqual(mock.test_get_write_data(), b"\x24\x03\x02\x01\x02\xfb\xef") - - def test_get_firmware_version(self): - dock, mock = self.create_sot() + assert r == (0x01, 0x02, 0x03, 0x04, 0x05, 0x06) + assert mock.test_get_write_data() == b"\x24\x03\x02\x01\x02\xfb\xef" + def test_get_firmware_version(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data( b"\x24\x02\x09\x01\x03\x03\x03\x00\x00\x00\x0b\x00\x14\x33" ) - hw_ver, fw_type, major, minor, patch = dock.get_firmware_version() - - self.assertEqual(mock.test_get_write_data(), b"\x24\x03\x02\x01\x03\xca\xdc") + hw_ver, fw_type, major, minor, patch = sot.get_firmware_version() - self.assertEqual(hw_ver, 3) - self.assertEqual(fw_type, FirmwareType.LogAndStream) - self.assertEqual(major, 0) - self.assertEqual(minor, 11) - self.assertEqual(patch, 0) + assert mock.test_get_write_data() == b"\x24\x03\x02\x01\x03\xca\xdc" - def test_set_rtc(self): - dock, mock = self.create_sot() + assert hw_ver == 3 + assert fw_type == FirmwareType.LogAndStream + assert major == 0 + assert minor == 11 + assert patch == 0 + def test_set_rtc(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data(b"\x24\xff\xd9\xb2") - dock.set_rtc(1.0) + sot.set_rtc(1.0) + expected = b"\x24\x01\x0a\x01\x04\x00\x80\x00\x00\x00\x00\x00\x00\x1c\xd2" wd = mock.test_get_write_data() - self.assertEqual( - wd, b"\x24\x01\x0a\x01\x04\x00\x80\x00\x00\x00\x00\x00\x00\x1c\xd2" - ) - - def test_get_rtc(self): - dock, mock = self.create_sot() + assert wd == expected + def test_get_rtc(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data( b"\x24\x02\x0a\x01\x05\x9d\x3d\x0d\x00\x00\x00\x00\x00\xb0\xc7" ) - r = dock.get_rtc() - self.assertAlmostEqual(r, 26.481353759765625) - - def test_get_config_rtc(self): - dock, mock = self.create_sot() + r = sot.get_rtc() + assert r == pytest.approx(26.481353759765625) + def test_get_config_rtc(self, sot: ShimmerDock, mock: MockSerial): mock.test_put_read_data( b"\x24\x02\x0a\x01\x04\x00\x00\x15\x00\x00\x00\x00\x00\xe4\xae" ) - r = dock.get_config_rtc() - self.assertEqual(r, 42.0) + r = sot.get_config_rtc() + assert r == 42.0 wd = mock.test_get_write_data() - self.assertEqual(wd, b"\x24\x03\x02\x01\x04\x5d\x45") + assert wd == b"\x24\x03\x02\x01\x04\x5d\x45" - def test_get_exg_register(self): - dock, mock = self.create_sot() + def test_get_exg_register(self, sot: ShimmerDock, mock: MockSerial): # Due to the firmware bug, we first need to emulate the call to set the # DAUGHTER_CARD CARD_ID @@ -139,14 +127,13 @@ def test_get_exg_register(self): ) exp_send_data2 = b"\x24\x03\x05\x01\x06\x0a\x0a\x00\x42\x74" - r = dock.get_exg_register(0) + r = sot.get_exg_register(0) wd = mock.test_get_write_data() - self.assertEqual(wd, exp_send_data1 + exp_send_data2) - - self.assertEqual(r.binary, b"\x00\x80\x10\x00\x00\x00\x00\x00\x02\x01") + assert wd == exp_send_data1 + exp_send_data2 - def test_get_exg_register_fail(self): - dock, mock = self.create_sot() + assert r.binary == b"\x00\x80\x10\x00\x00\x00\x00\x00\x02\x01" - self.assertRaises(ValueError, dock.get_exg_register, -1) + def test_get_exg_register_fail(self, sot: ShimmerDock, mock: MockSerial): + with pytest.raises(ValueError): + sot.get_exg_register(-1) From 81c698b5f703c8d5287efe29ae0ab441a76a3674 Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 8 Feb 2026 15:16:50 +0100 Subject: [PATCH 40/43] Remove further uses of channels attributes and functions --- pyshimmer/bluetooth/bt_api.py | 3 +-- pyshimmer/reader/shimmer_reader.py | 5 ++--- test/bluetooth/test_bt_commands.py | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/pyshimmer/bluetooth/bt_api.py b/pyshimmer/bluetooth/bt_api.py index 429fd55..87f47ff 100644 --- a/pyshimmer/bluetooth/bt_api.py +++ b/pyshimmer/bluetooth/bt_api.py @@ -60,7 +60,6 @@ ) from pyshimmer.bluetooth.bt_serial import BluetoothSerial from pyshimmer.dev.channels import ( - ChDataTypeAssignment, ChannelDataType, EChannelType, ESensorGroup, @@ -730,7 +729,7 @@ def start_streaming(self) -> None: """Start streaming data""" ctypes = self.get_data_types() - stream_types = [(t, ChDataTypeAssignment[t]) for t in ctypes] + stream_types = [(t, self._revision.get_channel_dtype(t)) for t in ctypes] self._bluetooth.stream_types = stream_types self._process_and_wait(StartStreamingCommand(self._revision)) diff --git a/pyshimmer/reader/shimmer_reader.py b/pyshimmer/reader/shimmer_reader.py index 3fdcc32..d3dfc55 100644 --- a/pyshimmer/reader/shimmer_reader.py +++ b/pyshimmer/reader/shimmer_reader.py @@ -20,7 +20,6 @@ import numpy as np -from pyshimmer.dev.base import ticks2sec, dr2sr from pyshimmer.dev.channels import EChannelType from pyshimmer.dev.exg import is_exg_ch, get_exg_ch, ExGRegister from pyshimmer.dev.revisions import HardwareRevision @@ -214,7 +213,7 @@ def load_file_data(self): else: self._ch_samples = samples - self._ts = ticks2sec(ts_sane) + self._ts = self.hardware_revision.ticks2sec(ts_sane) def get_exg_reg(self, chip_id: int) -> ExGRegister: return self._bin_reader.get_exg_reg(chip_id) @@ -240,7 +239,7 @@ def channels(self) -> list[EChannelType]: @property def sample_rate(self) -> float: - return dr2sr(self._bin_reader.sample_rate) + return self.hardware_revision.dr2sr(self._bin_reader.sample_rate) @property def exg_reg1(self) -> ExGRegister: diff --git a/test/bluetooth/test_bt_commands.py b/test/bluetooth/test_bt_commands.py index 543d4fb..80c1fb8 100644 --- a/test/bluetooth/test_bt_commands.py +++ b/test/bluetooth/test_bt_commands.py @@ -49,7 +49,7 @@ GetAllCalibrationCommand, ) from pyshimmer.bluetooth.bt_serial import BluetoothSerial -from pyshimmer.dev.channels import ChDataTypeAssignment, EChannelType, ESensorGroup +from pyshimmer.dev.channels import EChannelType, ESensorGroup from pyshimmer.dev.fw_version import FirmwareType from pyshimmer.dev.revisions import HardwareVersion, HardwareRevision, RevisionRegistry @@ -328,7 +328,7 @@ 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] + data_types = rev.get_channel_dtypes(channels) ch_and_types = list(zip(channels, data_types)) pkt = DataPacket(rev, ch_and_types) From 7143e4aeb8ffbbf63787ace556de81e527663a6f Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 8 Feb 2026 15:21:07 +0100 Subject: [PATCH 41/43] Remove global functions and attributes in dev.base and dev.channels --- pyshimmer/dev/base.py | 77 --------- pyshimmer/dev/channels.py | 282 ------------------------------- test/dev/test_device_base.py | 51 ------ test/dev/test_device_channels.py | 82 --------- 4 files changed, 492 deletions(-) delete mode 100644 test/dev/test_device_base.py diff --git a/pyshimmer/dev/base.py b/pyshimmer/dev/base.py index d583052..9bc95c8 100644 --- a/pyshimmer/dev/base.py +++ b/pyshimmer/dev/base.py @@ -15,81 +15,4 @@ # along with this program. If not, see . from __future__ import annotations -from typing import overload - -import numpy as np - -# Device clock rate in ticks per second -DEV_CLOCK_RATE: float = 32768.0 - DEFAULT_BAUDRATE = 115200 - - -def sr2dr(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. - - Args: - sr(float): The sampling rate in Hz - - Returns: - An integer which represents the equivalent device-specific sampling rate - """ - dr_dec = DEV_CLOCK_RATE / sr - return round(dr_dec) - - -def dr2sr(dr: int): - """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. - - Args: - dr(int): The absolute device rate as int - - Returns: - A floating-point number that represents the sampling rate in Hz - """ - return DEV_CLOCK_RATE / dr - - -@overload -def sec2ticks(t_sec: float) -> int: ... - - -@overload -def sec2ticks(t_sec: np.ndarray) -> np.ndarray: ... - - -def sec2ticks(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 - """ - return round(t_sec * DEV_CLOCK_RATE) - - -@overload -def ticks2sec(t_ticks: int) -> float: ... - - -@overload -def ticks2sec(t_ticks: np.ndarray) -> np.ndarray: ... - - -def ticks2sec(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 - """ - return t_ticks / DEV_CLOCK_RATE diff --git a/pyshimmer/dev/channels.py b/pyshimmer/dev/channels.py index 877d94a..44165b2 100644 --- a/pyshimmer/dev/channels.py +++ b/pyshimmer/dev/channels.py @@ -308,285 +308,3 @@ class ESensorGroup(Enum): # 16 bit channels of the second ADS1292R chip, conflicts with the corresponding # 24 bit channel EXG2_16BIT = auto() - - -""" -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, - ESensorGroup.TEMP: 22, -} - -ENABLED_SENSORS_LEN = 0x03 -SENSOR_DTYPE = ChannelDataType(size=ENABLED_SENSORS_LEN, signed=False, le=True) - - -def get_enabled_channels(sensors: list[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. - - Args: - sensors: A list of sensors that are enabled on a Shimmer - - Returns: - A list of channels in the corresponding order - - """ - channels = [SensorChannelAssignment[e] for e in sensors] - return flatten_list(channels) - - -def get_ch_dtypes(channels: list[EChannelType]) -> list[ChannelDataType]: - """Return the channel data types for a set of channels - - Args: - channels: A list of channels - Returns: - A list of channel data types with the same order - """ - dtypes = [ChDataTypeAssignment[ch] for ch in channels] - return dtypes - - -def sensors2bitfield(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 - """ - bitfield = 0 - for sensor in sensors: - bit_pos = SensorBitAssignments[sensor] - bitfield |= bit_pos - - return bitfield - - -def serialize_sensorlist(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 - """ - bitfield = sensors2bitfield(sensors) - return SENSOR_DTYPE.encode(bitfield) - - -def bitfield2sensors(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 - """ - enabled_sensors = [] - for sensor in ESensorGroup: - bit_pos = SensorBitAssignments[sensor] - if bit_is_set(bitfield, bit_pos): - enabled_sensors += [sensor] - - return sort_sensors(enabled_sensors) - - -def deserialize_sensors(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 - """ - bitfield = SENSOR_DTYPE.decode(bitfield_bin) - return bitfield2sensors(bitfield) - - -def sort_sensors(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. - - Args: - sensors: An unsorted list of sensors - - Returns: - A list with the same sensors as content but sorted according to their appearance - order in the data file - - """ - - def sort_key_fn(x): - return SensorOrder[x] - - sensors_sorted = sorted(sensors, key=sort_key_fn) - return sensors_sorted diff --git a/test/dev/test_device_base.py b/test/dev/test_device_base.py deleted file mode 100644 index b5fbaad..0000000 --- a/test/dev/test_device_base.py +++ /dev/null @@ -1,51 +0,0 @@ -# pyshimmer - API for Shimmer sensor devices -# Copyright (C) 2023 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 __future__ import annotations - -from unittest import TestCase - -from pyshimmer.dev.base import sr2dr, dr2sr, sec2ticks, ticks2sec - - -class DeviceBaseTest(TestCase): - - def test_sr2dr(self): - r = sr2dr(1024.0) - self.assertEqual(r, 32) - - r = sr2dr(500.0) - self.assertEqual(r, 66) - - def test_dr2sr(self): - r = dr2sr(65) - self.assertEqual(round(r), 504) - - r = dr2sr(32) - self.assertEqual(r, 1024.0) - - r = dr2sr(64) - self.assertEqual(r, 512.0) - - def test_sec2ticks(self): - r = sec2ticks(1.0) - self.assertEqual(r, 32768) - - def test_ticks2sec(self): - r = ticks2sec(32768) - self.assertEqual(r, 1.0) - - r = ticks2sec(65536) - self.assertEqual(r, 2.0) diff --git a/test/dev/test_device_channels.py b/test/dev/test_device_channels.py index ebdf200..a9c0ed6 100644 --- a/test/dev/test_device_channels.py +++ b/test/dev/test_device_channels.py @@ -20,16 +20,8 @@ import pytest from pyshimmer.dev.channels import ( - ChDataTypeAssignment, - get_ch_dtypes, - SensorChannelAssignment, - SensorBitAssignments, ChannelDataType, EChannelType, - ESensorGroup, - sort_sensors, - sensors2bitfield, - bitfield2sensors, ) @@ -140,77 +132,3 @@ 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) - - self.assertEqual(len(r), 2) - first, second = r - - self.assertEqual(first.size, 2) - self.assertEqual(first.little_endian, True) - self.assertEqual(first.signed, False) - - self.assertEqual(second.size, 2) - self.assertEqual(second.little_endian, False) - self.assertEqual(second.signed, True) - - def test_datatype_assignments(self): - from pyshimmer.dev.channels import EChannelType - - for ch_type in EChannelType: - if ch_type not in ChDataTypeAssignment: - self.fail(f"No data type assigned to channel type: {ch_type}") - - def test_sensor_channel_assignments(self): - from pyshimmer.dev.channels import ESensorGroup - - for sensor in ESensorGroup: - 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(): - if s1 != s2 and SensorBitAssignments[s1] == SensorBitAssignments[s2]: - self.fail( - f"Colliding bitfield assignments for sensor {s1} and {s2}" - ) - - def test_sort_sensors(self): - sensors = [ESensorGroup.BATTERY, ESensorGroup.ACCEL_LN] - expected = [ESensorGroup.ACCEL_LN, ESensorGroup.BATTERY] - r = sort_sensors(sensors) - self.assertEqual(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 = sort_sensors(sensors) - self.assertEqual(r, expected) From be032ac93dd2a73224210ab9e090a4a0c38f003a Mon Sep 17 00:00:00 2001 From: Lukas Magel Date: Sun, 8 Feb 2026 15:27:37 +0100 Subject: [PATCH 42/43] Update the Changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85007fb..706768a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ The project uses semantic versioning. - 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 +- Introduce new hardware revision classes to encapsulate all hardware-specific + code. As a consequence, all global functions in `pyshimmer.dev.base` and + `pyshimmer.dev.channels` were removed. ## 1.0.0 - 2025-10-25 From d5d69e675e146a0e34d3959bba8fb213917865a1 Mon Sep 17 00:00:00 2001 From: JongChern Date: Thu, 19 Feb 2026 23:07:11 +0800 Subject: [PATCH 43/43] Update shimmer3r.py (#39) pressure and temp sensor format update --- pyshimmer/dev/revisions/shimmer3r.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyshimmer/dev/revisions/shimmer3r.py b/pyshimmer/dev/revisions/shimmer3r.py index 80647e1..9780c70 100644 --- a/pyshimmer/dev/revisions/shimmer3r.py +++ b/pyshimmer/dev/revisions/shimmer3r.py @@ -54,8 +54,8 @@ class Shimmer3RRevision(BaseRevision): 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.TEMPERATURE: ChannelDataType(3, signed=False, le=True), + EChannelType.PRESSURE: ChannelDataType(3, signed=False, le=True), 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),