A state table
CLOCK and DATA together can only be in four states, and a turn walks round them in order. Interrupt on every change of either pin, look up the move in a sixteen-entry table, and add what it says: +1, -1 or 0. A bounce becomes a step forward and a step back, which cancel.
Four states, in order
Read CLOCK and DATA together and write them as two digits, CLOCK first, 1 for HIGH. There are only four possibilities: 11, 01, 00 and 10. At rest, with both contacts open, it is 11. Turning forward walks round them in that order, one digit changing at a time, and back to 11. Turning back walks the same circle the other way.
So every change of either pin is a move to a neighbour, and which neighbour says which way the knob went. That is all a state table is: for each of the sixteen pairs of old state and new state, write down +1, -1 or 0.
Play a clean cycle first: four moves forward, +4. Then DATA bounces: DATA falls, springs back, and falls again. The table scores that +1, -1, +1, and the cycle still totals +4. Nothing in the sketch had to recognise it as bounce.
Why it copes where the first count did not
The first count only looked at one event, CLOCK falling, and trusted a single read of DATA to give the direction. A bounce on CLOCK counted as another click; a late read got the direction wrong.
The table looks at every change of both pins and scores each on its own. A bounce is a move and its undo, which add to nothing. A read that arrives too late to see a state, a missed state in the figure, is a jump across the square, which no single change can make; the table scores it 0 rather than guessing. It loses count honestly instead of counting backwards.
The capacitors on the board swallow the shortest bounces before any of this. The table deals with whatever gets past them.
Steps and clicks
The table counts four steps per cycle, twice as fine as it needs to be for a
knob, so the sketch divides by STEPS_PER_CLICK. It is 4 because on many
EC11 parts one click is one full cycle. This part's clicks have not been
checked against its cycles, so turn the knob once round and compare: if every
click moves the printed number by two, set it to 2.
Both pins interrupt, on CHANGE. On an Uno that needs D2 and D3, its only
two external-interrupt pins, which is why the whole book puts CLOCK and DATA
there.
The code
One handler, attached to both CLOCK and DATA on every change. It reads both pins, looks up the move from the last state to this one, and adds the result to steps. loop() divides by STEPS_PER_CLICK and prints clicks.
/*
Rotary Encoder - a state table TK06 / /p/tk06
Wiring. Count from the square pad on the TinkerBlock board, knob
up, header at the bottom:
GND -> GND
VCC -> 5V on an Uno; 3V3 on an ESP32, ESP32-S3 or Pico
(CLOCK, DATA and BTN all reach whatever VCC is)
NC -> nothing (unconnected on the board)
BTN -> D4 on an Uno, GPIO 27 on an ESP32, GPIO 6 on an
ESP32-S3, GP15 on a Raspberry Pi Pico
CLOCK -> D2, GPIO 25, GPIO 4, GP13 (the same four boards)
DATA -> D3, GPIO 26, GPIO 5, GP14
Arduino IDE
Tools > Board your board, e.g. ESP32S3 Dev Module
Tools > Port the one that appears when you plug in
Tools > USB CDC On Boot Enabled (ESP32-S3 only)
No library needed.
*/
// CLOCK, DATA and BTN, as GPIO numbers.
// Uno: 2, 3, 4. ESP32: 25, 26, 27. ESP32-S3: 4, 5, 6. Pico: 13, 14, 15.
const int CLOCK_PIN = 4;
const int DATA_PIN = 5;
const int BUTTON_PIN = 6; // not used in this sketch
const int STEPS_PER_CLICK = 4; // on many EC11 parts; count yours
#ifndef IRAM_ATTR
#define IRAM_ATTR // only the ESP32 cores need it
#endif
// +1 a step forward, -1 a step back, 0 no move or a missed state.
// Index: old state * 4 + new state. A state is CLOCK * 2 + DATA.
const int8_t TABLE[16] = {0, -1, 1, 0, 1, 0, 0, -1,
-1, 0, 0, 1, 0, 1, -1, 0};
volatile uint8_t state = 3; // both HIGH: at rest
volatile long steps = 0;
// Runs on every change of either pin.
void IRAM_ATTR onEither() {
uint8_t now = (digitalRead(CLOCK_PIN) << 1) | digitalRead(DATA_PIN);
steps += TABLE[(state << 2) | now];
state = now;
}
void setup() {
Serial.begin(115200);
pinMode(CLOCK_PIN, INPUT); // the block has its own pull-ups
pinMode(DATA_PIN, INPUT);
state = (digitalRead(CLOCK_PIN) << 1) | digitalRead(DATA_PIN);
attachInterrupt(digitalPinToInterrupt(CLOCK_PIN), onEither, CHANGE);
attachInterrupt(digitalPinToInterrupt(DATA_PIN), onEither, CHANGE);
}
void loop() {
static long shown = 0;
noInterrupts(); // copy it in one piece
long now = steps;
interrupts();
long clicks = now / STEPS_PER_CLICK;
if (clicks != shown) {
Serial.println(clicks);
shown = clicks;
}
}A state is CLOCK × 2 + DATA, so 3 is both HIGH, at rest. The index into TABLE is the old state × 4 plus the new one. The sign convention matches the first count: CLOCK falling first is +1. STEPS_PER_CLICK is 4 on many EC11 parts; count yours.
The same table in MicroPython, with one handler on both pins for rising and falling edges. It reads both pins, looks up the move and adds it to steps; the main loop prints clicks when they change.
"""
Rotary Encoder - a state table, MicroPython TK06 / /p/tk06
Wiring. Count from the square pad on the TinkerBlock board, knob
up, header at the bottom:
GND -> GND
VCC -> 3V3 (never 5V: CLOCK, DATA and BTN all reach VCC)
NC -> nothing (unconnected on the board)
BTN -> GPIO 27 on an ESP32, GPIO 6 on an ESP32-S3,
GP15 on a Raspberry Pi Pico
CLOCK -> GPIO 25, GPIO 4, GP13 (the same three boards)
DATA -> GPIO 26, GPIO 5, GP14
Thonny
Run > Configure interpreter MicroPython (ESP32) or
MicroPython (Raspberry Pi Pico)
Save it to the board as main.py to run it on every power-up.
Nothing to install: machine and time are built in.
"""
from machine import Pin
import time
# CLOCK, DATA and BTN, as GPIO numbers.
# ESP32: 25, 26, 27. ESP32-S3: 4, 5, 6. Pico: 13, 14, 15.
CLOCK_PIN = 4
DATA_PIN = 5
STEPS_PER_CLICK = 4 # on many EC11 parts; count yours
# +1 a step forward, -1 a step back, 0 no move or a missed state.
# Index: old state * 4 + new state. A state is CLOCK * 2 + DATA.
TABLE = (0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0)
clock = Pin(CLOCK_PIN, Pin.IN) # no pull: the block has its own
data = Pin(DATA_PIN, Pin.IN)
state = clock.value() << 1 | data.value()
steps = 0
def on_either(pin): # every change of either pin
global state, steps
now = clock.value() << 1 | data.value()
steps += TABLE[state << 2 | now]
state = now
both = Pin.IRQ_RISING | Pin.IRQ_FALLING
clock.irq(trigger=both, handler=on_either)
data.irq(trigger=both, handler=on_either)
shown = 0
while True:
clicks = int(steps / STEPS_PER_CLICK)
if clicks != shown:
shown = clicks
print(clicks)
time.sleep_ms(10)The table is the Arduino sketch's, entry for entry. If a handler runs late and both pins have moved, the table scores 0 instead of guessing, so a late read loses a step rather than counting one backwards. Stop it with Ctrl-C in Thonny.
When it does not work
The table counts four steps per cycle and the sketch divides by STEPS_PER_CLICK, 4, which is right if one click is one full cycle. That is so on many EC11 parts but has not been checked on this one. Turn the knob one full turn, compare the steps with the clicks you felt, and set STEPS_PER_CLICK to the ratio.
Swap the numbers in CLOCK_PIN and DATA_PIN. Which contact leads when turned clockwise is the part's choice. Swapping the pins reverses every entry of the table at once.
Going from 01 to 10 means both pins changed between two reads, and a state was missed. The move could have been two steps either way, so the table scores it 0 rather than guess. With both pins on interrupts, it should almost never happen.
Most of the common ones use a state table of this kind, often with extra rules for where a click rests. Writing it once yourself shows what they are doing, and it is short enough to keep in a sketch.
Edit this page — content/books/rotary-encoder/a-state-table.mdx
Questions about this product
See what other owners have asked, and read their solutions.
Rotary Encoder
Loading discussions…
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.