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.
@@ -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
+ """
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
+