No hardware needed: polling over digitalio with debounce. The expensive part is the event queue, which upstream returns as Event objects; with a fixed event buffer, about 120 lines. Without a heap the queue depth has to be fixed at compile time, and the API should say so.
From the read-only audit of the layer against CircuitPython (2026-09-14), Uno-class boards first.
Fixed (Keys; KeyMatrix is not done)
The design, and why the queue holds nothing
A key's stored state moves only when its change is reported, so what is "waiting to be read"
is exactly the set of keys whose pins disagree with it. That is what a queue is for, and it
costs one bit a key instead of a buffer. overflowed is therefore always false and cannot be
otherwise, and max_events is refused rather than accepted and dropped: there is nothing to
size.
CircuitPython scans in the background on a tick. There is no background here, so the scan
happens inside events.get_into() -- the loop a program already writes reads the pins every
time round. interval is refused for the same reason: it is how often the background scan
runs, and here that is how often the program calls.
Measured on an Arduino Uno
The test holds keys down by pulling their pins low and drains the queue three times:
| pressed |
events, in order |
still waiting after the first |
| none |
none |
0 |
| key 1 |
(1, pressed), then none |
0 |
| keys 0 and 2 |
(0, pressed), (2, pressed), then none |
1 |
| keys 0, 1, 2 |
(0), (1), (2) |
2 |
Two deviations, both stated in the module
Keys takes a list of digitalio.DigitalInOut the caller has already made inputs, not a
list of pin names. A list of names has no storage behind it here: the names are compile-time
values and the list cannot be indexed at run time (PyMCU#308).
events.get() returns a new Event and there is no heap to build one on. It is refused
naming get_into(event), which is upstream's own allocation-free call.
Tests
- pymcu-circuitpython
c7cda74 — 10 tests in tests/test_keypad.py.
- pymcu-avr
ea9f73f — compat-cp-keypad, 5 tests. Red before: the module did not exist.
Still open
keypad.KeyMatrix is not written. It scans rows against columns, which needs the row pins to
switch between output and input on every pass, and the same list-of-instances iteration this
one uses would have to drive as well as read. It is a second sitting, not a variation.
One thing the change ran into
The queue owns the pins rather than asking the Keys for them. A list of instances loses its
iterability across one instance hop too many: the identical for p in ... loop was refused
as "not a compile-time iterable" when it reached through self._keys._pins, and compiled
when the list was one hop closer.
2026-09-15 follow-up: Event.timestamp, EventQueue, bool closed; Keys.events stays open
Event.key_number and Event.pressed are now real properties over backing fields (same
shape as Event.released already was), so inspect.getattr_static sees them.
Event.timestamp is a new property, stamped from supervisor.ticks_ms() by
EventQueue.get_into() on every reported transition. The private _EventQueue is renamed
to the public EventQueue, and it gained __bool__ (len(queue) > 0, matching PulseIn's)
to complete its surface against upstream's EventQueue. All four removed from
tests/parity/allowlist.toml.
Keys.events stays a plain instance attribute, tried and reverted as a @property: once
events became a property, keys.events.get_into(event) was refused by the compiler with
"its receiver is not a name bound to an object" -- a property that hands back a ZCA instance
loses it for further method dispatch. That is a compiler limit, not a layer decision, so
keypad.Keys.events stays tracked here rather than moving to a language-side issue of its
own; keypad.KeyMatrix is still the only genuinely unwritten piece of this issue.
pymcu-circuitpython 5ffcc89.
Filed as PyMCU/PyMCU#445 (milestone Classes): a @Property returning a ZCA instance loses it
for further method dispatch, with the minimal reproduction (Owner.q as a property over
Queue, owner.q.bump() refused the same way keys.events.get_into(event) was).
No hardware needed: polling over digitalio with debounce. The expensive part is the event queue, which upstream returns as
Eventobjects; with a fixed event buffer, about 120 lines. Without a heap the queue depth has to be fixed at compile time, and the API should say so.From the read-only audit of the layer against CircuitPython (2026-09-14), Uno-class boards first.
Fixed (Keys; KeyMatrix is not done)
The design, and why the queue holds nothing
A key's stored state moves only when its change is reported, so what is "waiting to be read"
is exactly the set of keys whose pins disagree with it. That is what a queue is for, and it
costs one bit a key instead of a buffer.
overflowedis therefore always false and cannot beotherwise, and
max_eventsis refused rather than accepted and dropped: there is nothing tosize.
CircuitPython scans in the background on a tick. There is no background here, so the scan
happens inside
events.get_into()-- the loop a program already writes reads the pins everytime round.
intervalis refused for the same reason: it is how often the background scanruns, and here that is how often the program calls.
Measured on an Arduino Uno
The test holds keys down by pulling their pins low and drains the queue three times:
Two deviations, both stated in the module
Keystakes a list ofdigitalio.DigitalInOutthe caller has already made inputs, not alist of pin names. A list of names has no storage behind it here: the names are compile-time
values and the list cannot be indexed at run time (PyMCU#308).
events.get()returns a newEventand there is no heap to build one on. It is refusednaming
get_into(event), which is upstream's own allocation-free call.Tests
c7cda74— 10 tests intests/test_keypad.py.ea9f73f—compat-cp-keypad, 5 tests. Red before: the module did not exist.Still open
keypad.KeyMatrixis not written. It scans rows against columns, which needs the row pins toswitch between output and input on every pass, and the same list-of-instances iteration this
one uses would have to drive as well as read. It is a second sitting, not a variation.
One thing the change ran into
The queue owns the pins rather than asking the
Keysfor them. A list of instances loses itsiterability across one instance hop too many: the identical
for p in ...loop was refusedas "not a compile-time iterable" when it reached through
self._keys._pins, and compiledwhen the list was one hop closer.
2026-09-15 follow-up: Event.timestamp, EventQueue, bool closed; Keys.events stays open
Event.key_numberandEvent.pressedare now real properties over backing fields (sameshape as
Event.releasedalready was), soinspect.getattr_staticsees them.Event.timestampis a new property, stamped fromsupervisor.ticks_ms()byEventQueue.get_into()on every reported transition. The private_EventQueueis renamedto the public
EventQueue, and it gained__bool__(len(queue) > 0, matchingPulseIn's)to complete its surface against upstream's
EventQueue. All four removed fromtests/parity/allowlist.toml.
Keys.eventsstays a plain instance attribute, tried and reverted as a@property: onceeventsbecame a property,keys.events.get_into(event)was refused by the compiler with"its receiver is not a name bound to an object" -- a property that hands back a ZCA instance
loses it for further method dispatch. That is a compiler limit, not a layer decision, so
keypad.Keys.eventsstays tracked here rather than moving to a language-side issue of itsown;
keypad.KeyMatrixis still the only genuinely unwritten piece of this issue.pymcu-circuitpython 5ffcc89.
Filed as PyMCU/PyMCU#445 (milestone Classes): a @Property returning a ZCA instance loses it
for further method dispatch, with the minimal reproduction (Owner.q as a property over
Queue, owner.q.bump() refused the same way keys.events.get_into(event) was).