chumicro-knobs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chumicro_knobs/__init__.py +22 -0
- chumicro_knobs/_adapters/__init__.py +0 -0
- chumicro_knobs/_adapters/base.py +75 -0
- chumicro_knobs/_adapters/cp.py +62 -0
- chumicro_knobs/_adapters/mp.py +125 -0
- chumicro_knobs/analog.py +115 -0
- chumicro_knobs/encoder.py +149 -0
- chumicro_knobs/testing.py +69 -0
- chumicro_knobs-0.1.0.dist-info/METADATA +212 -0
- chumicro_knobs-0.1.0.dist-info/RECORD +12 -0
- chumicro_knobs-0.1.0.dist-info/WHEEL +4 -0
- chumicro_knobs-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Rotary encoders and analog knobs, read as a position that holds still."""
|
|
2
|
+
|
|
3
|
+
import gc
|
|
4
|
+
|
|
5
|
+
from chumicro_knobs.analog import (
|
|
6
|
+
DEFAULT_DEADBAND,
|
|
7
|
+
DEFAULT_STEPS,
|
|
8
|
+
RAW_RANGE,
|
|
9
|
+
AnalogKnob,
|
|
10
|
+
)
|
|
11
|
+
from chumicro_knobs.encoder import DEFAULT_DETENT_STEPS, Encoder
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DEFAULT_DEADBAND",
|
|
15
|
+
"DEFAULT_DETENT_STEPS",
|
|
16
|
+
"DEFAULT_STEPS",
|
|
17
|
+
"RAW_RANGE",
|
|
18
|
+
"AnalogKnob",
|
|
19
|
+
"Encoder",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
gc.collect()
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""``EncoderSource`` and ``AnalogSource``: the contracts every per-runtime reader implements."""
|
|
2
|
+
|
|
3
|
+
#: How much of each new conversion a smoothed reading takes, as a right shift: the reading
|
|
4
|
+
#: keeps all but ``1 / 2 ** SMOOTHING_SHIFT`` of itself and takes the rest from the sample.
|
|
5
|
+
#: It runs after the median below and carries the whole burden of continuous noise, because a
|
|
6
|
+
#: median does nothing for that; the two reject different things and neither substitutes.
|
|
7
|
+
#:
|
|
8
|
+
#: A converter's noise is jumpy rather than merely wide, and it is the jumpiness that defeats
|
|
9
|
+
#: the deadband above it. That deadband anchors on whichever sample tripped it, so a reading
|
|
10
|
+
#: that leaps between noise extremes drags the anchor across a step boundary and back. This
|
|
11
|
+
#: removes the leaping, which is what lets the deadband behave the way it reads. Four is the
|
|
12
|
+
#: smallest shift that holds a parked knob on one number; at two it still wanders.
|
|
13
|
+
SMOOTHING_SHIFT = 4
|
|
14
|
+
|
|
15
|
+
def middle_of_three(first: int, second: int, third: int) -> int:
|
|
16
|
+
"""Return the middle of three readings, by comparison rather than by sorting.
|
|
17
|
+
|
|
18
|
+
Three is the smallest window with a middle, and one wild sample can never be the middle
|
|
19
|
+
of three, so a lone bad conversion is discarded outright rather than averaged in. That
|
|
20
|
+
is the half smoothing cannot do: a wild sample averaged in walks the reading steps away
|
|
21
|
+
and takes dozens of conversions to drain back out, while a discarded one moves nothing.
|
|
22
|
+
It costs no extra conversion, because the window holds readings the loop already took.
|
|
23
|
+
|
|
24
|
+
What it does not do is quieten continuous noise, where it is nearly useless alone, so it
|
|
25
|
+
runs ahead of the smoothing rather than instead of it.
|
|
26
|
+
"""
|
|
27
|
+
if first > second:
|
|
28
|
+
first, second = second, first
|
|
29
|
+
if second > third:
|
|
30
|
+
second = third
|
|
31
|
+
if first > second:
|
|
32
|
+
second = first
|
|
33
|
+
return second
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EncoderSource:
|
|
37
|
+
"""Contract for the reader that turns two quadrature pins into a detent count.
|
|
38
|
+
|
|
39
|
+
A source owns capture and quadrature decode, including the division into the clicks a
|
|
40
|
+
wrist feels. Bounds, wrap, and the per-tick readings are decided above it.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# Plain class, not a Protocol: MicroPython has no typing module to import.
|
|
44
|
+
|
|
45
|
+
#: Detents counted since the source was built, rising one way and falling the other.
|
|
46
|
+
#: Nothing clamps or resets it, so the difference between two ticks is the turning.
|
|
47
|
+
raw_position = 0
|
|
48
|
+
|
|
49
|
+
def poll(self, now_ms: int) -> None:
|
|
50
|
+
"""Take one capture step against the tick ``now_ms``, if this source needs one."""
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def deinit(self) -> None:
|
|
54
|
+
"""Release the pins and any interrupt this source installed."""
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AnalogSource:
|
|
59
|
+
"""Contract for the reader that turns one analog pin into a number.
|
|
60
|
+
|
|
61
|
+
A source owns the conversion and stops there. The deadband and the quantization into
|
|
62
|
+
steps are decided above it.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
#: Most recent conversion, 0 at the bottom of the sweep and 65535 at the top, whatever
|
|
66
|
+
#: the converter's native width is.
|
|
67
|
+
raw = 0
|
|
68
|
+
|
|
69
|
+
def poll(self, now_ms: int) -> None:
|
|
70
|
+
"""Convert once, as of the tick ``now_ms``, and store the answer in ``raw``."""
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def deinit(self) -> None:
|
|
74
|
+
"""Release the pin this source claimed."""
|
|
75
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CircuitPython sources: ``rotaryio`` for the shaft, ``analogio`` for the wiper."""
|
|
2
|
+
|
|
3
|
+
__chumicro_runtimes__ = ("circuitpython",) # pragma: no cover - CP runtime path
|
|
4
|
+
|
|
5
|
+
import analogio # pragma: no cover - CP runtime path
|
|
6
|
+
import rotaryio # pragma: no cover - CP runtime path
|
|
7
|
+
|
|
8
|
+
from chumicro_knobs._adapters.base import SMOOTHING_SHIFT, middle_of_three
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CpEncoderSource: # pragma: no cover - CP runtime path
|
|
12
|
+
"""Quadrature counting done by ``rotaryio.IncrementalEncoder`` in firmware.
|
|
13
|
+
|
|
14
|
+
The firmware watches the two pins from hardware rather than from the loop, so a fast
|
|
15
|
+
spin during a flash write or a socket read is still counted and a late tick reads the
|
|
16
|
+
whole turn. ``detent_steps`` becomes the encoder's ``divisor``, which handles a turn
|
|
17
|
+
that reverses part way into a detent.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, pin_a, pin_b, *, detent_steps: int) -> None:
|
|
21
|
+
self._encoder = rotaryio.IncrementalEncoder(pin_a, pin_b, divisor=detent_steps)
|
|
22
|
+
self.raw_position = self._encoder.position
|
|
23
|
+
|
|
24
|
+
def poll(self, now_ms: int) -> None:
|
|
25
|
+
"""Copy over the count the firmware kept while the loop was somewhere else."""
|
|
26
|
+
self.raw_position = self._encoder.position
|
|
27
|
+
|
|
28
|
+
def deinit(self) -> None:
|
|
29
|
+
"""Release the two pins and the counter behind them."""
|
|
30
|
+
self._encoder.deinit()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CpAnalogSource: # pragma: no cover - CP runtime path
|
|
34
|
+
"""One ``analogio.AnalogIn``, sampled on the tick that asks for it.
|
|
35
|
+
|
|
36
|
+
``AnalogIn.value`` reads 0 to 65535 on every board, scaled up by the firmware when the
|
|
37
|
+
converter underneath is narrower.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, pin) -> None:
|
|
41
|
+
self._converter = analogio.AnalogIn(pin)
|
|
42
|
+
reading = self._converter.value
|
|
43
|
+
# The window starts full of the first reading so the median has three from the outset.
|
|
44
|
+
self._recent = [reading, reading, reading]
|
|
45
|
+
self._slot = 0
|
|
46
|
+
# Carried scaled up by the shift so the fraction it keeps survives integer division.
|
|
47
|
+
self._smoothed = reading << SMOOTHING_SHIFT
|
|
48
|
+
self.raw = reading
|
|
49
|
+
|
|
50
|
+
def poll(self, now_ms: int) -> None:
|
|
51
|
+
"""Convert once, drop it if it is a lone outlier, and smooth what is left."""
|
|
52
|
+
recent = self._recent
|
|
53
|
+
recent[self._slot] = self._converter.value
|
|
54
|
+
slot = self._slot + 1
|
|
55
|
+
self._slot = 0 if slot >= 3 else slot
|
|
56
|
+
middle = middle_of_three(recent[0], recent[1], recent[2])
|
|
57
|
+
self._smoothed += middle - (self._smoothed >> SMOOTHING_SHIFT)
|
|
58
|
+
self.raw = self._smoothed >> SMOOTHING_SHIFT
|
|
59
|
+
|
|
60
|
+
def deinit(self) -> None:
|
|
61
|
+
"""Release the pin this knob claimed."""
|
|
62
|
+
self._converter.deinit()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""MicroPython sources: a capture interrupt for the shaft, ``machine.ADC`` for the wiper."""
|
|
2
|
+
|
|
3
|
+
__chumicro_runtimes__ = ("micropython",) # pragma: no cover - MP runtime path
|
|
4
|
+
|
|
5
|
+
import array # pragma: no cover - MP runtime path
|
|
6
|
+
|
|
7
|
+
import machine # pragma: no cover - MP runtime path
|
|
8
|
+
|
|
9
|
+
from chumicro_knobs._adapters.base import SMOOTHING_SHIFT, middle_of_three
|
|
10
|
+
|
|
11
|
+
try: # pragma: no cover - MP runtime path
|
|
12
|
+
from micropython import const
|
|
13
|
+
except ImportError:
|
|
14
|
+
def const(value):
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
# Quadrature decode, indexed by (previous_state << 2) | current_state where a state is
|
|
18
|
+
# (pin_a << 1) | pin_b. Each byte holds the step plus one: 0 steps back, 1 stays put, 2 steps
|
|
19
|
+
# forward. The four entries where both pins changed at once read as no movement, because a
|
|
20
|
+
# turning shaft cannot produce that and treating it as a step is how a dirty encoder invents
|
|
21
|
+
# detents. ``bytes`` keeps the table in flash, so indexing it costs nothing on the heap.
|
|
22
|
+
_QUADRATURE_STEPS = ( # pragma: no cover - MP runtime path
|
|
23
|
+
b"\x01\x00\x02\x01\x02\x01\x01\x00"
|
|
24
|
+
b"\x00\x01\x01\x02\x01\x02\x00\x01"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Slots the interrupt writes in the counter array: detents counted so far, quadrature steps
|
|
28
|
+
# banked toward the detent in progress, and the pin state the last edge saw.
|
|
29
|
+
_POSITION = const(0) # pragma: no cover - MP runtime path
|
|
30
|
+
_SUB_COUNT = const(1) # pragma: no cover - MP runtime path
|
|
31
|
+
_PREVIOUS_STATE = const(2) # pragma: no cover - MP runtime path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MpEncoderSource: # pragma: no cover - MP runtime path
|
|
35
|
+
"""Quadrature counting done by a pin interrupt this class installs and owns.
|
|
36
|
+
|
|
37
|
+
MicroPython has no encoder peripheral binding, so both pins get an interrupt into one
|
|
38
|
+
handler, which is what catches a spin starting and ending between two passes of the
|
|
39
|
+
loop. ``pin_a`` and ``pin_b`` take a pin number or a ``machine.Pin``, and
|
|
40
|
+
``detent_steps`` is the quadrature steps that make one detent.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, pin_a, pin_b, *, detent_steps: int) -> None:
|
|
44
|
+
self._pin_a = machine.Pin(pin_a, machine.Pin.IN, machine.Pin.PULL_UP)
|
|
45
|
+
self._pin_b = machine.Pin(pin_b, machine.Pin.IN, machine.Pin.PULL_UP)
|
|
46
|
+
self._detent_steps = detent_steps
|
|
47
|
+
|
|
48
|
+
# Sized once here so the interrupt only ever writes into slots that already exist.
|
|
49
|
+
self._counters = array.array("l", (0, 0, 0))
|
|
50
|
+
self._counters[_PREVIOUS_STATE] = (self._pin_a.value() << 1) | self._pin_b.value()
|
|
51
|
+
|
|
52
|
+
# Bound once and kept, so no callable is built on the way into an interrupt.
|
|
53
|
+
self._edge_handler = self._on_edge
|
|
54
|
+
edges = machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING
|
|
55
|
+
self._pin_a.irq(handler=self._edge_handler, trigger=edges)
|
|
56
|
+
self._pin_b.irq(handler=self._edge_handler, trigger=edges)
|
|
57
|
+
|
|
58
|
+
self.raw_position = 0
|
|
59
|
+
|
|
60
|
+
def _on_edge(self, pin) -> None:
|
|
61
|
+
"""Fold one pin change into the detent count. This runs in interrupt context.
|
|
62
|
+
|
|
63
|
+
It reads two pins, indexes a table that lives in flash, and writes small integers
|
|
64
|
+
into an array that already exists, so nothing here reaches the heap. It decides
|
|
65
|
+
nothing either: bounds, wrap, and every callback happen later on the shared tick, in
|
|
66
|
+
normal context, where a slow or careless callback is harmless. ``pin`` goes unread,
|
|
67
|
+
because both pins share this handler and both are sampled here anyway.
|
|
68
|
+
"""
|
|
69
|
+
counters = self._counters
|
|
70
|
+
detent_steps = self._detent_steps
|
|
71
|
+
state = (self._pin_a.value() << 1) | self._pin_b.value()
|
|
72
|
+
step = _QUADRATURE_STEPS[(counters[_PREVIOUS_STATE] << 2) | state] - 1
|
|
73
|
+
counters[_PREVIOUS_STATE] = state
|
|
74
|
+
sub_count = counters[_SUB_COUNT] + step
|
|
75
|
+
if sub_count >= detent_steps:
|
|
76
|
+
counters[_POSITION] += 1
|
|
77
|
+
sub_count = 0
|
|
78
|
+
elif sub_count <= -detent_steps:
|
|
79
|
+
counters[_POSITION] -= 1
|
|
80
|
+
sub_count = 0
|
|
81
|
+
counters[_SUB_COUNT] = sub_count
|
|
82
|
+
|
|
83
|
+
def poll(self, now_ms: int) -> None:
|
|
84
|
+
"""Copy over the count the interrupt kept while the loop was somewhere else."""
|
|
85
|
+
self.raw_position = self._counters[_POSITION]
|
|
86
|
+
|
|
87
|
+
def deinit(self) -> None:
|
|
88
|
+
"""Take the interrupt off both pins so nothing counts after this."""
|
|
89
|
+
self._pin_a.irq(handler=None)
|
|
90
|
+
self._pin_b.irq(handler=None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MpAnalogSource: # pragma: no cover - MP runtime path
|
|
94
|
+
"""One ``machine.ADC``, sampled on the tick that asks for it.
|
|
95
|
+
|
|
96
|
+
``pin`` is a pin number or a ``machine.Pin``. ``read_u16`` reports 0 to 65535 on every
|
|
97
|
+
port, stretched up from whatever the converter's native width is, so a 12-bit part
|
|
98
|
+
takes the same step arithmetic as a wider one.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, pin) -> None:
|
|
102
|
+
self._converter = machine.ADC(pin)
|
|
103
|
+
reading = self._converter.read_u16()
|
|
104
|
+
# The window starts full of the first reading so the median has three from the outset.
|
|
105
|
+
self._recent = [reading, reading, reading]
|
|
106
|
+
self._slot = 0
|
|
107
|
+
# Carried scaled up by the shift so the fraction it keeps survives integer division.
|
|
108
|
+
self._smoothed = reading << SMOOTHING_SHIFT
|
|
109
|
+
self.raw = reading
|
|
110
|
+
|
|
111
|
+
def poll(self, now_ms: int) -> None:
|
|
112
|
+
"""Convert once, drop it if it is a lone outlier, and smooth what is left."""
|
|
113
|
+
recent = self._recent
|
|
114
|
+
recent[self._slot] = self._converter.read_u16()
|
|
115
|
+
slot = self._slot + 1
|
|
116
|
+
self._slot = 0 if slot >= 3 else slot
|
|
117
|
+
middle = middle_of_three(recent[0], recent[1], recent[2])
|
|
118
|
+
self._smoothed += middle - (self._smoothed >> SMOOTHING_SHIFT)
|
|
119
|
+
self.raw = self._smoothed >> SMOOTHING_SHIFT
|
|
120
|
+
|
|
121
|
+
def deinit(self) -> None:
|
|
122
|
+
"""Do nothing, because ``machine.ADC`` claims no pin it could hand back.
|
|
123
|
+
|
|
124
|
+
The method is here so a knob is torn down the same way on either runtime.
|
|
125
|
+
"""
|
chumicro_knobs/analog.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Analog knobs: a converter reading held still by a deadband and quantized into steps."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
#: Full scale of the raw reading plus one. Dividing a 0 to 65535 conversion by this turns
|
|
6
|
+
#: it into a fraction of the sweep.
|
|
7
|
+
RAW_RANGE = 65536
|
|
8
|
+
|
|
9
|
+
#: How many positions a full sweep of the knob reports, numbered 0 to ``steps - 1``.
|
|
10
|
+
DEFAULT_STEPS = 100
|
|
11
|
+
|
|
12
|
+
#: How far the raw reading must move before ``value`` follows it. A parked wiper still
|
|
13
|
+
#: wanders a few counts, more on a noisy part or a long lead; 512 clears that and stays
|
|
14
|
+
#: under one step at the default 100 steps, where a step spans 655 counts.
|
|
15
|
+
DEFAULT_DEADBAND = 512
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _select_analog_source(pin):
|
|
19
|
+
"""Return the converter this runtime can build for one pin."""
|
|
20
|
+
runtime_name = sys.implementation.name
|
|
21
|
+
if runtime_name == "circuitpython": # pragma: no cover - CP runtime path
|
|
22
|
+
from chumicro_knobs._adapters.cp import CpAnalogSource
|
|
23
|
+
return CpAnalogSource(pin)
|
|
24
|
+
if runtime_name == "micropython": # pragma: no cover - MP runtime path
|
|
25
|
+
from chumicro_knobs._adapters.mp import MpAnalogSource
|
|
26
|
+
return MpAnalogSource(pin)
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
"CPython has no converter to sample. Build the knob with "
|
|
29
|
+
"source=FakeAnalogSource() from chumicro_knobs.testing and set a reading from "
|
|
30
|
+
"your test, or run this on CircuitPython or MicroPython.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AnalogKnob:
|
|
35
|
+
"""One potentiometer or slider, read as a step number that stops rattling.
|
|
36
|
+
|
|
37
|
+
Every reading is a plain attribute refreshed by ``check(now_ms)``, and ``just_moved`` is
|
|
38
|
+
true only for the tick the knob moved on. ``deadband`` is the analog counterpart of a
|
|
39
|
+
button's debounce, and ``steps`` gives the reading a size a person can aim at, so a
|
|
40
|
+
volume control lands on 47 and stays there. Sampling happens on the tick that asks for
|
|
41
|
+
it, because a voltage has no edge to miss between two passes of the loop.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pin: Analog-capable pin, named the way this runtime names pins. Pass a pin or pass
|
|
45
|
+
``source``.
|
|
46
|
+
source: Pre-built converter; overrides ``pin``. Tests inject a fake here.
|
|
47
|
+
steps: How many positions the sweep reports. ``value`` runs 0 to ``steps - 1``.
|
|
48
|
+
deadband: How far the raw 0 to 65535 reading must move before ``value`` follows.
|
|
49
|
+
Keep it under one step, ``65536 // steps`` counts, or the steps at the edges
|
|
50
|
+
become unreachable.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
pin: object | None = None,
|
|
56
|
+
*,
|
|
57
|
+
source: object | None = None,
|
|
58
|
+
steps: int = DEFAULT_STEPS,
|
|
59
|
+
deadband: int = DEFAULT_DEADBAND,
|
|
60
|
+
) -> None:
|
|
61
|
+
if source is not None:
|
|
62
|
+
self._source = source
|
|
63
|
+
elif pin is not None:
|
|
64
|
+
self._source = _select_analog_source(pin)
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError("AnalogKnob needs either pin= or source=")
|
|
67
|
+
|
|
68
|
+
self._steps = steps
|
|
69
|
+
self._deadband = deadband
|
|
70
|
+
|
|
71
|
+
#: Where the knob points, 0 at one end of the sweep and ``steps - 1`` at the other.
|
|
72
|
+
self.value = 0
|
|
73
|
+
#: The 0 to 65535 reading ``value`` was worked out from. It moves only when the
|
|
74
|
+
#: knob clears the deadband, so it is the settled reading rather than the newest.
|
|
75
|
+
self.raw = 0
|
|
76
|
+
#: Steps this tick added to ``value``, negative the other way round.
|
|
77
|
+
self.delta = 0
|
|
78
|
+
#: True only on the tick ``value`` changed.
|
|
79
|
+
self.just_moved = False
|
|
80
|
+
#: Called with the new step number when the knob moves.
|
|
81
|
+
self.on_change = None
|
|
82
|
+
|
|
83
|
+
def check(self, now_ms: int) -> bool:
|
|
84
|
+
"""Sample the pin and let ``value`` follow it when the reading cleared the deadband.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
now_ms: Tick this pass of the loop is measured from.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True when ``value`` changed, so a quiet tick can skip ``handle``.
|
|
91
|
+
"""
|
|
92
|
+
source = self._source
|
|
93
|
+
source.poll(now_ms)
|
|
94
|
+
raw = source.raw
|
|
95
|
+
if abs(raw - self.raw) <= self._deadband:
|
|
96
|
+
self.delta = 0
|
|
97
|
+
self.just_moved = False
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
self.raw = raw
|
|
101
|
+
value = raw * self._steps // RAW_RANGE
|
|
102
|
+
delta = value - self.value
|
|
103
|
+
self.value = value
|
|
104
|
+
self.delta = delta
|
|
105
|
+
self.just_moved = delta != 0
|
|
106
|
+
return self.just_moved
|
|
107
|
+
|
|
108
|
+
def handle(self, now_ms: int) -> None:
|
|
109
|
+
"""Call ``on_change`` when this tick's movement earned it."""
|
|
110
|
+
if self.just_moved and self.on_change is not None:
|
|
111
|
+
self.on_change(self.value)
|
|
112
|
+
|
|
113
|
+
def deinit(self) -> None:
|
|
114
|
+
"""Release the pin the source claimed."""
|
|
115
|
+
self._source.deinit()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Rotary encoders: detents counted into a position, with optional bounds and wrap."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
#: Quadrature steps that make one detent, the click a wrist feels as the shaft turns.
|
|
6
|
+
DEFAULT_DETENT_STEPS = 4
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _select_encoder_source(pin_a, pin_b, *, detent_steps):
|
|
10
|
+
"""Return the quadrature source this runtime can build for two pins."""
|
|
11
|
+
runtime_name = sys.implementation.name
|
|
12
|
+
if runtime_name == "circuitpython": # pragma: no cover - CP runtime path
|
|
13
|
+
from chumicro_knobs._adapters.cp import CpEncoderSource
|
|
14
|
+
return CpEncoderSource(pin_a, pin_b, detent_steps=detent_steps)
|
|
15
|
+
if runtime_name == "micropython": # pragma: no cover - MP runtime path
|
|
16
|
+
from chumicro_knobs._adapters.mp import MpEncoderSource
|
|
17
|
+
return MpEncoderSource(pin_a, pin_b, detent_steps=detent_steps)
|
|
18
|
+
raise RuntimeError(
|
|
19
|
+
"CPython has no pins to watch a shaft with. Build the encoder with "
|
|
20
|
+
"source=FakeEncoderSource() from chumicro_knobs.testing and turn it from "
|
|
21
|
+
"your test, or run this on CircuitPython or MicroPython.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Encoder:
|
|
26
|
+
"""One rotary encoder, read as a position in detents plus how far it just turned.
|
|
27
|
+
|
|
28
|
+
Every reading is a plain attribute refreshed by ``check(now_ms)``, and ``just_moved``
|
|
29
|
+
is true only for the tick the turning landed on. ``position`` counts detents from
|
|
30
|
+
zero rather than reporting an angle, because an encoder knows how far its shaft moved
|
|
31
|
+
and never where it points. The push switch under the shaft is a separate part on its
|
|
32
|
+
own pin.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
pin_a: First quadrature pin, named the way this runtime names pins. Pass both
|
|
36
|
+
pins or pass ``source``.
|
|
37
|
+
pin_b: Second quadrature pin. Swapping the two reverses which way counts up.
|
|
38
|
+
source: Pre-built quadrature source; overrides the pins. Tests inject a fake here.
|
|
39
|
+
detent_steps: Quadrature steps that make one detent. Four suits a panel-mount
|
|
40
|
+
encoder with a click at every cycle; use 1 for a smooth one with no clicks.
|
|
41
|
+
bounds: ``(low, high)`` inclusive range ``position`` is held inside, or None to let
|
|
42
|
+
it run in both directions forever.
|
|
43
|
+
wrap: True to carry ``position`` around the ends of ``bounds`` instead of stopping
|
|
44
|
+
there, which is what a hue selector or a menu ring wants. Needs ``bounds``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
pin_a: object | None = None,
|
|
50
|
+
pin_b: object | None = None,
|
|
51
|
+
*,
|
|
52
|
+
source: object | None = None,
|
|
53
|
+
detent_steps: int = DEFAULT_DETENT_STEPS,
|
|
54
|
+
bounds: tuple[int, int] | None = None,
|
|
55
|
+
wrap: bool = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
if detent_steps < 1:
|
|
58
|
+
# At zero the detent guard is satisfied by a step of zero, so a shaft
|
|
59
|
+
# standing still counts up; below zero every edge counts.
|
|
60
|
+
raise ValueError("detent_steps must be 1 or more")
|
|
61
|
+
if source is not None:
|
|
62
|
+
self._source = source
|
|
63
|
+
elif pin_a is not None and pin_b is not None:
|
|
64
|
+
self._source = _select_encoder_source(
|
|
65
|
+
pin_a, pin_b, detent_steps=detent_steps,
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError("Encoder needs both pin_a= and pin_b=, or source=")
|
|
69
|
+
|
|
70
|
+
if bounds is None:
|
|
71
|
+
if wrap:
|
|
72
|
+
raise ValueError("wrap=True needs bounds=(low, high) to wrap around")
|
|
73
|
+
self._bounded = False
|
|
74
|
+
self._low = 0
|
|
75
|
+
self._high = 0
|
|
76
|
+
self._span = 0
|
|
77
|
+
else:
|
|
78
|
+
self._bounded = True
|
|
79
|
+
self._low = bounds[0]
|
|
80
|
+
self._high = bounds[1]
|
|
81
|
+
self._span = self._high - self._low + 1
|
|
82
|
+
self._wrap = wrap
|
|
83
|
+
|
|
84
|
+
start_position = 0
|
|
85
|
+
if self._bounded:
|
|
86
|
+
if start_position < self._low:
|
|
87
|
+
start_position = self._low
|
|
88
|
+
elif start_position > self._high:
|
|
89
|
+
start_position = self._high
|
|
90
|
+
|
|
91
|
+
#: Detents counted so far, held inside ``bounds`` when there are any. Assign to it
|
|
92
|
+
#: to restore a reading saved at shutdown without turning the shaft.
|
|
93
|
+
self.position = start_position
|
|
94
|
+
#: Detents this tick added to ``position``, negative the other way round. Held
|
|
95
|
+
#: against a bound with no wrap, the turning that did not fit reports as zero.
|
|
96
|
+
self.delta = 0
|
|
97
|
+
#: True only on the tick ``position`` changed.
|
|
98
|
+
self.just_moved = False
|
|
99
|
+
#: Called with the signed detent change when the knob turns.
|
|
100
|
+
self.on_change = None
|
|
101
|
+
|
|
102
|
+
self._last_raw_position = self._source.raw_position
|
|
103
|
+
|
|
104
|
+
def check(self, now_ms: int) -> bool:
|
|
105
|
+
"""Read how far the shaft turned since the last tick and fold it into ``position``.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
now_ms: Tick this pass of the loop is measured from.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True when ``position`` changed, so a quiet tick can skip ``handle``.
|
|
112
|
+
"""
|
|
113
|
+
source = self._source
|
|
114
|
+
source.poll(now_ms)
|
|
115
|
+
raw_position = source.raw_position
|
|
116
|
+
moved = raw_position - self._last_raw_position
|
|
117
|
+
if moved == 0:
|
|
118
|
+
self.delta = 0
|
|
119
|
+
self.just_moved = False
|
|
120
|
+
return False
|
|
121
|
+
self._last_raw_position = raw_position
|
|
122
|
+
|
|
123
|
+
previous_position = self.position
|
|
124
|
+
position = previous_position + moved
|
|
125
|
+
if not self._bounded:
|
|
126
|
+
delta = moved
|
|
127
|
+
elif self._wrap:
|
|
128
|
+
position = self._low + (position - self._low) % self._span
|
|
129
|
+
delta = moved
|
|
130
|
+
else:
|
|
131
|
+
if position < self._low:
|
|
132
|
+
position = self._low
|
|
133
|
+
elif position > self._high:
|
|
134
|
+
position = self._high
|
|
135
|
+
delta = position - previous_position
|
|
136
|
+
|
|
137
|
+
self.position = position
|
|
138
|
+
self.delta = delta
|
|
139
|
+
self.just_moved = delta != 0
|
|
140
|
+
return self.just_moved
|
|
141
|
+
|
|
142
|
+
def handle(self, now_ms: int) -> None:
|
|
143
|
+
"""Call ``on_change`` when this tick's turning earned it."""
|
|
144
|
+
if self.just_moved and self.on_change is not None:
|
|
145
|
+
self.on_change(self.delta)
|
|
146
|
+
|
|
147
|
+
def deinit(self) -> None:
|
|
148
|
+
"""Release the pins and any interrupt the source installed."""
|
|
149
|
+
self._source.deinit()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Test-support helpers: a hand-turned :class:`FakeEncoderSource` and :class:`FakeAnalogSource`."""
|
|
2
|
+
|
|
3
|
+
__chumicro_test_support__ = True
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeEncoderSource:
|
|
7
|
+
"""Quadrature source a test turns by hand instead of by wrist.
|
|
8
|
+
|
|
9
|
+
``turn`` moves it by whole detents, which is what the runtime sources publish once they
|
|
10
|
+
have divided the quadrature steps down. Several turns before one tick add up.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
raw_position: Detent count the source starts from.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, raw_position: int = 0) -> None:
|
|
17
|
+
self.raw_position = raw_position
|
|
18
|
+
#: How many times a knob asked this source to capture.
|
|
19
|
+
self.poll_calls = 0
|
|
20
|
+
#: Tick of the last capture, which proves the knob passes the loop's timestamp down.
|
|
21
|
+
self.last_poll_ms = 0
|
|
22
|
+
#: How many times a knob released this source.
|
|
23
|
+
self.deinit_calls = 0
|
|
24
|
+
|
|
25
|
+
def turn(self, detents: int) -> None:
|
|
26
|
+
"""Move the shaft ``detents`` clicks, negative for the other direction."""
|
|
27
|
+
self.raw_position += detents
|
|
28
|
+
|
|
29
|
+
def poll(self, now_ms: int) -> None:
|
|
30
|
+
"""Record that a capture step was asked for; the count is moved by the test."""
|
|
31
|
+
self.poll_calls += 1
|
|
32
|
+
self.last_poll_ms = now_ms
|
|
33
|
+
|
|
34
|
+
def deinit(self) -> None:
|
|
35
|
+
"""Record that the knob released this source."""
|
|
36
|
+
self.deinit_calls += 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FakeAnalogSource:
|
|
40
|
+
"""Converter a test sets a reading on instead of turning a wiper.
|
|
41
|
+
|
|
42
|
+
``set_raw`` takes the 0 to 65535 scale every runtime reports on, so small moves between
|
|
43
|
+
ticks exercise the deadband.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
raw: Reading the source starts from.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, raw: int = 0) -> None:
|
|
50
|
+
self.raw = raw
|
|
51
|
+
#: How many times a knob asked this source to convert.
|
|
52
|
+
self.poll_calls = 0
|
|
53
|
+
#: Tick of the last conversion, which proves the knob passes the loop's timestamp down.
|
|
54
|
+
self.last_poll_ms = 0
|
|
55
|
+
#: How many times a knob released this source.
|
|
56
|
+
self.deinit_calls = 0
|
|
57
|
+
|
|
58
|
+
def set_raw(self, raw: int) -> None:
|
|
59
|
+
"""Park the wiper at ``raw`` on the 0 to 65535 scale."""
|
|
60
|
+
self.raw = raw
|
|
61
|
+
|
|
62
|
+
def poll(self, now_ms: int) -> None:
|
|
63
|
+
"""Record that a conversion was asked for; the reading is set by the test."""
|
|
64
|
+
self.poll_calls += 1
|
|
65
|
+
self.last_poll_ms = now_ms
|
|
66
|
+
|
|
67
|
+
def deinit(self) -> None:
|
|
68
|
+
"""Record that the knob released this source."""
|
|
69
|
+
self.deinit_calls += 1
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: chumicro-knobs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Rotary encoders and analog knobs, read as a position that holds still
|
|
5
|
+
Project-URL: Homepage, https://github.com/ChuMicro/ChuMicro
|
|
6
|
+
Project-URL: Documentation, https://chumicro.com/ChuMicro/knobs/stable/
|
|
7
|
+
Project-URL: Source, https://github.com/ChuMicro/ChuMicro/tree/main/libraries/knobs
|
|
8
|
+
Project-URL: Issues, https://github.com/ChuMicro/ChuMicro/issues
|
|
9
|
+
Project-URL: Bundle, https://github.com/ChuMicro/ChuMicro-Bundle
|
|
10
|
+
Author: ChuMicro
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: adc,circuitpython,embedded,esp32,microcontroller,micropython,potentiometer,quadrature,rotary-encoder,rp2040
|
|
14
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
20
|
+
Classifier: Topic :: System :: Hardware
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: chumicro-test-harness; extra == 'test'
|
|
24
|
+
Requires-Dist: chumicro-timing; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# chumicro-knobs
|
|
29
|
+
|
|
30
|
+
<img src="https://raw.githubusercontent.com/ChuMicro/ChuMicro/main/support/docs/chumicro_tip.png"
|
|
31
|
+
align="left" width="64" style="margin-right: 16px; margin-bottom: 8px;">
|
|
32
|
+
|
|
33
|
+
**Rotary encoders and analog knobs, read as a number that holds still.**
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
<br clear="left">
|
|
38
|
+
|
|
39
|
+
> Part of the [ChuMicro](https://github.com/ChuMicro/ChuMicro) family: small, focused Python libraries for microcontrollers and laptops. [Browse all libraries.](https://github.com/ChuMicro/ChuMicro/tree/main/libraries)
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# CircuitPython (after `circup bundle-add ChuMicro/ChuMicro-Bundle`)
|
|
45
|
+
circup install chumicro_knobs
|
|
46
|
+
|
|
47
|
+
# MicroPython
|
|
48
|
+
mpremote mip install github:ChuMicro/ChuMicro-Bundle/chumicro_knobs
|
|
49
|
+
|
|
50
|
+
# CPython
|
|
51
|
+
pip install chumicro-knobs
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For bundle setup, pre-compiled `.mpy` bundles, the experimental channel, and details on PyPI naming, see the [ChuMicro install guide](https://chumicro.com/ChuMicro/guides/install/).
|
|
55
|
+
|
|
56
|
+
## Quick example
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import board
|
|
62
|
+
from chumicro_knobs import Encoder
|
|
63
|
+
from chumicro_timing import ticks_ms
|
|
64
|
+
|
|
65
|
+
volume = Encoder(board.GP16, board.GP17, bounds=(0, 20))
|
|
66
|
+
|
|
67
|
+
while True:
|
|
68
|
+
now = ticks_ms()
|
|
69
|
+
volume.check(now)
|
|
70
|
+
|
|
71
|
+
if volume.just_moved: # true only on the tick the shaft moved
|
|
72
|
+
print("volume", volume.position) # walks 0 to 20 and stops at both ends
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
## A fast spin arrives whole
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
volume.check(now)
|
|
85
|
+
if volume.just_moved:
|
|
86
|
+
print(volume.delta) # every detent of the spin, even the ones during the stall
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## What's included
|
|
90
|
+
|
|
91
|
+
### Core
|
|
92
|
+
|
|
93
|
+
| Symbol | Description |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `Encoder(pin_a, pin_b, detent_steps=4, bounds=None, wrap=False)` | One rotary encoder on two signal pins, counted in detents |
|
|
96
|
+
| `AnalogKnob(pin, steps=100, deadband=512)` | One potentiometer or slider on one analog pin, read as a step number |
|
|
97
|
+
| `knob.check(now_ms)` | Take one reading; returns `True` when the number changed |
|
|
98
|
+
| `knob.handle(now_ms)` | Call `on_change` when the tick earned it |
|
|
99
|
+
| `knob.deinit()` | Hand the pins back, along with any interrupt the library installed |
|
|
100
|
+
|
|
101
|
+
### Readings, refreshed by `check(now_ms)`
|
|
102
|
+
|
|
103
|
+
| Symbol | Description |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `encoder.position` | Detents counted so far, held inside `bounds` when there are any. Assign to it to restore a saved value |
|
|
106
|
+
| `encoder.delta` | Detents this tick added to `position`, negative the other way round |
|
|
107
|
+
| `encoder.just_moved` | `True` only on the tick `position` changed |
|
|
108
|
+
| `knob.value` | Where the knob points, `0` at one end of the sweep and `steps - 1` at the other |
|
|
109
|
+
| `knob.delta` | Steps this tick added to `value`, negative the other way round |
|
|
110
|
+
| `knob.raw` | The settled 0 to 65535 reading `value` was worked out from |
|
|
111
|
+
| `knob.just_moved` | `True` only on the tick `value` changed |
|
|
112
|
+
|
|
113
|
+
### Callbacks, dispatched by `handle(now_ms)`
|
|
114
|
+
|
|
115
|
+
| Symbol | Description |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `encoder.on_change` | Called with the signed detent change when the shaft turns |
|
|
118
|
+
| `knob.on_change` | Called with the new step number when the knob moves |
|
|
119
|
+
|
|
120
|
+
### Defaults you can import
|
|
121
|
+
|
|
122
|
+
| Symbol | Description |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `DEFAULT_DETENT_STEPS` | `4`, the pulses one click of a detented encoder produces |
|
|
125
|
+
| `DEFAULT_STEPS` | `100` positions across a full sweep of an analog knob |
|
|
126
|
+
| `DEFAULT_DEADBAND` | `512`, how far a reading moves before `value` follows it |
|
|
127
|
+
| `RAW_RANGE` | `65536`, the raw scale every runtime reports a conversion on |
|
|
128
|
+
|
|
129
|
+
### Testing
|
|
130
|
+
|
|
131
|
+
| Symbol | Description |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `chumicro_knobs.testing.FakeEncoderSource` | A shaft your test turns by hand, so encoder logic runs with no board |
|
|
134
|
+
| `chumicro_knobs.testing.FakeAnalogSource` | A wiper your test parks where it likes, so the deadband is testable too |
|
|
135
|
+
|
|
136
|
+
## The reading holds still
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
`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:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
brightness = AnalogKnob(board.A0, steps=10) # reports 0 to 9
|
|
144
|
+
fine = AnalogKnob(board.A1, steps=256, deadband=128) # a finer sweep, a tighter deadband
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
## Where this fits
|
|
150
|
+
|
|
151
|
+
Depends on nothing. The timestamp you hand to `check()` comes from wherever your loop already gets one, and [`chumicro-timing`](https://github.com/ChuMicro/ChuMicro/tree/main/libraries/timing)'s `ticks_ms()` is the usual source. Used directly in user apps; nothing downstream depends on it.
|
|
152
|
+
|
|
153
|
+
Pairs with [`chumicro-runner`](https://github.com/ChuMicro/ChuMicro/tree/main/libraries/runner), which deals out turns to every service in your program:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
runner.add(volume) # check() and handle() are the runner's contract
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
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`](https://github.com/ChuMicro/ChuMicro/tree/main/libraries/buttons).
|
|
160
|
+
|
|
161
|
+
## Platform support
|
|
162
|
+
|
|
163
|
+
Works on CPython, MicroPython, and CircuitPython.
|
|
164
|
+
|
|
165
|
+
### RP2040 wants adjacent pins
|
|
166
|
+
|
|
167
|
+
`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.
|
|
168
|
+
|
|
169
|
+
`pin_a`, `pin_b`, and `pin` need real hardware, so on a laptop they raise and point you at the fakes below.
|
|
170
|
+
|
|
171
|
+
## Testing your code
|
|
172
|
+
|
|
173
|
+
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:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from chumicro_knobs import Encoder
|
|
177
|
+
from chumicro_knobs.testing import FakeEncoderSource
|
|
178
|
+
|
|
179
|
+
source = FakeEncoderSource()
|
|
180
|
+
volume = Encoder(source=source, bounds=(0, 20))
|
|
181
|
+
|
|
182
|
+
source.turn(3)
|
|
183
|
+
volume.check(0)
|
|
184
|
+
|
|
185
|
+
assert volume.position == 3
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Examples
|
|
189
|
+
|
|
190
|
+
| Example | What it shows |
|
|
191
|
+
|---|---|
|
|
192
|
+
| [`circuitpython_encoder_volume.py`](https://github.com/ChuMicro/ChuMicro/blob/main/libraries/knobs/examples/circuitpython_encoder_volume.py) | An encoder for volume and a potentiometer for brightness on CircuitPython |
|
|
193
|
+
| [`micropython_encoder_volume.py`](https://github.com/ChuMicro/ChuMicro/blob/main/libraries/knobs/examples/micropython_encoder_volume.py) | The same two knobs on MicroPython |
|
|
194
|
+
|
|
195
|
+
## Contributing
|
|
196
|
+
|
|
197
|
+
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](https://github.com/ChuMicro/ChuMicro), whose contributing guide covers setup and the test workflow.
|
|
198
|
+
|
|
199
|
+
## Docs
|
|
200
|
+
|
|
201
|
+
📖 **[Stable docs](https://chumicro.com/ChuMicro/knobs/stable/)** · **[Experimental docs](https://chumicro.com/ChuMicro/knobs/experimental/)**
|
|
202
|
+
|
|
203
|
+
## Find this library
|
|
204
|
+
|
|
205
|
+
- **PyPI:** [chumicro-knobs](https://pypi.org/project/chumicro-knobs/)
|
|
206
|
+
- **Bundle:** [ChuMicro-Bundle](https://github.com/ChuMicro/ChuMicro-Bundle/tree/main/chumicro_knobs) (CircuitPython & MicroPython)
|
|
207
|
+
- **Experimental bundle:** [ChuMicro-Bundle-Experimental](https://github.com/ChuMicro/ChuMicro-Bundle-Experimental/tree/main/chumicro_knobs)
|
|
208
|
+
- **Source:** [libraries/knobs](https://github.com/ChuMicro/ChuMicro/tree/main/libraries/knobs)
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
[MIT](https://github.com/ChuMicro/ChuMicro/blob/main/LICENSE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
chumicro_knobs/__init__.py,sha256=_oxx-vJH0PCyYaeiz8gpqtOXokQDyCjwsBkYSUjhxug,416
|
|
2
|
+
chumicro_knobs/analog.py,sha256=0U9bP9iPbEtVoIRSqfU4fWHd9mjDRDK0VOtQw9sZg1A,4634
|
|
3
|
+
chumicro_knobs/encoder.py,sha256=3832E_eo3kNDhId7SV9_VkkjAHN5ZedXa6A5f2F2hog,6160
|
|
4
|
+
chumicro_knobs/testing.py,sha256=fQrY9CDg1s50XnT9Iy9cqq11yKWcCqlIyEnmVU4DJqw,2453
|
|
5
|
+
chumicro_knobs/_adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
chumicro_knobs/_adapters/base.py,sha256=tYzPvfWu21n6_1xWqC_O1jesqTMXcYQCHAos9Qf2FC0,3403
|
|
7
|
+
chumicro_knobs/_adapters/cp.py,sha256=tioE5qzriraCPffpiq2WCSobHVTEsro93s6Wv4eL42U,2657
|
|
8
|
+
chumicro_knobs/_adapters/mp.py,sha256=2Il-pU8oACFQhw5i5ucHS6eqh8qqWgQcG2x92ADgHtY,5816
|
|
9
|
+
chumicro_knobs-0.1.0.dist-info/METADATA,sha256=Ydy4K4d1GaN2QlZBV_QkmJey2bPEY8VnMQYAuj34V6U,10466
|
|
10
|
+
chumicro_knobs-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
chumicro_knobs-0.1.0.dist-info/licenses/LICENSE,sha256=yV2dzHegsHSrEDhSKAICdTOEljKxiq20rQycXtRUlL4,1066
|
|
12
|
+
chumicro_knobs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ChuMicro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|