chumicro-knobs
Rotary encoders and analog knobs, read as a number that holds still.
Turn a shaft and encoder.position counts the clicks. Turn a potentiometer and knob.value lands on a step and stays there. A fast spin arrives whole even when your loop was busy elsewhere, a parked wiper keeps reporting the same number, and the same code runs on CircuitPython, MicroPython, and your laptop.
Part of the ChuMicro family: small, focused Python libraries for microcontrollers and laptops. Browse all libraries.
Install
# CircuitPython (after `circup bundle-add ChuMicro/ChuMicro-Bundle`)
circup install chumicro_knobs
# MicroPython
mpremote mip install github:ChuMicro/ChuMicro-Bundle/chumicro_knobs
# CPython
pip install chumicro-knobs
For bundle setup, pre-compiled .mpy bundles, the experimental channel, and details on PyPI naming, see the ChuMicro install guide.
Quick example
Wire the encoder's two signal pins to GPIO pins and its common pin to GND. The internal pull-ups are switched on for you, so no extra parts are needed.
import board
from chumicro_knobs import Encoder
from chumicro_timing import ticks_ms
volume = Encoder(board.GP16, board.GP17, bounds=(0, 20))
while True:
now = ticks_ms()
volume.check(now)
if volume.just_moved: # true only on the tick the shaft moved
print("volume", volume.position) # walks 0 to 20 and stops at both ends
The loop never pauses, so the rest of your program keeps running between turns. just_moved is true for exactly one pass, which means you can read it as many times as you like without it firing twice.
In a program with more going on, hand the loop to chumicro-runner and let the callback do the reading:
from chumicro_runner import Runner
volume.on_change = lambda detents: print("volume", volume.position)
runner = Runner()
runner.add(volume)
while True:
now = runner.tick()
runner.wait(now)
Every service in your program shares those same two lines.
A fast spin arrives whole
An encoder reports movement as pulses on two signal pins, and one brisk flick of the wrist sends dozens of them. If your loop stalls on a socket read or a flash write, a plain pin read looks at the wrong moments and most of the turn is gone. The counting here happens outside your loop, so a tick that arrives late still reads the whole spin.
On CircuitPython that counting is rotaryio, running in the firmware's own C, which on RP2040 boards is a state machine in the PIO block. On MicroPython the library installs an interrupt on both signal pins and decodes the pulses itself. Your program sets up neither one; it reads position on whichever runtime it happens to be on.
volume.check(now)
if volume.just_moved:
print(volume.delta) # every detent of the spin, even the ones during the stall
What's included
Core
| Symbol | Description |
|---|---|
Encoder(pin_a, pin_b, detent_steps=4, bounds=None, wrap=False) |
One rotary encoder on two signal pins, counted in detents |
AnalogKnob(pin, steps=100, deadband=512) |
One potentiometer or slider on one analog pin, read as a step number |
knob.check(now_ms) |
Take one reading; returns True when the number changed |
knob.handle(now_ms) |
Call on_change when the tick earned it |
knob.deinit() |
Hand the pins back, along with any interrupt the library installed |
Readings, refreshed by check(now_ms)
| Symbol | Description |
|---|---|
encoder.position |
Detents counted so far, held inside bounds when there are any. Assign to it to restore a saved value |
encoder.delta |
Detents this tick added to position, negative the other way round |
encoder.just_moved |
True only on the tick position changed |
knob.value |
Where the knob points, 0 at one end of the sweep and steps - 1 at the other |
knob.delta |
Steps this tick added to value, negative the other way round |
knob.raw |
The settled 0 to 65535 reading value was worked out from |
knob.just_moved |
True only on the tick value changed |
Callbacks, dispatched by handle(now_ms)
| Symbol | Description |
|---|---|
encoder.on_change |
Called with the signed detent change when the shaft turns |
knob.on_change |
Called with the new step number when the knob moves |
Defaults you can import
| Symbol | Description |
|---|---|
DEFAULT_DETENT_STEPS |
4, the pulses one click of a detented encoder produces |
DEFAULT_STEPS |
100 positions across a full sweep of an analog knob |
DEFAULT_DEADBAND |
512, how far a reading moves before value follows it |
RAW_RANGE |
65536, the raw scale every runtime reports a conversion on |
Testing
| Symbol | Description |
|---|---|
chumicro_knobs.testing.FakeEncoderSource |
A shaft your test turns by hand, so encoder logic runs with no board |
chumicro_knobs.testing.FakeAnalogSource |
A wiper your test parks where it likes, so the deadband is testable too |
The reading holds still
A potentiometer's voltage is never exactly steady. The low bits of a 12-bit converter wander a couple of counts under a parked wiper, which is 32 counts once the reading is scaled to the 0 to 65535 range every runtime reports on, and a noisier part on a long lead wanders several times that. A program that prints the raw reading shows a knob somebody is fiddling with.
deadband is how far the reading has to move before value follows it, and quantizing into steps gives the number a size a person can aim at:
brightness = AnalogKnob(board.A0, steps=10) # reports 0 to 9
fine = AnalogKnob(board.A1, steps=256, deadband=128) # a finer sweep, a tighter deadband
The default 512 sits well above the wander and well under the 655 counts one step spans at 100 steps. Keep deadband under the width of one step, which is 65536 // steps counts, so every step stays reachable including the ones at the ends of the sweep.
Where this fits
Depends on nothing. The timestamp you hand to check() comes from wherever your loop already gets one, and chumicro-timing's ticks_ms() is the usual source. Used directly in user apps; nothing downstream depends on it.
Pairs with chumicro-runner, which deals out turns to every service in your program:
runner.add(volume) # check() and handle() are the runner's contract
A rotary encoder's push switch is a button on its own pin, so an encoder with a click reads its shaft here and its switch with chumicro-buttons.
Platform support
Works on CPython, MicroPython, and CircuitPython.
RP2040 wants adjacent pins
rotaryio on RP2040 boards reads the two signal pins with one PIO state machine, which requires them to sit next to each other in GPIO numbering. board.GP16 and board.GP17 work; board.GP16 and board.GP20 do not. Other CircuitPython ports and every MicroPython port take any two pins.
pin_a, pin_b, and pin need real hardware, so on a laptop they raise and point you at the fakes below.
Testing your code
The chumicro_knobs.testing module provides FakeEncoderSource and FakeAnalogSource, hand-driven stand-ins for the hardware, so knob logic is an ordinary unit test with no board:
from chumicro_knobs import Encoder
from chumicro_knobs.testing import FakeEncoderSource
source = FakeEncoderSource()
volume = Encoder(source=source, bounds=(0, 20))
source.turn(3)
volume.check(0)
assert volume.position == 3
Examples
| Example | What it shows |
|---|---|
circuitpython_encoder_volume.py |
An encoder for volume and a potentiometer for brightness on CircuitPython |
micropython_encoder_volume.py |
The same two knobs on MicroPython |
Contributing
Issues, bug reports, and pull requests are welcome, and so is "I ran it on this board and here's what happened", some of the most useful feedback a hardware project can get. Development happens in the ChuMicro repository, whose contributing guide covers setup and the test workflow.
Docs
📖 Stable docs · Experimental docs
Find this library
- PyPI: chumicro-knobs
- Bundle: ChuMicro-Bundle (CircuitPython & MicroPython)
- Experimental bundle: ChuMicro-Bundle-Experimental
- Source: libraries/knobs
License
Metadata
Release files for chumicro-knobs 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| chumicro_knobs-0.1.1.tar.gz | 35.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| chumicro_knobs-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 51.8 kB
Release files / chumicro_knobs-0.1.1.tar.gz
| Download URL | chumicro_knobs-0.1.1.tar.gz |
|---|---|
| Size | 35.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4f1e81c402cb994f669c84c360cf8143c722aa2c8d410b87f2506939784f522c
|
|
BLAKE2b-256 checksum How to use checksums |
62ed80fc05f59f98607605a3f91c5769b7b89b25da0c65b5016850e6bcc5a1b3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency logRelease files / chumicro_knobs-0.1.1-py3-none-any.whl
| Download URL | chumicro_knobs-0.1.1-py3-none-any.whl |
|---|---|
| Size | 16.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
82ac2e9173d59c382248813a83d542c24e51215a0cb4950f761ddc3e97a2ca8e
|
|
BLAKE2b-256 checksum How to use checksums |
0f598f0e4c0526328ab036e88ea8745dab5918d72c4538745ff22aa6a14075fc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency log