Rotary Encoder via I2C

This Rotary Encoder peripheral connects via I2C, to the MCU of your choice (2.7V to 5.5V operating voltage). It is possible to have up to 8 IO expander chips on one I2C bus and so one address will be occupied by this peripheral. There are also 3 tactile switches, each with a LED next to it, along with one further LED. The use of these LEDs is programmable. A MicroPython class driver has been written for this peripheral below and so it would make a good UI addition to a student project. Note, that it is fairly straight forward to divide this board into two boards connected by a ribbon cable; UI (rotary encoder & switches) and I2C chip boards; see remote control as example.

"""
rotary3swMain.py
Micropython Rotary Encoder with three more switches and four LEDs - Main

MIT License
Copyright (c) 2025 Microtron NZ - Stephen Eichler
"""

from machine import Pin, I2C
import mcp23017
import rotary3sw
import time
#i2c = I2C(scl=Pin(22), sda=Pin(21))
i2c = I2C(id=0, scl=Pin(1), sda=Pin(0))
mcp = mcp23017.MCP23017(i2c)      

# interrupt pin
p21 = Pin(21, mode=Pin.IN)

# encoder pins
sw_pin = 5
clk_pin = 4
dt_pin = 3
sw1_pin = 0
sw2_pin = 1
sw3_pin = 2
led1_pin = 0
led2_pin = 1
led3_pin = 2
led0_pin = 3

def i2c_scan(i2c):
	try:
		devices = i2c.scan()
		if len(devices) == 0:
			print("No I2C devices found.")
		else:
			print("Found the following I2C devices:")
			for device in devices:
				print(f"Device address: 0x{device:02X}")
	except OSError as e:
		print(f"An error occurred during the I2C scan: {e}")

# callback with unicode art
def cb(val, sw, sw1, sw2, sw3):
	#print(sw1, " ", sw2, " ", sw3)
	volume = '\u2590' * val + '\xb7' * (10 - val)
	if sw:
		btn = '\u2581\u2583\u2581'
	else:
		btn = '\u2581\u2587\u2581'
	if sw1:
		btn1 = '\u2581\u2583\u2581'
	else:
		btn1 = '\u2581\u2587\u2581'
	if sw2:
		btn2 = '\u2581\u2583\u2581'
	else:
		btn2 = '\u2581\u2587\u2581'
	if sw3:
		btn3 = '\u2581\u2583\u2581'
	else:
		btn3 = '\u2581\u2587\u2581'
	print(volume + ' ' + btn + ' ' + btn1 + ' ' + btn2 + ' ' + btn3)

# simpler callback with just values
#def cb(val, sw):
#	print('value: {}, switch: {}'.format(val, sw))

i2c_scan(i2c)

# init
r = rotary3sw.Rotary3sw(mcp.porta, mcp.portb, p21, clk_pin, dt_pin, sw_pin, cb, sw1_pin, sw2_pin, sw3_pin, led1_pin, led2_pin, led3_pin, led0_pin)

# add irq
r.start()

# remove irq
# r.stop()

count = 0
while True:
	r.led(1, count%4 == 1)
	r.led(2, count%4 == 2)
	r.led(3, count%4 == 3)
	r.led(0, count%4 == 0)
	count += 1
	time.sleep_ms(1000)
==================================================================================
"""
Micropython Rotary Encoder with three more switches and four LEDs

MIT License
Copyright (c) 2025 Microtron NZ - Stephen Eichler

Adapted from:
MicroPython MCP23017 16-bit I/O Expander
https://github.com/mcauser/micropython-mcp23017

MIT License
Copyright (c) 2019 Mike Causer
"""

class Rotary3sw():
	def __init__(self, port, portb, int_pin, clk, dt, sw=None, cb=None, sw1=None, sw2=None, sw3=None,
				 led1=None, led2=None, led3=None, led0=None, start_val=0, min_val=0, max_val=10):
		self.port = port
		self.clk = clk
		self.dt = dt
		self.sw = sw
		self.cb = cb
		self.sw1 = sw1
		self.sw2 = sw2
		self.sw3 = sw3
		self.led1 = led1
		self.led2 = led2
		self.led3 = led3
		self.led0 = led0

		# initial value
		self.value = start_val
		self.min_val = min_val
		self.max_val = max_val

		pins = (1 << clk | 1 << dt)
		if self.sw is not None:
			pins |= 1 << sw
		if self.sw1 is not None:
			pins |= 1 << sw1
		if self.sw2 is not None:
			pins |= 1 << sw2
		if self.sw3 is not None:
			pins |= 1 << sw3

		# input
		self.port.mode |= pins
		# enable pull ups
		self.port.pullup |= pins
		# input inverted
		self.port.input_polarity |= pins
		# enable interrupt
		self.port.interrupt_enable |= pins

		# interrupt pin, set as input
		self.int_pin = int_pin
		self.int_pin.init(mode=int_pin.IN)

		# last 4 states (2-bits each)
		self.state = 0

		self.sw_state = 0
		self.sw1_state = 0
		self.sw2_state = 0
		self.sw3_state = 0

		self.portb = portb
		pinsb = 0
		if self.led1 is not None:
			pinsb |= 1 << led1
		if self.led2 is not None:
			pinsb |= 1 << led2
		if self.led3 is not None:
			pinsb |= 1 << led3
		if self.led0 is not None:
			pinsb |= 1 << led0

		self.portb.mode &= pinsb ^ 0xff
		self.portb.gpio = 0x00

	def _step(self, val):
		self.value = min(self.max_val, max(self.min_val, self.value + val))
		self._callback()

	def _switched(self, val):
		self.sw_state = val
		self._callback()

	def _switched1(self, val):
		self.sw1_state = val
		#print("sw1: ", val)
		self._callback()

	def _switched2(self, val):
		self.sw2_state = val
		self._callback()

	def _switched3(self, val):
		self.sw3_state = val
		self._callback()

	def _rotated(self, clk, dt):
		# shuffle left and add current 2-bit state
		self.state = (self.state & 0x3f) << 2 | (clk << 1) | dt
		if self.state == 180:
			self._step(-1)
		elif self.state == 120:
			self._step(1)

	def _callback(self):
		if callable(self.cb):
			self.cb(self.value, self.sw_state, self.sw1_state, self.sw2_state, self.sw3_state)

	def _irq(self, p):
		flagged = self.port.interrupt_flag
		captured = self.port.interrupt_captured
		if self.sw is not None and flagged == (1 << self.sw):
			self._switched((captured >> self.sw) & 1)
		elif self.sw1 is not None and flagged == (1 << self.sw1):
			self._switched1((captured >> self.sw1) & 1)
		elif self.sw2 is not None and flagged == (1 << self.sw2):
			self._switched2((captured >> self.sw2) & 1)
		elif self.sw3 is not None and flagged == (1 << self.sw3):
			self._switched3((captured >> self.sw3) & 1)
		else:
			clk = (captured >> self.clk) & 1
			dt = (captured >> self.dt) & 1
			if (flagged & (1 << self.clk | 1 << self.dt)) > 0:
				self._rotated(clk, dt)

	def start(self):
		self.int_pin.irq(trigger=self.int_pin.IRQ_FALLING, handler=self._irq)
		# clear previous interrupt, if any
		self.port.interrupt_captured

	def stop(self):
		self.int_pin.irq(None)
		# clear previous interrupt, if any
		self.port.interrupt_captured

	def led(self, led, value):
		if led > 3 or led < 0:
			return
		pindict = {1:self.led1, 2:self.led2, 3:self.led3, 0:self.led0}
		if value:
			self.portb.gpio |= 1 << pindict[led]
		else:
			self.portb.gpio &= (1 << pindict[led]) ^ 0xff
                
====================================================================================
>>> %Run -c $EDITOR_CONTENT

MPY: soft reboot
Found the following I2C devices:
Device address: 0x20
Device address: 0x50
▐········· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐········ ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐······· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐······ ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐····· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐▐···· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐▐▐··· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐▐▐▐·· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐▐▐▐▐· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐▐▐▐·· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐▐▐··· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐▐···· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐▐····· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐▐······ ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐▐······· ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐········ ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐········ ▁▃▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐········ ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐········ ▁▇▁ ▁▃▁ ▁▇▁ ▁▇▁
▐▐········ ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐········ ▁▇▁ ▁▇▁ ▁▃▁ ▁▇▁
▐▐········ ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁
▐▐········ ▁▇▁ ▁▇▁ ▁▇▁ ▁▃▁
▐▐········ ▁▇▁ ▁▇▁ ▁▇▁ ▁▇▁