Overview
Create your own interactive, wireless musical LED juggling balls using CircuitPython and the low latency ESP-NOW protocol.
These juggling balls shine bright with NeoPixels, and can wirelessly trigger MIDI notes based on catch detection with the LIS3DH accelerometer, and a Feather board to bridge messages to your computer or USB Host synthesizer. You can code any note pattern or interaction you like in CircuitPython.
While there are commercially available LED juggling props, there are none that send MIDI messages to a host computer or bridge are currently available, so let's make our own!
This project was inspired by this performance by renowned juggler Jay Gilligan.
These are the parts you'll need per juggling ball:
For the PCB version, you'll use two breadboard friendly NeoPixels per ball.
Page last edited October 15, 2025
Text editor powered by tinymce.
Printed Juggling Balls
Print as many of these as you can juggle! I designed them with a modified tennis ball/baseball pattern of two saddles that fit together, in order to print nicely on an FDM printer. They have a set of interlocking nubs that pop the halves together, and can be opened by prying and pulling one half out of the other when needed.
These are 68mm diameter balls -- you can scale them up or down to suit your juggling ball preference.
Translucent PLA works very well, diffuses the light nicely, and is very strong when printed with solid walls as these are. You can also experiment with PETG for even more strength.
If you like, you can use two rubber bands or o-rings in the grooves for added security.
Settings for FDM PLA printer
- 0.2mm Strength
- 3mm brim for bed adhesion
- 6 wall loops (prints solid at these dimensions)
- Fuzzy skin applied selectively to outer panels only (for better grip)
Page last edited October 15, 2025
Text editor powered by tinymce.
CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
There are two versions of this board: one with 8MB Flash/No PSRAM and one with 4MB Flash/2MB PSRAM. Each version has their own UF2 build for CircuitPython. There isn't an easy way to identify which version of the board you have by looking at the board silk. If you aren't sure which version you have, try either build to see which one works.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.
Click the reset button once (highlighted in red above), and then click it again when you see the RGB status LED(s) (highlighted in green above) turn purple (approximately half a second later). Sometimes it helps to think of it as a "slow double-click" of the reset button.
If you do not see the LED turning purple, you will need to reinstall the UF2 bootloader. See the Factory Reset page in this guide for details.
On some very old versions of the UF2 bootloader, the status LED turns red instead of purple.
For this board, tap reset and wait for the LED to turn purple, and as soon as it turns purple, tap reset again. The second tap needs to happen while the LED is still purple.
Once successful, you will see the RGB status LED(s) turn green (highlighted in green above), and a disk drive ending in "...BOOT" should appear on your host computer. If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.
If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called QTPYS3BOOT.
Drag the adafruit_circuitpython_etc.uf2 file to QTPYS3BOOT.
Copy or drag the UF2 file you downloaded to the BOOT drive.
Page last edited October 15, 2025
Text editor powered by tinymce.
Code the Juggling Balls
Download the Project Bundle
Your project will use a specific set of CircuitPython libraries, and the code.py file. To get everything you need, click on the Download Project Bundle link below, and uncompress the .zip file.
Connect your computer to the board via a known good USB power+data cable. A new flash drive should show up as CIRCUITPY.
Drag the contents of the uncompressed bundle directory onto your board CIRCUITPY drive, replacing any existing files or directories with the same names, and adding any new ones that are necessary.
# SPDX-FileCopyrightText: 2025 John Park for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
ESP-NOW MIDI Juggling Ball
communicates to Feather TFT connected to computer/synth via USB
"""
import time
import wifi
import espnow
import board
import neopixel
import analogio
import adafruit_lis3dh
# CONFIGURATION: Set this for each device
DEVICE_ID = "ball_A" # Options: "ball_A", "ball_B", "ball_C"
# Sleep configuration -- light sleep, will wake on tap detection
SLEEP_AFTER = 30 # Seconds of inactivity before sleep turns off NeoPixels/radio
# Device list
DEVICES = ["ball_A", "ball_B", "ball_C"]
ALL_COLORS = [0xEE0010, 0x00FF00, 0x0010EE] # indexed to DEVICES
# Current color state (starts with device's default)
current_color_index = DEVICES.index(DEVICE_ID)
CURRENT_COLOR = ALL_COLORS[current_color_index]
NUM_PIX = 2 # 2 for PCB version, however many you want for BFF
# Set up NeoPixel and I2C for accelerometer
pixel = neopixel.NeoPixel(board.A0, NUM_PIX) # board.A0 for PCB, A3 for BFF
pixel.brightness = 1.0
pixel.fill(CURRENT_COLOR)
# Set up battery monitoring
voltage_pin = analogio.AnalogIn(board.A2)
def get_battery_voltage():
"""Read battery voltage from A2 pin"""
# Take the raw voltage pin value, and convert it to voltage
voltage = (voltage_pin.value / 65536) * 2 * 3.3
return voltage
# Initialize I2C and LIS3DH accelerometer
try:
# i2c = board.STEMMA_I2C() # use this if connecting to STEMMA QT
i2c = board.I2C()
try:
lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c, address=0x18)
print("LIS3DH address: 0x18")
except ValueError:
lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c, address=0x19)
print("LIS3DH address: 0x19")
lis3dh.range = adafruit_lis3dh.RANGE_2_G
lis3dh.set_tap(1, 90)
has_accelerometer = True
print("LIS3DH accelerometer initialized with tap detection")
except Exception as e: # pylint:disable=broad-except
print(f"Accelerometer init failed: {e}")
has_accelerometer = False
# Channel switching hack
wifi.radio.start_ap(" ", "", channel=6, max_connections=0)
wifi.radio.stop_ap()
def format_mac(mac_bytes):
return ":".join(f"{b:02x}" for b in mac_bytes)
def get_my_mac():
return format_mac(wifi.radio.mac_address)
def cycle_color():
"""Cycle to the next color in the list"""
global current_color_index, CURRENT_COLOR # pylint: disable=global-statement
current_color_index = (current_color_index + 1) % len(ALL_COLORS)
CURRENT_COLOR = ALL_COLORS[current_color_index]
pixel.fill(CURRENT_COLOR)
print(f"Color changed to: {CURRENT_COLOR:06X}")
def flash():
"""Quick flash for tap feedback"""
pixel.fill((200, 200, 200))
time.sleep(0.15)
pixel.fill(CURRENT_COLOR)
def enter_sleep():
"""Enter low-power sleep mode"""
global is_sleeping # pylint: disable=global-statement
# print("Entering sleep mode - turning off NeoPixels and radio")
pixel.fill((0, 0, 0)) # Turn off all NeoPixels
pixel.brightness = 0
wifi.radio.stop_ap() # Turn off radio
is_sleeping = True
def wake_up():
"""Wake from sleep mode"""
global is_sleeping, last_activity_time # pylint: disable=global-statement
# print("Waking up - restoring NeoPixels and radio")
pixel.brightness = 1.0
pixel.fill(CURRENT_COLOR)
# Restart radio
wifi.radio.start_ap(" ", "", channel=6, max_connections=0)
wifi.radio.stop_ap()
is_sleeping = False
last_activity_time = time.monotonic()
def check_tap():
"""Check if accelerometer detects tap"""
if not has_accelerometer:
return False
try:
return lis3dh.tapped
except Exception as er: # pylint:disable=broad-except
print(f"Accelerometer read error: {er}")
return False
def send_trigger_message(trigger_type="tap"):
"""Send trigger message with current device color"""
current_time = time.monotonic() # pylint: disable=redefined-outer-name
message = f"TRIGGER|{DEVICE_ID}|{trigger_type}|{current_time:.1f}" # pylint: disable=redefined-outer-name
try:
e.send(message, broadcast_peer)
# print(f"TX: {message}")
flash()
return True
except Exception as exc: # pylint:disable=broad-except
print(f"Send error: {exc}")
return False
def send_battery_report():
"""Send battery voltage report to bridge with current color"""
voltage = get_battery_voltage()
current_time = time.monotonic() # pylint: disable=redefined-outer-name
message = ( # pylint: disable=redefined-outer-name
f"BATTERY|{DEVICE_ID}|{voltage:.2f}|{CURRENT_COLOR:06X}|{current_time:.1f}"
)
try:
e.send(message, broadcast_peer)
print(f"TX Battery: {message} ({voltage:.2f}V, color: {CURRENT_COLOR:06X})")
return True
except Exception as exb: # pylint:disable=broad-except
print(f"Battery report send error: {exb}")
return False
# Initialize ESP-NOW
e = espnow.ESPNow()
broadcast_peer = espnow.Peer(mac=b"\xff\xff\xff\xff\xff\xff", channel=6)
e.peers.append(broadcast_peer)
my_mac = get_my_mac()
print(f"{DEVICE_ID} ball starting - MAC: {my_mac}, Color: {CURRENT_COLOR:06X}")
# Clear accelerometer startup noise
print("Initializing accelerometer...")
time.sleep(0.5)
if has_accelerometer:
print("Clearing startup tap artifacts...")
for i in range(10):
try:
tap_state = lis3dh.tapped
if tap_state:
print(f"Cleared startup tap {i + 1}")
time.sleep(0.1)
except Exception: # pylint:disable=broad-except
pass
print("Accelerometer ready for tap detection")
# Timing variables
last_tap_time = 0
tap_debounce = 0.3
startup_time = time.monotonic()
startup_protection = 0.5
# Sleep/wake tracking
last_activity_time = time.monotonic()
is_sleeping = False
while True:
current_time = time.monotonic() # pylint: disable=redefined-outer-name
# Check for sleep timeout
if not is_sleeping and (current_time - last_activity_time > SLEEP_AFTER):
enter_sleep()
# Check for tap (primary trigger and wake-up source)
if has_accelerometer:
# Only check for taps after startup protection period
if current_time - startup_time > startup_protection:
if check_tap():
# If sleeping, wake up first
if is_sleeping:
wake_up()
# Check debounce for actual trigger
if current_time - last_tap_time > tap_debounce:
send_trigger_message("tap")
last_tap_time = current_time
last_activity_time = current_time
else:
# During startup protection, clear any false tap detections
if check_tap():
print("Ignoring tap during startup protection period")
# Check for incoming packets from bridge (only if not sleeping)
if not is_sleeping and e:
packet = e.read()
sender_mac = format_mac(packet.mac)
if sender_mac != my_mac:
message = packet.msg.decode("utf-8") # pylint:disable=redefined-outer-name
last_activity_time = current_time # Any message counts as activity
# Handle color change commands from bridge
if message.startswith("COLOR|"):
parts = message.split("|")
if len(parts) >= 3:
target_device = parts[1]
command = parts[2]
# Only respond if this message is for us
if target_device == DEVICE_ID and command == "next":
cycle_color()
# Send battery report when color changes
send_battery_report()
# Handle trigger messages from other balls for visual feedback
# elif message.startswith("TRIGGER|"):
# parts = message.split("|")
# if len(parts) >= 4:
# sender_device = parts[1]
# trigger_type = parts[2]
# print(f"Trigger from {sender_device} ({trigger_type})")
# # Brief red flash for other ball triggers
# flash()
time.sleep(0.01) # Fast polling for responsive tap detection
How the Juggling Ball Code Works
The ESP-NOW MIDI Juggling Ball code transforms a QT Py ESP32-S3 with an accelerometer into a wireless musical instrument that communicates with a central bridge device. Here's how the key components work together:
Configuration and Setup
Each ball needs a unique identity. At the top of the code, set the DEVICE_ID to either "ball_A", "ball_B", "ball_C", etc. This determines which ball you're programming and automatically assigns its default LED color from the ALL_COLORS list - pink for Ball A, green for Ball B, and blue for Ball C.
The code also includes a configurable SLEEP_AFTER timer (default 30 seconds) that puts the ball into a low-power mode when inactive. This conserves battery while keeping the accelerometer active for instant wake-up on the next catch.
Accelerometer Tap Detection
The LIS3DH accelerometer is the heart of the catch detection system. During initialization, the code tries both common I2C addresses (0x18 and 0x19). The accelerometer is configured for ±2G sensitivity range and a tap threshold of 90, which provides good sensitivity for juggling catches without triggering on gentle movements.
When you catch the ball, the accelerometer's built-in tap detection hardware recognizes the sudden deceleration and sets a flag. The main loop checks this flag every 10 milliseconds using the check_tap() function, providing fast response times.
To prevent false triggers during power-up and avoid multiple triggers from a single catch, the code includes:
- Startup protection: Ignores taps for 0.5 seconds after boot while clearing initialization noise
- Debouncing: Requires 0.3 seconds between valid taps to prevent double-triggering
ESP-NOW Wireless Communication
The balls communicate wirelessly using ESP-NOW, which provides low-latency peer-to-peer messaging. The code uses broadcast mode (MAC address FF:FF:FF:FF:FF:FF) so all devices on the network receive each message, but each ball only responds to messages addressed to its specific DEVICE_ID coming from the Bridge Feather.
When a catch is detected, the ball sends a TRIGGER message to the bridge in this format:
TRIGGER|ball_A|tap|123.4
The bridge receives this message and converts it to MIDI notes that play on your synthesizer or computer.
LED Feedback
Each ball has two NeoPixels that provide visual feedback:
- Solid color: Shows the ball's current color (which can be cycled remotely)
- White flash: Brief 150ms flash when a catch is detected, confirming the tap was registered, and because it looks cool
The LED colors can be changed wirelessly by pressing buttons on the bridge device. When the ball receives a COLOR message, it cycles to the next color in the ALL_COLORS list and responds with a battery status report.
Battery Monitoring
The ball monitors battery voltage through the analog pin A2 with a voltage divider circuit coming from the battery BFF. The get_battery_voltage() function reads the raw ADC value and converts it to actual voltage using the formula:
voltage = (raw_value / 65536) * 2 * 3.3
This voltage is reported to the bridge whenever the ball changes color, allowing you to monitor battery levels on the bridge's display screen. The bridge shows voltages in the format "3.7V" next to each ball's name.
Main Loop Operation
The main loop runs every 10 milliseconds and performs these checks in order:
- Sleep timeout check: If inactive for too long, enter sleep mode
- Tap detection: Check for accelerometer tap and wake if sleeping
- Message handling: Process incoming ESP-NOW messages from the bridge
-
COLORmessages: Cycle to next LED color and report battery status -
TRIGGERmessages: (commented out in current code) Could provide inter-ball visual feedback for future use, such as most recently triggered ball coloring the other balls
-
Message Protocol
The ball uses a simple pipe-delimited message format for all ESP-NOW communication:
Outgoing Messages
-
TRIGGER|ball_A|tap|123.4- Catch detected -
BATTERY|ball_A|3.75|EE0010|123.4- Battery report with voltage and current color (hex)
Incoming Messages
-
COLOR|ball_A|next- Command to cycle to next color
Page last edited October 15, 2025
Text editor powered by tinymce.
Assemble Juggling Balls
NeoPixel BFF Stacking Headers
Solder stacking headers under the NeoPixel BFF as shown here. The pins should protrude only 5mm above the board surface in order to proved extra space between boards to fit the battery.
Note: added pin plastic shown in the second photo is unnecessary, as it didn't provide the exact extra spacing I'd hoped it would!
Connect QT Py and NeoPixel BFF
Press the QT Py into the NeoPixel BFF header pins with the USB port and NeoPixel JST at the same end.
LiPoly Charger BFF
To power the circuit (and provide a convenient on/off switch) we'll use the LiPoly Charger BFF.
Solder header sockets underneath as shown.
Battery Stack
Fit the battery between the QT Py and NeoPixel BFF as shown, feeding the cable through.
Connect the LiPoly BFF onto the header pins, making sure to orient the JST connector at the same end as the NeoPixel BFF connector and QT Py USB connector as shown.
NOTE: it's easy to reverse the board orientation and risk frying everything, so triple check the orientation before proceeding!
Turn the power switch to 'OFF", then plug the battery cable into the LiPoly BFF.
LIS3DH Attachment and Connection
Attach the LIS3DH accelerometer/tap detector breakout board to the plastic header using an adhesive square. It's a good idea to clean off both surfaces with isopropyl alcohol first to remove any fingerprint grease.
Then, connect the short STEMMA QT cable to it and the QT Py as shown.
Put the Circuit in the Ball
Wrap the short NeoPixel strand around the circuit and fit it into one half of the ball.
Turn on the power, add some optional cushioning, then snap the second half into place.
Sleep
The code will put the board into light sleep mode after 30 seconds of inactivity -- a tap detection event will wake up the NeoPixels and ESP-NOW radio.
Page last edited October 15, 2025
Text editor powered by tinymce.
Juggling Bridge
The bridge device is used to receive catch trigger messages from the juggling balls and then send USB MIDI messages to the host computer or synth.
These next pages cover prepping the Feather ESP32-S2 or S3 Reverse TFT, coding it, and 3D printing an optional case.
Page last edited October 15, 2025
Text editor powered by tinymce.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.
Double-click the reset button (highlighted in red above), and you will see the RGB status LED(s) turn green (highlighted in green above). If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.
For this board, tap reset and wait for the LED to turn purple, and as soon as it turns purple, tap reset again. The second tap needs to happen while the LED is still purple.
If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called FTHRS3BOOT.
Drag the adafruit_circuitpython_etc.uf2 file to FTHRS3BOOT.
Page last edited October 15, 2025
Text editor powered by tinymce.
Code the Bridge
In order to enable the USB MIDI endpoint on the ESP32-S2 and S3 Feather boards, you'll need to save the boot.py file here to the CIRCUITPY drive and restart the board.
# SPDX-FileCopyrightText: 2025 John Park for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
ESP-NOW MIDI Juggling Bridge
boot.py - Minimal configuration for USB MIDI only
"""
import usb_hid
import usb_midi
# Disable everything except MIDI
usb_hid.disable() # No HID devices
usb_midi.enable() # Only MIDI
print("Minimal USB MIDI configuration loaded")
# SPDX-FileCopyrightText: 2025 John Park for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
ESP-NOW MIDI Bridge
Bridge ESP-NOW messages to USB MIDI
Runs on ESP32-S3 Feather TFT connected to computer/synth via USB
"""
import time
import wifi
import espnow
import usb_midi
import adafruit_midi
from adafruit_midi.note_on import NoteOn
from adafruit_midi.note_off import NoteOff
import board
import neopixel
import displayio
import terminalio
from adafruit_display_text import label
from adafruit_display_shapes.circle import Circle
import keypad
# Set up NeoPixel for visual feedback
pixel = neopixel.NeoPixel(board.NEOPIXEL, 1)
pixel.brightness = 1
# Set up buttons for color control
# D0 has opposite pull direction from D1 and D2
button_d0 = keypad.Keys(
(board.D0,), value_when_pressed=False, pull=True
) # Opposite pull
buttons_d1_d2 = keypad.Keys(
(board.D1, board.D2), value_when_pressed=True, pull=True
) # Normal pull
# MIDI note mappings - centralized on the bridge
MIDI_MAPPINGS = {
"ball_A": {
"notes": [42, 43, 45, 47], # Sequence of notes to cycle through
"color": (238, 0, 16), # Pink (for bridge LED/display)
},
"ball_B": {
"notes": [50, 50, 50, 50, 50, 62], # Single note
"color": (0, 255, 0), # Green (for bridge LED/display)
},
"ball_C": {
"notes": [47, 45, 40], # Sequence
"color": (0, 16, 238), # Blue (for bridge LED/display)
},
}
# Track current position in each device's note sequence
note_positions = {device: 0 for device in MIDI_MAPPINGS.keys()} # pylint:disable=consider-iterating-dictionary
# Track connection status for display (last seen time)
connection_status = {}
CONNECTION_TIMEOUT = 5.0
# Track current ball colors (start with defaults, update when COLOR command sent)
ball_colors = {
"ball_A": 0xEE0010, # Pink
"ball_B": 0x00FF00, # Green
"ball_C": 0x0010EE, # Blue
}
# All available colors for cycling
ALL_COLORS = [0xEE0010, 0x00FF00, 0x0010EE]
# Track battery voltages
ball_voltages = {"ball_A": "-.-V", "ball_B": "-.-V", "ball_C": "-.-V"}
# Channel switching hack for ESP-NOW
wifi.radio.start_ap(" ", "", channel=6, max_connections=0)
wifi.radio.stop_ap()
# Initialize MIDI
print("Available MIDI ports:", len(usb_midi.ports))
for i, port in enumerate(usb_midi.ports):
print(f"Port {i}: {port}")
midi_out_port = None
for port in usb_midi.ports:
if hasattr(port, "write"):
midi_out_port = port
break
if midi_out_port:
midi = adafruit_midi.MIDI(midi_out=midi_out_port, out_channel=0)
print(f"MIDI initialized with port: {midi_out_port}")
else:
print("No MIDI output port found!")
midi = None
def format_mac(mac_bytes):
"""Convert MAC address bytes to standard colon-separated format"""
return ":".join(f"{b:02x}" for b in mac_bytes)
def blink_color(color, count=1):
"""Blink the NeoPixel with specified color and count"""
for _ in range(count):
pixel[0] = color
time.sleep(0.02)
pixel[0] = (0, 0, 0)
def parse_trigger_message(message_str):
"""Parse trigger message and return device info"""
if not message_str.startswith("TRIGGER|"):
return None, None, None
try:
parts = message_str.split("|")
if len(parts) >= 4:
device_id = parts[1] # pylint:disable=redefined-outer-name
trigger_type = parts[2] # pylint:disable=redefined-outer-name
timestamp = parts[3] # pylint:disable=redefined-outer-name
return device_id, trigger_type, timestamp
except Exception: # pylint:disable=broad-except
pass
return None, None, None
def parse_battery_message(message_str):
"""Parse battery voltage message with color"""
if not message_str.startswith("BATTERY|"):
return None, None, None
try:
parts = message_str.split("|")
if len(parts) >= 4:
device_id = parts[1] # pylint:disable=redefined-outer-name
voltage = parts[2] # pylint:disable=redefined-outer-name
color_hex = int(parts[3], 16) # pylint:disable=redefined-outer-name
return device_id, voltage, color_hex
except Exception: # pylint:disable=broad-except
pass
return None, None, None
def send_midi_note(device_id, velocity=100, duration=0.05): # pylint:disable=redefined-outer-name
"""Send a MIDI note based on device mapping, cycling through sequence"""
if not midi or device_id not in MIDI_MAPPINGS:
print(f"MIDI not available or unknown device: {device_id}")
return
mapping = MIDI_MAPPINGS[device_id] # pylint:disable=redefined-outer-name
notes_sequence = mapping["notes"]
# Get current note from sequence
current_position = note_positions[device_id]
note = notes_sequence[current_position]
# Move to next position in sequence (with wrap-around)
note_positions[device_id] = (current_position + 1) % len(notes_sequence)
try:
midi.send(NoteOn(note, velocity))
time.sleep(duration)
midi.send(NoteOff(note, 0))
except Exception as err: # pylint:disable=broad-except
print(f"MIDI error: {err}")
def send_color_command(device_id): # pylint:disable=redefined-outer-name
"""Send color cycle command to a specific ball"""
try:
message = f"COLOR|{device_id}|next" # pylint:disable=redefined-outer-name
e.send(message, broadcast_peer)
print(f"Sent color command: {message}")
# Update tracked ball color
current_color_index = ALL_COLORS.index(ball_colors[device_id])
next_color_index = (current_color_index + 1) % len(ALL_COLORS)
ball_colors[device_id] = ALL_COLORS[next_color_index]
print(f"{device_id} color now: {ball_colors[device_id]:06X}")
return True
except Exception as ex: # pylint:disable=broad-except
print(f"Color send error: {ex}")
return False
def handle_button_presses():
"""Check for button presses and send color commands"""
# Handle D0 button (ball_A) - opposite pull direction
d0_event = button_d0.events.get()
if d0_event and d0_event.pressed:
send_color_command("ball_A")
print("ball_A color cycle")
# Handle D1 and D2 buttons (ball_B and ball_C)
d1_d2_event = buttons_d1_d2.events.get()
if d1_d2_event and d1_d2_event.pressed:
if d1_d2_event.key_number == 0: # D1 pressed (ball_B)
send_color_command("ball_B")
print("ball_B color cycle")
elif d1_d2_event.key_number == 1: # D2 pressed (ball_C)
send_color_command("ball_C")
print("ball_C color cycle")
# Initialize TFT display
display = board.DISPLAY
group = displayio.Group()
# Colors
BGCOLOR = 0x000000
TEXT_COLOR = 0xFFFFFF
DISCONNECTED_COLOR = 0x404040
def hex_to_rgb(hex_color):
"""Convert hex color to RGB tuple"""
r = (hex_color >> 16) & 0xFF
g = (hex_color >> 8) & 0xFF
b = hex_color & 0xFF
return (r, g, b)
# Create display elements in A, B, C order - TWO LINES PER BALL
ball_info = []
device_hex_colors = {
"ball_A": 0xEE0010, # Pink
"ball_B": 0x00FF00, # Green
"ball_C": 0x0010EE, # Blue
}
# Display balls in A, B, C order with two lines each
ordered_devices = ["ball_A", "ball_B", "ball_C"]
for i, device_id in enumerate(ordered_devices):
if device_id in MIDI_MAPPINGS:
mapping = MIDI_MAPPINGS[device_id]
y_pos_line1 = 10 + (i * 50) # First line
y_pos_line2 = y_pos_line1 + 20 # Second line (20 pixels below)
# Connection dot - use device hex color (fixed color for display)
dot_color = device_hex_colors[device_id]
dot = Circle(10, y_pos_line1 + 3, 4, fill=dot_color, outline=DISCONNECTED_COLOR)
group.append(dot)
# Line 1: Ball name and voltage in ball's current color
ball_name = device_id.replace("ball_", "Ball ")
voltage_text = ball_voltages[device_id]
line1_text = f"{ball_name} {voltage_text}"
line1_label = label.Label(
terminalio.FONT,
text=line1_text,
color=hex_to_rgb(ball_colors[device_id]), # Use ball's current color
scale=2, # Bigger font
x=25,
y=y_pos_line1,
)
group.append(line1_label)
# Line 2: MIDI notes
notes_list = ", ".join(str(n) for n in mapping["notes"])
line2_text = f"notes: {notes_list}"
line2_label = label.Label(
terminalio.FONT,
text=line2_text,
color=TEXT_COLOR,
scale=1, # Smaller font for notes line
x=25,
y=y_pos_line2,
)
group.append(line2_label)
ball_info.append(
{
"device_id": device_id,
"dot": dot,
"line1_label": line1_label,
"line2_label": line2_label,
"connected_color": dot_color,
"disconnected_color": DISCONNECTED_COLOR,
}
)
display.root_group = group
def update_connection_display():
"""Update connection status dots and text colors"""
current_time = time.monotonic() # pylint:disable=redefined-outer-name
for ball in ball_info:
device_id = ball["device_id"] # pylint:disable=redefined-outer-name
# Update connection dot
if device_id in connection_status:
time_since_last = current_time - connection_status[device_id]
if time_since_last < CONNECTION_TIMEOUT:
ball["dot"].fill = ball["connected_color"]
else:
ball["dot"].fill = ball["disconnected_color"]
else:
ball["dot"].fill = ball["disconnected_color"]
# Update line 1 text (ball name and voltage) with current ball color
ball_name = device_id.replace("ball_", "Ball ") # pylint:disable=redefined-outer-name
voltage_text = ball_voltages[device_id] # pylint:disable=redefined-outer-name
ball["line1_label"].text = f"{ball_name} {voltage_text}"
ball["line1_label"].color = hex_to_rgb(ball_colors[device_id])
# Initialize ESP-NOW
e = espnow.ESPNow()
broadcast_peer = espnow.Peer(mac=b"\xff\xff\xff\xff\xff\xff", channel=6)
e.peers.append(broadcast_peer)
print("ESP-NOW to MIDI Bridge starting...")
print("MIDI mappings loaded:")
for device, mapping in MIDI_MAPPINGS.items():
notes = mapping["notes"]
if len(notes) == 1:
print(f" {device}: Note {notes[0]} (single)")
else:
print(f" {device}: Notes {notes} (sequence)")
print("Button controls: D0=ball_A, D1=ball_B, D2=ball_C (color cycling)")
print("Listening for ball trigger messages...")
# Update display once at startup
update_connection_display()
last_display_update = time.monotonic()
last_message_time = {}
while True:
if e: # Packet available
packet = e.read()
sender_mac = format_mac(packet.mac)
message = packet.msg.decode("utf-8")
print(f"ESP-NOW RX from {sender_mac}: {message}")
# Parse battery messages
battery_device_id, voltage, color_hex = parse_battery_message(message)
if battery_device_id:
ball_voltages[battery_device_id] = f"{voltage}V"
# Update ball color from battery report
if color_hex is not None:
ball_colors[battery_device_id] = color_hex
print(
f"Updated {battery_device_id} voltage: {voltage}V, color: {color_hex:06X}"
)
else:
print(f"Updated {battery_device_id} voltage: {voltage}V")
# Trigger display update
update_connection_display()
# Parse the trigger message
device_id, trigger_type, timestamp = parse_trigger_message(message)
if device_id and device_id in MIDI_MAPPINGS:
current_time = time.monotonic()
# Update connection status
connection_status[device_id] = current_time
# Simple debouncing
if (
device_id not in last_message_time
or current_time - last_message_time[device_id] > 0.3
):
print(f"Converting {device_id} trigger to MIDI")
# Send MIDI note immediately
if trigger_type == "tap":
send_midi_note(device_id)
else:
send_midi_note(device_id)
# Use bridge's fixed color for LED feedback
bridge_color = MIDI_MAPPINGS[device_id]["color"]
blink_color(bridge_color, count=1)
last_message_time[device_id] = current_time
# Handle button presses for color control
handle_button_presses()
# Update display occasionally
current_time = time.monotonic()
if current_time - last_display_update > 2.0:
update_connection_display()
last_display_update = current_time
time.sleep(0.01)
Color Messages
Press one of the three buttons on the Feather bridge to cycle between colors per ball.
How the Bridge Code Works
The ESP-NOW MIDI Bridge runs on an ESP32-S3 Feather with TFT display and serves as the central hub that receives wireless messages from the juggling balls and converts them into MIDI notes for your synthesizer or music software. It also provides a visual status display and allows you to remotely control the balls' LED colors.
MIDI Note Mapping
The bridge's main job is to translate incoming ball catch messages into outgoing USB MIDI note messages. The note mappings per ball are are stored in the MIDI_MAPPINGS dictionary at the top of the code:
MIDI_MAPPINGS = {
"ball_A": {
"notes": [42, 43, 45, 47], # Sequence of notes
"color": (238, 0, 16), # Pink for bridge LED
},
...
}
Each ball can be assigned either a single repeating note or a sequence that cycles through. For example, Ball A might play notes 42, 43, 45, 47 in order, while Ball B could play the same note 50 six times before jumping to 62. This creates musical patterns as you juggle - each catch advances to the next note in the sequence.
The bridge keeps track of each ball's current position in its sequence using the note_positions dictionary. When a ball is caught, the bridge plays the current note, then increments the position (wrapping back to the start when it reaches the end).
To customize the music: Simply edit the "notes" lists in MIDI_MAPPINGS to create your own melodies and rhythms. MIDI note numbers range from 0-127, with 60 being middle C.
USB MIDI Output
During initialization, the bridge scans for available USB MIDI ports and automatically connects to the first output port it finds. This port is typically created when you connect the Feather to a computer running music software (like Ableton, GarageBand, or a DAW) or to a USB MIDI-capable synthesizer, such as the 1010 Music Bento or Blackbox sample station I used in the demonstration video.
The send_midi_note() function handles the actual MIDI transmission.
Incoming Messages
The incoming message elements are parsed by parse_battery_message() to extract:
-
device_id: Which ball is reporting -
voltage: Battery voltage (e.g., "3.75") -
color_hex: Ball's current LED color in hex
Battery messages update both the voltage display and sync the bridge's color tracking with each ball's actual LED color. This ensures the display always shows accurate status.
Button Controls for Remote Color Cycling
Three buttons on the Feather let you remotely change each ball's LED color:
- D0: Controls Ball A (note: this button has opposite pull direction)
- D1: Controls Ball B
- D2: Controls Ball C
When you press a button, the handle_button_presses() function:
- Sends a
COLOR|ball_X|nextmessage to the specified ball - Updates its local
ball_colorstracking by cycling to the next color inALL_COLORS - The ball receives the message, changes its LEDs, and responds with a battery report
- The battery report's color field syncs the bridge's display with the ball's new color
This bidirectional color synchronization ensures the display always shows accurate status even if balls are powered on/off or the bridge is restarted.
Visual Feedback
The bridge has its own onboard NeoPixel that provides visual feedback:
- Blinks briefly in the ball's color (from
MIDI_MAPPINGS, not the ball's current LED color) - One quick 20ms flash per catch
- Confirms MIDI notes were sent successfully
This LED uses the fixed "home" colors for each ball rather than their current LED colors, providing consistent visual reference regardless of remote color changes.
Connection Monitoring
The bridge tracks connection status by recording timestamps in the connection_status dictionary whenever a ball sends a message. The display shows:
- Connected (colored dot): Ball sent a message within the last 5 seconds
- Disconnected (gray dot): No message received for 5+ seconds
This timeout accommodates the balls' 30-second sleep mode. If you're juggling, the balls stay awake and the dots remain colored. During breaks, balls may sleep and show gray dots, but they'll reconnect instantly when you start juggling again.
Main Loop
The main while True loop runs every 10 milliseconds and performs these tasks in priority order:
- Process ESP-NOW messages: Check for incoming TRIGGER or BATTERY messages and handle them immediately
- Handle button presses: Check for color cycle button presses and send COLOR commands
- Update display periodically: Refresh the TFT every 2 seconds to show current status
This fast polling ensures low-latency MIDI output - catches are converted to notes within milliseconds of being detected, making the system feel responsive for musical performances.
Page last edited October 15, 2025
Text editor powered by tinymce.
CAD Files
3D Printed Parts
STL files for 3D printing are oriented to print "as-is" on FDM style machines. Parts are designed to 3D print without any support material using PLA filament. Original design source may be downloaded using the links below.
CAD Assembly
The Feather ESP32-S2 Reverse TFT Feather board is secured to the enclosure's back cover using both M2 and M2.5 fasteners. The back cover snap fits onto the front cover. The front cover is secured to the battery tray using M2.5 fastener.
Build Volume
The parts require a 3D printer with a minimum build volume.
- 68mm (X) x 62mm (Y) x 20mm (Z)
Design Source Files
The project assembly was designed in Fusion 360. This can be downloaded in different formats like STEP, STL and more.
Electronic components like Adafruit's boards, displays, connectors and more can be downloaded from the Adafruit CAD parts GitHub Repo.
Page last edited October 15, 2025
Text editor powered by tinymce.
Usage
To use the juggling balls, pop open the shell and turn on each ball using the on/off switch on the Lipoly BFF. Then, close the balls back up.
They'll immediately respond to tap detection on catch (or hard throw, too) and blink the NeoPixels, even if there's no bridge set up yet.
Bridge Over Troubled MIDI
Plug the bridge into your computer with a software synth or a hardware synth with USB Host MIDI.
You can now trigger MIDI messages with each ball catch.
Press the corresponding button on the bridge to change ball colors.
Start juggling and enjoy the show!
When you're done, open up and turn off the balls, then plug them in to USB C to charge them so they'll be ready for the next performance.
Page last edited October 15, 2025
Text editor powered by tinymce.