Happy Modular HotKeys - ESP32

by lennoxlow in Circuits > Arduino

361 Views, 2 Favorites, 0 Comments

Happy Modular HotKeys - ESP32

output.gif
Screenshot 2026-06-15 at 10.33.52 PM.png
FDEF6WBMQK204XH.gif

I wanted a physical button on my desk.

Something satisfying to press whenever I wanted to trigger a python script that generates personalised meme compilations.

So I built one and filmed the process.

Then I built a second one, because I also wanted to trigger more ridiculous scripts.

Feature creep set in and I needed to make them wireless so I could use them anywhere in the house - communicating over WiFi and serial to my PC, but also with each other.

Oh. And they had to dance. Because why not.

I called them "QuickKeys"


Press one QuickKey and:

  1. A meme compilation generates itself and starts playing
  2. Another module somewhere else in the house lights up and joins in
  3. The screen does a small dance, for no operational reason whatsoever
  4. Nothing happens, because you forgot to start the listener (it's always this)


Inside each module:

  1. An ESP32 works as the brains, handling button input, role negotiation, and communication
  2. An SSD1306 OLED displays a 20-frame bounce animation whenever any button on the network is pressed
  3. A shared flag pin lets modules figure out who's broadcaster and who's follower with zero configuration
  4. A serial bus connects modules together, and WiFi UDP broadcast lets the broadcaster tell anything on the network that a trigger fired
  5. On the PC side, a small Python listener watches both serial and WiFi simultaneously and runs whatever you want when a trigger comes through

The QuickKey can trigger anything your heart desires - just by editing the listener.

Supplies

Screenshot 2026-06-15 at 9.45.23 PM.png
Screenshot 2026-06-15 at 9.31.13 PM.png
Screenshot 2026-06-15 at 9.32.54 PM.png
Screenshot 2026-06-15 at 6.27.02 PM.png
Screenshot 2026-06-15 at 9.33.39 PM.png

You'll need, per module:

  1. 1x ESP32 C3 Supermini dev board
  2. 1x 0.66 Inch OLED display, 64x48, I2C, address 0x3C
  3. 1x tactile push button
  4. Wire
  5. USB-C cable for flashing and power
  6. 5-pin magnetic pogo connectors (male + female)
  7. 3D printed shell (top + bottom)

If you're building more than one module - and you should, because the negotiation logic is the fun part - you'll also need to connect the shared flag pin and serial bus between modules. The pogo connectors handle this magnetically, more on that in the enclosure step.

The Enclosure

The Enclosure The Enclosure The Enclosure

Each module lives in a small 3D printed shell - top and bottom halves, printed in whatever colour you have lying around.

The magnetic pogo connectors sit on one face of the shell. Five pins carry power, ground, the flag bus, and the serial pair between modules. Line two modules up and they snap together magnetically, both physically and electrically - that's the entire "wiring between modules" step done by magnets.

Design Tips If You Make Your Own:

  1. Leave space inside for the ESP32, OLED, and button
  2. Cut a window for the OLED so the animation is actually visible
  3. Position the pogo connectors on a flat face so modules can snap together flush
  4. Don't forget a cutout for the USB-C port

Tip: Print the pogo connector recess slightly undersized and let the pins press-fit in. Glue is for people who don't trust their tolerances.

I trust my tolerances roughly 60% of the time, so I used lots of hot glue.

Wire It Up

monalisa.png
Screenshot 2026-06-15 at 9.31.45 PM.png
Screenshot 2026-06-15 at 6.27.22 PM.png
Screenshot 2026-06-15 at 6.28.28 PM.png
Screenshot 2026-06-15 at 6.24.55 PM.png
Screenshot 2026-06-15 at 6.29.53 PM.png
Screenshot 2026-06-15 at 6.30.18 PM.png
Screenshot 2026-06-15 at 6.30.47 PM.png
Screenshot 2026-06-15 at 6.31.35 PM.png
Screenshot 2026-06-22 at 7.54.31 PM.png

The GPIO map for each module (Refer to my perfect Paint.exe wiring diagram):

GPIO 4 - I2C SDA → OLED

GPIO 5 - I2C SCL → OLED

GPIO 6 - Button

GPIO 7 - Broadcaster/Follower flag pin

GPIO 20 - Serial RX

GPIO 21 - Serial TX


The button wires to GPIO6 on one leg and GND on the other. The firmware uses INPUT_PULLUP, so the pin sits HIGH normally and pressing the button pulls it LOW.

That's your trigger.


The OLED connects via I2C - SDA to GPIO4, SCL to GPIO5, plus power and ground.


If you're building multiple modules, connect GPIO7 on every module together with a single shared wire. This is the flag bus, and it's how the modules figure out their roles.

For the serial bus between modules, connect TX of one module to RX of the next.

The firmware automatically swaps RX/TX on follower modules so a simple daisy chain works.

Tip: If you're using the magnetic pogo connectors, the flag pin and serial bus lines run through them - so modules negotiate roles the moment they physically connect.

Snapping two modules together is the setup process.

Flash the Firmware

You'll need the Arduino IDE with ESP32C3 board support installed. If you've done the Chaos Orb build, you already have this set up.

  1. Install Adafruit SSD1306 and Adafruit GFX libraries via Library Manager
  2. Open quickkey.ino
  3. Update your WiFi credentials:

cpp

const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
  1. Select your ESP32C3 board and port
  2. Upload

Repeat for every module. The firmware is identical across all of them - there's no "broadcaster firmware" and "follower firmware."

Every module figures out its own role at boot.

Downloads

Negotiation

output.gif

This is the part I'm most pleased with.

Power on the first module. It checks the shared flag pin (GPIO7). Nobody's claimed it yet, so it's reading LOW. The module sets the pin HIGH, declares itself as the Broadcaster, and connects to WiFi. The OLED displays MASTER along with a short device ID.

Now power on a second module. It checks the same pin. It's HIGH now — a broadcaster already exists. So this module becomes Follower, announces itself over the serial bus, and waits. The OLED displays SLAVE.

No configuration. No setup steps. No "designate this one as primary." They just figure it out from the state of a single shared wire.

If you unplug the broadcaster mid-session, the follower notices the flag pin go LOW, waits two seconds to make sure it's not a glitch, and promotes itself to broadcaster automatically.

Tip: If you power on multiple modules at once, whichever one finishes booting first claims broadcaster. It's a race condition and I have made peace with that.

Communication

Here's where it gets satisfying.

Press the button on a follower module. The follower sends a trigger message over the serial bus to the broadcaster, including its own device ID. The broadcaster receives it, and broadcasts it over WiFi UDP to anything listening on the network. Every connected module - broadcaster and all followers - play the OLED animation.

Press the button on the broadcaster. Same result, just starting from the other direction - the Broadcaster broadcasts directly.

Either way: press any button, every screen reacts. It doesn't matter which one you press.

The animation itself is a 20-frame bounce sequence stored as raw bitmaps directly in the ESP32's flash memory - no SD card needed.

It plays forward, then reverses, in a loop, until the next trigger resets it.

Connect It to Something

The button is only half the project.

The other half is listener.py - a Python script that runs on your PC and watches for triggers over both serial and WiFi at the same time.

This matters because the broadcaster module sends every trigger down both channels - if the WiFi UDP packet gets lost, the serial message still gets through, and vice versa.

To stop that from double-firing your script, the listener deduplicates anything it's seen in the last half second.

Out of the box, the default behaviour is simple: any trigger it hasn't seen before runs main.py in the same folder as the listener.


python

SERIAL_PORT = "/dev/tty.usbmodem14601" # change to your port — COM3 etc on Windows
BAUD = 115200
UDP_PORT = 5555

Change SERIAL_PORT to whatever your broadcaster module shows up as. On Windows that'll look like COM3. Everything else can stay as-is.

If you want different buttons to do different things, the listener has a hook for that:


TRIGGER_HANDLERS: dict[str, callable] = {
# "MASTER": lambda guid, source: subprocess.run(["osascript", "-e", '...']),
}

Add an entry here and that specific unit's button press runs your custom function instead of the default main.py. Leave it empty and everything just runs main.py.

I used the default behaviour to trigger a Reddit meme compilation generator.

But the button has no idea what it's triggering. It just knows it was pressed, and something on the other end is listening. That's deliberate. Whatever you connect it to is up to you.

Downloads

You're Done!

Screenshot 2026-06-15 at 10.25.52 PM.png

Press a button. Something happens somewhere else.

Possibly the right thing.

This is, much like the Chaos Orb, a profoundly unnecessary device.

Unlike the Chaos Orb, this one is at least theoretically useful - a generic physical trigger for anything that runs from a command line.

And it dances!


What you connect it to is entirely your problem now.