Skip to content

Commit 1358acf

Browse files
committed
[feat]: adding sensor classes for hall effect sensors and brushed motor encoder counter. Updated base sensor class accordingly
1 parent f55e528 commit 1358acf

4 files changed

Lines changed: 516 additions & 5 deletions

File tree

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,13 @@ This library solves common challenges in developing, testing, and deploying robo
5353
5454
The library currently supports the following hardware components:
5555
56-
| Sensors | Unit Tests | Hardware Tests | Benchmarks | Documentation |
57-
| -------------------- | ---------- | -------------- | ---------- | ------------- |
58-
| AS5048B Encoder |||||
59-
| Lord Microstrain IMU |||||
60-
| SRI Loadcell |||||
56+
| Sensors | Unit Tests | Hardware Tests | Benchmarks | Documentation |
57+
| ----------------------- | ----------- | -------------- | ---------- | ------------- |
58+
| AS5048B Encoder |||||
59+
| Lord Microstrain IMU |||||
60+
| SRI Loadcell |||||
61+
| DRV5056 Hall Effect |||||
62+
| LS7366R Encoder Counter |||||
6163
6264
| Actuators | Unit Tests | Hardware Tests | Benchmarks | Documentation |
6365
| ------------- | ---------- | -------------- | ---------- | ------------- |

opensourceleg/sensors/base.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,53 @@ def calibrate(self) -> None:
214214
pass
215215

216216

217+
class EncoderCounterBase(SensorBase, ABC):
218+
"""
219+
Abstract base class for encoder counter LS7366R.
220+
221+
Encoder counters interface with incremental encoders.
222+
"""
223+
224+
# Encoder Counter-specific offline configuration
225+
_OFFLINE_PROPERTIES: ClassVar[list[str]] = [*SensorBase._OFFLINE_PROPERTIES, "position", "velocity"]
226+
_OFFLINE_PROPERTY_DEFAULTS: ClassVar[dict[str, Any]] = {
227+
**SensorBase._OFFLINE_PROPERTY_DEFAULTS,
228+
"position": 0.0,
229+
"velocity": 0.0,
230+
}
231+
232+
def __init__(
233+
self,
234+
tag: str,
235+
offline: bool = False,
236+
**kwargs: Any,
237+
) -> None:
238+
"""
239+
Initialize the encoder counter.
240+
"""
241+
super().__init__(tag=tag, offline=offline, **kwargs)
242+
243+
def __repr__(self) -> str:
244+
"""
245+
Return a string representation of the encoder sensor.
246+
247+
Returns:
248+
str: "EncoderCounterBase"
249+
"""
250+
return "EncoderCounterBase"
251+
252+
@property
253+
@abstractmethod
254+
def count(self) -> float:
255+
"""
256+
Get the current encoder count.
257+
258+
Returns:
259+
float: The current encoder count.
260+
"""
261+
pass
262+
263+
217264
class EncoderBase(SensorBase, ABC):
218265
"""
219266
Abstract base class for encoder sensors.
@@ -513,5 +560,40 @@ def gyro_z(self) -> float:
513560
pass
514561

515562

563+
class HallBase(SensorBase, ABC):
564+
"""
565+
Abstract base class for Hall effect sensors.
566+
567+
Hall effect sensors measure magnetic fields.
568+
"""
569+
570+
# hall-specific offline configuration
571+
_OFFLINE_PROPERTIES: ClassVar[list[str]] = [
572+
*SensorBase._OFFLINE_PROPERTIES,
573+
"field_mT",
574+
]
575+
_OFFLINE_PROPERTY_DEFAULTS: ClassVar[dict[str, Any]] = {
576+
**SensorBase._OFFLINE_PROPERTY_DEFAULTS,
577+
"field_mT": 0.0,
578+
}
579+
580+
def __init__(self, tag: str, offline: bool = False, **kwargs: Any) -> None:
581+
"""
582+
Initialize the Hall effect sensor.
583+
"""
584+
super().__init__(tag=tag, offline=offline, **kwargs)
585+
586+
@property
587+
@abstractmethod
588+
def field_mT(self) -> float:
589+
"""
590+
Get the estimated magnetic response
591+
592+
Returns:
593+
float: Magnetic field in mT.
594+
"""
595+
pass
596+
597+
516598
if __name__ == "__main__":
517599
pass
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""
2+
Import LS7366R then create an object by calling enc = LS7366R(csx, clk, byte_mode)
3+
csx is either CE0 or CE1, clk is the speed, byte_mode is the bytemode 1-4 the resolution of your counter.
4+
"""
5+
6+
from time import sleep
7+
from typing import ClassVar, Final, cast
8+
9+
import spidev
10+
11+
from opensourceleg.logging import LOGGER
12+
from opensourceleg.sensors.base import (
13+
EncoderCounterBase,
14+
)
15+
16+
17+
class LS7366R(EncoderCounterBase):
18+
# -------------------------------------------
19+
# Constants
20+
21+
# Commands
22+
CLEAR_COUNTER = 0x20
23+
CLEAR_STATUS = 0x30
24+
READ_COUNTER = 0x60
25+
READ_STATUS = 0x70
26+
WRITE_MODE0 = 0x88
27+
WRITE_MODE1 = 0x90
28+
29+
# Modes
30+
31+
# May need to be change "QUADRATURE_COUNT_MODE" line depending on the quadrature count mode... look at datasheet.
32+
# These values are in HEX (base 16) whereas the data sheet displays them in binary.
33+
# Datasheet can be found here: https://www.lsicsi.com/pdfs/Data_Sheets/LS7366R.pdf
34+
35+
# 0x00: non-quadrature count mode. (A = clock, B = direction).
36+
# 0x01: x1 quadrature count mode (one count per quadrature cycle).
37+
# 0x02: x2 quadrature count mode (two counts per quadrature cycle).
38+
# 0x03: x4 quadrature count mode (four counts per quadrature cycle).
39+
40+
QUADRATURE_COUNT_MODE = 0x03 # originally was 0x00
41+
42+
class CounterConfig:
43+
"""Counter byte-mode configuration constants for the LS7366R."""
44+
45+
FOURBYTE_COUNTER: Final = 0x00
46+
THREEBYTE_COUNTER: Final = 0x01
47+
TWOBYTE_COUNTER: Final = 0x02
48+
ONEBYTE_COUNTER: Final = 0x03
49+
50+
MODES: ClassVar[list[int]] = [ONEBYTE_COUNTER, TWOBYTE_COUNTER, THREEBYTE_COUNTER, FOURBYTE_COUNTER]
51+
52+
# ----------------------------------------------
53+
# Constructor
54+
55+
def __init__(
56+
self,
57+
csx: int = 0,
58+
clk: int = 1000000,
59+
byte_mode: int = 4,
60+
max_val: int = 4294967295, # for four byte mode, only correct for four byte mode
61+
spi_bus: int = 0,
62+
offline: bool = False,
63+
tag: str = "EncoderCounter",
64+
) -> None:
65+
"""
66+
Initialize the LS7366R encoder counter and configure the SPI interface.
67+
68+
Args:
69+
csx (int): SPI chip select line (CE0 or CE1). Defaults to 0.
70+
clk (int): SPI clock speed in Hz. Defaults to 1000000.
71+
byte_mode (int): Counter resolution in bytes (1 to 4). Defaults to 4.
72+
max_val (int): Maximum counter value for signed conversion. Defaults to 4294967295.
73+
spi_bus (int): SPI bus number. Defaults to 0.
74+
offline (bool): If True, skips SPI initialization. Defaults to False.
75+
tag (str): Human-readable identifier for this encoder instance. Defaults to "EncoderCounter".
76+
"""
77+
78+
super().__init__(tag=tag, offline=offline)
79+
80+
self.counter_size = byte_mode # Sets the byte mode that will be used
81+
self.max_val = max_val # Maximum value for the counter, used for signed count conversion
82+
83+
self.spi = spidev.SpiDev() # Initialize object
84+
self.spi.open(spi_bus, csx) # Which CS line will be used
85+
self.spi.max_speed_hz = clk # Speed of clk (modifies speed transaction)
86+
87+
# Init the Encoder
88+
LOGGER.info(f"Clearing Encoder CS{csx!s}'s Count...\t")
89+
self.clear_counter()
90+
LOGGER.info(f"Clearing Encoder CS{csx!s}'s Status..\t")
91+
self.clear_status()
92+
93+
self.spi.xfer2([self.WRITE_MODE0, self.QUADRATURE_COUNT_MODE])
94+
95+
sleep(0.1) # Rest
96+
97+
self.spi.xfer2([self.WRITE_MODE1, self.CounterConfig.MODES[self.counter_size - 1]])
98+
99+
def close(self) -> None:
100+
LOGGER.info("Closing Encoder...")
101+
self.clear_counter()
102+
self.clear_status()
103+
self.spi.close()
104+
self.spi = None
105+
106+
def clear_counter(self) -> str:
107+
"""
108+
Send the clear counter command to the encoder over SPI.
109+
110+
Returns:
111+
str: "[DONE]" on success.
112+
"""
113+
self.spi.xfer2([self.CLEAR_COUNTER])
114+
115+
return "[DONE]"
116+
117+
def clear_status(self) -> str:
118+
"""
119+
Send the clear status command to the encoder over SPI.
120+
121+
Returns:
122+
str: "[DONE]" on success.
123+
"""
124+
self.spi.xfer2([self.CLEAR_STATUS])
125+
126+
return "[DONE]"
127+
128+
def read_counter(self) -> int:
129+
"""
130+
Read the current encoder count over SPI.
131+
132+
Converts the raw multi-byte SPI response into a signed integer based on the configured byte mode.
133+
134+
Returns:
135+
int: Signed encoder count.
136+
"""
137+
read_transaction = [self.READ_COUNTER]
138+
139+
read_transaction.extend([0] * self.counter_size)
140+
141+
data = self.spi.xfer2(read_transaction)
142+
143+
encoder_count = 0
144+
for i in range(self.counter_size):
145+
encoder_count = (encoder_count << 8) + data[i + 1]
146+
147+
if data[1] != 255:
148+
self.encoder_count = encoder_count
149+
else:
150+
self.encoder_count = encoder_count - (self.max_val + 1)
151+
152+
return self.encoder_count
153+
154+
def read_status(self) -> int:
155+
"""
156+
Read the status register of the encoder over SPI.
157+
158+
Returns:
159+
int: 8-bit status register value.
160+
"""
161+
data = self.spi.xfer2([self.READ_STATUS, 0xFF])
162+
163+
return cast(int, data[1])
164+
165+
def start(self) -> None:
166+
"""Start the encoder counter. Not required for this driver."""
167+
pass
168+
169+
def stop(self) -> None:
170+
"""
171+
Stop the encoder by closing the SPI connection and clearing registers.
172+
"""
173+
self.close()
174+
LOGGER.info("Motor encoder stopped successfully.")
175+
176+
def update(self) -> None:
177+
"""
178+
Update the encoder state by reading the latest counter value from SPI.
179+
"""
180+
self.read_counter()
181+
182+
@property
183+
def count(self) -> int:
184+
"""
185+
Encoder position in counts.
186+
187+
Returns:
188+
int: Counts reading from the sensor.
189+
"""
190+
return self.read_counter()
191+
192+
@property
193+
def data(self) -> None:
194+
"""Not yet supported by this library."""
195+
raise NotImplementedError("Data not implemented.")
196+
197+
@property
198+
def is_streaming(self) -> bool:
199+
"""Not yet supported by this library."""
200+
raise NotImplementedError("Is streaming not implemented.")

0 commit comments

Comments
 (0)