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/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) 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/revisions/__init__.py b/pyshimmer/dev/revisions/__init__.py new file mode 100644 index 0000000..1cebed6 --- /dev/null +++ b/pyshimmer/dev/revisions/__init__.py @@ -0,0 +1,17 @@ +# 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/revisions/revision.py b/pyshimmer/dev/revisions/revision.py new file mode 100644 index 0000000..89dfc0a --- /dev/null +++ b/pyshimmer/dev/revisions/revision.py @@ -0,0 +1,266 @@ +# 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 __future__ import annotations + +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 ..channels import EChannelType, ChannelDataType, ESensorGroup +from pyshimmer.util import bit_is_set, flatten_list + + +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 + + @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 + + @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 + 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 + + +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: + 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: ... + + @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: + if len(sensors) == 0: + return 0x0 + + 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 new file mode 100644 index 0000000..3c06b9e --- /dev/null +++ b/pyshimmer/dev/revisions/shimmer3.py @@ -0,0 +1,201 @@ +# 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 __future__ import annotations + +from .revision import BaseRevision +from ..channels import EChannelType, ChannelDataType, ESensorGroup + + +class Shimmer3Revision(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=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/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 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..a155a24 --- /dev/null +++ b/test/dev/revision/test_shimmer3_revision.py @@ -0,0 +1,200 @@ +# 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 __future__ import annotations + +import itertools + +import numpy as np +import pytest + +from pyshimmer import Shimmer3Revision, EChannelType +from pyshimmer.dev.channels import ESensorGroup + + +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])) + + 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 diff --git a/test/dev/test_device_channels.py b/test/dev/test_device_channels.py index f0bea02..ebdf200 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, ) @@ -61,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): @@ -147,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 @@ -168,6 +173,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():