# SPDX-FileCopyrightText: 2025 SensorStim Neurotechnology GmbH <support@capture2go.com>
#
# SPDX-License-Identifier: MIT
# WARNING: This file is automatically generated. Do not edit manually!
import ctypes
import enum
import math
import struct
from typing import Type, TypeVar
import zlib
import numpy as np
from .utils import qmult, quatFromGyr, decodeQuat, addHeading
T_AbstractPackage = TypeVar('T_AbstractPackage', bound='AbstractPackage')
packages: dict['SensorHeader', Type['AbstractPackage']] = {}
"""
Global package registry that maps protocol header values to package classes.
This dictionary is populated by the :py:func:`register_package` decorator. It enables lookup of the
appropriate package class for a given header value when parsing protocol messages.
Key:
SensorHeader: The header value identifying the package type.
Value:
Type[AbstractPackage]: The class implementing the package structure for that header.
"""
[docs]
def register_package(cls: Type[T_AbstractPackage]) -> Type[T_AbstractPackage]:
"""
Decorator to register a package class.
This function is used as a class decorator for subclasses of AbstractPackage. It ensures that
each package class is registered in the global `packages` dictionary, mapping its header value
to the class type. This enables automatic lookup and instantiation of package classes based on
header values in the protocol.
Args:
cls (Type[T_AbstractPackage]): The package class to register. Must have a unique `header` attribute.
Returns:
Type[T_AbstractPackage]: The same class, unmodified.
Raises:
AssertionError: If the class has no header, or if the header or class is already registered.
"""
global packages
assert cls.header is not None
assert cls.header not in packages
assert cls not in packages.values()
packages[cls.header] = cls
return cls
[docs]
class AbstractPackage(ctypes.Structure):
"""
Base class for all protocol package structures.
This class provides the common interface and serialization logic for all protocol packages.
Subclasses should define the appropriate fields and set the `header` attribute to a unique value.
"""
header: 'SensorHeader | None' = None
"""
The protocol header value associated with this package type.
This should be set to a unique value for each subclass, or None for abstract base classes.
"""
_pack_ = 1
_layout_ = 'ms'
_fields_ = []
[docs]
@classmethod
def frombytes(cls, val: bytes):
"""
Create a package instance from a bytes object.
Args:
val (bytes): The raw bytes to parse into a package instance.
Returns:
AbstractPackage: An instance of the package class with fields populated from the bytes.
"""
return cls.from_buffer_copy(val)
[docs]
def pack(self, header: 'SensorHeader | None' = None):
"""
Serialize the package to bytes, including header and CRC.
Args:
header (SensorHeader | None, optional): The header value to use. If None, uses self.header.
Returns:
bytes: The serialized package as a byte string, ready for transmission.
"""
if header is None:
header = self.header
data = struct.pack('<H', header) + bytes(self)
crc = zlib.crc32(data)
return struct.pack('<BIB', 2, crc, len(data) - 2) + data
[docs]
def parse(self):
"""
Convert the package fields to a dictionary.
Subclasses may override this method to return data as numpy arrays with approprate shapes and apply useful
processing like converting from fixed point numbers to floats in physical units.
Returns:
dict: A dictionary mapping field names to their values.
"""
return {f: getattr(self, f) for f, _ in self._fields_} # type: ignore
def __repr__(self):
return f'{self.__class__.__name__}({self.parse()})'
MAX_PAYLOAD_SIZE = 236
MAX_PKG_SIZE = 244
GYR_SCALE_FACTOR = np.deg2rad(2000.0) / 32768.0
ACC_SCALE_FACTOR = 16.0 / 32768.0 * 9.81
MAG_SCALE_FACTOR = 1.0 / 16.0
DELTA_SCALE_FACTOR = math.pi / 32768.0
GYR_BIAS_SCALE_FACTOR = np.deg2rad(2.0) / 32768.0
# IMU data error flags (member errorFlags)
ERROR_FLAG_TIME_GAP = 0x01
ERROR_FLAG_GYR_CLIPPING = 0x02
ERROR_FLAG_ACC_CLIPPING = 0x04
ERROR_FLAG_MAG_CLIPPING = 0x08
ERROR_FLAG_PROCESSING_ISSUE = 0x10
# classes for commands that do not have a payload (i.e., there is no corresponding struct):
@register_package
class CmdGetDeviceInfo(AbstractPackage):
header = SensorHeader.CMD_GET_DEVICE_INFO
"""SensorHeader.CMD_GET_DEVICE_INFO (0x0070)"""
@register_package
class CmdSleep(AbstractPackage):
header = SensorHeader.CMD_SLEEP
"""SensorHeader.CMD_SLEEP (0x0110)"""
@register_package
class AckSleep(AbstractPackage):
header = SensorHeader.ACK_SLEEP
"""SensorHeader.ACK_SLEEP (0x0111)"""
@register_package
class CmdDeepSleep(AbstractPackage):
header = SensorHeader.CMD_DEEP_SLEEP
"""SensorHeader.CMD_DEEP_SLEEP (0x0112)"""
@register_package
class AckDeepSleep(AbstractPackage):
header = SensorHeader.ACK_DEEP_SLEEP
"""SensorHeader.ACK_DEEP_SLEEP (0x0113)"""
@register_package
class CmdGetMeasurementMode(AbstractPackage):
header = SensorHeader.CMD_GET_MEASUREMENT_MODE
"""SensorHeader.CMD_GET_MEASUREMENT_MODE (0x0121)"""
@register_package
class CmdGetMeasurementBurstMode(AbstractPackage):
header = SensorHeader.CMD_GET_MEASUREMENT_BURST_MODE
"""SensorHeader.CMD_GET_MEASUREMENT_BURST_MODE (0x0124)"""
@register_package
class CmdGetRecordingConfig(AbstractPackage):
header = SensorHeader.CMD_GET_RECORDING_CONFIG
"""SensorHeader.CMD_GET_RECORDING_CONFIG (0x0141)"""
@register_package
class CmdStartStreaming(AbstractPackage):
header = SensorHeader.CMD_START_STREAMING
"""SensorHeader.CMD_START_STREAMING (0x0150)"""
@register_package
class AckStartStreaming(AbstractPackage):
header = SensorHeader.ACK_START_STREAMING
"""SensorHeader.ACK_START_STREAMING (0x0151)"""
@register_package
class CmdStopStreaming(AbstractPackage):
header = SensorHeader.CMD_STOP_STREAMING
"""SensorHeader.CMD_STOP_STREAMING (0x0152)"""
@register_package
class AckStopStreaming(AbstractPackage):
header = SensorHeader.ACK_STOP_STREAMING
"""SensorHeader.ACK_STOP_STREAMING (0x0153)"""
@register_package
class CmdStartRecording(AbstractPackage):
header = SensorHeader.CMD_START_RECORDING
"""SensorHeader.CMD_START_RECORDING (0x0154)"""
@register_package
class AckStartRecording(AbstractPackage):
header = SensorHeader.ACK_START_RECORDING
"""SensorHeader.ACK_START_RECORDING (0x0155)"""
@register_package
class CmdStopRecording(AbstractPackage):
header = SensorHeader.CMD_STOP_RECORDING
"""SensorHeader.CMD_STOP_RECORDING (0x0156)"""
@register_package
class AckStopRecording(AbstractPackage):
header = SensorHeader.ACK_STOP_RECORDING
"""SensorHeader.ACK_STOP_RECORDING (0x0157)"""
@register_package
class CmdStopStreamingAndClearBuffer(AbstractPackage):
header = SensorHeader.CMD_STOP_STREAMING_AND_CLEAR_BUFFER
"""SensorHeader.CMD_STOP_STREAMING_AND_CLEAR_BUFFER (0x0158)"""
@register_package
class AckStopStreamingAndClearBuffer(AbstractPackage):
header = SensorHeader.ACK_STOP_STREAMING_AND_CLEAR_BUFFER
"""SensorHeader.ACK_STOP_STREAMING_AND_CLEAR_BUFFER (0x0159)"""
@register_package
class CmdGetRealTimeStreamingMode(AbstractPackage):
header = SensorHeader.CMD_GET_REAL_TIME_STREAMING_MODE
"""SensorHeader.CMD_GET_REAL_TIME_STREAMING_MODE (0x0161)"""
@register_package
class CmdStopRealTimeStreaming(AbstractPackage):
header = SensorHeader.CMD_STOP_REAL_TIME_STREAMING
"""SensorHeader.CMD_STOP_REAL_TIME_STREAMING (0x0163)"""
@register_package
class AckStopRealTimeStreaming(AbstractPackage):
header = SensorHeader.ACK_STOP_REAL_TIME_STREAMING
"""SensorHeader.ACK_STOP_REAL_TIME_STREAMING (0x0164)"""
@register_package
class CmdGetLedConfig(AbstractPackage):
header = SensorHeader.CMD_GET_LED_CONFIG
"""SensorHeader.CMD_GET_LED_CONFIG (0x0181)"""
@register_package
class CmdGetLedMode(AbstractPackage):
header = SensorHeader.CMD_GET_LED_MODE
"""SensorHeader.CMD_GET_LED_MODE (0x0184)"""
@register_package
class CmdGetStatus(AbstractPackage):
header = SensorHeader.CMD_GET_STATUS
"""SensorHeader.CMD_GET_STATUS (0x0200)"""
@register_package
class CmdFsListFiles(AbstractPackage):
header = SensorHeader.CMD_FS_LIST_FILES
"""SensorHeader.CMD_FS_LIST_FILES (0x0500)"""
@register_package
class CmdFsStopGetBytes(AbstractPackage):
header = SensorHeader.CMD_FS_STOP_GET_BYTES
"""SensorHeader.CMD_FS_STOP_GET_BYTES (0x0505)"""
@register_package
class AckFsStopGetBytes(AbstractPackage):
header = SensorHeader.ACK_FS_STOP_GET_BYTES
"""SensorHeader.ACK_FS_STOP_GET_BYTES (0x0506)"""
@register_package
class CmdFsFormatFilesystem(AbstractPackage):
header = SensorHeader.CMD_FS_FORMAT_FILESYSTEM
"""SensorHeader.CMD_FS_FORMAT_FILESYSTEM (0x050D)"""
@register_package
class AckFsFormatFilesystem(AbstractPackage):
header = SensorHeader.ACK_FS_FORMAT_FILESYSTEM
"""SensorHeader.ACK_FS_FORMAT_FILESYSTEM (0x050E)"""
# remaining content of package.hpp:
[docs]
@enum.unique
class ErrorCode(enum.IntEnum):
NO_ERROR = 0x00
"""No error occurred."""
FILE_NOT_FOUND = 0xF0
"""File was not found."""
FILE_DELETION_FAILED = 0xF1
"""File deletion failed."""
FILE_SYSTEM_ERROR = 0xF2
"""File system error occurred."""
FILE_ALREADY_EXISTS = 0xF3
"""File already exists."""
FILE_TOO_SHORT = 0xF4
"""File is too short."""
FILE_NAME_INVALID = 0xF5
"""File name is invalid."""
FILE_SYSTEM_FULL = 0xF6
"""File system is full."""
FILE_SYSTEM_BUSY = 0xF7
"""File system is busy."""
RECORDING_CONFIG_NOT_SET = 0xF9
"""Recording configuration is not set."""
CALIB_PARAM_FLASH_ERROR = 0xFA
"""Error when flashing calibration parameters."""
WRONG_STATE = 0xFB
"""Device is in the wrong state."""
PKG_ERROR = 0xFC
"""Could not parse received package."""
UNKNOWN_COMMAND = 0xFD
"""Unknown command received."""
SEND_BUFFER_FULL = 0xFE
"""Send buffer is full."""
UNKNOWN_ERROR = 0xFF
"""An unknown error occurred."""
[docs]
@enum.unique
class SensorState(enum.IntEnum):
OFF = 0
"""Sensor is powered off."""
IDLE = enum.auto()
"""Sensor is idle."""
STREAMING = enum.auto()
"""Sensor is streaming data."""
RECORDING = enum.auto()
"""Sensor is recording data."""
[docs]
@enum.unique
class ConnectionState(enum.IntEnum):
OFFLINE = 0
"""Device is offline."""
ADVERTISING = enum.auto()
"""Device is advertising via BLE."""
BLE_CONNECTED = enum.auto()
"""Device is connected via BLE."""
USB_CONNECTED = enum.auto()
"""Device is connected via USB."""
[docs]
@enum.unique
class SamplingMode(enum.IntEnum):
MODE_DISABLED = 0x00
"""Disabled."""
MODE_200HZ = 0x01
"""Sampling at 200 Hz."""
MODE_100HZ = 0x02
"""Sampling at 100 Hz."""
MODE_50HZ = 0x03
"""Sampling at 50 Hz."""
MODE_25HZ = 0x04
"""Sampling at 25 Hz."""
MODE_10HZ = 0x05
"""Sampling at 10 Hz."""
MODE_1HZ = 0x06
"""Sampling at 1 Hz."""
[docs]
@enum.unique
class SyncMode(enum.IntEnum):
NO_SYNC = 0x00
"""Synchronization is disabled."""
SYNC_SENDER = 0x01
"""Device is a sync sender."""
SYNC_RECEIVER = 0x02
"""Device is a sync receiver."""
[docs]
@enum.unique
class ProcessExtensionMode(enum.IntEnum):
NO_EXTENSION = 0x0000
"""No processing extension."""
_RESERVED01 = 0x0101
[docs]
@enum.unique
class CalibrationDataMode(enum.IntEnum):
CALIB_DATA_DISABLED = 0x00
"""Calibration data is disabled."""
CALIB_DATA_FULL = 0x01
"""Full calibration data is generated."""
CALIB_DATA_MAG = 0x02
"""Magnetometer calibration data is generated."""
[docs]
@enum.unique
class RealTimeDataMode(enum.IntEnum):
REAL_TIME_DATA_DISABLED = 0x00
"""Real-time data is disabled."""
REAL_TIME_DATA_QUAT = 0x01
"""Send ``DataQuatFixedRt`` packages via the real-time channel."""
REAL_TIME_DATA_FULL = 0x02
"""Send ``DataFullFixedRt`` packages via the real-time channel."""
[docs]
@register_package
class DataDeviceInfo(AbstractPackage):
"""
Device information such as protocol version, serial, hardware and firmware details.
**Fields**:
* **protocolVersion**: *uint16* -- Protocol version, currently 1. Values >= 32768 (0x8000) are reserved for
custom applications.
* **serial**: *char[6]* -- Device serial.
* **hardwareRevision**: *char[8]* -- Hardware revision string.
* **firmwareRevision**: *char[8]* -- Firmware revision string.
* **firmwareVersion**: *char[12]* -- Firmware version string.
* **firmwareDate**: *char[11]* -- Firmware build date.
2 + 6 + 8 + 8 + 12 + 11 = 47 bytes
"""
header = SensorHeader.DATA_DEVICE_INFO
"""SensorHeader.DATA_DEVICE_INFO (0x0071)"""
_fields_ = [
('protocolVersion', ctypes.c_uint16),
('serial', ctypes.c_char * 6),
('hardwareRevision', ctypes.c_char * 8),
('firmwareRevision', ctypes.c_char * 8),
('firmwareVersion', ctypes.c_char * 12),
('firmwareDate', ctypes.c_char * 11),
]
def parse(self):
return {f: self.protocolVersion if f == 'protocolVersion'
else getattr(self, f).decode().strip() for f, _ in self._fields_} # type: ignore
[docs]
@register_package
class DataMeasurementMode(AbstractPackage):
"""
Configures the IMU measurement mode.
The measurement mode determines which sensor data packages are generated, how synchronization is configured, and if
bias estimation and magnetic disturbance rejection are enabled.
The ``syncId`` should be set to a random 64 bit number that is re-generated whenever the set of employed sensors
change. One device should be configured as sender and the other devices as receivers with the same ``syncId``.
Note: The ``processExtensionMode`` is reserved for future use. Set to ``NO_EXTENSION`` (0) for now.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds) when the mode is first applied. Set to 0 in
``CmdSetMeasurementMode`` to apply immediately or use a later timestamp to delay applying the new mode.
* **fullFloat200HzEnabled**: *bool* -- Enables full float packages at 200 Hz.
* **fullFixedMode**: *SamplingMode, uint8* -- Sampling mode for full fixed packages.
* **fullPackedMode**: *SamplingMode, uint8* -- Sampling mode for full packed packaged.
* **quatFloatMode**: *SamplingMode, uint8* -- Sampling mode for orientation float packages.
* **quatFixedMode**: *SamplingMode, uint8* -- Sampling mode for orientation fixed packages.
* **quatPackedMode**: *SamplingMode, uint8* -- Sampling mode for orientation packed packages.
* **statusMode**: *uint8* -- Interval of status packages in seconds, 0 to disable. Recommended: 1 s.
* **calibDataMode**: *CalibrationDataMode, uint8* -- Calibration data mode. For internal use only; set to 0.
* **processExtensionMode**: *ProcessExtensionMode, uint16* -- Process extension mode.
* **syncMode**: *SyncMode, uint8* -- Synchronization mode.
* **syncId**: *uint64* -- Synchronization ID.
* **disableBiasEstimation**: *bool* -- If true, rest detection and online bias estimation are not performed.
* **disableMagDistRejection**: *bool* -- If true, magnetic disturbance rejection is not performed.
* **disableMagData**: *bool* -- If true, the full packed/fixed modes will generate the 6D variants without
magnetometer data.
8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 2 + 1 + 8 + 1 + 1 + 1 = 30 bytes
"""
header = SensorHeader.DATA_MEASUREMENT_MODE
"""SensorHeader.DATA_MEASUREMENT_MODE (0x0122)"""
_fields_ = [
('timestamp', ctypes.c_int64),
('fullFloat200HzEnabled', ctypes.c_bool),
('fullFixedMode', ctypes.c_uint8),
('fullPackedMode', ctypes.c_uint8),
('quatFloatMode', ctypes.c_uint8),
('quatFixedMode', ctypes.c_uint8),
('quatPackedMode', ctypes.c_uint8),
('statusMode', ctypes.c_uint8),
('calibDataMode', ctypes.c_uint8),
('processExtensionMode', ctypes.c_uint16),
('syncMode', ctypes.c_uint8),
('syncId', ctypes.c_uint64),
('disableBiasEstimation', ctypes.c_bool),
('disableMagDistRejection', ctypes.c_bool),
('disableMagData', ctypes.c_bool),
]
@register_package
class CmdSetMeasurementMode(DataMeasurementMode):
header = SensorHeader.CMD_SET_MEASUREMENT_MODE
"""SensorHeader.CMD_SET_MEASUREMENT_MODE (0x0120)"""
[docs]
@register_package
class DataMeasurementBurstMode(AbstractPackage):
"""
Configures the burst mode for short-term high-frequency measurements.
The burst mode is meant for recording high-frequency IMU data at ~1666 Hz for a short period of time (around a
second). The burst time window can be configured in advance. The sensor will store the last received
DataMeasurementBurstMode and start the burst measurement as soon as the configured startTimestamp is reached.
Note: Longer burst measurements might be possible, depending on the device connection and configured measurement
mode, but this cannot be guaranteed.
**Fields**:
* **enabled**: *bool* -- Indicates if burst mode is enabled.
* **startTimestamp**: *int64* -- Start timestamp (in nanoseconds) for burst mode.
* **endTimestamp**: *int64* -- End timestamp (in nanoseconds) for burst mode. Set to 0 to disable, set to
0x8000000000000000 (max int64) for continuous measurement.
* **endTimestampIsRelative**: *bool* -- Indicates if end timestamp is relative to start.
* **accZOnly**: *bool* -- If true, only the z-axis accelerometer data is saved.
1 + 8 + 8 + 1 + 1 = 19 bytes
"""
header = SensorHeader.DATA_MEASUREMENT_BURST_MODE
"""SensorHeader.DATA_MEASUREMENT_BURST_MODE (0x0125)"""
_fields_ = [
('enabled', ctypes.c_bool),
('startTimestamp', ctypes.c_int64),
('endTimestamp', ctypes.c_int64),
('endTimestampIsRelative', ctypes.c_bool),
('accZOnly', ctypes.c_bool),
]
@register_package
class CmdSetMeasurementBurstMode(DataMeasurementBurstMode):
header = SensorHeader.CMD_SET_MEASUREMENT_BURST_MODE
"""SensorHeader.CMD_SET_MEASUREMENT_BURST_MODE (0x0123)"""
[docs]
@register_package
class DataRecordingConfig(AbstractPackage):
"""
Configures file name and automatic end for data recording to internal storage.
**Fields**:
* **endTimestamp**: *int64* -- End timestamp (in nanoseconds) for recording. 0 for open-end recording.
* **endTimestampIsRelative**: *bool* -- If true, end timestamp is relative.
* **filename**: *char[65]* -- File name for recording. Maximum file name length is 64 characters, the rest
needs to be filled with zero bytes.
8 + 1 + 65 = 74 bytes
"""
header = SensorHeader.DATA_RECORDING_CONFIG
"""SensorHeader.DATA_RECORDING_CONFIG (0x0142)"""
_fields_ = [
('endTimestamp', ctypes.c_int64),
('endTimestampIsRelative', ctypes.c_bool),
('filename', ctypes.c_char * 65),
]
@register_package
class CmdSetRecordingConfig(DataRecordingConfig):
header = SensorHeader.CMD_SET_RECORDING_CONFIG
"""SensorHeader.CMD_SET_RECORDING_CONFIG (0x0140)"""
[docs]
@register_package
class DataRealTimeStreamingMode(AbstractPackage):
"""
Configures real-time data streaming.
**Fields**:
* **mode**: *RealTimeDataMode, uint8* -- Real-time data mode.
* **rateLimit**: *uint8* -- Maximum sending frequency in Hz, set to 0 to use the default value (currently 50
Hz).
1 + 1 = 2 bytes
"""
header = SensorHeader.DATA_REAL_TIME_STREAMING_MODE
"""SensorHeader.DATA_REAL_TIME_STREAMING_MODE (0x0162)"""
_fields_ = [
('mode', ctypes.c_uint8),
('rateLimit', ctypes.c_uint8),
]
@register_package
class CmdStartRealTimeStreaming(DataRealTimeStreamingMode):
header = SensorHeader.CMD_START_REAL_TIME_STREAMING
"""SensorHeader.CMD_START_REAL_TIME_STREAMING (0x0160)"""
[docs]
@register_package
class DataAbsoluteTime(AbstractPackage):
"""
Absolute time to be applied to the sensor clock.
Note: The acknowlegement to ``CmdSetAbsoluteTime`` is a ``DataAbsoluteTime`` package with the same timestamp.
Otherwise, ``DataAbsoluteTime`` is not used. To get the current time on the sensor, look at the timestamps in the
``DataStatus`` or ``DataClockRoundtrip`` packages.
**Fields**:
* **newTimestamp**: *int64* -- New absolute timestamp (in nanoseconds).
8 bytes
"""
header = SensorHeader.DATA_ABSOLUTE_TIME
"""SensorHeader.DATA_ABSOLUTE_TIME (0x0171)"""
_fields_ = [
('newTimestamp', ctypes.c_int64),
]
@register_package
class CmdSetAbsoluteTime(DataAbsoluteTime):
header = SensorHeader.CMD_SET_ABSOLUTE_TIME
"""SensorHeader.CMD_SET_ABSOLUTE_TIME (0x0170)"""
[docs]
@register_package
class DataClockRoundtrip(AbstractPackage):
"""
Clock roundtrip package for estimation of clock drift between host and sensors.
To estimate clock drift between the host and the sensors, send this package regularly to the sensor (e.g., every
second). When sending, set ``hostSendTimestamp`` to the current host time and the other 3 timestamps to zero.
The sensor will set ``sensorReceiveTimestamp`` directly after receiving the package and ``sensorSendTimestamp``
directly when sending the package.
The host should then set ``hostReceiveTimestamp`` directly after receiving the package. (The Python SDK does this
automatically.)
Calculate ``(hostReceiveTimestamp + sensorReceiveTimestamp - hostSendTimestamp - sensorSendTimestamp)/2`` to
estimate the transmission delay and ``(hostSendTimestamp + hostReceiveTimestamp - sensorReceiveTimestamp -
sensorSendTimestamp)/2`` to estimate the host clock offset. Note that single samples will jitter significantly and
outliers can occurr due to transmission issues.
**Fields**:
* **hostSendTimestamp**: *int64* -- Timestamp (in nanoseconds) when host sent the message.
* **sensorReceiveTimestamp**: *int64* -- Timestamp (in nanoseconds) when sensor received the message.
* **sensorSendTimestamp**: *int64* -- Timestamp (in nanoseconds) when sensor sent the response.
* **hostReceiveTimestamp**: *int64* -- Timestamp (in nanoseconds) when host received the response.
8 + 8 + 8 + 8 = 32 bytes
"""
header = SensorHeader.DATA_CLOCK_ROUNDTRIP
"""SensorHeader.DATA_CLOCK_ROUNDTRIP (0x0172)"""
_fields_ = [
('hostSendTimestamp', ctypes.c_int64),
('sensorReceiveTimestamp', ctypes.c_int64),
('sensorSendTimestamp', ctypes.c_int64),
('hostReceiveTimestamp', ctypes.c_int64),
]
[docs]
@register_package
class DataLedConfig(AbstractPackage):
"""
Configures the device LED.
**Fields**:
* **brightnessPercentage**: *uint8* -- LED brightness percentage (default: 40).
* **alternativeColors**: *bool* -- If true, alternative LED colors are used (blue instead of green).
* **notifyColor**: *uint32* -- Notification color RGB value (default: white, 0xFFFFFF).
1 + 1 + 4 = 6 bytes
"""
header = SensorHeader.DATA_LED_CONFIG
"""SensorHeader.DATA_LED_CONFIG (0x0182)"""
_fields_ = [
('brightnessPercentage', ctypes.c_uint8),
('alternativeColors', ctypes.c_bool),
('notifyColor', ctypes.c_uint32),
]
@register_package
class CmdSetLedConfig(DataLedConfig):
header = SensorHeader.CMD_SET_LED_CONFIG
"""SensorHeader.CMD_SET_LED_CONFIG (0x0180)"""
[docs]
@register_package
class DataLedMode(AbstractPackage):
"""
Configures the LED notification light (typically white unless changed in ``DataLedConfig``).
This is used by the app to flash the LED for one second at the start and end of the measurement.
Note: The ``DataLedMode`` sent as a response to ``CmdSetLedMode`` will always contain the actual and absolute
timestamps. Those timestamps can be used for example for synchronization of IMU data and a video that recorded the
flashing LED.
**Fields**:
* **notifyStartTimestamp**: *int64* -- Start timestamp (in nanoseconds) for LED notification. Set to 0 for
immediate start (if end timestamp is not 0), set both to 0 to disable.
* **notifyEndTimestamp**: *int64* -- End timestamp (in nanoseconds) for LED notification. Set to 0 for
unlimited duration (if end timestamp is not 0), set both to 0 to disable.
* **endTimestampIsRelative**: *bool* -- If true, end timestamp is relative to the start.
8 + 8 + 1 = 17 bytes
"""
header = SensorHeader.DATA_LED_MODE
"""SensorHeader.DATA_LED_MODE (0x0185)"""
_fields_ = [
('notifyStartTimestamp', ctypes.c_int64),
('notifyEndTimestamp', ctypes.c_int64),
('endTimestampIsRelative', ctypes.c_bool),
]
@register_package
class CmdSetLedMode(DataLedMode):
header = SensorHeader.CMD_SET_LED_MODE
"""SensorHeader.CMD_SET_LED_MODE (0x0183)"""
[docs]
@register_package
class DataSyncOutputMode(AbstractPackage):
"""
Synchronization output mode configuration.
Setting this pulls the USB-C sideband use pin (SBU) down for the configured duration. The resulting electrical
signal can be used for precise time synchronization between the sensors and other systems or to automatically start
or stop other measurement systems.
The response to ``CmdSetSyncOutputMode`` is sent after the pulse is finished and includes precise and absolute
timestamps.
**Fields**:
* **startTimestamp**: *int64* -- Start timestamp (in nanoseconds) for sync output. Set to 0 for immediate
start (if end timestamp is not 0), set both to 0 to disable.
* **endTimestamp**: *int64* -- End timestamp (in nanoseconds) for sync output. Set to 0 for unlimited duration
(if end timestamp is not 0), set both to 0 to disable (max pulse duration 1 sec).
* **endTimestampIsRelative**: *bool* -- If true, end timestamp is relative to the start.
8 + 8 + 1 = 17 bytes
"""
header = SensorHeader.DATA_SYNC_OUTPUT_MODE
"""SensorHeader.DATA_SYNC_OUTPUT_MODE (0x0187)"""
_fields_ = [
('startTimestamp', ctypes.c_int64),
('endTimestamp', ctypes.c_int64),
('endTimestampIsRelative', ctypes.c_bool),
]
@register_package
class CmdSetSyncOutputMode(DataSyncOutputMode):
header = SensorHeader.CMD_SET_SYNC_OUTPUT_MODE
"""SensorHeader.CMD_SET_SYNC_OUTPUT_MODE (0x0186)"""
[docs]
@register_package
class DataStatus(AbstractPackage):
"""
Device status information.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds).
* **sensorState**: *SensorState, uint8* -- Current sensor state.
* **connectionState**: *ConnectionState, uint8* -- Current connection state.
* **gyrBias**: *int16[3]* -- Gyroscope bias estimate, fixed-point, 2°/s full range. The gyroscope measurements
already have this bias removed.
* **synchronized**: *bool* -- If true, device is either a sync sender or it is a sync receiver and received
sufficient sync information recently.
* **battery**: *uint8* -- Battery level in percent. When charging, 128 is added to the percentage.
* **freeStoragePercentage**: *uint8* -- Free storage percentage.
8 + 1 + 1 + 3*2 + 1 + 1 + 1 = 19 bytes
"""
header = SensorHeader.DATA_STATUS
"""SensorHeader.DATA_STATUS (0x0201)"""
_fields_ = [
('timestamp', ctypes.c_int64),
('sensorState', ctypes.c_uint8),
('connectionState', ctypes.c_uint8),
('gyrBias', ctypes.c_int16 * 3),
('synchronized', ctypes.c_bool),
('battery', ctypes.c_uint8),
('freeStoragePercentage', ctypes.c_uint8),
]
def parse(self):
vals = super().parse()
vals['gyrBias'] = np.array(self.gyrBias, float).reshape((1, 3)) * GYR_BIAS_SCALE_FACTOR
charging = bool(vals['battery'] & 0x80)
vals['batteryPercentage'] = vals.pop('battery') - 0x80 if charging else vals.pop('battery')
vals['isCharging'] = charging
return vals
[docs]
class DataFullPacked(AbstractPackage):
"""
Full IMU data (raw data and orientations), encoded as fixed-point numbers and with 8 samples per package.
This is the main package type for recording and transmitting raw data.
Note: The orientation is only provided for the first sample because the remaining values can be extrapolated from
the first orientation sample and the gyroscope measurements. See the Python SDK code of the ``parse`` method for an
implementation.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds) of the first sample.
* **gyr**: *int16[24]* -- 8 gyroscope measurements, 2000°/s full range.
* **acc**: *int16[24]* -- 8 accelerometer measurements, 16*9.81 m/s² full range.
* **mag**: *int16[24]* -- 8 magnetometer measurements, in µT/16.
* **quat**: *uint64* -- 6D orientation for first sample, including rest+magDist flags.
* **delta**: *int16* -- Heading offset for first sample. The 9D orientation can be calculated from ``quat`` and
``delta``.
* **errorFlags**: *uint8* -- Error flags.
8 + 24*2 + 24*2 + 24*2 + 8 + 2 + 1 = 163 bytes
200 Hz: 32600 bytes/s
100 Hz: 16300 bytes/s
50 Hz: 8150 bytes/s
25 Hz: 4075 bytes/s
10 Hz: 1630 bytes/s
1 Hz: 163 bytes/s
"""
_fields_ = [
('timestamp', ctypes.c_int64),
('gyr', ctypes.c_int16 * 24),
('acc', ctypes.c_int16 * 24),
('mag', ctypes.c_int16 * 24),
('quat', ctypes.c_uint64),
('delta', ctypes.c_int16),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
timestamp = np.arange(self.timestamp, self.timestamp + int(8e9/self.rate) - 1, int(1e9/self.rate))
gyr = np.array(self.gyr, float).reshape((8, 3)) * GYR_SCALE_FACTOR
acc = np.array(self.acc, float).reshape((8, 3)) * ACC_SCALE_FACTOR
mag = np.array(self.mag, float).reshape((8, 3)) * MAG_SCALE_FACTOR
delta = np.full((8, 1), self.delta * DELTA_SCALE_FACTOR, float)
decoded = decodeQuat(self.quat)
gyrquat = quatFromGyr(gyr, self.rate) # Axis-angle quat from the gyroscope measurements.
quat = np.empty((8, 4))
quat9D = np.empty((8, 4))
quat[0] = decoded[0]
quat9D[0] = addHeading(decoded[0], delta[0, 0])
for i in range(7): # Perform gyroscope prediction for the missing quaternion samples.
quat[i+1] = qmult(quat[i], gyrquat[i+1])
quat9D[i+1] = addHeading(quat[i+1], delta[i+1, 0])
restDetected = np.full((8, 1), decoded[1], bool)
magDistDetected = np.full((8, 1), decoded[2], bool)
return {
'timestamp': timestamp.reshape(8, 1),
'gyr': gyr,
'acc': acc,
'mag': mag,
'quat': quat,
'quat9D': quat9D,
'delta': delta,
'restDetected': restDetected,
'magDistDetected': magDistDetected,
'errorFlags': np.full((8, 1), self.errorFlags, np.uint8),
}
def __repr__(self):
vals = self.parse()
s = [f'{self.__class__.__name__}(timestamp={self.timestamp:_}, data:\n',
'gyr | acc | mag | '
'quat | delta | rest dist | errorFlags']
for i in range(8):
s.append(f'\n{vals["gyr"][i, 0]: 8.4f} {vals["gyr"][i, 1]: 8.4f} {vals["gyr"][i, 2]: 8.4f} | '
f'{vals["acc"][i, 0]: 9.4f} {vals["acc"][i, 1]: 9.4f} {vals["acc"][i, 2]: 9.4f} | '
f'{vals["mag"][i, 0]: 9.4f} {vals["mag"][i, 1]: 9.4f} {vals["mag"][i, 2]: 9.4f} | '
f'{vals["quat"][i, 0]: 7.4f} {vals["quat"][i, 1]: 7.4f} {vals["quat"][i, 2]: 7.4f} '
f'{vals["quat"][i, 3]: 7.4f} | {vals["delta"][i, 0]: 7.4f} | '
f'{vals["restDetected"][i, 0]: 4} {vals["magDistDetected"][i, 0]: 4} | '
f'{vals["errorFlags"][i, 0]:d}')
s.append(' )')
return ''.join(s)
@register_package
class DataFullPacked200Hz(DataFullPacked):
header = SensorHeader.DATA_FULL_PACKED_200HZ
"""SensorHeader.DATA_FULL_PACKED_200HZ (0x0221)"""
rate = 200
@register_package
class DataFullPacked100Hz(DataFullPacked):
header = SensorHeader.DATA_FULL_PACKED_100HZ
"""SensorHeader.DATA_FULL_PACKED_100HZ (0x0222)"""
rate = 100
@register_package
class DataFullPacked50Hz(DataFullPacked):
header = SensorHeader.DATA_FULL_PACKED_50HZ
"""SensorHeader.DATA_FULL_PACKED_50HZ (0x0223)"""
rate = 50
@register_package
class DataFullPacked25Hz(DataFullPacked):
header = SensorHeader.DATA_FULL_PACKED_25HZ
"""SensorHeader.DATA_FULL_PACKED_25HZ (0x0224)"""
rate = 25
@register_package
class DataFullPacked10Hz(DataFullPacked):
header = SensorHeader.DATA_FULL_PACKED_10HZ
"""SensorHeader.DATA_FULL_PACKED_10HZ (0x0225)"""
rate = 10
@register_package
class DataFullPacked1Hz(DataFullPacked):
header = SensorHeader.DATA_FULL_PACKED_1HZ
"""SensorHeader.DATA_FULL_PACKED_1HZ (0x0226)"""
rate = 1
[docs]
class DataFull6DPacked(AbstractPackage):
"""
Variant of ``DataFullPacked`` that does not include magnetometer data. See ``DataFullPacked`` for more details.
Set ``disableMagData`` to true in the measurement mode to generate this package instead of ``DataFullPacked``.
Note: Even with magnetometer data output disabled, this package still contains the full 6D and 9D orientations.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds) of the first sample.
* **gyr**: *int16[24]* -- 8 gyroscope measurements, 2000°/s full range.
* **acc**: *int16[24]* -- 8 accelerometer measurements, 16*9.81 m/s² full range.
* **quat**: *uint64* -- 6D orientation for first sample, including rest+magDist flags.
* **delta**: *int16* -- Heading offset for first sample. The 9D orientation can be calculated from ``quat`` and
``delta``.
* **errorFlags**: *uint8* -- Error flags.
8 + 24*2 + 24*2 + 8 + 2 + 1 = 115 bytes
200 Hz: 23000 bytes/s
100 Hz: 11500 bytes/s
50 Hz: 5750 bytes/s
25 Hz: 2875 bytes/s
10 Hz: 1150 bytes/s
1 Hz: 115 bytes/s
"""
_fields_ = [
('timestamp', ctypes.c_int64),
('gyr', ctypes.c_int16 * 24),
('acc', ctypes.c_int16 * 24),
('quat', ctypes.c_uint64),
('delta', ctypes.c_int16),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
timestamp = np.arange(self.timestamp, self.timestamp + int(8e9/self.rate) - 1, int(1e9/self.rate))
gyr = np.array(self.gyr, float).reshape((8, 3)) * GYR_SCALE_FACTOR
acc = np.array(self.acc, float).reshape((8, 3)) * ACC_SCALE_FACTOR
delta = np.full((8, 1), self.delta * DELTA_SCALE_FACTOR, float)
decoded = decodeQuat(self.quat)
gyrquat = quatFromGyr(gyr, self.rate) # Axis-angle quat from the gyroscope measurements.
quat = np.empty((8, 4))
quat9D = np.empty((8, 4))
quat[0] = decoded[0]
quat9D[0] = addHeading(decoded[0], delta[0, 0])
for i in range(7): # Perform gyroscope prediction for the missing quaternion samples.
quat[i+1] = qmult(quat[i], gyrquat[i+1])
quat9D[i+1] = addHeading(quat[i+1], delta[i+1, 0])
restDetected = np.full((8, 1), decoded[1], bool)
magDistDetected = np.full((8, 1), decoded[2], bool)
return {
'timestamp': timestamp.reshape(8, 1),
'gyr': gyr,
'acc': acc,
'quat': quat,
'quat9D': quat9D,
'delta': delta,
'restDetected': restDetected,
'magDistDetected': magDistDetected,
'errorFlags': np.full((8, 1), self.errorFlags, np.uint8),
}
def __repr__(self):
vals = self.parse()
s = [f'{self.__class__.__name__}(timestamp={self.timestamp:_}, data:\n',
'gyr | acc | '
'quat | delta | rest dist | errorFlags']
for i in range(8):
s.append(f'\n{vals["gyr"][i, 0]: 8.4f} {vals["gyr"][i, 1]: 8.4f} {vals["gyr"][i, 2]: 8.4f} | '
f'{vals["acc"][i, 0]: 9.4f} {vals["acc"][i, 1]: 9.4f} {vals["acc"][i, 2]: 9.4f} | '
f'{vals["quat"][i, 0]: 7.4f} {vals["quat"][i, 1]: 7.4f} {vals["quat"][i, 2]: 7.4f} '
f'{vals["quat"][i, 3]: 7.4f} | {vals["delta"][i, 0]: 7.4f} | '
f'{vals["restDetected"][i, 0]: 4} {vals["magDistDetected"][i, 0]: 4} | '
f'{vals["errorFlags"][i, 0]:d}')
s.append(' )')
return ''.join(s)
@register_package
class DataFull6DPacked200Hz(DataFull6DPacked):
header = SensorHeader.DATA_FULL_6D_PACKED_200HZ
"""SensorHeader.DATA_FULL_6D_PACKED_200HZ (0x0231)"""
rate = 200
@register_package
class DataFull6DPacked100Hz(DataFull6DPacked):
header = SensorHeader.DATA_FULL_6D_PACKED_100HZ
"""SensorHeader.DATA_FULL_6D_PACKED_100HZ (0x0232)"""
rate = 100
@register_package
class DataFull6DPacked50Hz(DataFull6DPacked):
header = SensorHeader.DATA_FULL_6D_PACKED_50HZ
"""SensorHeader.DATA_FULL_6D_PACKED_50HZ (0x0233)"""
rate = 50
@register_package
class DataFull6DPacked25Hz(DataFull6DPacked):
header = SensorHeader.DATA_FULL_6D_PACKED_25HZ
"""SensorHeader.DATA_FULL_6D_PACKED_25HZ (0x0234)"""
rate = 25
@register_package
class DataFull6DPacked10Hz(DataFull6DPacked):
header = SensorHeader.DATA_FULL_6D_PACKED_10HZ
"""SensorHeader.DATA_FULL_6D_PACKED_10HZ (0x0235)"""
rate = 10
@register_package
class DataFull6DPacked1Hz(DataFull6DPacked):
header = SensorHeader.DATA_FULL_6D_PACKED_1HZ
"""SensorHeader.DATA_FULL_6D_PACKED_1HZ (0x0236)"""
rate = 1
[docs]
class DataFullFixed(AbstractPackage):
"""
Single sample containing full IMU data (raw data and orientations), encoded as fixed-point numbers.
This can be useful for streaming or recording at low sampling rates. In most cases, ``DataFullPacked`` is the better
option.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds).
* **gyr**: *int16[3]* -- Gyroscope measurement, 2000°/s full range.
* **acc**: *int16[3]* -- Accelerometer measurement, 16*9.81 m/s² full range.
* **mag**: *int16[3]* -- Magnetometer measurement, in µT/16.
* **quat**: *uint64* -- 6D orientation, including rest+magDist flags.
* **delta**: *int16* -- Heading offset. The 9D orientation can be calculated from ``quat`` and ``delta``.
* **errorFlags**: *uint8* -- Error flags.
8 + 3*2 + 3*2 + 3*2 + 8 + 2 + 1 = 37 bytes
200 Hz: 7400 bytes/s
100 Hz: 3700 bytes/s
50 Hz: 1850 bytes/s
25 Hz: 925 bytes/s
10 Hz: 370 bytes/s
1 Hz: 37 bytes/s
"""
_fields_ = [
('timestamp', ctypes.c_int64),
('gyr', ctypes.c_int16 * 3),
('acc', ctypes.c_int16 * 3),
('mag', ctypes.c_int16 * 3),
('quat', ctypes.c_uint64),
('delta', ctypes.c_int16),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
quat, restDetected, magDistDetected = decodeQuat(self.quat)
return {
'timestamp': self.timestamp,
'gyr': np.array(self.gyr, float) * GYR_SCALE_FACTOR,
'acc': np.array(self.acc, float) * ACC_SCALE_FACTOR,
'mag': np.array(self.mag, float) * MAG_SCALE_FACTOR,
'quat': quat,
'quat9D': addHeading(quat, self.delta * DELTA_SCALE_FACTOR),
'delta': self.delta * DELTA_SCALE_FACTOR,
'restDetected': restDetected,
'magDistDetected': magDistDetected,
'errorFlags': self.errorFlags,
}
def __repr__(self):
vals = self.parse()
return f'{self.__class__.__name__}(timestamp={self.timestamp:_}, ' \
f'gyr=[{vals["gyr"][0]: 8.4f} {vals["gyr"][1]: 8.4f} {vals["gyr"][2]: 8.4f}], ' \
f'acc=[{vals["acc"][0]: 9.4f} {vals["acc"][1]: 9.4f} {vals["acc"][2]: 9.4f}], ' \
f'mag=[{vals["mag"][0]: 9.4f} {vals["mag"][1]: 9.4f} {vals["mag"][2]: 9.4f}], ' \
f'quat=[{vals["quat"][0]: 7.4f} {vals["quat"][1]: 7.4f} {vals["quat"][2]: 7.4f} ' \
f'{vals["quat"][3]: 7.4f}], delta={vals["delta"]: 6.4f}, ' \
f'rest={vals["restDetected"]:1}, dist={vals["magDistDetected"]:1}, flags={vals["errorFlags"]:d})'
@register_package
class DataFullFixed200Hz(DataFullFixed):
header = SensorHeader.DATA_FULL_FIXED_200HZ
"""SensorHeader.DATA_FULL_FIXED_200HZ (0x0241)"""
rate = 200
@register_package
class DataFullFixed100Hz(DataFullFixed):
header = SensorHeader.DATA_FULL_FIXED_100HZ
"""SensorHeader.DATA_FULL_FIXED_100HZ (0x0242)"""
rate = 100
@register_package
class DataFullFixed50Hz(DataFullFixed):
header = SensorHeader.DATA_FULL_FIXED_50HZ
"""SensorHeader.DATA_FULL_FIXED_50HZ (0x0243)"""
rate = 50
@register_package
class DataFullFixed25Hz(DataFullFixed):
header = SensorHeader.DATA_FULL_FIXED_25HZ
"""SensorHeader.DATA_FULL_FIXED_25HZ (0x0244)"""
rate = 25
@register_package
class DataFullFixed10Hz(DataFullFixed):
header = SensorHeader.DATA_FULL_FIXED_10HZ
"""SensorHeader.DATA_FULL_FIXED_10HZ (0x0245)"""
rate = 10
@register_package
class DataFullFixed1Hz(DataFullFixed):
header = SensorHeader.DATA_FULL_FIXED_1HZ
"""SensorHeader.DATA_FULL_FIXED_1HZ (0x0246)"""
rate = 1
@register_package
class DataFullFixedRt(DataFullFixed):
header = SensorHeader.DATA_FULL_FIXED_RT
"""SensorHeader.DATA_FULL_FIXED_RT (0x0247)"""
[docs]
class DataFull6DFixed(AbstractPackage):
"""
Variant of ``DataFullFixed`` that does not include magnetometer data. See ``DataFullFixed`` for more details.
Set ``disableMagData`` to true in the measurement mode to generate this package instead of ``DataFullFixed``.
Note: Even with magnetometer data output disabled, this package still contains the full 6D and 9D orientations.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds).
* **gyr**: *int16[3]* -- Gyroscope measurement, 2000°/s full range.
* **acc**: *int16[3]* -- Accelerometer measurement, 16*9.81 m/s² full range.
* **quat**: *uint64* -- 6D orientation, including rest+magDist flags.
* **delta**: *int16* -- Heading offset. The 9D orientation can be calculated from ``quat`` and ``delta``.
* **errorFlags**: *uint8* -- Error flags.
8 + 3*2 + 3*2 + 8 + 2 + 1 = 31 bytes
200 Hz: 6200 bytes/s
100 Hz: 3100 bytes/s
50 Hz: 1550 bytes/s
25 Hz: 775 bytes/s
10 Hz: 310 bytes/s
1 Hz: 31 bytes/s
"""
_fields_ = [
('timestamp', ctypes.c_int64),
('gyr', ctypes.c_int16 * 3),
('acc', ctypes.c_int16 * 3),
('quat', ctypes.c_uint64),
('delta', ctypes.c_int16),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
quat, restDetected, magDistDetected = decodeQuat(self.quat)
return {
'timestamp': self.timestamp,
'gyr': np.array(self.gyr, float) * GYR_SCALE_FACTOR,
'acc': np.array(self.acc, float) * ACC_SCALE_FACTOR,
'quat': quat,
'quat9D': addHeading(quat, self.delta * DELTA_SCALE_FACTOR),
'delta': self.delta * DELTA_SCALE_FACTOR,
'restDetected': restDetected,
'magDistDetected': magDistDetected,
'errorFlags': self.errorFlags,
}
def __repr__(self):
vals = self.parse()
return f'{self.__class__.__name__}(timestamp={self.timestamp:_}, ' \
f'gyr=[{vals["gyr"][0]: 8.4f} {vals["gyr"][1]: 8.4f} {vals["gyr"][2]: 8.4f}], ' \
f'acc=[{vals["acc"][0]: 9.4f} {vals["acc"][1]: 9.4f} {vals["acc"][2]: 9.4f}], ' \
f'quat=[{vals["quat"][0]: 7.4f} {vals["quat"][1]: 7.4f} {vals["quat"][2]: 7.4f} ' \
f'{vals["quat"][3]: 7.4f}], delta={vals["delta"]: 6.4f}, ' \
f'rest={vals["restDetected"]:1}, dist={vals["magDistDetected"]:1}, flags={vals["errorFlags"]:d})'
@register_package
class DataFull6DFixed200Hz(DataFull6DFixed):
header = SensorHeader.DATA_FULL_6D_FIXED_200HZ
"""SensorHeader.DATA_FULL_6D_FIXED_200HZ (0x0251)"""
rate = 200
@register_package
class DataFull6DFixed100Hz(DataFull6DFixed):
header = SensorHeader.DATA_FULL_6D_FIXED_100HZ
"""SensorHeader.DATA_FULL_6D_FIXED_100HZ (0x0252)"""
rate = 100
@register_package
class DataFull6DFixed50Hz(DataFull6DFixed):
header = SensorHeader.DATA_FULL_6D_FIXED_50HZ
"""SensorHeader.DATA_FULL_6D_FIXED_50HZ (0x0253)"""
rate = 50
@register_package
class DataFull6DFixed25Hz(DataFull6DFixed):
header = SensorHeader.DATA_FULL_6D_FIXED_25HZ
"""SensorHeader.DATA_FULL_6D_FIXED_25HZ (0x0254)"""
rate = 25
@register_package
class DataFull6DFixed10Hz(DataFull6DFixed):
header = SensorHeader.DATA_FULL_6D_FIXED_10HZ
"""SensorHeader.DATA_FULL_6D_FIXED_10HZ (0x0255)"""
rate = 10
@register_package
class DataFull6DFixed1Hz(DataFull6DFixed):
header = SensorHeader.DATA_FULL_6D_FIXED_1HZ
"""SensorHeader.DATA_FULL_6D_FIXED_1HZ (0x0256)"""
rate = 1
[docs]
@register_package
class DataFullFloat200Hz(AbstractPackage):
"""
Single sample containing full IMU data (raw data and orientations) at 200 Hz, encoded as 32-bit floating point
numbers.
Unlike the other data packages, this package is not packed and included padding bytes.
Note: In most applications, the more efficient ``DataFullPacked`` or ``DataFullFixed`` packages should be prefered.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds).
* **gyr**: *float[3]* -- Gyroscope measurement in rad/s.
* **acc**: *float[3]* -- Accelerometer measurement in m/s².
* **mag**: *float[3]* -- Magnetometer measurement in µT.
* **quat**: *float[4]* -- 6D orientation quaternion (w, x, y, z).
* **delta**: *float* -- Heading offset in rad. The 9D orientation can be calculated from ``quat`` and ``delta``.
* **restDetected**: *bool* -- Indicates if sensor is at rest.
* **magDistDetected**: *bool* -- Indicates if magnetic disturbance is detected.
* **errorFlags**: *uint8* -- Error flags.
8 + 3*4 + 3*4 + 3*4 + 4*4 + 4 + 1 + 1 + 1 = 67 bytes (+ padding)
"""
header = SensorHeader.DATA_FULL_FLOAT_200HZ
"""SensorHeader.DATA_FULL_FLOAT_200HZ (0x0261)"""
_pack_ = 0
_fields_ = [
('timestamp', ctypes.c_int64),
('gyr', ctypes.c_float * 3),
('acc', ctypes.c_float * 3),
('mag', ctypes.c_float * 3),
('quat', ctypes.c_float * 4),
('delta', ctypes.c_float),
('restDetected', ctypes.c_bool),
('magDistDetected', ctypes.c_bool),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
return {
'timestamp': self.timestamp,
'gyr': np.array(self.gyr, float),
'acc': np.array(self.acc, float),
'mag': np.array(self.mag, float),
'quat': np.array(self.quat, float),
'quat9D': addHeading(np.array(self.quat, float), self.delta),
'delta': self.delta,
'restDetected': self.restDetected,
'magDistDetected': self.magDistDetected,
'errorFlags': self.errorFlags,
}
def __repr__(self):
vals = self.parse()
return f'{self.__class__.__name__}(timestamp={self.timestamp:_}, ' \
f'gyr=[{vals["gyr"][0]: 8.4f} {vals["gyr"][1]: 8.4f} {vals["gyr"][2]: 8.4f}], ' \
f'acc=[{vals["acc"][0]: 9.4f} {vals["acc"][1]: 9.4f} {vals["acc"][2]: 9.4f}], ' \
f'mag=[{vals["mag"][0]: 9.4f} {vals["mag"][1]: 9.4f} {vals["mag"][2]: 9.4f}], ' \
f'quat=[{vals["quat"][0]: 7.4f} {vals["quat"][1]: 7.4f} {vals["quat"][2]: 7.4f} ' \
f'{vals["quat"][3]: 7.4f}], delta={vals["delta"]: 6.4f}, ' \
f'rest={vals["restDetected"]:1}, dist={vals["magDistDetected"]:1}, flags={vals["errorFlags"]:d})'
[docs]
class DataQuatPacked(AbstractPackage):
"""
Orientation data, encoded as fixed-point numbers and with 20 samples per package.
This is the main package type for recording and transmitting orientation data.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds) of the first sample.
* **quat**: *uint64[20]* -- 6D orientation samples, including rest+magDist flags.
* **delta**: *int16[20]* -- Heading offset. The 9D orientation can be calculated from ``quat`` and ``delta``.
* **errorFlags**: *uint8[20]* -- Error flags.
8 + 20*8 + 20*2 + 20 = 228 bytes
200 Hz: 45600 bytes/s
100 Hz: 22800 bytes/s
50 Hz: 11400 bytes/s
25 Hz: 5700 bytes/s
10 Hz: 2280 bytes/s
1 Hz: 228 bytes/s
"""
_fields_ = [
('timestamp', ctypes.c_int64),
('quat', ctypes.c_uint64 * 20),
('delta', ctypes.c_int16 * 20),
('errorFlags', ctypes.c_uint8 * 20),
]
def parse(self):
timestamp = np.arange(self.timestamp, self.timestamp+int(20e9/self.rate)-1, int(1e9/self.rate))
decoded = [decodeQuat(q) for q in self.quat]
quat = np.array([d[0] for d in decoded])
restDetected = np.array([d[1] for d in decoded])
magDistDetected = np.array([d[2] for d in decoded])
delta = np.array(self.delta, float) * DELTA_SCALE_FACTOR
quat9D = np.array([addHeading(q, d) for q, d in zip(quat, delta)])
return {
'timestamp': timestamp.reshape(20, 1),
'quat': quat,
'quat9D': quat9D,
'delta': delta.reshape(20, 1),
'restDetected': restDetected.reshape(20, 1),
'magDistDetected': magDistDetected.reshape(20, 1),
'errorFlags': np.array(self.errorFlags, np.uint8).reshape(20, 1),
}
def __repr__(self):
vals = self.parse()
data = np.column_stack([vals[k] for k in ['quat', 'delta', 'restDetected', 'magDistDetected', 'errorFlags']])
s = f'{self.__class__.__name__}(timestamp={self.timestamp}, data:\n' \
f' quat_w quat_x quat_y quat_z delta rest magDist errorFlags' \
f'\n{np.array2string(data, precision=8, sign=" ", max_line_width=1000)})'
return s
@register_package
class DataQuatPacked200Hz(DataQuatPacked):
header = SensorHeader.DATA_QUAT_PACKED_200HZ
"""SensorHeader.DATA_QUAT_PACKED_200HZ (0x0271)"""
rate = 200
@register_package
class DataQuatPacked100Hz(DataQuatPacked):
header = SensorHeader.DATA_QUAT_PACKED_100HZ
"""SensorHeader.DATA_QUAT_PACKED_100HZ (0x0272)"""
rate = 100
@register_package
class DataQuatPacked50Hz(DataQuatPacked):
header = SensorHeader.DATA_QUAT_PACKED_50HZ
"""SensorHeader.DATA_QUAT_PACKED_50HZ (0x0273)"""
rate = 50
@register_package
class DataQuatPacked25Hz(DataQuatPacked):
header = SensorHeader.DATA_QUAT_PACKED_25HZ
"""SensorHeader.DATA_QUAT_PACKED_25HZ (0x0274)"""
rate = 25
@register_package
class DataQuatPacked10Hz(DataQuatPacked):
header = SensorHeader.DATA_QUAT_PACKED_10HZ
"""SensorHeader.DATA_QUAT_PACKED_10HZ (0x0275)"""
rate = 10
@register_package
class DataQuatPacked1Hz(DataQuatPacked):
header = SensorHeader.DATA_QUAT_PACKED_1HZ
"""SensorHeader.DATA_QUAT_PACKED_1HZ (0x0276)"""
rate = 1
[docs]
class DataQuatFixed(AbstractPackage):
"""
Single orientation sample, encoded as fixed-point numbers.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds).
* **quat**: *uint64* -- 6D orientation, including rest+magDist flags.
* **delta**: *int16* -- Heading offset. The 9D orientation can be calculated from ``quat`` and ``delta``.
* **errorFlags**: *uint8* -- Error flags.
8 + 8 + 2 + 1 = 19 bytes
200 Hz: 3800 bytes/s
100 Hz: 1900 bytes/s
50 Hz: 950 bytes/s
25 Hz: 475 bytes/s
10 Hz: 190 bytes/s
1 Hz: 19 bytes/s
"""
_fields_ = [
('timestamp', ctypes.c_int64),
('quat', ctypes.c_uint64),
('delta', ctypes.c_int16),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
quat, restDetected, magDistDetected = decodeQuat(self.quat)
delta = self.delta * DELTA_SCALE_FACTOR
return {
'timestamp': self.timestamp,
'quat': quat,
'quat9D': addHeading(quat, delta),
'delta': delta,
'restDetected': restDetected,
'magDistDetected': magDistDetected,
'errorFlags': self.errorFlags,
}
@register_package
class DataQuatFixed200Hz(DataQuatFixed):
header = SensorHeader.DATA_QUAT_FIXED_200HZ
"""SensorHeader.DATA_QUAT_FIXED_200HZ (0x0281)"""
rate = 200
@register_package
class DataQuatFixed100Hz(DataQuatFixed):
header = SensorHeader.DATA_QUAT_FIXED_100HZ
"""SensorHeader.DATA_QUAT_FIXED_100HZ (0x0282)"""
rate = 100
@register_package
class DataQuatFixed50Hz(DataQuatFixed):
header = SensorHeader.DATA_QUAT_FIXED_50HZ
"""SensorHeader.DATA_QUAT_FIXED_50HZ (0x0283)"""
rate = 50
@register_package
class DataQuatFixed25Hz(DataQuatFixed):
header = SensorHeader.DATA_QUAT_FIXED_25HZ
"""SensorHeader.DATA_QUAT_FIXED_25HZ (0x0284)"""
rate = 25
@register_package
class DataQuatFixed10Hz(DataQuatFixed):
header = SensorHeader.DATA_QUAT_FIXED_10HZ
"""SensorHeader.DATA_QUAT_FIXED_10HZ (0x0285)"""
rate = 10
@register_package
class DataQuatFixed1Hz(DataQuatFixed):
header = SensorHeader.DATA_QUAT_FIXED_1HZ
"""SensorHeader.DATA_QUAT_FIXED_1HZ (0x0286)"""
rate = 1
@register_package
class DataQuatFixedRt(DataQuatFixed):
header = SensorHeader.DATA_QUAT_FIXED_RT
"""SensorHeader.DATA_QUAT_FIXED_RT (0x0287)"""
[docs]
class DataQuatFloat(AbstractPackage):
"""
Single orientation sample, encoded as 32-bit floating point numbers.
Note: In most applications, the more efficient ``QuatFullPacked`` or ``QuatFullFixed`` packages should be prefered.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds).
* **quat**: *float[4]* -- 6D orientation quaternion (w, x, y, z).
* **delta**: *float* -- Heading offset in rad. The 9D orientation can be calculated from ``quat`` and ``delta``.
* **restDetected**: *bool* -- Indicates if sensor is at rest.
* **magDistDetected**: *bool* -- Indicates if magnetic disturbance is detected.
* **errorFlags**: *uint8* -- Error flags.
8 + 4*4 + 4 + 1 + 1 + 1 = 31 bytes
200 Hz: 6200 bytes/s
100 Hz: 3100 bytes/s
50 Hz: 1550 bytes/s
25 Hz: 775 bytes/s
10 Hz: 310 bytes/s
1 Hz: 31 bytes/s
"""
_fields_ = [
('timestamp', ctypes.c_int64),
('quat', ctypes.c_float * 4),
('delta', ctypes.c_float),
('restDetected', ctypes.c_bool),
('magDistDetected', ctypes.c_bool),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
return {
'timestamp': self.timestamp,
'quat': np.array(self.quat, float),
'quat9D': addHeading(np.array(self.quat, float), self.delta),
'delta': self.delta,
'restDetected': self.restDetected,
'magDistDetected': self.magDistDetected,
'errorFlags': self.errorFlags,
}
@register_package
class DataQuatFloat200Hz(DataQuatFloat):
header = SensorHeader.DATA_QUAT_FLOAT_200HZ
"""SensorHeader.DATA_QUAT_FLOAT_200HZ (0x0291)"""
rate = 200
@register_package
class DataQuatFloat100Hz(DataQuatFloat):
header = SensorHeader.DATA_QUAT_FLOAT_100HZ
"""SensorHeader.DATA_QUAT_FLOAT_100HZ (0x0292)"""
rate = 100
@register_package
class DataQuatFloat50Hz(DataQuatFloat):
header = SensorHeader.DATA_QUAT_FLOAT_50HZ
"""SensorHeader.DATA_QUAT_FLOAT_50HZ (0x0293)"""
rate = 50
@register_package
class DataQuatFloat25Hz(DataQuatFloat):
header = SensorHeader.DATA_QUAT_FLOAT_25HZ
"""SensorHeader.DATA_QUAT_FLOAT_25HZ (0x0294)"""
rate = 25
@register_package
class DataQuatFloat10Hz(DataQuatFloat):
header = SensorHeader.DATA_QUAT_FLOAT_10HZ
"""SensorHeader.DATA_QUAT_FLOAT_10HZ (0x0295)"""
rate = 10
@register_package
class DataQuatFloat1Hz(DataQuatFloat):
header = SensorHeader.DATA_QUAT_FLOAT_1HZ
"""SensorHeader.DATA_QUAT_FLOAT_1HZ (0x0296)"""
rate = 1
[docs]
@register_package
class DataRawBurst(AbstractPackage):
"""
Raw data at ~1666 Hz of short high-frequency burst measurements.
Each package contains 16 samples, i.e., packages are generated at ~104 Hz.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds) of the first sample.
* **gyr**: *int16[48]* -- 16 gyroscope measurements.
* **acc**: *int16[48]* -- 16 accelerometer measurements.
* **mag**: *int16[3]* -- Magnetometer measurement for first sample.
* **errorFlags**: *uint8* -- Error flags.
8 + 48*2 + 48*2 + 3*2 + 1 = 207 bytes
"""
header = SensorHeader.DATA_RAW_BURST
"""SensorHeader.DATA_RAW_BURST (0x0300)"""
_fields_ = [
('timestamp', ctypes.c_int64),
('gyr', ctypes.c_int16 * 48),
('acc', ctypes.c_int16 * 48),
('mag', ctypes.c_int16 * 3),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
timestamp = np.full((16, 1), np.nan, int)
timestamp[0] = self.timestamp
mag = np.array(self.mag, float).reshape(1, 3) * MAG_SCALE_FACTOR
mag = np.repeat(mag, 16, axis=0)
return {
'timestamp': timestamp,
'gyr': np.array(self.gyr, float).reshape(16, 3) * GYR_SCALE_FACTOR,
'acc': np.array(self.acc, float).reshape(16, 3) * ACC_SCALE_FACTOR,
'mag': mag,
'errorFlags': np.full((16, 1), self.errorFlags, np.uint8),
}
def __repr__(self):
vals = self.parse()
s = [f'{self.__class__.__name__}(timestamp={self.timestamp:_}, mag={vals["mag"][0]}, '
f'errorFlags={self.errorFlags}, data:\n',
'gyr | acc']
for i in range(16):
s.append(f'\n{vals["gyr"][i, 0]: 8.4f} {vals["gyr"][i, 1]: 8.4f} {vals["gyr"][i, 2]: 8.4f} | '
f'{vals["acc"][i, 0]: 9.4f} {vals["acc"][i, 1]: 9.4f} {vals["acc"][i, 2]: 9.4f}')
s.append(' )')
return ''.join(s)
[docs]
@register_package
class DataAccZBurst(AbstractPackage):
"""
Z-component of the accelerometer measurement at ~1666 Hz of short high-frequency burst measurements.
Compared to the full burst mode, choosing this package type reduces the data rate but still allows for measurement
of high-frequency vibrations orthogonal to the device surface.
Each package contains 64 samples, i.e., packages are generated at ~26 Hz.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds) of the first sample.
* **accZ**: *int16[64]* -- 64 z-axis accelerometer measurements.
* **errorFlags**: *uint8* -- Error flags.
8 + 64*2 + 1 = 137 bytes
"""
header = SensorHeader.DATA_ACCZ_BURST
"""SensorHeader.DATA_ACCZ_BURST (0x0301)"""
_fields_ = [
('timestamp', ctypes.c_int64),
('accZ', ctypes.c_int16 * 64),
('errorFlags', ctypes.c_uint8),
]
def parse(self):
timestamp = np.full((64, 1), np.nan, int)
timestamp[0] = self.timestamp
return {
'timestamp': timestamp,
'accZ': np.array(self.accZ, float).reshape(64, 1) * ACC_SCALE_FACTOR,
'errorFlags': np.full((64, 1), self.errorFlags, np.uint8),
}
def __repr__(self):
vals = self.parse()
return f'{self.__class__.__name__}(t={self.timestamp:_}, ' \
f'accZ in m/s²: {vals["accZ"].flatten()}, errorFlags={self.errorFlags})'
[docs]
@register_package
class DataSyncTrigger(AbstractPackage):
"""
Hardware synchronization event.
When a rising or falling edge of the USB-C sideband use pin (SBU) is detected, a ``DataSyncTrigger`` package with
the current sensor timestamp is generated. Those packages can be used for precise time synchronization between the
sensors and other systems.
**Fields**:
* **timestamp**: *int64* -- Timestamp (in nanoseconds).
* **value**: *uint8* -- 0 for falling edge, 1 for rising edge.
8 + 1 = 9 bytes
"""
header = SensorHeader.DATA_SYNC_TRIGGER
"""SensorHeader.DATA_SYNC_TRIGGER (0x0400)"""
_fields_ = [
('timestamp', ctypes.c_int64),
('value', ctypes.c_uint8),
]
[docs]
@register_package
class DataFsFileCount(AbstractPackage):
"""
Number of files on the sensor.
**Fields**:
* **fileCount**: *uint16* -- Number of files.
2 bytes
"""
header = SensorHeader.DATA_FS_FILE_COUNT
"""SensorHeader.DATA_FS_FILE_COUNT (0x0501)"""
_fields_ = [
('fileCount', ctypes.c_uint16),
]
def __repr__(self):
s = f'{self.__class__.__name__}(fileCount={self.fileCount})'
return s
[docs]
@register_package
class DataFsFile(AbstractPackage):
"""
Information about a file on the sensor.
**Fields**:
* **index**: *uint16* -- Index.
* **filename**: *char[65]* -- File name.
* **size**: *uint32* -- File size in bytes.
2 + 65 + 4 = 71 bytes
"""
header = SensorHeader.DATA_FS_FILE
"""SensorHeader.DATA_FS_FILE (0x0502)"""
_fields_ = [
('index', ctypes.c_uint16),
('filename', ctypes.c_char * 65),
('size', ctypes.c_uint32),
]
def __repr__(self):
s = f'{self.__class__.__name__}(index={self.index},' \
f' filename={self.filename}, size={self.size})'
return s
[docs]
@register_package
class CmdFsGetBytes(AbstractPackage):
"""
Command to get bytes from a file on the sensor.
**Fields**:
* **filename**: *char[65]* -- File name.
* **startPos**: *uint32* -- Start position in the file.
* **endPos**: *uint32* -- End position in the file. Set to 0 to transfer the whole file.
65 + 4 + 4 = 73 bytes
"""
header = SensorHeader.CMD_FS_GET_BYTES
"""SensorHeader.CMD_FS_GET_BYTES (0x0503)"""
_fields_ = [
('filename', ctypes.c_char * 65),
('startPos', ctypes.c_uint32),
('endPos', ctypes.c_uint32),
]
[docs]
@register_package
class DataFsBytes(AbstractPackage):
"""
Chunk of bytes from a file on the sensor.
**Fields**:
* **offset**: *uint32* -- Offset in the file.
* **payload**: *uint8[232]* -- Payload data (``MAX_PAYLOAD_SIZE`` - 4).
4 + 232 = 236 bytes
"""
header = SensorHeader.DATA_FS_BYTES
"""SensorHeader.DATA_FS_BYTES (0x0504)"""
variable_size = True
payload = bytes()
_fields_ = [
('offset', ctypes.c_uint32),
]
@classmethod
def frombytes(cls, val):
pkg = cls.from_buffer_copy(val[0:ctypes.sizeof(ctypes.c_int32)])
pkg.payload = val[ctypes.sizeof(ctypes.c_int32):]
return pkg
def parse(self):
vals = super().parse()
vals['payload'] = self.payload
return vals
def __repr__(self):
return f'{self.__class__.__name__}({self.offset}, {len(self.payload)} bytes: {self.payload.hex()})'
[docs]
class DataFsFilename(AbstractPackage):
"""
This package contains a file name for filesystem operations, used by various commands.
**Fields**:
* **filename**: *char[65]* -- File name.
65 bytes
"""
_fields_ = [
('filename', ctypes.c_char * 65),
]
@register_package
class CmdFsGetSize(DataFsFilename):
header = SensorHeader.CMD_FS_GET_SIZE
"""SensorHeader.CMD_FS_GET_SIZE (0x0507)"""
@register_package
class CmdFsDeleteFile(DataFsFilename):
header = SensorHeader.CMD_FS_DELETE_FILE
"""SensorHeader.CMD_FS_DELETE_FILE (0x0509)"""
@register_package
class AckFsDeleteFile(DataFsFilename):
header = SensorHeader.ACK_FS_DELETE_FILE
"""SensorHeader.ACK_FS_DELETE_FILE (0x050A)"""
[docs]
@register_package
class DataFsSize(AbstractPackage):
"""
Size of a file on the sensor.
**Fields**:
* **filename**: *char[65]* -- File name.
* **fileSize**: *uint32* -- File size in bytes.
65 + 4 = 69 bytes
"""
header = SensorHeader.DATA_FS_SIZE
"""SensorHeader.DATA_FS_SIZE (0x0508)"""
_fields_ = [
('filename', ctypes.c_char * 65),
('fileSize', ctypes.c_uint32),
]
[docs]
@register_package
class SensorError(AbstractPackage):
"""
Represents an error that occurred on the sensor.
**Fields**:
* **errorCode**: *ErrorCode, uint8* -- Error code.
* **command**: *SensorHeader, uint16* -- If applicable: the command that caused the error, otherwise: ``ERROR``.
1 + 2 = 3 bytes
"""
header = SensorHeader.ERROR
"""SensorHeader.ERROR (0xFFFF)"""
_fields_ = [
('errorCode', ctypes.c_uint8),
('command', ctypes.c_uint16),
]
def __repr__(self):
name = self.__class__.__name__
return f'{name}(errorCode={ErrorCode(self.errorCode)!r}, command: {SensorHeader(self.command)!r})'
[docs]
class SensorSerialPackage(AbstractPackage):
"""
Package frame including header, payload size, and checksum.
**Fields**:
* **startByte**: *uint8* -- Fixed 0x02 byte.
* **crc32**: *uint32* -- Checksum calculated over ``header`` and ``payload``.
* **payloadSize**: *uint8* -- Size of ``payload``, between 0 and 236.
* **header**: *SensorHeader, uint16* -- Value that identifies the command or data package.
* **payload**: *uint8[236]* -- Variable-length payload, between 0 and 236 bytes.
1 + 4 + 1 + 2 + 236 = 244 bytes
"""
_fields_ = [
('startByte', ctypes.c_uint8),
('crc32', ctypes.c_uint32),
('payloadSize', ctypes.c_uint8),
('header', ctypes.c_uint16),
# + variable length payload, up until MAX_PAYLOAD_SIZE bytes
]