From 1358acfe0bba118c97cd750170fc381bc64ca5c6 Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 1 Jul 2026 16:14:10 -0400 Subject: [PATCH 1/2] [feat]: adding sensor classes for hall effect sensors and brushed motor encoder counter. Updated base sensor class accordingly --- README.md | 12 +- opensourceleg/sensors/base.py | 82 +++++++++ opensourceleg/sensors/encoderCounter.py | 200 +++++++++++++++++++++ opensourceleg/sensors/hall.py | 227 ++++++++++++++++++++++++ 4 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 opensourceleg/sensors/encoderCounter.py create mode 100644 opensourceleg/sensors/hall.py diff --git a/README.md b/README.md index 60031703..679cbef6 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,13 @@ This library solves common challenges in developing, testing, and deploying robo The library currently supports the following hardware components: -| Sensors | Unit Tests | Hardware Tests | Benchmarks | Documentation | -| -------------------- | ---------- | -------------- | ---------- | ------------- | -| AS5048B Encoder | ✅ | ✅ | ❌ | ✅ | -| Lord Microstrain IMU | ✅ | ✅ | ❌ | ✅ | -| SRI Loadcell | ✅ | ✅ | ❌ | ✅ | +| Sensors | Unit Tests | Hardware Tests | Benchmarks | Documentation | +| ----------------------- | ----------- | -------------- | ---------- | ------------- | +| AS5048B Encoder | ✅ | ✅ | ❌ | ✅ | +| Lord Microstrain IMU | ✅ | ✅ | ❌ | ✅ | +| SRI Loadcell | ✅ | ✅ | ❌ | ✅ | +| DRV5056 Hall Effect | ❌ | ❌ | ❌ | ❌ | +| LS7366R Encoder Counter | ❌ | ❌ | ❌ | ❌ | | Actuators | Unit Tests | Hardware Tests | Benchmarks | Documentation | | ------------- | ---------- | -------------- | ---------- | ------------- | diff --git a/opensourceleg/sensors/base.py b/opensourceleg/sensors/base.py index 9ceadba1..cf56cba4 100644 --- a/opensourceleg/sensors/base.py +++ b/opensourceleg/sensors/base.py @@ -214,6 +214,53 @@ def calibrate(self) -> None: pass +class EncoderCounterBase(SensorBase, ABC): + """ + Abstract base class for encoder counter LS7366R. + + Encoder counters interface with incremental encoders. + """ + + # Encoder Counter-specific offline configuration + _OFFLINE_PROPERTIES: ClassVar[list[str]] = [*SensorBase._OFFLINE_PROPERTIES, "position", "velocity"] + _OFFLINE_PROPERTY_DEFAULTS: ClassVar[dict[str, Any]] = { + **SensorBase._OFFLINE_PROPERTY_DEFAULTS, + "position": 0.0, + "velocity": 0.0, + } + + def __init__( + self, + tag: str, + offline: bool = False, + **kwargs: Any, + ) -> None: + """ + Initialize the encoder counter. + """ + super().__init__(tag=tag, offline=offline, **kwargs) + + def __repr__(self) -> str: + """ + Return a string representation of the encoder sensor. + + Returns: + str: "EncoderCounterBase" + """ + return "EncoderCounterBase" + + @property + @abstractmethod + def count(self) -> float: + """ + Get the current encoder count. + + Returns: + float: The current encoder count. + """ + pass + + class EncoderBase(SensorBase, ABC): """ Abstract base class for encoder sensors. @@ -513,5 +560,40 @@ def gyro_z(self) -> float: pass +class HallBase(SensorBase, ABC): + """ + Abstract base class for Hall effect sensors. + + Hall effect sensors measure magnetic fields. + """ + + # hall-specific offline configuration + _OFFLINE_PROPERTIES: ClassVar[list[str]] = [ + *SensorBase._OFFLINE_PROPERTIES, + "field_mT", + ] + _OFFLINE_PROPERTY_DEFAULTS: ClassVar[dict[str, Any]] = { + **SensorBase._OFFLINE_PROPERTY_DEFAULTS, + "field_mT": 0.0, + } + + def __init__(self, tag: str, offline: bool = False, **kwargs: Any) -> None: + """ + Initialize the Hall effect sensor. + """ + super().__init__(tag=tag, offline=offline, **kwargs) + + @property + @abstractmethod + def field_mT(self) -> float: + """ + Get the estimated magnetic response + + Returns: + float: Magnetic field in mT. + """ + pass + + if __name__ == "__main__": pass diff --git a/opensourceleg/sensors/encoderCounter.py b/opensourceleg/sensors/encoderCounter.py new file mode 100644 index 00000000..e022c7a3 --- /dev/null +++ b/opensourceleg/sensors/encoderCounter.py @@ -0,0 +1,200 @@ +""" +Import LS7366R then create an object by calling enc = LS7366R(csx, clk, byte_mode) +csx is either CE0 or CE1, clk is the speed, byte_mode is the bytemode 1-4 the resolution of your counter. +""" + +from time import sleep +from typing import ClassVar, Final, cast + +import spidev + +from opensourceleg.logging import LOGGER +from opensourceleg.sensors.base import ( + EncoderCounterBase, +) + + +class LS7366R(EncoderCounterBase): + # ------------------------------------------- + # Constants + + # Commands + CLEAR_COUNTER = 0x20 + CLEAR_STATUS = 0x30 + READ_COUNTER = 0x60 + READ_STATUS = 0x70 + WRITE_MODE0 = 0x88 + WRITE_MODE1 = 0x90 + + # Modes + + # May need to be change "QUADRATURE_COUNT_MODE" line depending on the quadrature count mode... look at datasheet. + # These values are in HEX (base 16) whereas the data sheet displays them in binary. + # Datasheet can be found here: https://www.lsicsi.com/pdfs/Data_Sheets/LS7366R.pdf + + # 0x00: non-quadrature count mode. (A = clock, B = direction). + # 0x01: x1 quadrature count mode (one count per quadrature cycle). + # 0x02: x2 quadrature count mode (two counts per quadrature cycle). + # 0x03: x4 quadrature count mode (four counts per quadrature cycle). + + QUADRATURE_COUNT_MODE = 0x03 # originally was 0x00 + + class CounterConfig: + """Counter byte-mode configuration constants for the LS7366R.""" + + FOURBYTE_COUNTER: Final = 0x00 + THREEBYTE_COUNTER: Final = 0x01 + TWOBYTE_COUNTER: Final = 0x02 + ONEBYTE_COUNTER: Final = 0x03 + + MODES: ClassVar[list[int]] = [ONEBYTE_COUNTER, TWOBYTE_COUNTER, THREEBYTE_COUNTER, FOURBYTE_COUNTER] + + # ---------------------------------------------- + # Constructor + + def __init__( + self, + csx: int = 0, + clk: int = 1000000, + byte_mode: int = 4, + max_val: int = 4294967295, # for four byte mode, only correct for four byte mode + spi_bus: int = 0, + offline: bool = False, + tag: str = "EncoderCounter", + ) -> None: + """ + Initialize the LS7366R encoder counter and configure the SPI interface. + + Args: + csx (int): SPI chip select line (CE0 or CE1). Defaults to 0. + clk (int): SPI clock speed in Hz. Defaults to 1000000. + byte_mode (int): Counter resolution in bytes (1 to 4). Defaults to 4. + max_val (int): Maximum counter value for signed conversion. Defaults to 4294967295. + spi_bus (int): SPI bus number. Defaults to 0. + offline (bool): If True, skips SPI initialization. Defaults to False. + tag (str): Human-readable identifier for this encoder instance. Defaults to "EncoderCounter". + """ + + super().__init__(tag=tag, offline=offline) + + self.counter_size = byte_mode # Sets the byte mode that will be used + self.max_val = max_val # Maximum value for the counter, used for signed count conversion + + self.spi = spidev.SpiDev() # Initialize object + self.spi.open(spi_bus, csx) # Which CS line will be used + self.spi.max_speed_hz = clk # Speed of clk (modifies speed transaction) + + # Init the Encoder + LOGGER.info(f"Clearing Encoder CS{csx!s}'s Count...\t") + self.clear_counter() + LOGGER.info(f"Clearing Encoder CS{csx!s}'s Status..\t") + self.clear_status() + + self.spi.xfer2([self.WRITE_MODE0, self.QUADRATURE_COUNT_MODE]) + + sleep(0.1) # Rest + + self.spi.xfer2([self.WRITE_MODE1, self.CounterConfig.MODES[self.counter_size - 1]]) + + def close(self) -> None: + LOGGER.info("Closing Encoder...") + self.clear_counter() + self.clear_status() + self.spi.close() + self.spi = None + + def clear_counter(self) -> str: + """ + Send the clear counter command to the encoder over SPI. + + Returns: + str: "[DONE]" on success. + """ + self.spi.xfer2([self.CLEAR_COUNTER]) + + return "[DONE]" + + def clear_status(self) -> str: + """ + Send the clear status command to the encoder over SPI. + + Returns: + str: "[DONE]" on success. + """ + self.spi.xfer2([self.CLEAR_STATUS]) + + return "[DONE]" + + def read_counter(self) -> int: + """ + Read the current encoder count over SPI. + + Converts the raw multi-byte SPI response into a signed integer based on the configured byte mode. + + Returns: + int: Signed encoder count. + """ + read_transaction = [self.READ_COUNTER] + + read_transaction.extend([0] * self.counter_size) + + data = self.spi.xfer2(read_transaction) + + encoder_count = 0 + for i in range(self.counter_size): + encoder_count = (encoder_count << 8) + data[i + 1] + + if data[1] != 255: + self.encoder_count = encoder_count + else: + self.encoder_count = encoder_count - (self.max_val + 1) + + return self.encoder_count + + def read_status(self) -> int: + """ + Read the status register of the encoder over SPI. + + Returns: + int: 8-bit status register value. + """ + data = self.spi.xfer2([self.READ_STATUS, 0xFF]) + + return cast(int, data[1]) + + def start(self) -> None: + """Start the encoder counter. Not required for this driver.""" + pass + + def stop(self) -> None: + """ + Stop the encoder by closing the SPI connection and clearing registers. + """ + self.close() + LOGGER.info("Motor encoder stopped successfully.") + + def update(self) -> None: + """ + Update the encoder state by reading the latest counter value from SPI. + """ + self.read_counter() + + @property + def count(self) -> int: + """ + Encoder position in counts. + + Returns: + int: Counts reading from the sensor. + """ + return self.read_counter() + + @property + def data(self) -> None: + """Not yet supported by this library.""" + raise NotImplementedError("Data not implemented.") + + @property + def is_streaming(self) -> bool: + """Not yet supported by this library.""" + raise NotImplementedError("Is streaming not implemented.") diff --git a/opensourceleg/sensors/hall.py b/opensourceleg/sensors/hall.py new file mode 100644 index 00000000..91d2c085 --- /dev/null +++ b/opensourceleg/sensors/hall.py @@ -0,0 +1,227 @@ +"""Module for using the DRV5056 family of Hall effect sensors.""" + +from typing import ClassVar, Optional + +from opensourceleg.logging import LOGGER +from opensourceleg.sensors.base import HallBase + + +class DRV5056(HallBase): + """ + Class for communication with the DRV5056Ax family of Hall effect sensors. + + This class allows reading Hall effect sensors in mT, mV, or V. + """ + + # Class attributes + + # Power supply voltage options + _DRV_VCC_3_3 = 3.3 + _DRV_VCC_5 = 5 + + _QUIESCENT_OFFSET = 0.6 # V + + # Unit conversions + _V_TO_MV = 1000 + + # Helpful dictionaries + _SENSOR_TO_SENS: ClassVar[dict[str, float]] = { + "A1": 200, # mV / mT + "A2": 100, + "A3": 50, + "A4": 25, + "A6": 100, + "A8": 66.6, + "Z1": 200, + "Z2": 100, + "Z3": 50, + "Z4": 25, + } + + _SENS_3_3 = 0.6 + + _SENSOR_TO_RANGE: ClassVar[dict[str, int]] = { + "A1": 20, + "A2": 39, + "A3": 79, + "A4": 158, + "A6": 39, + "A8": 64, + "Z1": 20, + "Z2": 39, + "Z3": 79, + "Z4": 158, + } + + _SENS_3_3_RANGE: ClassVar[dict[str, int]] = { + "A1": 19, + "A2": 39, + "A3": 78, + "A4": 155, + "A6": 39, + "A8": 65, + "Z1": 19, + "Z2": 39, + "Z3": 78, + "Z4": 155, + } + + _SENSOR_TO_COMP: ClassVar[dict[str, float]] = { + "A1": 0.0012, + "A2": 0.0012, + "A3": 0.0012, + "A4": 0.0012, + "A6": 0.0012, + "A8": 0.0012, + "Z1": 0.0, + "Z2": 0.0, + "Z3": 0.0, + "Z4": 0.0, + } + + def __init__( + self, + tag: str = "DRV5056A1", + offline: bool = False, + sensor_num: str = "A1", + t_a: int = 23, + supply_voltage: float = 5, + sens_v_per_mT: Optional[float] = None, + ): + """ + Initialize the DRV5056 instance. + + Args: + tag (str): Identifier for the Hall effect instance. Default is "DRV5056A1". + offline (bool): If True, the ADC operates in offline mode. Default is False. + sensor_num (str): Hall effect sensor part number. Default is A1. + t_a (int): Ambient temperature. Default is 23 degrees Celsius. + supply_voltage (float): Power supply voltage. Default is 5 V. + sens_v_per_mT (float, optional): Sensitivity of the sensor in V/mT. Default is None. + + """ + if offline: + exit(1) + + super().__init__(tag=tag, offline=offline) + + self._sensor_num = sensor_num + self._t_a = t_a + self._supply_voltage = supply_voltage + self._sens_v_per_mT = sens_v_per_mT + + def __repr__(self) -> str: + return "DRV5056" + + def configure( + self, + ) -> None: + """ + Configure Hall effect settings based on part number and supply voltage. + + Raises: + ValueError: If sensor_num is not a supported part number. + ValueError: If supply voltage is outside the supported range. + """ + # --- SENSITIVITY --- + if self._sensor_num not in self._SENSOR_TO_SENS: + raise ValueError( + f"Unsupported sensor={self._sensor_num}. Choose from {sorted(self._SENSOR_TO_SENS.keys())}" + ) + self.base_sensitivity = self._SENSOR_TO_SENS[self._sensor_num] + self.lower_volt_sensitivity = self._SENS_3_3 * self.base_sensitivity + + # --- RANGE --- + self.base_range = self._SENSOR_TO_RANGE[self._sensor_num] + self.lower_volt_range = self._SENS_3_3_RANGE[self._sensor_num] + + # --- TEMPERATURE COMPENSATION --- + self._s_tc = self._SENSOR_TO_COMP[self._sensor_num] + + # --- VCC --- + if self._supply_voltage != self._DRV_VCC_3_3 and self._supply_voltage != self._DRV_VCC_5: + if 4.5 <= self._supply_voltage <= 5.5: + self.range = self.base_range + self._sensitivity = self.base_sensitivity * self._supply_voltage / self._DRV_VCC_5 + elif 3 <= self._supply_voltage <= 3.6: + self.range = self.lower_volt_range + self._sensitivity = self.lower_volt_sensitivity * self._supply_voltage / self._DRV_VCC_3_3 + else: + raise ValueError("Supply voltage out of range.") + elif self._supply_voltage == self._DRV_VCC_3_3: + self.range = self.lower_volt_range + self._sensitivity = self.lower_volt_sensitivity + elif self._supply_voltage == self._DRV_VCC_5: + self.range = self.base_range + self._sensitivity = self.base_sensitivity + + def start(self) -> None: + """Start the Hall effect sensor by enabling the data stream.""" + self._streaming = True + + def stop(self) -> None: + """Stop the Hall effect sensor by disabling the data stream.""" + self._streaming = False + + def update(self) -> None: + """Calculate the estimated magnetic response.""" + self.field_strength = (self.voltage * self._V_TO_MV - self._QUIESCENT_OFFSET) / ( + self._sensitivity * (1 + (self._s_tc * (self._t_a - 25))) + ) + if self.range == self.field_strength: + LOGGER.error("Careful. The sensor may be out of range and your magnetic field may be higher.") + + def drv5056_field_mT(self, volts: float, vcc: float) -> float: + """ + Estimate magnetic field if sensitivity is known. + Many DRV5056 parts are ratiometric: Vout ~ Vcc/2 at 0 field. + + Args: + volts (float): Output voltage of the sensor in volts. + vcc (float): Supply voltage in volts. + + Returns: + float: Estimated Magnetic field in millitesla (mT). + + Raises: + ValueError: If the sensor sensitivity has not been set. + """ + if self._sens_v_per_mT is None: + raise ValueError("Sensor sensitivity (_sens_v_per_mT) is not set. ") + return (volts - (vcc / 2.0)) / self._sens_v_per_mT + + @property + def is_streaming(self) -> bool: + """ + Check if the Hall is currently streaming data. + + Returns: + bool: True if streaming, False otherwise. + """ + return self._streaming + + @property + def voltage(self) -> float: + """ + Get the latest Hall effect data. + + Returns: + float: Voltage reading from the sensor. + """ + return 0.0 + + @property + def field_mT(self) -> float: + """ + Get the latest Hall effect data in millitesla. + + Returns: + float: Magnetic field strength in millitesla (mT). + """ + self.update() + return self.field_strength + + @property + def data(self) -> float: + """Not yet supported by this library.""" + raise NotImplementedError("Data not implemented.") From 390590fa75c2b9b7c120080cec3061977e0184b4 Mon Sep 17 00:00:00 2001 From: Emily Bywater Date: Mon, 6 Jul 2026 16:39:49 -0400 Subject: [PATCH 2/2] [feat]: adding sensor classes for hall effect and brushed motor encoder counter. Updated base class and read me. Unit tests to follow --- codecov.yaml | 3 +++ opensourceleg/sensors/base.py | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/codecov.yaml b/codecov.yaml index 058cfb76..dcf1f400 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -7,3 +7,6 @@ coverage: default: target: 90% threshold: 0.5% +ignore: + - "opensourceleg/sensors/encoderCounter.py" + - "opensourceleg/sensors/hall.py" diff --git a/opensourceleg/sensors/base.py b/opensourceleg/sensors/base.py index cf56cba4..f1310044 100644 --- a/opensourceleg/sensors/base.py +++ b/opensourceleg/sensors/base.py @@ -238,7 +238,7 @@ def __init__( """ Initialize the encoder counter. """ - super().__init__(tag=tag, offline=offline, **kwargs) + super().__init__(tag=tag, offline=offline, **kwargs) # pragma: no cover def __repr__(self) -> str: """ @@ -247,7 +247,7 @@ def __repr__(self) -> str: Returns: str: "EncoderCounterBase" """ - return "EncoderCounterBase" + return "EncoderCounterBase" # pragma: no cover @property @abstractmethod @@ -258,7 +258,7 @@ def count(self) -> float: Returns: float: The current encoder count. """ - pass + pass # pragma: no cover class EncoderBase(SensorBase, ABC): @@ -581,7 +581,7 @@ def __init__(self, tag: str, offline: bool = False, **kwargs: Any) -> None: """ Initialize the Hall effect sensor. """ - super().__init__(tag=tag, offline=offline, **kwargs) + super().__init__(tag=tag, offline=offline, **kwargs) # pragma: no cover @property @abstractmethod @@ -592,7 +592,7 @@ def field_mT(self) -> float: Returns: float: Magnetic field in mT. """ - pass + pass # pragma: no cover if __name__ == "__main__":