Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rotaryio module #937

Merged
merged 2 commits into from
Jan 31, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/rotaryio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
`rotaryio` - Support for reading rotation sensors
===========================================================
See `CircuitPython:rotaryio` in CircuitPython for more details.

* Author(s): Melissa LeBlanc-Williams
"""

from __future__ import annotations
import threading
import microcontroller
import digitalio

# Define the state transition table for the quadrature encoder
transitions = [
0, # 00 -> 00 no movement
-1, # 00 -> 01 3/4 ccw (11 detent) or 1/4 ccw (00 at detent)
+1, # 00 -> 10 3/4 cw or 1/4 cw
0, # 00 -> 11 non-Gray-code transition
+1, # 01 -> 00 2/4 or 4/4 cw
0, # 01 -> 01 no movement
0, # 01 -> 10 non-Gray-code transition
-1, # 01 -> 11 4/4 or 2/4 ccw
-1, # 10 -> 00 2/4 or 4/4 ccw
0, # 10 -> 01 non-Gray-code transition
0, # 10 -> 10 no movement
+1, # 10 -> 11 4/4 or 2/4 cw
0, # 11 -> 00 non-Gray-code transition
+1, # 11 -> 01 1/4 or 3/4 cw
-1, # 11 -> 10 1/4 or 3/4 ccw
0, # 11 -> 11 no movement
]


class IncrementalEncoder:
"""
IncrementalEncoder determines the relative rotational position based on two series of
pulses. It assumes that the encoder’s common pin(s) are connected to ground,and enables
pull-ups on pin_a and pin_b.

Create an IncrementalEncoder object associated with the given pins. It tracks the
positional state of an incremental rotary encoder (also known as a quadrature encoder.)
Position is relative to the position when the object is constructed.
"""

def __init__(
self, pin_a: microcontroller.Pin, pin_b: microcontroller.Pin, divisor: int = 4
):
"""
Create an IncrementalEncoder object associated with the given pins. It tracks the
positional state of an incremental rotary encoder (also known as a quadrature encoder.)
Position is relative to the position when the object is constructed.

:param microcontroller.Pin pin_a: The first pin connected to the encoder.
:param microcontroller.Pin pin_b: The second pin connected to the encoder.
:param int divisor: The number of pulses per encoder step. Default is 4.
"""
self._pin_a = digitalio.DigitalInOut(pin_a)
self._pin_a.switch_to_input(pull=digitalio.Pull.UP)
self._pin_b = digitalio.DigitalInOut(pin_b)
self._pin_b.switch_to_input(pull=digitalio.Pull.UP)
self._position = 0
self._last_state = 0
self._divisor = divisor
self._sub_count = 0
self._poll_thread = threading.Thread(target=self._polling_loop, daemon=True)
self._poll_thread.start()

def deinit(self):
"""Deinitializes the IncrementalEncoder and releases any hardware resources for reuse."""
self._pin_a.deinit()
self._pin_b.deinit()
if self._poll_thread.is_alive():
self._poll_thread.join()

def __enter__(self) -> IncrementalEncoder:
"""No-op used by Context Managers."""
return self

def __exit__(self, _type, _value, _traceback):
"""
Automatically deinitializes when exiting a context. See
:ref:`lifetime-and-contextmanagers` for more info.
"""
self.deinit()

@property
def divisor(self) -> int:
"""The divisor of the quadrature signal. Use 1 for encoders without detents, or encoders
with 4 detents per cycle. Use 2 for encoders with 2 detents per cycle. Use 4 for encoders
with 1 detent per cycle."""
return self._divisor

@divisor.setter
def divisor(self, value: int):
self._divisor = value

@property
def position(self) -> int:
"""The current position in terms of pulses. The number of pulses per rotation is defined
by the specific hardware and by the divisor."""
return self._position

@position.setter
def position(self, value: int):
self._position = value

def _get_pin_state(self) -> int:
"""Returns the current state of the pins."""
return self._pin_a.value << 1 | self._pin_b.value

def _polling_loop(self):
while True:
self._poll_encoder()

def _poll_encoder(self):
# Check the state of the pins
# if either pin has changed, update the state
new_state = self._get_pin_state()
if new_state != self._last_state:
self._state_update(new_state)
self._last_state = new_state

def _state_update(self, new_state: int):
new_state &= 3
index = self._last_state << 2 | new_state
sub_increment = transitions[index]
self._sub_count += sub_increment
if self._sub_count >= self._divisor:
self._position += 1
self._sub_count = 0
elif self._sub_count <= -self._divisor:
self._position -= 1
self._sub_count = 0