Table of Contents
Introduction
Rotary encoders are one of those components that can really elevate your project’s user interface. They give you that satisfying tactile feedback when scrolling through menus or adjusting values, making your projects feel more professional and intuitive to use. But here’s the challenge – traditional rotary encoders can be a bit tricky to work with. They require multiple GPIO pins, careful debouncing, and tight timing requirements to read reliably.
Enter the Adafruit I2C Rotary Encoder – and this is a real game-changer! This clever little module packages a rotary encoder, a pushbutton switch, and an RGB NeoPixel LED into a single unit that communicates over I2C. What does that mean for you? Well, you can add multiple encoders to your project using just two wires (plus power and ground), freeing up your precious GPIO pins for other tasks. And the best part? The tricky debouncing and timing stuff is all handled for you by the module’s built-in microcontroller.
In this article, we’ll dive deep into the Adafruit I2C Rotary Encoder. We’ll examine its specifications, understand how it works, and put it through its paces with two practical projects. First, we’ll build a hierarchical menu system for controlling an RGB LED – perfect for learning how to create intuitive navigation interfaces. Then we’ll have some fun building an Etch-A-Sketch drawing application using two encoders. Along the way, you’ll learn techniques that you can apply to all sorts of projects.
The Adafruit I2C Rotary Encoder is available from Adafruit Industries and comes in various colors. You can find complete documentation and additional resources at https://learn.adafruit.com/adafruit-i2c-qt-rotary-encoder.
Adafruit Seesaw I2C Encoder

What Makes It Special?
The Adafruit I2C Rotary Encoder is built on the “Seesaw” platform, Adafruit’s innovative approach to creating I2C-connected peripherals. You might be wondering – what’s with the name “Seesaw”? Well, it’s actually quite clever! These modules use an ATSAMD09 microcontroller running custom firmware that acts as a bridge between I2C and various sensors and controls. It “sees” the hardware and provides “saw” (serial) access to it – hence, Seesaw! This architecture allows Adafruit to create a whole family of I2C devices with consistent programming interfaces, making them easy to use together in your projects.
The encoder module packs several features into one compact package:
- Rotary Encoder: A 24-position rotary encoder with excellent mechanical feel
- Pushbutton Switch: Integrated into the encoder shaft for selection and confirmation
- RGB NeoPixel: A built-in addressable RGB LED for visual feedback
- STEMMA QT/Qwiic Connector: For easy, solder-free daisy-chaining
- Configurable I2C Address: Allows up to eight encoders on the same bus
Pinout and Connections
The encoder breakout board features the following connections:

STEMMA QT/Qwiic Connector (4-pin JST SH):
- VCC (3.3V or 5V)
- GND
- SDA (I2C Data)
- SCL (I2C Clock)

Additional Pads:
- Same I2C and power connections as solder pads
- A0, A1, A2: Address configuration pads
- INT: Interrupt output (optional, active low)
The board works with both 3.3V and 5V logic levels, making it compatible with a wide range of microcontrollers. The STEMMA QT connectors use a standard JST SH 1mm pitch connector, which is compatible with SparkFun’s Qwiic system – so if you have Qwiic cables lying around, they’ll work perfectly!
Configuring the I2C Address
One of the most useful features of the Seesaw encoder is the ability to change its I2C address. This is what allows you to connect multiple encoders to the same I2C bus – you can have up to eight encoders working together! The default address is 0x36, but you can modify it by bridging the address pads on the back of the board.
Here’s how the addressing works:
| Pads Bridged | I2C Address |
| None (default) | 0x36 |
| A0 | 0x37 |
| A1 | 0x38 |
| A0 + A1 | 0x39 |
| A2 | 0x3A |
| A0 + A2 | 0x3B |
| A1 + A2 | 0x3C |
| A0 + A1 + A2 | 0x3D |
To bridge a pad, simply add a small solder blob across the two pads on the back of the board. This creates a connection that the Seesaw firmware detects at startup, adjusting the I2C address accordingly.
Important Note: Address 0x3C is commonly used by SSD1306 OLED displays, so you’ll want to avoid that address if you’re using one of these popular displays in your project.
How a Rotary Encoder Works
Before we dive deeper into using the Seesaw encoder, it’s worth taking a moment to understand how rotary encoders actually work. Unlike potentiometers, which provide variable resistance, rotary encoders are digital devices that detect rotational movement via two output signals.

A typical rotary encoder has two output pins (often labeled A and B) that produce quadrature signals – square waves that are 90 degrees out of phase with each other. As you rotate the shaft, these signals transition in a specific pattern. When you turn the encoder clockwise, output A transitions before output B. When you turn it counter-clockwise, output B transitions before A. By monitoring both signals and their timing, a microcontroller can determine not only that rotation has occurred, but also which direction you’re turning and how far the shaft has rotated.

Here’s the beautiful part – the Seesaw firmware handles all of this complexity for you! It monitors encoder signals, manages debouncing, and provides a simple position counter accessible via I2C. This counter increments or decrements based on rotation direction, giving you a straightforward way to track rotational input without worrying about the low-level signal timing.
The encoder in the Adafruit module has 24 detents (click positions) per full rotation, providing sufficient resolution for menu navigation and value adjustment.
The Seesaw Ecosystem
The I2C Rotary Encoder is just one member of Adafruit’s growing Seesaw family. Other Seesaw products include:
- Seesaw Soil Sensor: Measures moisture and temperature
- Seesaw Arcade Buttons: Multiple buttons on one I2C address
- Mini TFT with Joystick: Display and analog input combined
- Seesaw NeoKey: Mechanical keyboard switches with NeoPixels
All of these devices share the same Adafruit_Seesaw library and programming patterns, making it easy to mix and match different input types in your projects. Once you’ve learned to work with one Seesaw device, you can quickly integrate others – the API is consistent across the whole family.
Using the I2C Encoder
The best way to get started with the I2C Rotary Encoder is to run through some of Adafruit’s example sketches. These examples are included with the Adafruit_Seesaw library, which you can install through the Arduino Library Manager.
Installing the Library
To install the required library:
- Open the Arduino IDE
- Go to Sketch → Include Library → Manage Libraries
- Search for “Adafruit Seesaw”
- Click Install on Adafruit Seesaw Library (this will also install dependencies like Adafruit_BusIO)
You can also find the library source code and additional examples on GitHub at https://github.com/adafruit/Adafruit_Seesaw.
Example 1: Basic Encoder Demo
The encoder_basic example demonstrates the fundamental operation of a single encoder. You’ll find this sketch under File → Examples → Adafruit Seesaw → encoder → encoder_basic.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
/* * This example shows how to read from a seesaw encoder module. * The available encoder API is: * int32_t getEncoderPosition(); int32_t getEncoderDelta(); void enableEncoderInterrupt(); void disableEncoderInterrupt(); void setEncoderPosition(int32_t pos); */ #include "Adafruit_seesaw.h" #include <seesaw_neopixel.h> #define SS_SWITCH 24 #define SS_NEOPIX 6 #define SEESAW_ADDR 0x36 Adafruit_seesaw ss; seesaw_NeoPixel sspixel = seesaw_NeoPixel(1, SS_NEOPIX, NEO_GRB + NEO_KHZ800); int32_t encoder_position; void setup() { Serial.begin(115200); while (!Serial) delay(10); Serial.println("Looking for seesaw!"); if (! ss.begin(SEESAW_ADDR) || ! sspixel.begin(SEESAW_ADDR)) { Serial.println("Couldn't find seesaw on default address"); while(1) delay(10); } Serial.println("seesaw started"); uint32_t version = ((ss.getVersion() >> 16) & 0xFFFF); if (version != 4991){ Serial.print("Wrong firmware loaded? "); Serial.println(version); while(1) delay(10); } Serial.println("Found Product 4991"); // set not so bright! sspixel.setBrightness(20); sspixel.show(); // use a pin for the built in encoder switch ss.pinMode(SS_SWITCH, INPUT_PULLUP); // get starting position encoder_position = ss.getEncoderPosition(); Serial.println("Turning on interrupts"); delay(10); ss.setGPIOInterrupts((uint32_t)1 << SS_SWITCH, 1); ss.enableEncoderInterrupt(); } void loop() { if (! ss.digitalRead(SS_SWITCH)) { Serial.println("Button pressed!"); } int32_t new_position = ss.getEncoderPosition(); // did we move arounde? if (encoder_position != new_position) { Serial.println(new_position); // display new position // change the neopixel color sspixel.setPixelColor(0, Wheel(new_position & 0xFF)); sspixel.show(); encoder_position = new_position; // and save for next round } // don't overwhelm serial port delay(10); } uint32_t Wheel(byte WheelPos) { WheelPos = 255 - WheelPos; if (WheelPos < 85) { return sspixel.Color(255 - WheelPos * 3, 0, WheelPos * 3); } if (WheelPos < 170) { WheelPos -= 85; return sspixel.Color(0, WheelPos * 3, 255 - WheelPos * 3); } WheelPos -= 170; return sspixel.Color(WheelPos * 3, 255 - WheelPos * 3, 0); } |
What It Does:
This example initializes one encoder at the default address (0x36) and continuously monitors both the rotary encoder position and the pushbutton state. As you rotate the encoder, you’ll see the position value change in the Serial Monitor. Turning clockwise increments the value, while counter-clockwise rotation decrements it. The position is stored as a 32-bit signed integer, so it can handle a wide range of values without wrapping around.

The example also demonstrates button reading. Note that the button is active LOW – it reads as 0 when pressed and 1 when released. That’s why you’ll see !encoder.digitalRead(SS_SWITCH) in the code – the exclamation mark inverts the logic for more intuitive understanding.
NeoPixel Control:
One of the coolest features is the built-in NeoPixel LED. The example sketch includes a “Wheel” function that cycles through rainbow colors as you turn the encoder. This provides immediate visual feedback and shows you how to control the LED. The NeoPixel is addressed through the Seesaw interface using the seesaw_neopixel.h library – you don’t need separate GPIO pins for it!
Example 2: Multiple Encoders
The multiple_encoders example shows you how to work with two (or more) encoders on the same I2C bus. You’ll find this under File → Examples → Adafruit Seesaw → encoder → multiple_encoders.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
/* Demo with 128x64 OLED display and multiple I2C encoders wired up. The sketch will auto- * detect up to 4 encoder on the first 4 addresses. Twisting will display text on OLED * and change neopixel color. * set USE_OLED to true t */ #define USE_OLED false // set to false to skip the OLED, true to use it! #include "Adafruit_seesaw.h" #include <seesaw_neopixel.h> #if USE_OLED #include <Adafruit_SH110X.h> #include <Fonts/FreeSans9pt7b.h> Adafruit_SH1107 display = Adafruit_SH1107(64, 128, &Wire); #endif #define SS_SWITCH 24 // this is the pin on the encoder connected to switch #define SS_NEOPIX 6 // this is the pin on the encoder connected to neopixel #define SEESAW_BASE_ADDR 0x36 // I2C address, starts with 0x36 // create 4 encoders! Adafruit_seesaw encoders[4]; // create 4 encoder pixels seesaw_NeoPixel encoder_pixels[4] = { seesaw_NeoPixel(1, SS_NEOPIX, NEO_GRB + NEO_KHZ800), seesaw_NeoPixel(1, SS_NEOPIX, NEO_GRB + NEO_KHZ800), seesaw_NeoPixel(1, SS_NEOPIX, NEO_GRB + NEO_KHZ800), seesaw_NeoPixel(1, SS_NEOPIX, NEO_GRB + NEO_KHZ800)}; int32_t encoder_positions[] = {0, 0, 0, 0}; bool found_encoders[] = {false, false, false, false}; void setup() { Serial.begin(115200); // wait for serial port to open while (!Serial) delay(10); Serial.println("128x64 OLED + seesaw Encoders test"); #if USE_OLED display.begin(0x3C, true); // Address 0x3C default Serial.println("OLED begun"); display.display(); delay(500); // Pause for half second display.setRotation(1); display.setFont(&FreeSans9pt7b); display.setTextColor(SH110X_WHITE); #endif Serial.println("Looking for seesaws!"); for (uint8_t enc=0; enc<sizeof(found_encoders); enc++) { // See if we can find encoders on this address if (! encoders[enc].begin(SEESAW_BASE_ADDR + enc) || ! encoder_pixels[enc].begin(SEESAW_BASE_ADDR + enc)) { Serial.print("Couldn't find encoder #"); Serial.println(enc); } else { Serial.print("Found encoder + pixel #"); Serial.println(enc); uint32_t version = ((encoders[enc].getVersion() >> 16) & 0xFFFF); if (version != 4991){ Serial.print("Wrong firmware loaded? "); Serial.println(version); while(1) delay(10); } Serial.println("Found Product 4991"); // use a pin for the built in encoder switch encoders[enc].pinMode(SS_SWITCH, INPUT_PULLUP); // get starting position encoder_positions[enc] = encoders[enc].getEncoderPosition(); Serial.println("Turning on interrupts"); delay(10); encoders[enc].setGPIOInterrupts((uint32_t)1 << SS_SWITCH, 1); encoders[enc].enableEncoderInterrupt(); // set not so bright! encoder_pixels[enc].setBrightness(30); encoder_pixels[enc].show(); found_encoders[enc] = true; } } Serial.println("Encoders started"); } void loop() { #if USE_OLED display.clearDisplay(); uint16_t display_line = 1; #endif for (uint8_t enc=0; enc<sizeof(found_encoders); enc++) { if (found_encoders[enc] == false) continue; int32_t new_position = encoders[enc].getEncoderPosition(); // did we move around? if (encoder_positions[enc] != new_position) { Serial.print("Encoder #"); Serial.print(enc); Serial.print(" -> "); Serial.println(new_position); // display new position encoder_positions[enc] = new_position; // change the neopixel color, mulitply the new positiion by 4 to speed it up encoder_pixels[enc].setPixelColor(0, Wheel((new_position*4) & 0xFF)); encoder_pixels[enc].show(); } #if USE_OLED // draw the display display.setCursor(0, 20*display_line++); display.print("Enc #"); display.print(enc); display.print(" : "); display.print(encoder_positions[enc]); #endif if (! encoders[enc].digitalRead(SS_SWITCH)) { Serial.print("Encoder #"); Serial.print(enc); Serial.println(" pressed"); #if USE_OLED display.print(" P"); #endif } } #if USE_OLED display.display(); #endif // don't overwhelm serial port yield(); delay(10); } uint32_t Wheel(byte WheelPos) { WheelPos = 255 - WheelPos; if (WheelPos < 85) { return seesaw_NeoPixel::Color(255 - WheelPos * 3, 0, WheelPos * 3); } if (WheelPos < 170) { WheelPos -= 85; return seesaw_NeoPixel::Color(0, WheelPos * 3, 255 - WheelPos * 3); } WheelPos -= 170; return seesaw_NeoPixel::Color(WheelPos * 3, 255 - WheelPos * 3, 0); } |
What It Does:
This sketch initializes two encoders – one at the default address (0x36) and another at address 0x37 (with the A0 pad bridged). Each encoder is set up with its own NeoPixel color: the first encoder shows blue, and the second shows red. As you turn each encoder independently, you’ll see its position values update separately in the Serial Monitor.

This example demonstrates an important concept: each encoder is completely independent. They maintain their own position counters, button states, and NeoPixel colors. You simply create separate object instances and initialize them with different I2C addresses. This is the foundation for more complex projects, such as our Etch-A-Sketch, where we’ll use two encoders to control X- and Y-axis movement.
Building a Menu System
Now that we understand how the encoder works, let’s build something practical – a hierarchical menu system! This project will use a single I2C rotary encoder to navigate through menus and control the color and pattern of an RGB LED. It’s the perfect example of how rotary encoders excel at menu navigation.
What You’ll Need
- Seeeduino XIAO RP2040 microcontroller
- SSD1306 OLED display (128×64 pixels, I2C)
- Adafruit I2C Rotary Encoder (default address 0x36)
- STEMMA QT cables (or jumper wires if you prefer soldering)
The XIAO RP2040 is a great choice for this project because it’s compact, has built-in I2C support, and even includes an onboard NeoPixel LED that we can use as our RGB display. Both the OLED and encoder connect to the same I2C bus, making the wiring incredibly simple!
How the Menu System Works
Our menu system uses a state machine approach with three different states: Main Menu, Color Submenu, and Pattern Submenu. The encoder rotation lets you scroll through menu options, and pressing the encoder button selects an item or returns to the previous menu. It’s a classic hierarchical navigation pattern that you’ve probably used in countless devices.
Menu Structure
Here’s how the menu hierarchy is organized:
Main Menu:
- Color – Opens the color selection submenu
- Pattern – Opens the pattern selection submenu
Color Submenu:
- Eight color choices: Red, Green, Blue, Yellow, Cyan, Magenta, White, and Off
- Pressing the button applies the selected color and returns to the main menu
Pattern Submenu:
- Solid – LED stays on continuously
- Slow Blink – Blinks every second
- Fast Blink – Blinks every 250ms
- Pulse – Smooth breathing effect
- Rainbow – Cycles through the color spectrum
Key Code Features
Here is the code for our Menu System:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 |
/* Rotary Encoder Menu System rotary-encoder-menu.ino Use Rotary Encoder and OLED to set RGB LED Color & Blink Rate Uses Adafruit I2C Rotary Encoder Module and XIAO RP2040 Code development assisted by Claude Code DroneBot Workshop 2026 https://dronebotworkshop.com */ // Include Required Libraries #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> #include <Adafruit_NeoPixel.h> #include "Adafruit_seesaw.h" #include <seesaw_neopixel.h> // OLED Display settings #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 #define SCREEN_ADDRESS 0x3C Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); // NeoPixel settings for XIAO RP2040 #define NEOPIXEL_PIN 12 // NEOPIXEL_POWER is already defined in the board variant (pin 11) #define NUMPIXELS 1 Adafruit_NeoPixel pixels(NUMPIXELS, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800); // Rotary Encoder Adafruit_seesaw encoder; #define ENCODER_ADDR 0x36 #define SS_NEOPIX 6 int32_t encoder_position = 0; // Encoder's built-in NeoPixel seesaw_NeoPixel encoderPixel = seesaw_NeoPixel(1, SS_NEOPIX, NEO_GRB + NEO_KHZ800); // Menu states enum MenuState { MAIN_MENU, COLOR_SUBMENU, PATTERN_SUBMENU }; MenuState currentState = MAIN_MENU; // Main menu items const char* mainMenuItems[] = { "Color", "Pattern" }; const int mainMenuCount = 2; int mainMenuSelection = 0; // Color submenu items const char* colorMenuItems[] = { "Red", "Green", "Blue", "Yellow", "Cyan", "Magenta", "White", "Off" }; const int colorMenuCount = 8; int colorMenuSelection = 0; // Pattern submenu items const char* patternMenuItems[] = { "Solid", "Slow Blink", "Fast Blink", "Pulse", "Rainbow" }; const int patternMenuCount = 5; int patternMenuSelection = 0; // LED control variables uint32_t currentColor = pixels.Color(255, 0, 0); // Default red int currentPattern = 0; // 0=Solid, 1=Slow Blink, 2=Fast Blink, 3=Pulse, 4=Rainbow unsigned long lastPatternUpdate = 0; int patternStep = 0; bool ledState = true; // Button state tracking bool lastButtonState = false; void setup() { Serial.begin(115200); delay(1000); Serial.println("Menu Demo - Single Encoder"); Serial.println("Seeeduino Xiao RP2040"); // Initialize NeoPixel pinMode(NEOPIXEL_POWER, OUTPUT); digitalWrite(NEOPIXEL_POWER, HIGH); delay(100); pixels.begin(); pixels.setBrightness(50); pixels.setPixelColor(0, currentColor); pixels.show(); // Initialize I2C FIRST - required for OLED and Encoder Wire.begin(); // Initialize OLED if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) { Serial.println(F("SSD1306 allocation failed")); for (;;) ; } display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.println(F("Initializing...")); display.display(); // Initialize Encoder if (!encoder.begin(ENCODER_ADDR)) { Serial.println("Encoder not found!"); display.clearDisplay(); display.setCursor(0, 0); display.println(F("Encoder ERROR")); display.println(F("Check I2C wiring")); display.println(F("Addr: 0x36")); display.display(); while (1) delay(10); } Serial.println("Encoder found!"); encoder.pinMode(24, INPUT_PULLUP); encoder.enableEncoderInterrupt(); // Initialize encoder's built-in NeoPixel encoderPixel.begin(ENCODER_ADDR); encoderPixel.setBrightness(30); encoderPixel.setPixelColor(0, encoderPixel.Color(255, 0, 0)); // Start with red encoderPixel.show(); encoder_position = encoder.getEncoderPosition(); Serial.println("Setup complete!"); Serial.println("Rotate encoder to navigate"); Serial.println("Press button to select"); // Display initial menu displayMenu(); } void loop() { // Read encoder position int32_t new_position = encoder.getEncoderPosition(); if (new_position != encoder_position) { int32_t delta = new_position - encoder_position; encoder_position = new_position; handleEncoderRotation(delta); displayMenu(); } // Read button - simplified like the working code bool buttonPressed = !encoder.digitalRead(24); if (buttonPressed && !lastButtonState) { // Button was just pressed Serial.println("Button press detected!"); handleButtonPress(); displayMenu(); delay(200); // Simple debounce delay } lastButtonState = buttonPressed; // Update LED pattern updateLEDPattern(); delay(10); } void handleEncoderRotation(int32_t delta) { switch (currentState) { case MAIN_MENU: mainMenuSelection += (delta > 0) ? 1 : -1; if (mainMenuSelection < 0) mainMenuSelection = mainMenuCount - 1; if (mainMenuSelection >= mainMenuCount) mainMenuSelection = 0; Serial.print("Main menu selection: "); Serial.println(mainMenuItems[mainMenuSelection]); break; case COLOR_SUBMENU: colorMenuSelection += (delta > 0) ? 1 : -1; if (colorMenuSelection < 0) colorMenuSelection = colorMenuCount - 1; if (colorMenuSelection >= colorMenuCount) colorMenuSelection = 0; Serial.print("Color selection: "); Serial.println(colorMenuItems[colorMenuSelection]); break; case PATTERN_SUBMENU: patternMenuSelection += (delta > 0) ? 1 : -1; if (patternMenuSelection < 0) patternMenuSelection = patternMenuCount - 1; if (patternMenuSelection >= patternMenuCount) patternMenuSelection = 0; Serial.print("Pattern selection: "); Serial.println(patternMenuItems[patternMenuSelection]); break; } } void handleButtonPress() { Serial.print("Button pressed! Current state: "); switch (currentState) { case MAIN_MENU: Serial.println("MAIN_MENU"); // Enter submenu based on selection if (mainMenuSelection == 0) { currentState = COLOR_SUBMENU; Serial.println("-> Entering Color submenu"); } else if (mainMenuSelection == 1) { currentState = PATTERN_SUBMENU; Serial.println("-> Entering Pattern submenu"); } break; case COLOR_SUBMENU: Serial.println("COLOR_SUBMENU"); // Apply color selection and return to main menu applyColorSelection(); currentState = MAIN_MENU; Serial.println("-> Returning to Main menu"); break; case PATTERN_SUBMENU: Serial.println("PATTERN_SUBMENU"); // Apply pattern selection and return to main menu applyPatternSelection(); currentState = MAIN_MENU; Serial.println("-> Returning to Main menu"); break; } } void applyColorSelection() { switch (colorMenuSelection) { case 0: currentColor = pixels.Color(255, 0, 0); break; // Red case 1: currentColor = pixels.Color(0, 255, 0); break; // Green case 2: currentColor = pixels.Color(0, 0, 255); break; // Blue case 3: currentColor = pixels.Color(255, 255, 0); break; // Yellow case 4: currentColor = pixels.Color(0, 255, 255); break; // Cyan case 5: currentColor = pixels.Color(255, 0, 255); break; // Magenta case 6: currentColor = pixels.Color(255, 255, 255); break; // White case 7: currentColor = pixels.Color(0, 0, 0); break; // Off } Serial.print("Color set to: "); Serial.println(colorMenuItems[colorMenuSelection]); // Update encoder's NeoPixel to match selected color encoderPixel.setPixelColor(0, currentColor); encoderPixel.show(); // Reset pattern state patternStep = 0; ledState = true; } void applyPatternSelection() { currentPattern = patternMenuSelection; Serial.print("Pattern set to: "); Serial.println(patternMenuItems[patternMenuSelection]); // Reset pattern state patternStep = 0; ledState = true; lastPatternUpdate = millis(); } void updateLEDPattern() { unsigned long currentTime = millis(); switch (currentPattern) { case 0: // Solid pixels.setPixelColor(0, currentColor); pixels.show(); break; case 1: // Slow Blink (1 second interval) if (currentTime - lastPatternUpdate >= 1000) { ledState = !ledState; pixels.setPixelColor(0, ledState ? currentColor : 0); pixels.show(); lastPatternUpdate = currentTime; } break; case 2: // Fast Blink (250ms interval) if (currentTime - lastPatternUpdate >= 250) { ledState = !ledState; pixels.setPixelColor(0, ledState ? currentColor : 0); pixels.show(); lastPatternUpdate = currentTime; } break; case 3: // Pulse if (currentTime - lastPatternUpdate >= 20) { int brightness = (sin(patternStep * 0.1) + 1.0) * 127.5; uint8_t r = ((currentColor >> 16) & 0xFF) * brightness / 255; uint8_t g = ((currentColor >> 8) & 0xFF) * brightness / 255; uint8_t b = (currentColor & 0xFF) * brightness / 255; pixels.setPixelColor(0, pixels.Color(r, g, b)); pixels.show(); patternStep = (patternStep + 1) % 63; lastPatternUpdate = currentTime; } break; case 4: // Rainbow if (currentTime - lastPatternUpdate >= 50) { pixels.setPixelColor(0, Wheel(patternStep)); pixels.show(); patternStep = (patternStep + 1) % 256; lastPatternUpdate = currentTime; } break; } } // Rainbow color wheel helper function uint32_t Wheel(byte WheelPos) { WheelPos = 255 - WheelPos; if (WheelPos < 85) { return pixels.Color(255 - WheelPos * 3, 0, WheelPos * 3); } if (WheelPos < 170) { WheelPos -= 85; return pixels.Color(0, WheelPos * 3, 255 - WheelPos * 3); } WheelPos -= 170; return pixels.Color(WheelPos * 3, 255 - WheelPos * 3, 0); } void displayMenu() { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); switch (currentState) { case MAIN_MENU: { display.println("=== MAIN MENU ==="); display.println(); for (int i = 0; i < mainMenuCount; i++) { if (i == mainMenuSelection) { display.print("> "); } else { display.print(" "); } display.println(mainMenuItems[i]); } break; } case COLOR_SUBMENU: { display.println("=== COLOR ==="); display.println(); // Display 6 items at a time, centered on selection int startIdx = max(0, min(colorMenuSelection - 2, colorMenuCount - 6)); for (int i = 0; i < min(6, colorMenuCount); i++) { int itemIdx = startIdx + i; if (itemIdx == colorMenuSelection) { display.print("> "); } else { display.print(" "); } display.println(colorMenuItems[itemIdx]); } break; } case PATTERN_SUBMENU: { display.println("=== PATTERN ==="); display.println(); for (int i = 0; i < patternMenuCount; i++) { if (i == patternMenuSelection) { display.print("> "); } else { display.print(" "); } display.println(patternMenuItems[i]); } break; } } display.display(); } |
Let’s take a closer look at how it works.
State Machine:
The code uses an enum called MenuState to track which menu you’re currently viewing. This makes the navigation logic clean and easy to understand. When you press the button, the code checks the current state and either enters a submenu or applies a selection and returns to the main menu.
Wrap-Around Navigation:
When you reach the end of a menu list and continue rotating, the selection wraps around to the beginning. This circular navigation feels natural and prevents you from getting “stuck” at the end of a list. The code handles this with simple modulo arithmetic.
Visual Feedback:
The encoder’s built-in NeoPixel changes color to match your selected color choice, providing instant visual confirmation. The OLED display shows the current menu with a “> ” indicator next to the selected item, making it clear where you are in the navigation.
Non-Blocking Pattern Updates:
The LED patterns (blinking, pulsing, rainbow) update without using delay() commands. Instead, the code uses millis() to check elapsed time. This keeps the interface responsive – you can always navigate the menu, even while a pattern is running.
Critical I2C Initialization:
One important detail in the code: Wire.begin() must be called before initializing the OLED display and encoder. This was a lesson learned through debugging – if you initialize I2C devices before calling Wire.begin(), you’ll end up with a blank display!

Build an Etch-A-Sketch
Now for the fun part – let’s build a digital Etch-A-Sketch! This project uses two I2C rotary encoders to create a drawing application with all sorts of features you’d never get with a mechanical toy. We’ll have color selection, adjustable line width, backlight control, and even the ability to save and load your settings.

What You’ll Need
- ESP32-C6 DevKitC microcontroller
- ILI9341 TFT display (320×240 pixels, SPI)
- Two Adafruit I2C Rotary Encoders (addresses 0x36 and 0x37)
- STEMMA QT cables
The ESP32-C6 provides plenty of processing power and has separate I2C and SPI interfaces, letting us connect the encoders and display without conflicts. Remember to bridge the A0 pad on one encoder to change its address to 0x37!
How the Etch-A-Sketch Works
Our digital Etch-A-Sketch improves on the classic toy in several ways. The left encoder controls horizontal movement, while the right encoder controls vertical movement – just like the original. We’ve added multiple operating modes you can cycle through by pressing the left encoder button. Each mode gives you different functionality:
Operating Modes:
- Draw Mode (Red NeoPixel): Both encoders control X and Y movement
- Color Mode (Current color NeoPixel): Left encoder cycles through 9 colors
- Width Mode (Orange NeoPixel): Left encoder adjusts line width from 1 to 10 pixels
- Brightness Mode (Yellow NeoPixel): Left encoder adjusts display backlight
Additional Features:
- Press the right encoder button to clear the screen
- Press both buttons simultaneously to enter Save/Load mode
- A status bar at the bottom shows current color, line width, backlight percentage, and mode
- A crosshair cursor shows your drawing position
Key Code Features
Here is the code for the Etch-a-Sketch:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 |
/* Etch-a-Sketch etch-a-sketch.ino Etch-a-Sketch emulator using Rotary Encoders and TFT Display Uses Adafruit I2C Rotary Encoder Module and ESP32-C6 DevKitC Code development assisted by Claude Code DroneBot Workshop 2026 https://dronebotworkshop.com */ // Include Required Libraries #include <SPI.h> #include <Adafruit_GFX.h> #include <Adafruit_ILI9341.h> #include "Adafruit_seesaw.h" #include <seesaw_neopixel.h> #include <Preferences.h> // TFT Display pins for ESP32-C6 #define TFT_CS 10 #define TFT_DC 11 #define TFT_RST 4 #define TFT_MOSI 19 #define TFT_SCK 18 #define TFT_LED 5 // Backlight control // I2C pins for ESP32-C6 (Default I2C pins) #define I2C_SDA 6 #define I2C_SCL 7 // PWM settings for backlight #define BACKLIGHT_FREQ 5000 #define BACKLIGHT_RESOLUTION 8 // Seesaw encoder definitions #define SS_SWITCH 24 #define SS_NEOPIX 6 #define ENCODER_X_ADDR 0x36 // Left encoder (X-axis) #define ENCODER_Y_ADDR 0x37 // Right encoder (Y-axis) // Display dimensions - LANDSCAPE MODE #define SCREEN_WIDTH 320 #define SCREEN_HEIGHT 240 #define DRAWING_HEIGHT 200 // Leave room for status bar at bottom (40 pixels) // Create objects Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST); Adafruit_seesaw encoderX, encoderY; seesaw_NeoPixel pixelX = seesaw_NeoPixel(1, SS_NEOPIX, NEO_GRB + NEO_KHZ800); seesaw_NeoPixel pixelY = seesaw_NeoPixel(1, SS_NEOPIX, NEO_GRB + NEO_KHZ800); Preferences preferences; // Drawing state int16_t cursorX = SCREEN_WIDTH / 2; int16_t cursorY = DRAWING_HEIGHT / 2; int32_t encX_position = 0; int32_t encY_position = 0; bool btnX_state = false; bool btnY_state = false; // Cursor state bool cursorVisible = true; unsigned long lastCursorBlink = 0; // Color palette - 16-bit RGB565 for TFT display const uint16_t colors[] = { ILI9341_RED, ILI9341_ORANGE, ILI9341_YELLOW, ILI9341_GREEN, ILI9341_CYAN, ILI9341_BLUE, ILI9341_MAGENTA, ILI9341_WHITE, ILI9341_BLACK }; // NeoPixel colors - 24-bit RGB888 (matches the display colors) const uint32_t neopixelColors[] = { 0xFF0000, // Red 0xFF8000, // Orange 0xFFFF00, // Yellow 0x00FF00, // Green 0x00FFFF, // Cyan 0x0000FF, // Blue 0xFF00FF, // Magenta 0xFFFFFF, // White 0x000000 // Black (off) }; const uint8_t colorCount = 9; const char* colorNames[] = { "Red", "Orange", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White", "Eraser" }; uint8_t currentColor = 0; uint8_t lineWidth = 2; uint8_t backlightBrightness = 255; // 0-255 // Menu mode enum Mode { MODE_DRAWING, MODE_COLOR_SELECT, MODE_WIDTH_SELECT, MODE_BRIGHTNESS_SELECT }; Mode currentMode = MODE_DRAWING; // Forward declarations void updateModeIndicator(); void setup() { Serial.begin(115200); delay(1000); Serial.println("Etch-A-Sketch Starting..."); // Initialize backlight with PWM (new ESP32 core 3.x API) ledcAttach(TFT_LED, BACKLIGHT_FREQ, BACKLIGHT_RESOLUTION); ledcWrite(TFT_LED, backlightBrightness); // Initialize I2C on default pins Wire.begin(I2C_SDA, I2C_SCL); // Initialize SPI for TFT on alternate pins SPI.begin(TFT_SCK, -1, TFT_MOSI, TFT_CS); // SCK=18, MISO=-1, MOSI=19, SS=10 // Initialize TFT tft.begin(); tft.setRotation(3); // Landscape mode (status bar on bottom, rotated 180° from rotation 1) tft.fillScreen(ILI9341_BLACK); // Splash screen tft.setTextColor(ILI9341_WHITE); tft.setTextSize(3); tft.setCursor(80, 70); tft.println("Etch-A-"); tft.setCursor(80, 100); tft.println("Sketch!"); tft.setTextSize(1); tft.setCursor(120, 140); tft.println("Loading..."); delay(2000); // Initialize Encoder X (Left/Horizontal) if (!encoderX.begin(ENCODER_X_ADDR)) { Serial.println("Encoder X not found!"); tft.fillScreen(ILI9341_RED); tft.setTextSize(2); tft.setCursor(50, 70); tft.println("Encoder X ERROR!"); tft.setTextSize(1); tft.setCursor(50, 100); tft.println("Check I2C wiring:"); tft.setCursor(50, 115); tft.println("SDA=GPIO6 SCL=GPIO7"); tft.setCursor(50, 130); tft.println("Address: 0x36"); while (1) delay(10); } encoderX.pinMode(SS_SWITCH, INPUT_PULLUP); encoderX.enableEncoderInterrupt(); pixelX.begin(ENCODER_X_ADDR); pixelX.setBrightness(40); updateModeIndicator(); // Set initial color based on mode encX_position = encoderX.getEncoderPosition(); Serial.println("Encoder X initialized"); // Initialize Encoder Y (Right/Vertical) if (!encoderY.begin(ENCODER_Y_ADDR)) { Serial.println("Encoder Y not found!"); tft.fillScreen(ILI9341_RED); tft.setTextSize(2); tft.setCursor(50, 70); tft.println("Encoder Y ERROR!"); tft.setTextSize(1); tft.setCursor(50, 100); tft.println("Check I2C address"); tft.setCursor(50, 115); tft.println("Should be 0x37"); tft.setCursor(50, 130); tft.println("(A0 pad bridged)"); while (1) delay(10); } encoderY.pinMode(SS_SWITCH, INPUT_PULLUP); encoderY.enableEncoderInterrupt(); pixelY.begin(ENCODER_Y_ADDR); pixelY.setBrightness(40); pixelY.setPixelColor(0, 0x0000FF); // Blue for Y pixelY.show(); encY_position = encoderY.getEncoderPosition(); Serial.println("Encoder Y initialized"); // Initialize NVS preferences.begin("etchasketch", false); // Load saved settings backlightBrightness = preferences.getUChar("backlight", 255); ledcWrite(TFT_LED, backlightBrightness); // Clear screen and setup UI tft.fillScreen(ILI9341_BLACK); drawStatusBar(); drawCursor(); Serial.println("Ready to draw!"); Serial.println(""); Serial.println("Mode: LANDSCAPE (320x240)"); Serial.println("Pin Configuration:"); Serial.println(" TFT: CS=10, DC=11, RST=4, MOSI=19, SCK=18, LED=5"); Serial.println(" I2C: SDA=6, SCL=7"); Serial.println(""); Serial.println("Controls:"); Serial.println(" X Encoder (Left) = Horizontal / Adjust values"); Serial.println(" Y Encoder (Right) = Vertical"); Serial.println(" X Button = Cycle modes"); Serial.println(" Y Button = Clear screen"); Serial.println(" Both Buttons = Save/Load menu"); Serial.println(""); Serial.println("NeoPixel Indicators:"); Serial.println(" Left: Red=DRAW, Current Color=COLOR mode, Orange=WIDTH, Yellow=LIGHT"); Serial.println(" Right: Blue=Y-axis control"); } void loop() { // Read X encoder int32_t new_encX = encoderX.getEncoderPosition(); if (new_encX != encX_position) { int32_t delta = new_encX - encX_position; if (currentMode == MODE_DRAWING) { // Erase cursor before moving eraseCursor(); // Move horizontally (2 pixels per click) moveCursor(delta * 2, 0); // Redraw cursor at new position drawCursor(); } else if (currentMode == MODE_COLOR_SELECT) { // Change color with each click changeColor(delta > 0 ? 1 : -1); // Update position after processing encX_position = new_encX; } else if (currentMode == MODE_WIDTH_SELECT) { // Change width with each click changeWidth(delta > 0 ? 1 : -1); // Update position after processing encX_position = new_encX; } else if (currentMode == MODE_BRIGHTNESS_SELECT) { changeBrightness(delta * 5); } // Update position for drawing and brightness modes if (currentMode == MODE_DRAWING || currentMode == MODE_BRIGHTNESS_SELECT) { encX_position = new_encX; } } // Read Y encoder (only used in drawing mode) int32_t new_encY = encoderY.getEncoderPosition(); if (new_encY != encY_position) { int32_t delta = new_encY - encY_position; if (currentMode == MODE_DRAWING) { // Erase cursor before moving eraseCursor(); // Move vertically (2 pixels per click, inverted) moveCursor(0, -delta * 2); // Redraw cursor at new position drawCursor(); } encY_position = new_encY; } // Read X button - Mode selection bool btnX_current = !encoderX.digitalRead(SS_SWITCH); if (btnX_current && !btnX_state) { handleXButtonPress(); } btnX_state = btnX_current; // Read Y button - Clear/Save bool btnY_current = !encoderY.digitalRead(SS_SWITCH); if (btnY_current && !btnY_state) { handleYButtonPress(); } btnY_state = btnY_current; // Check for both buttons (Save/Load menu) if (btnX_state && btnY_state) { handleBothButtons(); delay(500); // Debounce } // Blink cursor in non-drawing modes if (currentMode != MODE_DRAWING) { if (millis() - lastCursorBlink > 500) { if (cursorVisible) { eraseCursor(); } else { drawCursor(); } cursorVisible = !cursorVisible; lastCursorBlink = millis(); } } else { // Ensure cursor is visible in drawing mode if (!cursorVisible) { drawCursor(); cursorVisible = true; } } delay(5); } // Update the mode indicator NeoPixel based on current mode void updateModeIndicator() { uint32_t color; const char* colorName; switch (currentMode) { case MODE_DRAWING: color = 0xFF0000; // Red for DRAW mode colorName = "RED (DRAW)"; break; case MODE_COLOR_SELECT: color = neopixelColors[currentColor]; // Current color colorName = "CURRENT COLOR (COLOR)"; break; case MODE_WIDTH_SELECT: color = 0xFF8000; // Orange for WIDTH mode colorName = "ORANGE (WIDTH)"; break; case MODE_BRIGHTNESS_SELECT: color = 0xFFFF00; // Yellow for BRIGHTNESS mode colorName = "YELLOW (LIGHT)"; break; } // Debug output Serial.print("updateModeIndicator() - Mode: "); Serial.print(currentMode); Serial.print(", Setting NeoPixel to: "); Serial.print(colorName); Serial.print(" (0x"); Serial.print(color, HEX); Serial.println(")"); pixelX.setPixelColor(0, color); pixelX.show(); // Verify it was set delay(10); // Give NeoPixel time to update } void moveCursor(int16_t deltaX, int16_t deltaY) { // Only proceed if there's actual movement if (deltaX == 0 && deltaY == 0) { return; } // Calculate new position int16_t newX = cursorX + deltaX; int16_t newY = cursorY + deltaY; // Apply bounds checking newX = constrain(newX, lineWidth, SCREEN_WIDTH - lineWidth - 1); newY = constrain(newY, lineWidth, DRAWING_HEIGHT - lineWidth - 1); // Draw line from old position to new position if (cursorX != newX || cursorY != newY) { if (lineWidth == 1) { tft.drawLine(cursorX, cursorY, newX, newY, colors[currentColor]); } else { // Draw thick line using circles drawThickLine(cursorX, cursorY, newX, newY, colors[currentColor], lineWidth); } // Update cursor position cursorX = newX; cursorY = newY; } } void drawThickLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t color, uint8_t width) { // Bresenham's line algorithm with circular brush int16_t dx = abs(x1 - x0); int16_t dy = abs(y1 - y0); int16_t sx = (x0 < x1) ? 1 : -1; int16_t sy = (y0 < y1) ? 1 : -1; int16_t err = dx - dy; while (true) { tft.fillCircle(x0, y0, width / 2, color); if (x0 == x1 && y0 == y1) break; int16_t e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } } void drawCursor() { // Draw crosshair cursor uint16_t cursorColor = ILI9341_CYAN; // Cyan is more visible int16_t size = 4; // Horizontal line tft.drawFastHLine(cursorX - size, cursorY, size * 2 + 1, cursorColor); // Vertical line tft.drawFastVLine(cursorX, cursorY - size, size * 2 + 1, cursorColor); // Center pixel in contrasting color tft.drawPixel(cursorX, cursorY, ILI9341_YELLOW); cursorVisible = true; } void eraseCursor() { // Erase crosshair cursor by drawing in black int16_t size = 4; // Horizontal line tft.drawFastHLine(cursorX - size, cursorY, size * 2 + 1, ILI9341_BLACK); // Vertical line tft.drawFastVLine(cursorX, cursorY - size, size * 2 + 1, ILI9341_BLACK); cursorVisible = false; } void drawStatusBar() { // Status bar at bottom (horizontal bar) tft.fillRect(0, DRAWING_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT - DRAWING_HEIGHT, ILI9341_DARKGREY); tft.drawFastHLine(0, DRAWING_HEIGHT, SCREEN_WIDTH, ILI9341_WHITE); updateStatusBar(); } void updateStatusBar() { // Clear status area tft.fillRect(0, DRAWING_HEIGHT + 2, SCREEN_WIDTH, 38, ILI9341_DARKGREY); // Show current color circle tft.fillCircle(15, DRAWING_HEIGHT + 20, 10, colors[currentColor]); tft.drawCircle(15, DRAWING_HEIGHT + 20, 11, ILI9341_WHITE); // Show color name tft.setTextColor(ILI9341_WHITE); tft.setTextSize(1); tft.setCursor(30, DRAWING_HEIGHT + 8); tft.print(colorNames[currentColor]); // Show line width tft.setCursor(30, DRAWING_HEIGHT + 23); tft.print("Width: "); tft.print(lineWidth); tft.print("px"); // Show backlight brightness tft.setCursor(120, DRAWING_HEIGHT + 23); tft.print("Backlight: "); tft.print((backlightBrightness * 100) / 255); tft.print("%"); // Mode indicator on right side tft.setCursor(260, DRAWING_HEIGHT + 15); switch (currentMode) { case MODE_DRAWING: tft.print("DRAW"); break; case MODE_COLOR_SELECT: tft.print("COLOR"); break; case MODE_WIDTH_SELECT: tft.print("WIDTH"); break; case MODE_BRIGHTNESS_SELECT: tft.print("LIGHT"); break; } } void handleXButtonPress() { // Erase cursor before mode change eraseCursor(); if (currentMode == MODE_DRAWING) { currentMode = MODE_COLOR_SELECT; Serial.println("Switched to COLOR mode"); } else if (currentMode == MODE_COLOR_SELECT) { currentMode = MODE_WIDTH_SELECT; Serial.println("Switched to WIDTH mode"); } else if (currentMode == MODE_WIDTH_SELECT) { currentMode = MODE_BRIGHTNESS_SELECT; Serial.println("Switched to BRIGHTNESS mode"); } else { currentMode = MODE_DRAWING; Serial.println("Switched to DRAW mode"); } // Update display and mode indicator updateStatusBar(); updateModeIndicator(); // Single function to handle all mode colors // Redraw cursor at current position drawCursor(); delay(200); } void handleYButtonPress() { if (currentMode == MODE_DRAWING && !btnX_state) { // Clear screen eraseCursor(); tft.fillRect(0, 0, SCREEN_WIDTH, DRAWING_HEIGHT, ILI9341_BLACK); cursorX = SCREEN_WIDTH / 2; cursorY = DRAWING_HEIGHT / 2; drawCursor(); // Flash Y encoder pixelY.setPixelColor(0, 0xFFFFFF); pixelY.show(); delay(100); pixelY.setPixelColor(0, 0x0000FF); pixelY.show(); Serial.println("Screen cleared!"); } } void handleBothButtons() { // Erase cursor during menu eraseCursor(); // Save/Load menu (centered on drawing area) tft.fillRect(80, 40, 160, 120, ILI9341_DARKGREY); tft.drawRect(80, 40, 160, 120, ILI9341_WHITE); tft.setTextColor(ILI9341_WHITE); tft.setTextSize(2); tft.setCursor(100, 50); tft.print("SAVE/LOAD"); tft.setTextSize(1); tft.setCursor(90, 80); tft.print("Rotate X: Select"); tft.setCursor(90, 95); tft.print("Press X: Confirm"); tft.setCursor(90, 115); tft.print("Press Y: Cancel"); int selection = 0; // 0=Save, 1=Load bool menuActive = true; int32_t lastEncPos = encoderX.getEncoderPosition(); while (menuActive) { // Show current selection tft.fillRect(90, 135, 140, 20, ILI9341_DARKGREY); tft.setTextSize(2); tft.setCursor(120, 135); if (selection == 0) { tft.print("SAVE"); } else { tft.print("LOAD"); } // Read X encoder for selection int32_t new_encX = encoderX.getEncoderPosition(); if (new_encX != lastEncPos) { selection = 1 - selection; // Toggle lastEncPos = new_encX; } // Check buttons bool btnX_new = !encoderX.digitalRead(SS_SWITCH); bool btnY_new = !encoderY.digitalRead(SS_SWITCH); if (btnX_new && !btnX_state) { // Confirm if (selection == 0) { saveDrawing(); } else { loadDrawing(); } menuActive = false; } if (btnY_new && !btnY_state) { // Cancel menuActive = false; } btnX_state = btnX_new; btnY_state = btnY_new; delay(50); } // Update encoder position to current value encX_position = encoderX.getEncoderPosition(); // Restore screen tft.fillRect(80, 40, 160, 120, ILI9341_BLACK); drawStatusBar(); updateModeIndicator(); // Restore mode indicator drawCursor(); // Wait for buttons to be released while (!encoderX.digitalRead(SS_SWITCH) || !encoderY.digitalRead(SS_SWITCH)) { delay(50); } btnX_state = false; btnY_state = false; } void changeColor(int32_t delta) { currentColor = (currentColor + delta + colorCount) % colorCount; updateStatusBar(); updateModeIndicator(); // Update NeoPixel to match new color Serial.print("Color changed to: "); Serial.println(colorNames[currentColor]); } void changeWidth(int32_t delta) { int newWidth = lineWidth + delta; lineWidth = constrain(newWidth, 1, 10); updateStatusBar(); Serial.print("Width changed to: "); Serial.println(lineWidth); } void changeBrightness(int32_t delta) { int newBrightness = backlightBrightness + delta; backlightBrightness = constrain(newBrightness, 20, 255); ledcWrite(TFT_LED, backlightBrightness); updateStatusBar(); // Save brightness to NVS preferences.putUChar("backlight", backlightBrightness); } void saveDrawing() { // Save current drawing settings to NVS preferences.putUChar("lastColor", currentColor); preferences.putUChar("lastWidth", lineWidth); preferences.putUChar("backlight", backlightBrightness); // Flash confirmation for (int i = 0; i < 3; i++) { pixelX.setPixelColor(0, 0x00FF00); pixelY.setPixelColor(0, 0x00FF00); pixelX.show(); pixelY.show(); delay(100); pixelX.setPixelColor(0, 0x000000); pixelY.setPixelColor(0, 0x000000); pixelX.show(); pixelY.show(); delay(100); } // Restore encoder colors updateModeIndicator(); pixelY.setPixelColor(0, 0x0000FF); pixelY.show(); Serial.println("Drawing settings saved!"); } void loadDrawing() { // Load last drawing settings currentColor = preferences.getUChar("lastColor", 0); lineWidth = preferences.getUChar("lastWidth", 2); backlightBrightness = preferences.getUChar("backlight", 255); // Apply backlight setting ledcWrite(TFT_LED, backlightBrightness); updateStatusBar(); // Flash confirmation pixelX.setPixelColor(0, 0xFFFF00); pixelY.setPixelColor(0, 0xFFFF00); pixelX.show(); pixelY.show(); delay(500); // Restore encoder colors updateModeIndicator(); pixelY.setPixelColor(0, 0x0000FF); pixelY.show(); Serial.println("Drawing settings loaded!"); } |
Dual-Encoder Coordination:
The code maintains separate position trackers for each encoder (encX_position and encY_position). When in Draw mode, any change in either encoder moves the cursor and draws a line. The movement is scaled by 2 pixels per encoder click, which provides a good balance between precision and speed.
Thick Line Drawing:
When the line width exceeds 1 pixel, the code uses Bresenham’s line algorithm combined with filled circles to create smooth, thick lines. Each point along the line is filled with a circle at the current width, creating a paintbrush effect that looks much better than simple thick lines.
Cursor Management:
The cursor is implemented as a small crosshair that’s always visible in Draw mode but blinks in other modes. Before any screen update, the cursor is erased by drawing it in black, then redrawn after the update. This prevents the cursor from leaving artifacts as it moves around the screen.
NeoPixel Mode Indicators:
The left encoder’s NeoPixel changes color based on the current mode: red for Draw, the current selected color for Color mode, orange for Width, and yellow for Brightness. This provides instant visual feedback on what the encoder will control. The right encoder always stays blue to indicate its role as the Y-axis controller.
Persistent Settings with NVS:
The ESP32’s Non-Volatile Storage (NVS) system saves your last used color, line width, and backlight brightness. When you press both encoder buttons, you can choose to save your current settings or load previously saved ones. The encoders flash green when saving and yellow when loading, providing clear confirmation.
Mixed Bus Architecture:
This project demonstrates an important concept – you can use different communication protocols simultaneously. The TFT display uses SPI for high-speed graphics, while the encoders use I2C. The ESP32 handles both buses independently with no conflicts. This is one of the advantages of using microcontrollers with dedicated hardware peripherals.
Conclusion
The Adafruit I2C Rotary Encoder really is a game-changer for Arduino projects. By combining a rotary encoder, pushbutton, and RGB LED in a single I2C package, it dramatically simplifies both the hardware and software design of interactive projects. No more worrying about debouncing, no more eating up GPIO pins, no more complex timing code – it all just works!
What We’ve Learned
Throughout this article, we’ve covered a lot of ground:
- How the Seesaw platform works and why it’s called “Seesaw”
- Rotary encoder principles and quadrature signal detection
- I2C address configuration using solder pads
- Building hierarchical menu systems with state machines
- Creating responsive, non-blocking user interfaces
- Managing multiple encoders on one I2C bus
- Integrating encoders with displays (both OLED and TFT)
- Using NeoPixels for intuitive visual feedback
- Implementing save/load functionality with non-volatile storage
Future Project Ideas
The techniques we’ve demonstrated can be applied to countless other projects. Here are some ideas to get you thinking:
Audio Projects:
- Multi-band equalizer with separate encoders for each frequency band
- Software-defined radio tuner with frequency and volume controls
- MIDI controller with multiple parameter controls
Robotics:
- Tank drive robot with independent left/right motor control
- Pan-tilt camera platform controller
- Robotic arm joint position control
Home Automation:
- Smart thermostat with temperature and schedule controls
- Smart lighting controller with hue, saturation, and brightness adjustment
- Garden irrigation timer with zone and duration settings
Design Tips for Your Projects
When incorporating I2C rotary encoders into your own projects, keep these principles in mind:
- Plan Your I2C Addresses: Before ordering hardware, map out all I2C devices and their addresses to avoid conflicts.
- Use Visual Feedback: The built-in NeoPixel is valuable – use it to indicate modes, confirm actions, or show status.
- Think in Modes: A single encoder can control many parameters through mode switching.
- Keep Menus Shallow: Deep menu hierarchies frustrate users – aim for 2-3 levels maximum.
- Use Wrap-around: Circular navigation feels more natural than hitting boundaries.
- Update Displays Efficiently: Only redraw changed elements to maintain smooth operation.
Final Thoughts
The I2C Rotary Encoder represents a significant evolution in hobby electronics. By offloading encoder reading to a dedicated microcontroller and providing I2C access, Adafruit has created a building block that makes complex interfaces accessible to makers at all levels.
The two projects in this article – the hierarchical menu system and the Etch-A-Sketch – demonstrate just a fraction of what’s possible. The real power of these encoders lies in their flexibility: they’re equally at home in simple volume controls and sophisticated multi-parameter interfaces.
As you design your own projects, remember that good user interfaces are invisible – they feel natural and intuitive, allowing users to focus on their task rather than fighting with controls. The I2C Rotary Encoder, with its combination of tactile feedback, precise control, and visual indication, gives you the tools to create interfaces that delight rather than frustrate.
Whether you’re building a one-off project or designing a product, the Adafruit I2C Rotary Encoder deserves a place in your toolkit. Its combination of simplicity, capability, and polish makes it a joy to work with – and that joy translates directly into better projects.
Now it’s your turn to experiment. What will you build?
Parts List
Here are some components you’ll need to complete the experiments in this article. Please note that some of these links may be affiliate links, and DroneBot Workshop may receive a commission on your purchases. This does not increase the cost to you and is a method of supporting this ad-free website.
- Adafruit I2C Rotary Encoder – Available from Adafruit Industries
- Seeeduino XIAO RP2040 – Compact microcontroller with built-in NeoPixel
- ESP32-C6 DevKitC – Modern ESP32 with WiFi 6
- SSD1306 OLED Display – 128×64 pixel I2C display
- ILI9341 TFT Display – 320×240 pixel color display
- STEMMA QT Cables – For easy encoder connections
Resources
Adafruit I2C Rotary Encoder – Adafruit’s guide to using the I2C Rotary Encoders
Code – All the code used in this article, packed into a nice ZIP file
PDF Version – A PDF version of this article, also in a ZIP file.




the pdf is not working. This seems to be true on many of the tutorials. No pdf, despite the icon for it at the beginning.