Awhile back Adafruit featured a story on their blog about a really cool fidget toy built using a rotary encoder and a NeoPixel-compatible LED ring. I loved the idea so much that I wanted to make my own version, and spent a few days designing my own with parts I had on-hand. As it turns out, I wasn't the only one: the Ruiz Brothers built an incredibly stylish take on this idea using an ANO rotary encoder and published an excellent learn guide for it!
While there are physical differences between the two versions, the hardware is similar enough that I wanted to write a CircuitPython firmware that would work their version as well as mine. I wanted the firmware to have multiple modes, with the ability to add more in the future as ideas came to me. Ideally it would be something others could build on as well.
Update: You can see a live demo of the firmware running on the Ruiz brothers' fidget toy in this episode of 3D Hangouts
The Firmware
The initial version includes 4 modes:
- Spin (the bottle) mode
- Rainbow Particle mode
- Power Meter Simulator mode
- Memory Sequence Game mode
A detailed description of these modes can be found below in the Firmware Walkthrough section.
I wrote the firmware in CircuitPython 8, and like the Ruiz brothers' fidget toy it's designed specifically for the Feather RP2040. It should work on any modern Feather microcontroller that can support CircuitPython, but that's beyond the scope of this playground topic (NOTE: The Memory Sequence Game's high score function requires a microcontroller that supports the NVM feature). The firmware assumes some specific hardware configuration constraints:
- Device must use an ANO rotary encoder + breakout PCB (both the Stemma QT and non-Stemma versions are supported). While it's possible to use another encoder and 5 buttons, that is beyond the scope of this playground topic.
- Device must use a 16 or 24 LED NeoPixel-compatible ring. Other sizes might work, but that's beyond the scope of this playground topic.
- Device must include a switch of some sort connected to the EN and GND pins of the Feather.
- LED ring must be powered by the 3v3 pin of the Feather, so that the LEDs will power down when the EN pin is pulled low by the power switch. While it's possible to power down the LEDs involving additional hardware and possibly firmware modifications, that's beyond the scope of this playground topic.
Install CircuitPython
Follow the instructions on the Adafruit rotary fidget learn guide to install CircuitPython on your Feather. The current version of the code is written for CircuitPython 8, so make sure you install that! NOTE: if you aren't using the RP2040 Feather, you'll need to find the correct version for your microcontroller from the CircuitPython download page.
Get the code
You can download the code here. Either clone the repo or download it as a zip file and unzip somewhere. Then copy the entire contents of the CIRCUITPY folder from the github repo onto your Feather's CIRCUITPY drive.
Configure the firmware
You might need to make a couple of small edits to the code on your Feather's CIRCUITPY drive depending on your specific hardware configuration.
code.py
Near the top of code.py, you'll notice this bit of code:
# Use this import if you are using the regular breakout PCB (https://www.adafruit.com/product/5221) from device.device_gpio import DeviceGPIO as FidgetDevice # Use this import if you are using the Stemma QT PCB (https://www.adafruit.com/product/5740) # from device.device_seesaw import DeviceSeesaw as FidgetDevice
If you're using the non-stemma breakout, you can leave this code alone. If you are using the Stemma QT version, you'll need to change this block of code to look like this:
# Use this import if you are using the regular breakout PCB (https://www.adafruit.com/product/5221) # from device.device_gpio import DeviceGPIO as FidgetDevice # Use this import if you are using the Stemma QT PCB (https://www.adafruit.com/product/5740) from device.device_seesaw import DeviceSeesaw as FidgetDevice
Next, look for this line:
device = FidgetDevice(neopixel_pin=board.D5, led_count=16)
If necessary, change the neopixel_pin to whichever pin your LEDs are connected to, and change the led_count to match the number of LEDs on your ring. Save the file.
device_gpio.py/device_seesaw.py
Next, on your Feather's CIRCUITPY drive, you'll want to open up whichever device file you included in code.py and make sure the hardware pin assignments are what you want.
For the Stemma QT version, you'll want to edit device_seesaw.py in the device folder if you want to modify any of the button assignments to match your device's orientation. Don't forget to save!
# Edit pins as necessary BUTTON_PINS = [ # Select 1, # Up 2, # Left 3, # Down 4, # Right 5 ]
For the non-Stemma version, you'll want to edit device_gpio.py in the device folder if you want to modify the rotary encoder or button pin assignments. Again, don't forget to save!
# Edit pins as necessary BUTTON_PINS = [ # Select board.D25, # Up board.A3, # Left board.A2, # Down board.A1, # Right board.D24 ] ENCODER_PIN_A = board.MOSI ENCODER_PIN_B = board.SCK
When everything is properly configured, you should see the UP button pointing at a single white LED a couple seconds after the device is powered on, and the rotary encoder and buttons should behave as described in the Firmware Walkthrough below.
Firmware Walkthrough
Currently there are 4 modes. Press and hold the SELECT button for 1 second in any mode to cycle to the next mode.
Spin (the bottle) mode
This mode features an inertial "pointer". When spinning the rotary encoder, acceleration is applied to the pointer. Continued spinning makes the pointer move faster up to a maximum speed. Friction is applied to the pointer so that it will slow down over time if no acceleration is applied. A motion blur is applied to the LED buffer to give a "laser"/"meteor" effect. The controls are as follows:
- Rotary encoder: apply acceleration to the pointer in the direction of the spin
- SELECT (press): "Spin the bottle". Random acceleration will be applied to the pointer. If the pointer is already moving, it will also change direction.
- UP/DOWN: Change the pointer color.
- LEFT/RIGHT: Change the background color.
Be careful not to set both the pointer and background to black, you might trick yourself into thinking the fidget toy is powered off!
Rainbow Particle mode
This mode features a rainbow spread out across the LED ring. Pressing the buttons will spawn simple "particle" effects. A motion blur is applied to the LED buffer because it looks cool. The controls are as follows:
- Rotary encoder: rotate the rainbow in the direction of the spin
- UP/DOWN/LEFT/RIGHT: Spawn a particle on the opposite side of the fidget toy that is "attracted" to the button pressed.
- SELECT (press): Spawn a particle "explosion" at a random position on the LED ring.
Power Meter Simulator mode:
This mode features a simulated "power meter". The controls are as follows:
- Rotary Encoder: Adjust the start/end position of the meter
- UP: Cycle the brightness through 4 possible values
- LEFT: Cycle through the available colors for the "low" side of the meter
- RIGHT: Cycle through the available colors for the "high" side of the meter
- DOWN: Cycle through the available colors for the middle portion of the meter.
Again, be careful not to set all of the colors to black, and trick yourself into thinking the device is powered off!
Memory Sequence Game mode
This mode features a variation of the popular Simon electronic toy game. I had a hard time finding the original game's rules, so I improvised where necessary. Like Simon, the game will light up a sequence of quadrants, colored red, blue, yellow, and green. Then, the player must reproduce that sequence correctly by pressing the button on the ANO directional pad corresponding to the colored quadrants. Failure to reproduce the sequence, either due to pressing an incorrect quadrant, or by taking to long to press a quadrant, will result in a game over. If you scored higher than zero, you will be shown your score. The score is displayed as a series of lit LEDs, each color describing a different point value, as follows:
- White pixels are worth 10 points
- Green pixels are worth 5 points
- Blue pixels are worth 1 point
Currently, to beat the game, and get a special "reward" screen, you must make it through a sequence of 50 quadrants. The game also utilizes the NVM feature to save the high score, even when the device is powered down.
From the main "fidget" mode, the controls are as follows:
- LEFT/GREEN: Press to light up the green quadrant. Hold for a moment to start the game.
- RIGHT/BLUE: Press to light up the blue quadrant. Hold for a moment to see the high score, if there is one. From the high score screen, press and hold any direction button to return to the main screen.
- UP/RED: Press to light up the red quadrant. Hold for a moment to see the score from the most recent game, if there is one (NOTE: recent score resets if you leave the game mode or power down the device). From the score screen, press and hold any direction button to return to the main screen.
- Rotary Encoder: Spin around to move the LED "spokes".
- SELECT (press): Make the "spokes" brighter.
Notes on the code (or: Why did you program the ANO input that way?)
Originally, my idea to make the device compatible with both the Stemma and non-Stemma versions of the ANO breakout board was to take advantage of the consistent interface between the standard digitalio and rotaryio libraries and the parallel implementation for I2C devices using the adafruit_seesaw library. The code to initialize the non-Stemma version looked something like this:
import board import digitalio import rotaryio from adafruit_debouncer import Button import ring_light import device def _make_button(pin, long_duration_ms=500): button_io = digitalio.DigitalInOut(pin) button_io.direction = digitalio.Direction.INPUT button_io.pull = digitalio.Pull.UP return Button(button_io, short_duration_ms=0, long_duration_ms=long_duration_ms, value_when_pressed=False) def make_device(neopixel_pin, led_count): # Edit pins as necessary return device.Device(ring_light=ring_light.RingLight(neopixel_pin, led_count), rotary_encoder=rotaryio.IncrementalEncoder(board.MOSI, board.SCK, divisor=2), select_button=_make_button(board.D25, long_duration_ms=1000), up_button=_make_button(board.A3), left_button=_make_button(board.A2), down_button=_make_button(board.A1), right_button=_make_button(board.D24))
The code for the Stemma version looked something like this:
import board from adafruit_seesaw import seesaw, rotaryio, digitalio from adafruit_debouncer import Button import ring_light import device _seesaw = seesaw.Seesaw(board.STEMMA_I2C(), addr=0x49) def _make_button(pin, long_duration_ms=500): _seesaw.pin_mode(pin, _seesaw.INPUT_PULLUP) ss_io = digitalio.DigitalIO(_seesaw, pin) return Button(ss_io, short_duration_ms=0, long_duration_ms=long_duration_ms, value_when_pressed=False) def make_device(neopixel_pin, led_count): # Edit pins as necessary return device.Device(ring_light=ring_light.RingLight(neopixel_pin, led_count), rotary_encoder=rotaryio.IncrementalEncoder(_seesaw), select_button=_make_button(1, long_duration_ms=1000), up_button=_make_button(2), left_button=_make_button(3), down_button=_make_button(4), right_button=_make_button(5))
In theory, this was great. It meant my actual Device class could use identical code to interface with the ANO encoder, regardless of which breakout was used. The Device.update() method looked something like this:
def update(self): # Update rotary encoder position rotary_encoder_position = self._rotary_encoder.position self._rotary_encoder_delta = rotary_encoder_position - self._last_rotary_encoder_position self._last_rotary_encoder_position = rotary_encoder_position # Update buttons for button in self._buttons: button.update()
Simple! I wrote the rest of the firmware using this code and testing against the non-Stemma breakout board, and everything was going swimmingly. Input was snappy and animation was smooth, the sun was shining, and the birds were singing. Finally, after like 4 whole days of waiting (basically an eternity in hobby time really), I received my Stemma QT ANO breakout in the mail, and was excited to see if the code I'd written actually worked with it as well. The good news was, it did! The bad news was...using the seesaw version of rotaryio and digitalio is *incredibly* slow. The parallel digitalio/rotaryio implementation in seesaw is a handy feature, and works great if you don't have many buttons or don't mind the extra processing overhead, but in my case, reading the rotary encoder and 5 buttons one at a time was taking longer than 50ms! In comparison, this same code took less than 2ms using the non-Stemma breakout with the standard digitalio/rotaryio. This absolutely destroyed my snappy response times and smooth animation. The clouds blew in, and the birds flew away. It was time to start looking for a plan B.
The solution
I set out to discover how to do two things:
- Given the overhead of polling each control over I2C every loop, I wanted to read the rotary encoder position and button states only when something actually happened. Usually this is achieved with some sort of "interrupt" signal that occurs when the state of any of of the controls change.
- Given the overhead of reading each button one at a time over I2C, I wanted to see if we could read them all in one shot.
I began digging through the seesaw docs and various learn guides that use the Stemma ANO rotary breakout, but they all used the same rotaryio/digitalio based code (which makes sense; it's much simpler). Also, none of the guides/samples I could find even mentioned seesaw's interrupt capability, much less showed how to use it. Eventually I decided to go straight to the source, and started looking at the seesaw github. Digging into the lower levels of the API, I found a couple things that looked exactly like what I was hoping for.
The first part of the solution were these the seesaw methods enable_encoder_interrupt()
and set_GPIO_interrupts(pins)
. These methods allowed me to specify that I would like to receive an interrupt signal when the rotary encoder changes, or when any of the specified GPIO change state. Then, I could call get_GPIO_interrupt_flag()
to see if any of the tracked GPIO changed states since the last time it was called, and if so, receive a packed integer where each bit represents a button or rotary encoder pin. This packed integer can be inspected to see what changed, and update the state tracking for the changed controls accordingly.
The next part of the solution was the seesaw method encoder_delta()
, which returns the change in encoder position since the last update. This information is exactly what I want 99% of the time I'm using a rotary encoder, so this was perfect.
Finally I still needed a way to read all of the buttons at once. Luckily there's the seesaw method digital_read_bulk(pins)
that does exactly that!
Aside: I started my programming career developing for the Gameboy Advance many, many years ago. Incidentally, the code for parsing button input from the hardware on those old consoles is very similar to the way seesaw code works under the hood, and the code I ended up writing for this firmware looks similar as a result. If you'd like to read more there are a couple of good write-ups here and here.
Back to the code: After a bit of hacking around and experimenting, I ended up with something that looks like this for the input update code for the seesaw breakout
def _update_input(self): interrupt_flags = self._seesaw.get_GPIO_interrupt_flag() # Read buttons and update self._buttons_state if (interrupt_flags & self.SS_BUTTON_BITS) != 0: self._buttons_state = ~self._seesaw.digital_read_bulk(self.SS_BUTTON_BITS) self._buttons_state &= self.SS_BUTTON_BITS self._buttons_state >>= 1 # Update self._rotary_encoder_delta if (interrupt_flags & self.ROTARY_BITS) == 0: self._rotary_encoder_delta = 0 else: self._rotary_encoder_delta = self._seesaw.encoder_delta()
With this code, the input processing time dropped from over 50ms each loop to ~10ms on loops where no input was detected, and ~20-30ms when input was detected and the state needed to be read. A massive improvement! And fortunately enough to make the input feel snappy and the animation smooth again. Unfortunately, the method of processing the input was so different from the rotaryio/digitalio paradigm that I needed to re-write the rest of the input handling code in the firmware in order to continue supporting both versions of the ANO breakout. I ended up unifying the interface in a base class called Device, and made a subclass for each breakout board, DeviceGPIO and DeviceSeesaw. The main Device class defines the interface for the possible interactions with the hardware, while the subclasses do the nitty gritty work of collating the input and reporting it back to the base Device class. This way, regardless of which ANO breakout board is used, the code to inspect the state of the rotary encoder or a button is identical:
if device.pressed(device.BUTTON_UP): do_whatever() position += device.rotary_encoder_delta
One other unusual thing you might notice in the device_seesaw code is this:
# Boost the I2C clock frequency. Seems to buy us 1-2 ms on the Feather RP2040. i2c = busio.I2C(board.SCL, board.SDA, frequency=1000000)
Since we've only got one I2C device attached to our Feather, I used the lower-level I2C initializer to create an I2C bus with a 1MHz clock frequency, as opposed to the 100kHz frequency used by default when initializing using.
i2c = board.STEMMA_I2C()
or
i2c = board.I2C()
I'd hoped for a bigger savings from this, but since we're only sporadically accessing the I2C bus (relatively speaking), it only saves us about 2 ms. Still, I'll take it! Note that if more I2C devices were to be added to the device, it may be desirable to drop down to the standard board.I2C()
or board.STEMMA_I2C()
initializers for compatibility reasons.
One last thing I'd like to mention here is how well-designed the Adafruit Seesaw library is. The layered approach it takes allows the versatility to use familiar, high-level abstractions, while allowing the ability to drop down into the lower layers when more specific requirements are imposed. Not to mention that the Seesaw library is used to power dozens of I2C breakout boards featuring arbitrary layouts of several different input mechanisms.
Where to go from here
I'll be experimenting with more ways to extend the functionality of these fidget toys. There's still a ton of cool things that could be achieved with simple modifications, even just choosing a different Feather board. For example, you could use a Feather ESP32-S2 or ESP32-S3 and take advantage of the built-in battery monitor (maybe the simulated power meter could become an actual power meter!), or even take advantage of the WiFi capability to create remote-control modes, or ESP-NOW to create modes that allow fidget devices to communicate directly with each other. Or, you could use a RP2040 Propmaker Feather and a small speaker, and add sound effects with wav files or using synthio, or you could use the built-in accelerometer, or....
If you've built (or are planning to build) an ANO-based fidget toy, I hope this firmware is helpful to you! I'm usually lurking on the Adafruit discord (username: @squid.jpg) if you have any questions or suggestions.