Improving the EC11 Encoder and RS485 Test Setup
Improving the EC11 Encoder and RS485 Test Setup#
Today I mainly concentrated on improving the transmitter-side and receiver-side software for the EC11 rotary encoder test setup.
The setup uses the Digital IO Expander board to read the rotary encoder and push button, sends events over RS485, and displays the result on a Raspberry Pi Pico 2 W receiver with an OLED display.

Test Setup with OLED Display
The aim was not to build a complete production-ready control system, but to validate the hardware and prove that the boards could work together in a simple but realistic integration test.
The transmitter-side code#
import time
from machine import UART, Pin
# --- CONFIGURATION CONSTANTS ---
BAUDRATE = 115200
UART_ID = 0
UART_TX_PIN = 0
UART_RX_PIN = 1
RS485_DE_RE_PIN = 2
# Shift Register Digital IO Pins
PIN_IN_DATA = 6
PIN_IN_LOAD = 8
PIN_IN_CLK = 9
# Shift Register Bit Mapping
PIN_A_POS = 0 # Encoder Pin A
PIN_B_POS = 1 # Encoder Pin B
PIN_PUSH_POS = 2 # Encoder Switch (Push Button)
SLEEP_TIME_US = 250
DEBOUNCE_THRESHOLD = 20 # Requires state to be stable for 20 loops
# --- HARDWARE INITIALIZATION ---
uart = UART(UART_ID, baudrate=BAUDRATE, tx=Pin(UART_TX_PIN), rx=Pin(UART_RX_PIN))
de_re = Pin(RS485_DE_RE_PIN, Pin.OUT)
de_re.value(1) # Set RS485 to Transmit Mode
indata = Pin(PIN_IN_DATA, Pin.IN)
_inload = Pin(PIN_IN_LOAD, Pin.OUT)
inclk = Pin(PIN_IN_CLK, Pin.OUT)
# --- STATE VARIABLES ---
previous_register_value = 0
previous_encoder = 0 # Stores the 2-bit state of A and B (00, 01, 10, 11)
# Debounce State Variables
debounced_push_state = False # The current "confirmed" state of the button
button_bounce_counter = 0 # Tracks consecutive matching reads
# --- MAIN LOOP ---
print("Transmitter active with button debounce. Polling...")
while True:
# Latch parallel data into the 74HC165 shift register
_inload.value(0)
time.sleep_us(SLEEP_TIME_US)
_inload.value(1)
current_register_value = 0
actions = []
# Read the 16 bits sequentially from the shift register
for i in range(16):
if indata.value():
current_register_value |= (1 << i)
inclk.value(0)
time.sleep_us(SLEEP_TIME_US)
inclk.value(1)
# --- 1. DEBOUNCE & PROCESS SWITCH (PUSH BUTTON) ---
raw_push_state = bool(current_register_value & (1 << PIN_PUSH_POS))
if raw_push_state != debounced_push_state:
button_bounce_counter += 1
if button_bounce_counter >= DEBOUNCE_THRESHOLD:
debounced_push_state = raw_push_state
button_bounce_counter = 0
if debounced_push_state:
actions.append("PUSHDOWN")
else:
actions.append("PUSHUP")
else:
button_bounce_counter = 0
# --- 2. DECODE ROTARY ENCODER USING QUADRATURE STATE MACHINE ---
pin_a = bool(current_register_value & (1 << PIN_A_POS))
pin_b = bool(current_register_value & (1 << PIN_B_POS))
current_encoder = (pin_a << 1) | pin_b
if current_encoder != previous_encoder:
# Create unique 4-bit transition identifier: (previous << 2) | current
transition = (previous_encoder << 2) | current_encoder
# CW Transitions: 00->10 (2), 10->11 (11), 11->01 (13), 01->00 (4)
if transition in (2, 11, 13, 4):
actions.append("CW")
# CCW Transitions: 00->01 (1), 01->11 (7), 11->10 (14), 10->00 (8)
elif transition in (1, 7, 14, 8):
actions.append("CCW")
previous_encoder = current_encoder
# --- 3. TRANSMIT VALID ACTIONS ---
if actions:
for action in actions:
message = f"{action}\r\n"
uart.write(message.encode())
print(f"Sent: {action}")
uart.flush()
previous_register_value = current_register_value
time.sleep_us(SLEEP_TIME_US)
The transmitter code works as follows:
- Initialisation: It configures the UART and control pins required to communicate through the RS485 building block board at 115200 baud.
- Shift Register Interface: It maps the pins used to bit-bang and read data payloads from the Digital IO Expander board.
- Integrator Debounce: It actively monitors the raw push-button state. By requiring the switch state to remain identical across sequential register passes, it effectively mitigates mechanical contact bounce.
- State Machine Decoding: It aggregates the encoder’s quadrature signals into a 2-bit value, mapping transitions into an explicit look-up sequence to establish direction.
- Bus Dispatch: It formats detected control sequences into string instructions and propagates them down the RS485 line.
The commands are deliberately simple:
CWCCWPUSHDOWNPUSHUP
Each command is sent with a carriage return and newline terminator. For the purposes of this test, that is enough. The transmitter is not sending a full structured packet format yet. It is simply detecting control events and forwarding them to the receiver.
The receiver-side code#
import time
import framebuf
from machine import UART, Pin, I2C
from sh1106 import SH1106_I2C
# --- CONFIGURATION CONSTANTS ---
UART_ID = 0
BAUDRATE = 115200
UART_TX_PIN = 0
UART_RX_PIN = 1
RS485_DE_RE_PIN = 2
I2C_ID = 1
SDA_PIN = 14
SCL_PIN = 15
SCREEN_WIDTH = 128
SCREEN_HEIGHT = 64
OLED_ADDR = 0x3C
# --- HARDWARE INITIALIZATION ---
uart = UART(UART_ID, baudrate=BAUDRATE, tx=Pin(UART_TX_PIN), rx=Pin(UART_RX_PIN))
de_re = Pin(RS485_DE_RE_PIN, Pin.OUT)
de_re.value(0) # Set RS485 to Receive Mode
i2c = I2C(I2C_ID, sda=Pin(SDA_PIN), scl=Pin(SCL_PIN))
oled = SH1106_I2C(SCREEN_WIDTH, SCREEN_HEIGHT, i2c, None, OLED_ADDR, rotate=180, delay=0)
oled.init_display()
# --- FUNCTIONS ---
def change_display_value(value, fire=False):
"""Updates the OLED display with the current counter value and progress bar."""
oled.fill(0) # Clear screen (Black)
oled.text("Current Value:", 0, 5)
oled.fill_rect(0, 15, value, 15, 255) # Progress bar
oled.text(str(value), 0, 35)
if fire:
oled.text("FIRE", 0, 50)
oled.show()
# --- MAIN LOOP ---
print("Initialization complete. Waiting for data...")
display_value = 0
change_display_value(display_value)
while True:
if uart.any():
data = uart.read()
try:
decoded_text = data.decode('utf-8')
print(f"Received: {decoded_text.strip()}")
commands_received = decoded_text.split("\r\n")
for command in commands_received:
command = command.strip()
if command == "CW" and display_value < 127:
display_value += 1
change_display_value(display_value)
elif command == "CCW" and display_value > 0:
display_value -= 1
change_display_value(display_value)
elif command == "PUSHDOWN":
change_display_value(display_value, fire=True)
elif command == "PUSHUP":
change_display_value(display_value, fire=False)
except UnicodeError:
print("Error: Received data, but couldn't decode it as UTF-8.")
time.sleep_us(1000) # Small delay to reduce CPU overhead
The receiver-side code uses the SH1106 MicroPython OLED driver to display the current counter value on the OLED display. The project can be found here:
https://github.com/robert-hh/SH1106
The library is MIT licensed, so many thanks to Robert Hammelrath and the other contributors for making it available.
The receiver code is fairly simple. It initialises the UART, configures the RS485 transceiver for receive mode, sets up I2C for the SH1106 OLED display, and then waits for incoming data.
When a recognised command is received:
CWincrements the display value.CCWdecrements the display value.PUSHDOWNdisplays aFIREmessage.PUSHUPclears theFIREmessage.
The display also shows a simple progress-bar-style graphic, which gives a nice visual indication that the encoder is being decoded and transmitted correctly.
Here is a short video of it in action:
Conclusion#
The main purpose of this test was to prove the utility of the RS485 boards, the Digital IO Expander board, and the USB-C breakout board in a combined setup.
All of the boards performed well during this test and, more importantly, the test has helped validate the circuits used in the current board revisions.
This is exactly the kind of integration test I wanted these building block boards to support: simple enough to put together quickly, but realistic enough to expose whether the boards are actually useful when connected together.
What I would do differently#
I kept this integration test timeboxed so that I did not fall down a rabbit hole of continuously tweaking the code. The main goal was hardware validation, not building a complete production-ready control system.
Now that the hardware has been validated, there are a few things I would do differently in a real-world application.
Process more data at the transmitter#
I would move more of the sensor processing logic onto the transmitter side.
In this test setup, the transmitter only sends simple commands over the bus. That works fine for a demonstration, but an EC11 rotary encoder can generate several state changes for a single turn, depending on how quickly it is rotated.
In a more complete system, it would probably make more sense for the transmitter to process the encoder locally and send a more meaningful value over the bus, such as a preprocessed value from 0 to 255.
That would reduce the amount of data sent over RS485 and reduce the amount of processing required on the receiver side. The receiver could then concentrate on display updates, higher-level control logic, or data received from multiple transmitter boards.
Address the Serialization Loop Latency#
The EC11 decoder transition table implemented above provides excellent filtering against contact noise. However, we have to look closely at the polling loop timing mechanics.
While the individual clock line pause is explicitly defined as SLEEP_TIME_US = 250 (250 microseconds), this occurs inside a 16-step serialization loop to bit-bang the shift register inputs. This pushes the execution of a single full register capture loop to roughly 4 milliseconds.
Add the trailing loop sleep, and our actual sampling window drops to roughly ~235 Hz. Because of this serialization loop latency, a user spinning the physical dial quickly will inevitably cause the script to skip valid state steps. Additionally, our DEBOUNCE_THRESHOLD of 20 loops forces the push switch to hold its state for roughly 85 milliseconds before confirming a click, adding a minute but perceptible latency to user input.
For a serious application, I would swap this configuration out entirely for a dedicated hardware SPI bus connection to read the shift registers in microsecond clusters, or break the encoder pins out to dedicated interrupt-capable microcontroller GPIO.
Use dedicated interrupt-capable GPIO for encoder decoding#
Building on the timing bottleneck mentioned above, using hardware interrupts (IRQs) or a dedicated encoder subprocessor is the ideal way forward.
Something like an ATtiny1614, CH32V003, or similar ultra-low-cost MCU could manage the strict, real-time quadrature decoding loop locally on dedicated interrupt lines, exposing clean position values to the transmitter host over a standard I2C interface.
Add a more robust communication protocol#
The current protocol is intentionally basic. It sends simple text commands over the RS485 link.
For a real application, I would want something more robust. That could include command buffering, message framing, checksums, addressing, and a clearer distinction between event messages and state messages.
This becomes especially important if multiple transmitter boards are attached to the same RS485 bus, or if the system grows beyond a simple point-to-point test.
Consider using an RTOS#
I would also consider using an RTOS such as FreeRTOS or Zephyr to organise the different tasks.
In this test code, everything runs inside a simple loop. That is fine for a bench test, but as the system grows it would be cleaner to separate responsibilities into different tasks.
For example:
- One task could handle RS485 communication.
- One task could handle I2C communication.
- One task could handle GPIO polling or input processing.
- One task could handle display updates.
That kind of structure would make the firmware easier to extend, especially when adding more advanced features such as buffering, device addressing, and multi-hop message routing.
Future board idea#
This test has also made me think about a possible enhanced version of the Digital IO Expander board.
The current board exposes digital inputs and outputs through shift registers, which is useful for simple binary signals. However, there are cases where some inputs would benefit from being connected directly to a microcontroller.
An enhanced version could keep the same general digital IO arrangement, but add a small microcontroller such as an STM32G0. Some pins could still be handled through shift registers, while others could be connected directly to interrupt-capable GPIO.
That would make the board more flexible for inputs such as rotary encoders, pulse inputs, switches that need more careful debouncing, or sensors that need a faster response.
It could also expose I2C for additional devices, and if the MCU provides suitable analogue-capable pins, it could support simple analogue inputs as well.
The microcontroller could then handle RS485 communication, sensor decoding, and more sophisticated input processing, while the shift registers continue to handle simpler on/off inputs and outputs.
That feels like a useful direction for a future building block board.
If you are looking for reusable open hardware modules to speed up your bench testing, or need support designing custom breakout circuits, check out our catalog at Protoworks (NI).
- 2026-10-17 Refining the Protoworks service offerings Lab
- 2026-09-27 Belfast Tech Week Lab
- 2026-09-19 Refining the UCMOD Architecture R&D
- 2026-09-06 Giving AI Agents a Way to Talk to Protoworks Software
- 2026-09-03 A Morning at a Manufacturing Trade Show Lab
- 2026-09-01 Debugging 921600 Baud on the ATtiny1614 R&D