Documentation
Rotary Encoder
Source:
hardware_io/rotary_encoder/README.md
The rotary_encoder component provides a common interface for receiving rotary encoder events.
Two rotary encoder implementations are provided:
GpioRotaryEncoderSeesawRotaryEncoder
Both implementations use the RotaryEncoder interface and report rotation and button events using callbacks.
Rotary Encoder Interface
The RotaryEncoder interface reports the following events:
- Rotation
- Button pressed
- Button released
Rotation callbacks receive the number of encoder steps.
def rotated(steps: int) -> None:
print(f"Rotated: {steps}")
Positive values indicate clockwise rotation.
Negative values indicate counterclockwise rotation.
Example:
+1
+2
-1
The reported direction can be reversed when creating an encoder.
GPIO Rotary Encoder
The GpioRotaryEncoder reads a rotary encoder connected directly to Raspberry Pi GPIO pins.
The encoder uses two GPIO inputs for rotation:
- Channel A
- Channel B
An optional GPIO input can be used for the encoder push button.
Pin Numbering
GpioRotaryEncoderPins uses physical Raspberry Pi header pin numbers.
For example:
from hardware_io.rotary_encoder import (
GpioRotaryEncoder,
GpioRotaryEncoderPins,
)
encoder = GpioRotaryEncoder(
pins=GpioRotaryEncoderPins(
pin_a=11,
pin_b=13,
button=15,
)
)
In this example:
Physical Pin 11 -> BCM GPIO17
Physical Pin 13 -> BCM GPIO27
Physical Pin 15 -> BCM GPIO22
Physical header pin numbers are converted to BCM GPIO numbers using the GPIO header mapping.
Using the GPIO Encoder
import time
from hardware_io.rotary_encoder import (
GpioRotaryEncoder,
GpioRotaryEncoderPins,
)
def rotated(steps: int) -> None:
print(f"Rotated: {steps}")
def button_pressed() -> None:
print("Button pressed")
def button_released() -> None:
print("Button released")
encoder = GpioRotaryEncoder(
pins=GpioRotaryEncoderPins(
pin_a=11,
pin_b=13,
button=15,
)
)
encoder.start(
rotated=rotated,
button_pressed=button_pressed,
button_released=button_released,
)
try:
while True:
encoder.poll()
time.sleep(0.01)
except KeyboardInterrupt:
pass
finally:
encoder.cleanup()
The poll() method dispatches accumulated rotation events and should be called
periodically. tick() remains as a backward-compatible alias.
Seesaw Rotary Encoder
The SeesawRotaryEncoder reads a rotary encoder through an Adafruit Seesaw I2C device.
Multiple Seesaw rotary encoders can share the same I2C bus.
Each Seesaw device must use a unique I2C address.
The default address is:
0x36
Additional encoders can be configured using different addresses.
For example:
Encoder 1 -> 0x36
Encoder 2 -> 0x37
Encoder 3 -> 0x38
The I2C address is configured using the address jumpers on the Seesaw board.
Using One Seesaw Encoder
from hardware_io.rotary_encoder import SeesawRotaryEncoder
def rotated(steps: int) -> None:
print(f"Rotated: {steps}")
def button_pressed() -> None:
print("Button pressed")
def button_released() -> None:
print("Button released")
encoder = SeesawRotaryEncoder(
address=0x36,
)
encoder.start(
rotated=rotated,
button_pressed=button_pressed,
button_released=button_released,
)
The Seesaw encoder monitors rotation and button events in a background thread.
The Seesaw implementation inherits the no-op poll() behavior because it
monitors events in its own background thread.
Using Multiple Seesaw Encoders
A single I2C bus can be shared between multiple encoders.
import board
from hardware_io.rotary_encoder import SeesawRotaryEncoder
i2c = board.I2C()
encoder_1 = SeesawRotaryEncoder(
address=0x36,
i2c=i2c,
)
encoder_2 = SeesawRotaryEncoder(
address=0x37,
i2c=i2c,
)
encoder_3 = SeesawRotaryEncoder(
address=0x38,
i2c=i2c,
)
Each encoder can use its own callbacks.
Required Libraries
GPIO Rotary Encoder
The GPIO implementation requires the Raspberry Pi GPIO library.
On Raspberry Pi OS:
sudo apt install python3-rpi.gpio
The Python package can also be installed using:
python3 -m pip install RPi.GPIO
Seesaw Rotary Encoder
The Seesaw implementation requires the Adafruit Blinka and Seesaw libraries.
Install the required Python packages using:
python3 -m pip install \
adafruit-blinka \
adafruit-circuitpython-seesaw
I2C Support
I2C must be enabled on the Raspberry Pi when using Seesaw rotary encoders.
I2C can be enabled using:
sudo raspi-config
Select:
Interface Options
-> I2C
-> Enable
The connected I2C devices can be displayed using i2cdetect.
Install the I2C tools:
sudo apt install i2c-tools
Scan the I2C bus:
i2cdetect -y 1
Example output with three Seesaw encoders:
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- 36 37 38 -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --
The addresses 0x36, 0x37, and 0x38 indicate three detected Seesaw devices.
Component Tests
Example CLI applications are provided in the component_test directory.
rotary_encoder/
├── __init__.py
├── rotary_encoder_if.py
├── gpio_rotary_encoder.py
├── seesaw_rotary_encoder.py
├── README.md
└── component_test/
├── __init__.py
├── gpio_rotary_encoder_cli.py
└── seesaw_rotary_encoder_cli.py
GPIO Encoder Component Test
Run the GPIO rotary encoder component test from the project root:
python3 -m hardware_io.rotary_encoder.component_test.gpio_rotary_encoder_cli \
--pin-a 11 \
--pin-b 13 \
--button 15
The pin arguments use physical Raspberry Pi header pin numbers.
Example output:
GPIO rotary encoder component test
Channel A physical pin: 11
Channel B physical pin: 13
Button physical pin: 15
Press Ctrl+C to stop.
Rotated clockwise: +1
Rotated clockwise: +1
Button pressed
Button released
Rotated counterclockwise: -1
Use the --reverse option to reverse the reported rotation direction:
python3 -m hardware_io.rotary_encoder.component_test.gpio_rotary_encoder_cli \
--pin-a 11 \
--pin-b 13 \
--button 15 \
--reverse
Press Ctrl+C to stop the component test.
Seesaw Encoder Component Test
The Seesaw component test can monitor one or more rotary encoders on a shared
I2C bus. Pass the addresses of all connected encoders to --addresses. Each
address must be unique.
When --addresses is omitted, the test uses three encoders at:
0x36
0x37
0x38
Run the component test from the project root:
python3 -m hardware_io.rotary_encoder.component_test.seesaw_rotary_encoder_cli
To test a single encoder:
python3 -m hardware_io.rotary_encoder.component_test.seesaw_rotary_encoder_cli \
--addresses 0x36
To test two encoders:
python3 -m hardware_io.rotary_encoder.component_test.seesaw_rotary_encoder_cli \
--addresses 0x36 0x37
Additional addresses can be supplied in the same way to test any number of encoders:
python3 -m hardware_io.rotary_encoder.component_test.seesaw_rotary_encoder_cli \
--addresses 0x36 0x37 0x38 0x39
Encoders are named encoder-1 through encoder-N in the order their addresses
appear on the command line.
Example output:
Initializing shared I2C bus...
Seesaw rotary encoder component test
Configured encoders:
encoder-1: 0x36
encoder-2: 0x37
encoder-3: 0x38
Rotate or press any encoder.
Press Ctrl+C to stop.
[encoder-1 0x36] rotated clockwise: +1
[encoder-2 0x37] button pressed
[encoder-2 0x37] button released
[encoder-3 0x38] rotated counterclockwise: -1
Use the --reverse option to reverse the reported rotation direction for all encoders:
python3 -m hardware_io.rotary_encoder.component_test.seesaw_rotary_encoder_cli \
--addresses 0x36 0x37 \
--reverse
Each Seesaw position counter is zeroed when its encoder is initialized. Position
changes are then read from the device’s incremental-delta register instead of
being calculated from its signed 32-bit absolute position. Invalid half-range
deltas are ignored, so a normal encoder step is not reported as a large
-2147483648 change.
Press Ctrl+C to stop the component test.
Car UI Event Routing
The Car UI uses encoder 0x36 as the system-volume encoder by default.
Clockwise and counterclockwise steps from that encoder are routed to global
volume up and volume down operations regardless of which panel is displayed.
Pressing that encoder toggles system mute.
All configured encoders are wrapped by RotaryEncoderInputAdapter, which
publishes normalized InputEvent values through the frontend-thread queue.
InputMapper assigns semantic UiAction values, and CarUiFrontend offers
those actions to the active ScreenUiIf before applying shell-level behavior.
Screens never receive physical encoder addresses, pins, callbacks, or device
objects. The volume encoder remains globally mapped to volume and mute actions.
RotaryEncoderIf
-> RotaryEncoderInputAdapter
-> InputEvent
-> UiInputEventDispatcher
-> InputMapper
-> UiAction
-> active ScreenUiIf / CarUiFrontend
The configured encoder list and volume role are set in
config/runtime.toml:
[input.rotary_encoders]
volume_index = 0
[[input.rotary_encoders.devices]]
driver = "seesaw"
address = 0x36
[[input.rotary_encoders.devices]]
driver = "seesaw"
address = 0x37
[[input.rotary_encoders.devices]]
driver = "gpio"
pin_a = 11
pin_b = 13
button = 15
The driver may be seesaw or gpio. Seesaw devices require a unique 7-bit
address; GPIO devices require unique physical header pins. volume_index
selects one entry in device order and is independent of the hardware driver.
The Car UI input runtime receives only RotaryEncoderIf instances and logical
device identifiers. Seesaw addresses and GPIO pins remain confined to
configuration and runtime hardware construction.
System Volume Encoder Component Test
Run the configuration-driven volume test from the project root:
python3 -m apps.carUi.input.component_test.volume_encoder_cli
The test loads config/runtime.toml, constructs and starts
only the device selected by volume_index through RotaryEncoderIf, and
routes it to the real PipeWire system-volume controller. Contextual encoders
are intentionally not started, so a missing panel encoder cannot block this
test. Each detected step prints the resulting volume:
Car UI volume encoder component test
Volume encoder device index: 0
Initial system volume: 10/20
Initial mute state: unmuted
Rotate the configured volume encoder or press it to toggle mute.
Press Ctrl+C to stop.
Volume up -> level 11/20
Audio muted
Audio unmuted
Volume down -> level 10/20
The test changes the actual default audio sink volume. It requires wpctl and
the configured encoder hardware. Use --config for another runtime TOML,
--volume-steps to change the reported range, or --step-percent to change
the PipeWire adjustment made per encoder step. The default is 20 reported
levels because the default 5% PipeWire increment divides the full range into
20 steps. The Car UI’s eight-bar volume indicator is a separate visual scale.
Positive adjustments are limited to 100% to prevent PipeWire amplification.
In the full application, VolumeManager maps audio levels 0..20
proportionally onto indicator bars 0..8.
When muted, all eight bars use the red muted color while preserving the
underlying level for unmute.
Design
The rotary encoder interface reports rotation and button events using callbacks.
Hardware-specific behavior is contained within each rotary encoder implementation.
The GPIO implementation uses Raspberry Pi GPIO interrupts and dispatches
accumulated rotation through the interface’s poll() method.
The Seesaw implementation reads encoder state over I2C and monitors the devices using background threads.