Skip to content

ESP32: Add Quadrature Encoder and Pulse Counter classes. - #19239

Draft
IhorNehrutsa wants to merge 1 commit into
micropython:masterfrom
IhorNehrutsa:ESP32_PCNT_Encoder_Counter
Draft

IhorNehrutsa wants to merge 1 commit into
micropython:masterfrom
IhorNehrutsa:ESP32_PCNT_Encoder_Counter

Conversation

@IhorNehrutsa

@IhorNehrutsa IhorNehrutsa commented May 18, 2026 •

Copy link
Copy Markdown
Contributor

Designed for high-resolution rotary encoders (40000 - 240000 PPR)
image
https://www.usdigital.com/products/encoders/incremental/shaft/s2/?q=S2-?-?-?-D
https://www.usdigital.com/products/encoders/incremental/kit/ec35/?q=EC35-?-?-?-?-?-?-B

>>> counter = Counter(0, src=Pin(5), direction=Pin(17))
>>> counter
Counter(0), src=Pin(5), direction=Pin(17), edge=Counter.RISING, max=32767, min=-32767)
  1. counter.value() changes across the entire int32_t (or int64_t) range (software extension)
  2. max from 1 to 32767 (hardware limitation)
  3. min = -max (software requirements)
  4. counter.cycles() = counter.value() / (max * 2) (or software calculated in inherited class as counter.value() / PPR)

I did not use generative AI tools when creating this PR.

@IhorNehrutsa
IhorNehrutsa marked this pull request as draft May 18, 2026 13:38
@github-actions

github-actions Bot commented May 18, 2026 •

Copy link
Copy Markdown

Code size report:

Reference:  nrf: Restore interrupt-based I2C with nrfx v3. [964803a]
Comparison: esp32/encoder: Add Encoder and Counter classes. [merge of a9ac008]
  mpy-cross:    +0 +0.000% 
   bare-arm:    +0 +0.000% 
minimal x86:    +0 +0.000% 
   unix x64:    +0 +0.000% standard
      stm32:    +0 +0.000% PYBV10
      esp32:  -464 -0.026% ESP32_GENERIC[incl -1616(data) +24(bss)]
     mimxrt:    +0 +0.000% TEENSY40
        rp2:    +0 +0.000% RPI_PICO_W
       samd:    +0 +0.000% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:    +0 +0.000% VIRT_RV32

@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch from 927084c to 6044972 Compare May 18, 2026 14:00
@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch 3 times, most recently from 1205c14 to 8f64762 Compare May 19, 2026 07:59
@codecov

codecov Bot commented May 19, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.51%. Comparing base (964803a) to head (a9ac008).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #19239   +/-   ##
=======================================
  Coverage   98.51%   98.51%           
=======================================
  Files         177      177           
  Lines       22927    22927           
=======================================
  Hits        22586    22586           
  Misses        341      341           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch 5 times, most recently from f8dc3e9 to 82e2bfe Compare May 19, 2026 11:50
@IhorNehrutsa

Copy link
Copy Markdown
Contributor Author

@robert-hh
May you test on the MIMXRT port?
tests/extmod_hardware/machine_counter.py

@robert-hh

Copy link
Copy Markdown
Contributor

@IhorNehrutsa
The test has to be changed. Line 142, 143 have to be:

        self.counter.init(in_pin, direction=Counter.UP, match=100)
        self.counter.irq(handler=callback, trigger=Counter.IRQ_MATCH)

@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch from 82e2bfe to 676b293 Compare May 19, 2026 13:03
@IhorNehrutsa

Copy link
Copy Markdown
Contributor Author

@robert-hh

with the irq.flags() method. The argument *hard* specifies, whether the callback is called

Does irq.flags() mean the Counter.status() and Encoder.status()?

@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch from c7195db to b3527aa Compare May 20, 2026 07:28
@robert-hh

Copy link
Copy Markdown
Contributor

Does irq().flags() mean the Counter.status() and Encoder.status()

There is no Counter.status() or Encoder.status() method. irq().flags() returns the flag which caused the IRQ to happen. And that may be different for Counter and Encoder.

@IhorNehrutsa

Copy link
Copy Markdown
Contributor Author

@robert-hh
Oh, thanks, I was watching the old version. :(
May you give a small example of using irq().flags() instead of xxx.status() in tests\extmod_hardware\machine_counter.py?

@robert-hh

Copy link
Copy Markdown
Contributor

Try to call Counter.irq().flags() instead of Counter.status().

@IhorNehrutsa

Copy link
Copy Markdown
Contributor Author

@robert-hh
It seems better to set the handler after disabling interrupts

self->mp_irq_obj->handler = handler;

after line
ENC_DisableInterrupts(self->instance, ENCODER_ALL_INTERRUPTS);

@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch 2 times, most recently from 508af32 to bd50aab Compare May 26, 2026 09:29
@IhorNehrutsa

IhorNehrutsa commented May 26, 2026 •

Copy link
Copy Markdown
Contributor Author

@robert-hh
In file
tests/extmod_hardware/machine_counter.py
added print counter.irq().flags() in callback function.
May you test it on the MIMXRT port?
Thanks

>>> %run -c $EDITOR_CONTENT
test_connections (__main__.TestConnections) ... ok
test_count_rising_UP (__main__.TestCounter) ... ok
test_count_rising_DOWN (__main__.TestCounter) ... ok
test_count_falling_UP_DOWN (__main__.TestCounter) ... ok
test_count_pin_direction (__main__.TestCounter) ...
 callback counter.value():      5000, counter.irq().flags():  4, flags.IRQ_MATCH:True

 callback counter.value():      5000, counter.irq().flags():  4, flags.IRQ_MATCH:True
 ok
test_irq (__main__.TestCounter) ...
 callback counter.value():     50000, counter.irq().flags():  4, flags.IRQ_MATCH:True

 callback counter.value():    -50000, counter.irq().flags():  4, flags.IRQ_MATCH:True
 ok
----------------------------------------------------------------------
Ran 6 tests

OK
>>> 

@robert-hh

Copy link
Copy Markdown
Contributor

Tested with Teensy 4.0: works.

~/Temp$ mpr run machine_counter.py 
test_connections (__main__.TestConnections) ... ok
test_count_rising_UP (__main__.TestCounter) ... ok
test_count_rising_DOWN (__main__.TestCounter) ... ok
test_count_falling_UP_DOWN (__main__.TestCounter) ... skipped: FALLING edge not supported
test_count_pin_direction (__main__.TestCounter) ... skipped: Pin as a count direction is not supported
test_irq (__main__.TestCounter) ...
 callback counter.value():     50000, counter.irq().flags():136, flags.IRQ_MATCH:True

 callback counter.value():    -50000, counter.irq().flags(): 72, flags.IRQ_MATCH:True
 ok
----------------------------------------------------------------------
Ran 6 tests

OK (skipped=2)

@robert-hh

robert-hh commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

I shall note that setting the Counting direction with a Pin works as well with the MIMXRT port, with 0 for counting up and 1 for counting down. So the polarity is inverted, and using the constants counter.UP and counter.DOWN fails at MIMXRT because they are defined with the wrong values. I'll have to fix that. The test script should use self.counter.UP and self.counter.DOWN.

@IhorNehrutsa

Copy link
Copy Markdown
Contributor Author

@robert-hh
Another question:
Is it possible not to reset the counter to 0 during Counter.init()?
Due to this feature, this code is not possible, but I think it is more natural for a user:

    def test_irq(self):
        self.counter.init(in_pin, direction=Counter.UP, match=50_000)
        self.counter.irq(handler=callback, trigger=Counter.IRQ_MATCH)
        toggle(60_000)
        self.assertCounter(60_000)
        self.assertEqual(callback_counter_value, 50_000)

        self.counter.init(in_pin, match=100_000)  # try to count UP, with new match value, but calling init() ZEROES the counter
        toggle(60_000)
        self.assertCounter(120_000)
        self.assertEqual(callback_counter_value, 100_000)

@robert-hh

Copy link
Copy Markdown
Contributor

Technically it may be possible, but it breaks existing behavior. At the moment, a call to init() will reset the counter to the min value, whatever it is. One can of course set the counter to any value after a call to init(),

@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch from 8285c07 to 18c8e35 Compare June 2, 2026 08:00
@IhorNehrutsa

Copy link
Copy Markdown
Contributor Author

I tried to make the levels the same for MIMXRT and ESP32, but I can't seem to set the levels to the same.

You cannot make the levels the same, because they are hard-wired. Bout one can have Counter.UP and Counter.DOWN have a value matching the required levels of each port.

Actually, I almost managed to set the same levels for the MIMXRT and the ESP32. In this case
test_count_rising_UP
test_count_rising_DOWN
test_count_falling_UP_DOWN
test_count_pin_direction
are ok
but in test_irq_UP_DOWN
the Counter.UP and the Counter.DOWN is inverted.
Maybe I will come back to this later...

@robert-hh

Copy link
Copy Markdown
Contributor

It runs fine with MIMXRT, but fails now again with ESP32, after it worked before.

Comment thread ports/esp32/machine_encoder.c Outdated
// self->mp_irq_flags |= status;
self->mp_irq_flags = status;
MICROPY_END_ATOMIC_SECTION(atomic_state);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not know if that is intentional, but you schedule a IRQ handler separately for every possible event. Usually the events are collected and then the IRQ handler is called only once. The IRQ handler has to check any any event again.

Comment thread ports/esp32/machine_encoder.c Outdated
// when counting up
self->counter += self->count_max;
if ((self->mp_irq_trigger & PCNT_EVT_H_LIM) && (self->mp_irq_obj->handler)) {
mp_sched_schedule(self->mp_irq_obj->handler, MP_OBJ_FROM_PTR(self));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please look at mp_irq_handler(self->mp_irq_obj) to call the IRQ handler. It checks as well whether a callback exists and supports hard IRQ as well.

@robert-hh

Copy link
Copy Markdown
Contributor

Both MIMXRT and ESP32 pass the test with machine_counter.py now.

Comment thread ports/esp32/machine_encoder.c Outdated
static mp_obj_t machine_pcnt_cycles(size_t n_args, const mp_obj_t *args) {
mp_pcnt_obj_t *self = MP_OBJ_TO_PTR(args[0]);
counter_t value = get_counter_value(self);
int32_t counts_per_cycle = self->count_max - self->count_min;

@robert-hh robert-hh Jun 2, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This formula is only right if count_max is excluded from the counting range. If the counting range is count_min to and including count_max, then
counts_per_cycle = self->count_max - self->count_min + 1
MIMXRT's range includes the max value.

@robert-hh

Copy link
Copy Markdown
Contributor

The behavior of the Counter class differs between MIMXRT and ESP32 with respect to setting min and max.
With MIMXRT, the value v returned by counter.value() always is min <= v <= max, and for every wrap-around the value returned by counter.cycles() changes.
With ESP32, the value returned by counter.value() advances, even if the min/max values are passed. Only the value returned by counter.cycles() is calculated back from the value of the counter. It does NOT count the number of times a min/max value was exceeded. Both aspects seems wrong. Setting a count range with min/max shall restrict the output of counter.value() to that range. Totals can always be calculated using counter.value() and counter.cycles() if needed. And cycles() should count the number of times the min/max values were exceeded by counting.

@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch 2 times, most recently from 0fd9623 to 8e6227f Compare June 3, 2026 12:32
@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch 12 times, most recently from 8b79b17 to ab7666b Compare June 12, 2026 10:34
@IhorNehrutsa

Copy link
Copy Markdown
Contributor Author

WIP

@robert-hh
Robert, do you have some time to look at machine_counter.py and machine_encoder.py?

@robert-hh

Copy link
Copy Markdown
Contributor

@IhorNehrutsa Can do so, but it may take a few days.

@robert-hh

Copy link
Copy Markdown
Contributor

The test scripts work with your new ESP32 code, but fails with MIMXRT (Teensy 4.0). See below. So it seems that the behavior you implemented for ESP32 does not match the MIMXRT one. I did not look where the test scripts are wrong. The existing tests pass on MIMXRT.

MicroPython/micropython/tests$ mpr run extmod_hardware/machine_encoder.py 
test_connections (__main__.TestConnections) ... Testing channel 0 Testing channel 1 ok
test_basics (__main__.TestEncoder) ... ok
test_partial (__main__.TestEncoder) ... FAIL

======================================================================
FAIL: test_partial <class 'TestEncoder'>
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest.py", line 399, in run_one
  File "<stdin>", line 141, in test_partial
  File "<stdin>", line 106, in assertPosition
  File "unittest.py", line 97, in assertEqual
AssertionError: 2 vs (expected) 1

----------------------------------------------------------------------
Ran 3 tests

FAILED (failures=1, errors=0)
MicroPython/micropython/tests$ mpr run extmod_hardware/machine_counter.py 
test_connections (__main__.TestConnections) ... ok
test_count_rising_UP (__main__.TestCounter) ... ok
test_count_rising_DOWN (__main__.TestCounter) ... ok
test_count_falling_UP_DOWN (__main__.TestCounter) ... skipped: FALLING edge not supported
test_irq_UP_DOWN (__main__.TestCounter) ...
  callbacks:1, counter.value():150000, counter.cycles():0, counter.irq().flags():136, MATCH:8, ROLL_OVER:0, ROLL_UNDER:0

  callbacks:2, counter.value():-150000, counter.cycles():-1, counter.irq().flags():72, MATCH:8, ROLL_OVER:0, ROLL_UNDER:64
 ok
test_cycles (__main__.TestCounter) ... FAIL
test_max_UP_DOWN (__main__.TestCounter) ... FAIL
test_max_DOWN_UP (__main__.TestCounter) ... FAIL
test_count_pin_direction (__main__.TestCounter) ... FAIL
test_irq_HERE_DOWN_UP_DOWN (__main__.TestCounter) ... skipped: HERE not supported
test_irq_HERE_UP_DOWN_UP (__main__.TestCounter) ... skipped: HERE not supported
test__src (__main__.TestCounter) ... skipped: _src input pin not supported

======================================================================
FAIL: test_cycles <class 'TestCounter'>
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest.py", line 399, in run_one
  File "<stdin>", line 155, in test_cycles
  File "<stdin>", line 93, in assertCounter
  File "unittest.py", line 97, in assertEqual
AssertionError: -1 vs (expected) 9

======================================================================
FAIL: test_max_UP_DOWN <class 'TestCounter'>
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest.py", line 399, in run_one
  File "<stdin>", line 182, in test_max_UP_DOWN
  File "<stdin>", line 93, in assertCounter
  File "unittest.py", line 97, in assertEqual
AssertionError: 77 vs (expected) 225

======================================================================
FAIL: test_max_DOWN_UP <class 'TestCounter'>
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest.py", line 399, in run_one
  File "<stdin>", line 243, in test_max_DOWN_UP
  File "<stdin>", line 93, in assertCounter
  File "unittest.py", line 97, in assertEqual
AssertionError: 53 vs (expected) -450

======================================================================
FAIL: test_count_pin_direction <class 'TestCounter'>
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest.py", line 399, in run_one
  File "<stdin>", line 298, in test_count_pin_direction
  File "<stdin>", line 93, in assertCounter
  File "unittest.py", line 97, in assertEqual
AssertionError: -1 vs (expected) 1

----------------------------------------------------------------------
Ran 12 tests

FAILED (failures=4, errors=0)

jaenrig-ifx pushed a commit to Infineon/micropython-psoc-edge that referenced this pull request Jul 1, 2026
The values should be 0 for UP and 1 for DOWN to match the documentation.
Instead they were -2 and -3, causing a test with a count direction set
by a Pin to fail, when the symbols were used instead of numbers..

Tested with the machine_encoder.py and machine_counter.py script and
the extended machine_counter.py script from PR micropython#19239.

Signed-off-by: robert-hh <[email protected]>
Co-Authored-By: robert-hh <[email protected]>
Co-Authored-By: Jonathan Hogg <[email protected]>

Signed-off-by: IhorNehrutsa <[email protected]>
@IhorNehrutsa
IhorNehrutsa force-pushed the ESP32_PCNT_Encoder_Counter branch from ab7666b to a9ac008 Compare July 3, 2026 10:38
jakub-vesely pushed a commit to jakub-vesely/micropython that referenced this pull request Sep 21, 2026
The values should be 0 for UP and 1 for DOWN to match the documentation.
Instead they were -2 and -3, causing a test with a count direction set
by a Pin to fail, when the symbols were used instead of numbers..

Tested with the machine_encoder.py and machine_counter.py script and
the extended machine_counter.py script from PR micropython#19239.

Signed-off-by: robert-hh <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants