BLE Beacon NeoPixels
2026-07-21 | By Adafruit Industries
License: See Original Project 3D Printing
Courtesy of Adafruit
Guide by Ruiz Brothers
Overview
We were inspired by the Disney MagicBand+ to build a pair of wearable mouse ears that light up in response to the same Bluetooth Low Energy broadcasts that drive the bands at the parks.
The ears listen for Disney's manufacturer advertisements and react in real time. When a MagicBand+ light command is sent, a Starlight Bubble Wand cast, or a Fab 50 statue beacon is detected nearby, the ears animate to match what's happening around them - including the fireworks at Magic Kingdom and Epcot.
This means you can use Disney products like the bubble wands to cast colors to our custom NeoPixel projects.
The wearable headbands are powered by a QT Py ESP32-S3 and a LiPo BFF, while a remote runs on the Adafruit CLUE.
We think this project could be adapted for accessibility projects like mobile scooters, wheelchairs or even strollers. We have a special "Find Me" beacon mode that could help you spot your accessible vehicles in dark and crowded areas.
There are two separate projects that run CircuitPython code. The Adafruit CLUE acts as a BLE remote that pulls from a list of commands that was captured from the Disney theme parks and ties them to LED animations. The TFT display on the CLUE shows a menu of animations to choose from and can send BLE commands to trigger the LEDs on Disney MagicBands and accessories.
Parts
Short Headers Kit for Feather - 12-pin + 16-pin Female Headers
Short Feather Male Headers - 12-pin and 16-pin Male Header Set
Silicone Cover Stranded-Core Ribbon Cable - 4 Wires 1 Meter Long
Circuit Diagram
This provides a visual reference for wiring of the components. This diagram was created using Fritzing software.
Some of the wiring can be reduced by sharing power and ground connections across the two NeoPixel Jewels with a ribbon cable.
Mickey Ear Wires Lengths:
Left ear: 24.5 cm
Right ear (QT Py Case Side): 18.5 cm
CircuitPython on CLUE
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 flash drive to iterate.
The following instructions will show you how to install CircuitPython. If you've already installed CircuitPython but are looking to update it or reinstall it, the same steps work for that as well!
Set up CircuitPython Quick Start!
Follow this quick step-by-step for super-fast Python power :)
Download the latest version of CircuitPython for CLUE from circuitpython.org
Click the link above to download the latest version of CircuitPython for the CLUE.
Download and save it to your desktop (or wherever is handy).
Plug your CLUE into your computer using a known-good USB cable.
A lot of people end up using charge-only USB cables and it is very frustrating! So make sure you have a USB cable you know is good for data sync.
Double-click the Reset button on the top (magenta arrow) on your board, and you will see the NeoPixel RGB LED (green arrow) turn green. If it turns red, check the USB cable, try another USB port, etc. Note: The little red LED next to the USB connector will pulse red. That's ok!
If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!
You will see a new disk drive appear called CLUEBOOT.
Drag the adafruit-circuitpython-clue-etc.uf2 file to CLUEBOOT.
The LED will flash. Then, the CLUEBOOT drive will disappear, and a new disk drive called CIRCUITPY will appear.
If this is the first time you're installing CircuitPython or you're doing a completely fresh install after erasing the filesystem, you will have two files - boot_out.txt, and code.py, and one folder - lib on your CIRCUITPY drive.
If CircuitPython was already installed, the files present before reloading CircuitPython should still be present on your CIRCUITPY drive. Loading CircuitPython will not create new files if there was already a CircuitPython filesystem present.
That's it, you're done! :)
CLUE Code
This is the firmware that runs on the Adafruit CLUE - the BLE remote you carry to fire commands at the Beacon Ears or test new park signatures. It draws a category grid on the TFT, broadcasts BLE adverts when you select a command, and has a Listen Mode that captures unique Disney packets to a CSV file for reverse-engineering new park show codes.
To program your CLUE remote, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''MagicBand+ BLE remote for the Adafruit CLUE (nRF52840).
Broadcasts Disney MagicBand+ BLE commands using the 0x0183 manufacturer
identifier. Grid of category tiles on startup; A/B navigate, double-tap B
opens the selected category. In the list view, A/B scroll, double-tap A
fires the highlighted command, double-tap B returns to grid, and a long
press on B fires the OFF command to cancel a running animation. Shake the
CLUE to pick a random command; confirm with double-tap A or cancel with B.
'''
# Target: Adafruit CLUE (nRF52840) - the BLE remote
import gc
import os
import random
import time
import _bleio
import alarm
import board
import digitalio
import microcontroller
import neopixel
import pwmio
import supervisor
from adafruit_debouncer import Button
import ble_transmitter
import command_library
import ui
_STATE_GRID = 0
_STATE_LIST = 1
_STATE_CONFIRM = 2
_SHAKE_THRESHOLD = 32.0 # m/s^2 magnitude (higher = harder shake needed)
_SHAKE_COOLDOWN = 1.5 # seconds between shake triggers
_PALETTE = command_library.CATEGORIES
# Disney-style ascending chime played on the onboard piezo after each fire.
_CHIME = ((523, 0.08), (659, 0.08), (784, 0.14))
# Direct hardware access - skips loading adafruit_clue's full sensor suite.
i2c = board.I2C()
try:
from adafruit_lsm6ds.lsm6ds33 import LSM6DS33
accel = LSM6DS33(i2c)
except (OSError, RuntimeError, ImportError):
from adafruit_lsm6ds.lsm6ds3trc import LSM6DS3TRC
accel = LSM6DS3TRC(i2c)
display = board.DISPLAY
DISPLAY_ACTIVE_BRIGHTNESS = 0.8
DISPLAY_SLEEP_TIMEOUT_S = 30.0 # sleep TFT backlight after this many idle seconds
display.brightness = DISPLAY_ACTIVE_BRIGHTNESS
# Battery voltage monitoring intentionally not implemented on the CLUE.
# Unlike the Feather Sense which has a hardwired voltage divider from the
# LIPO rail, the CLUE has no such divider exposed on CircuitPython's board
# module. Pin-probing testing reads unconnected floating values.
#
# For power awareness, rely on the display auto-sleep feature which is the
# larger power saver anyway (~25-35mA savings when backlight is off).
pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.3)
pixel.fill((0, 0, 0))
# PWM-driven speaker avoids per-note audio buffer allocations.
speaker = pwmio.PWMOut(board.SPEAKER, variable_frequency=True, duty_cycle=0)
btn_a_io = digitalio.DigitalInOut(board.BUTTON_A)
btn_a_io.switch_to_input(pull=digitalio.Pull.UP)
btn_b_io = digitalio.DigitalInOut(board.BUTTON_B)
btn_b_io.switch_to_input(pull=digitalio.Pull.UP)
gc.collect()
grid_view = ui.GridView(_PALETTE)
list_view = ui.ListView()
confirm_view = ui.ConfirmView()
listen_view = ui.ListenView()
display.root_group = grid_view.group
button_a = Button(btn_a_io, value_when_pressed=False,
short_duration_ms=250, long_duration_ms=600)
button_b = Button(btn_b_io, value_when_pressed=False,
short_duration_ms=250, long_duration_ms=600)
def play_chime():
'''Play the Disney-style confirmation chime via hardware PWM.
Respects silent mode - skip playback when _silent_mode is on.
'''
if _silent_mode[0]:
return
for freq, dur in _CHIME:
speaker.frequency = int(freq)
speaker.duty_cycle = 0x8000
time.sleep(dur)
speaker.duty_cycle = 0
def pulse_pixel_and_fire(payload, display_color=(80, 0, 160)):
'''Light the onboard NeoPixel while broadcasting, then fade.'''
pixel.fill(display_color)
ble_transmitter.broadcast(payload)
pixel.fill((0, 0, 0))
def fire_command(command, status_setter):
'''Broadcast a single command or play a multi-step sequence.
command is a (name, payload, needs_ping) tuple. Payload can be a raw
bytes packet for single commands or a tuple of step-tuples for shows.
When needs_ping is True, a short CC03 wake ping is broadcast first to
prime the band's receiver before the actual command.
'''
gc.collect()
name, payload, needs_ping = command[0], command[1], command[2]
# Intercept the listen-mode sentinel: instead of broadcasting, this
# transitions to packet capture mode for protocol research.
if payload == b"LISTEN":
run_listen_mode()
return
if isinstance(payload, bytes):
status_setter(f"Firing: {name}", 0x00FF00)
play_chime()
if needs_ping:
pixel.fill((30, 30, 30))
ble_transmitter.broadcast(
command_library.PING_PAYLOAD, duration=0.5,
)
pulse_pixel_and_fire(payload)
else:
status_setter(f"Playing: {name}", 0x00FFFF)
play_chime()
if needs_ping:
pixel.fill((30, 30, 30))
ble_transmitter.broadcast(
command_library.PING_PAYLOAD, duration=0.5,
)
total = len(payload)
for i, step in enumerate(payload):
step_bytes, hold, color = step
status_setter(f"{name} {i + 1}/{total}", 0x00FFFF)
pixel.fill(color)
ble_transmitter.broadcast(step_bytes, duration=hold)
pixel.fill((0, 0, 0))
status_setter("Ready", 0x404040)
def _listen_capture_loop(seen):
'''BLE scanning loop for run_listen_mode. Returns when user holds B.
seen: dict[bytes, list] mapping payload to [first_seen, count, rssi].
Mutates seen in place. Returns total elapsed seconds.
'''
total_count = 0
last_rssi = None
start_time = time.monotonic()
last_ui_update = 0.0
adapter = _bleio.adapter
if not adapter.enabled:
adapter.enabled = True
while True:
button_b.update()
button_a.update()
# Require a long-press to exit listening mode, so accidental
# B taps during a show don't end recording.
if button_b.long_press:
break
try:
for entry in adapter.start_scan(
interval=0.04, window=0.04,
minimum_rssi=-100, timeout=0.2,
extended=False, active=False):
payload = _extract_disney_payload(entry.advertisement_bytes)
if payload is None:
continue
total_count += 1
last_rssi = entry.rssi
key = bytes(payload)
if key in seen:
seen[key][1] += 1
seen[key][2] = entry.rssi
else:
seen[key] = [time.monotonic() - start_time, 1, entry.rssi]
finally:
adapter.stop_scan()
# Throttle UI updates to once per second to limit bitmap
# reallocation churn (memory is tight on the CLUE).
now = time.monotonic()
if now - last_ui_update >= 1.0:
last_ui_update = now
listen_view.update_stats(now - start_time, total_count,
len(seen), last_rssi)
return time.monotonic() - start_time
def _save_capture_with_fallback(seen, elapsed):
'''Try to save. If FS is read-only, set NVM flag for next-boot retry.'''
try:
path = _save_capture(seen, elapsed)
short = path.split("/")[-1]
listen_view.set_status(f"Saved: {short} B", 0x00FF00)
except OSError as err:
# Filesystem is read-only - host has ownership. Set NVM flag
# so next reset auto-creates the marker and enters capture mode.
try:
microcontroller.nvm[0] = 1
listen_view.set_status("Reset to enable save B", 0xFF8000)
except (AttributeError, ImportError):
listen_view.set_status(f"Save fail: {err} B", 0xFF0000)
def _wait_for_dismiss_press():
'''Wait for the next short B-press to dismiss the save confirmation.
The user was holding B to stop capture, so adafruit_debouncer.Button
has already emitted a long_press for that hold. We just need to wait
for the next short_count tick - the debouncer handles release-detect
and debounce timing internally.
'''
while True:
button_b.update()
if button_b.short_count > 0:
return
def run_listen_mode():
'''Enter BLE listening mode - capture unique 0x0183 packets to file.
Stops broadcasting, starts BLE scanning, transitions UI to the
listen view. User holds B to stop and save the capture.
'''
# Aggressively free memory before allocating capture state.
gc.collect()
display.root_group = listen_view.group
note_activity()
if supervisor.runtime.usb_connected:
listen_view.set_status("USB - hold B to stop", 0xFF8000)
else:
listen_view.set_status("Hold B to stop")
seen = {} # payload bytes -> [first_seen_time, count, last_rssi]
elapsed = _listen_capture_loop(seen)
listen_view.set_status("Saving...", 0x00FFFF)
_save_capture_with_fallback(seen, elapsed)
_wait_for_dismiss_press()
enter_grid()
gc.collect()
def _extract_disney_payload(ad_bytes):
'''Walk a BLE advert and extract the 0x0183 manufacturer payload.'''
DISNEY_CID = 0x0183
i = 0
while i < len(ad_bytes):
length = ad_bytes[i]
if length == 0 or i + 1 + length > len(ad_bytes):
break
ad_type = ad_bytes[i + 1]
if ad_type == 0xFF and length >= 3:
cid = ad_bytes[i + 2] | (ad_bytes[i + 3] << 8)
if cid == DISNEY_CID:
return bytes(ad_bytes[i + 4:i + 1 + length])
i += 1 + length
return None
def _save_capture(seen, total_elapsed):
'''Write captured packets to /captures/listen_NNN.txt.
Returns the file path on success. Raises OSError if the filesystem
is not writable (e.g., USB host has ownership of the drive).
'''
# Find next available sequence number
base_dir = "/captures"
try:
os.mkdir(base_dir)
except OSError:
pass # already exists
existing = []
try:
existing = os.listdir(base_dir)
except OSError:
pass
seq = 0
while f"listen_{seq:03d}.txt" in existing:
seq += 1
path = f"{base_dir}/listen_{seq:03d}.txt"
with open(path, "w", encoding="utf-8") as out:
out.write(f"# Listen capture, total elapsed {total_elapsed:.1f}s\n")
out.write(f"# {len(seen)} unique packets captured\n")
out.write("# format: first_seen_s rssi count hex\n")
# Sort by first_seen for readable chronological log
items = sorted(seen.items(), key=lambda kv: kv[1][0])
for payload, info in items:
first_seen, count, rssi = info
out.write(f"{first_seen:.3f} {rssi:>4} {count:>4} {payload.hex()}\n")
return path
def fire_off(status_setter):
'''Shortcut to broadcast the OFF command with distinct visual feedback.'''
_, payload, _ = command_library.OFF_COMMAND
gc.collect()
status_setter("Off", 0xFF4040)
play_chime()
pixel.fill((40, 40, 40))
ble_transmitter.broadcast(payload, duration=1.5)
pixel.fill((0, 0, 0))
status_setter("Ready", 0x404040)
# Precompute the pool of commands eligible for shake-random firing.
# Excluded:
# - Custom sub-protocol commands (Ears Battery, Ears Brightness) -
# payload starts with 0xAA. These only affect the QT Py ears, not
# bands/wands, and would feel arbitrary as a random selection.
# - The LISTEN sentinel - it triggers BLE capture mode, not a fire.
# Otherwise we include all commands. needs_ping=True commands still get
# their wake-ping when fired (handled by fire_command).
_RELIABLE_COMMANDS = []
for _cat_idx, (_, _commands) in enumerate(_PALETTE):
for _cmd in _commands:
_payload = _cmd[1]
# Skip sentinel command
if _payload == b"LISTEN":
continue
# Skip our custom sub-protocol packets (start with 0xAA)
if (isinstance(_payload, bytes) and len(_payload) > 0
and _payload[0] == 0xAA):
continue
# Sequences (tuple of step tuples) are kept - their first step's
# bytes are the command marker. Sequences don't use the AA prefix.
_RELIABLE_COMMANDS.append((_cat_idx, _cmd))
# Silent mode mutes the CLUE's piezo chime. Toggled with a long press on A.
# Stored in a single-element list so button handlers can mutate without
# needing `global`.
_silent_mode = [False]
def toggle_silent(status_setter):
'''Flip silent mode and show brief confirmation in the status bar.'''
_silent_mode[0] = not _silent_mode[0]
if _silent_mode[0]:
status_setter("Silent ON", 0xFFAA00)
else:
status_setter("Silent OFF", 0x40C0FF)
# --- Display sleep management ---
# When no buttons have been pressed or commands fired within
# DISPLAY_SLEEP_TIMEOUT_S, the TFT backlight turns off to save power.
# Any button press wakes it immediately.
#
# Primary mechanism: display.brightness = 0.0, which on the CLUE drives
# the backlight PWM pin to 0% duty cycle.
_last_activity_time = [time.monotonic()]
_display_sleeping = [False]
def note_activity():
'''Mark the current moment as user-active - wake display if sleeping.'''
_last_activity_time[0] = time.monotonic()
if _display_sleeping[0]:
display.brightness = DISPLAY_ACTIVE_BRIGHTNESS
_display_sleeping[0] = False
print(f"[DISPLAY] wake at t={time.monotonic():.1f}s")
def check_display_sleep():
'''Called every loop iteration - put display to sleep if idle too long.'''
if _display_sleeping[0]:
return
idle_s = time.monotonic() - _last_activity_time[0]
if idle_s >= DISPLAY_SLEEP_TIMEOUT_S:
display.brightness = 0.0
_display_sleeping[0] = True
print(f"[DISPLAY] sleep at t={time.monotonic():.1f}s"
f" (idle for {idle_s:.0f}s)")
def enter_light_sleep():
'''Put the CLUE into light sleep. Wakes on A or B button press.
Light sleep suspends the running program until an alarm fires.
Unlike deep sleep, Python state is preserved - selected category,
silent mode, etc. all stay in RAM. We use light sleep (not deep
sleep) because CP 10.1.4 deep sleep on nRF52 has known reliability
issues; light sleep works correctly and gives meaningful power
savings for wearable-scale sessions.
Returns the two new PinAlarm objects so the caller can deinit them
and re-setup button handling after wake. This function does NOT
reinit the buttons itself (doing so requires module-scope
reassignment which complicates the function signature).
'''
# Fade out chime speaker if it was running (shouldn't be, but safe)
speaker.duty_cycle = 0
# Turn off onboard status pixel
pixel.fill((0, 0, 0))
# Turn off display backlight
display.brightness = 0.0
# Release the digital pins before setting up PinAlarm on the same
# pins. The adafruit_debouncer.Button wrapper doesn't have deinit()
# itself - we only need to release the underlying DigitalInOut
# objects (btn_a_io and btn_b_io).
btn_a_io.deinit()
btn_b_io.deinit()
# Wait for both buttons to actually be released before arming the
# PinAlarms. Without this pause, the still-held state of the triggering
# buttons would fire the wake alarm immediately (level-triggered alarms
# fire as soon as they see the "pressed" state, which is what started
# the sleep in the first place).
# Brief temporary reads to detect release
_wait_release_a = digitalio.DigitalInOut(board.BUTTON_A)
_wait_release_a.switch_to_input(pull=digitalio.Pull.UP)
_wait_release_b = digitalio.DigitalInOut(board.BUTTON_B)
_wait_release_b.switch_to_input(pull=digitalio.Pull.UP)
# Buttons are active-low: .value == True means released
_release_deadline = time.monotonic() + 3.0 # safety cap
while time.monotonic() < _release_deadline:
if _wait_release_a.value and _wait_release_b.value:
break
time.sleep(0.05)
_wait_release_a.deinit()
_wait_release_b.deinit()
# Configure pin alarms on both buttons. NRF requires level-triggered
# (edge=False) with value=False (active low since buttons pull to
# ground when pressed) and pull=True (enable internal pull-up).
pin_alarm_a = alarm.pin.PinAlarm(
pin=board.BUTTON_A, value=False, pull=True)
pin_alarm_b = alarm.pin.PinAlarm(
pin=board.BUTTON_B, value=False, pull=True)
print("[LIGHT SLEEP] entering")
# Blocks here until an alarm fires. On wake, execution resumes.
alarm.light_sleep_until_alarms(pin_alarm_a, pin_alarm_b)
print("[LIGHT SLEEP] woken")
# PinAlarm objects don't expose deinit() - they release their pins
# when garbage collected. Drop the references and force a gc pass
# so the pins are available for DigitalInOut recreation by the
# caller.
del pin_alarm_a
del pin_alarm_b
gc.collect()
def pick_random_command():
'''Pick a random reliable command from the no-ping-needed pool.
Falls back to the full library if nothing is marked reliable.
'''
if _RELIABLE_COMMANDS:
return _RELIABLE_COMMANDS[random.randint(0, len(_RELIABLE_COMMANDS) - 1)]
cat_idx = random.randint(0, len(_PALETTE) - 1)
commands = _PALETTE[cat_idx][1]
command = commands[random.randint(0, len(commands) - 1)]
return cat_idx, command
def shake_magnitude():
'''Return the current accelerometer magnitude in m/s^2.'''
a_x, a_y, a_z = accel.acceleration
return (a_x * a_x + a_y * a_y + a_z * a_z) ** 0.5
def enter_list(cat_idx):
'''Switch to list view for the given category index.'''
gc.collect()
name, commands = _PALETTE[cat_idx]
list_view.load_category(cat_idx, name, commands)
display.root_group = list_view.group
def enter_grid():
'''Switch back to the grid view.'''
display.root_group = grid_view.group
def enter_confirm(name):
'''Switch to the confirm modal for a random-picked command.'''
confirm_view.set_command(name)
display.root_group = confirm_view.group
def handle_grid(last_shake_time):
'''Input handling while the grid view is active.'''
if button_b.long_press:
fire_off(grid_view.set_status)
return _STATE_GRID, last_shake_time, None, None
if button_a.short_count == 3:
toggle_silent(grid_view.set_status)
return _STATE_GRID, last_shake_time, None, None
if button_a.short_count == 2:
enter_list(grid_view.selected)
return _STATE_LIST, last_shake_time, None, None
if button_a.short_count == 1:
grid_view.prev_tile()
if button_b.short_count == 1:
grid_view.next_tile()
now = time.monotonic()
if shake_magnitude() > _SHAKE_THRESHOLD and now - last_shake_time > _SHAKE_COOLDOWN:
cat_idx, command = pick_random_command()
grid_view.set_tile(cat_idx)
enter_confirm(command[0])
return _STATE_CONFIRM, now, command, _STATE_GRID
return _STATE_GRID, last_shake_time, None, None
def handle_list(last_shake_time):
'''Input handling while the list view is active.
Single-exit cascade: each branch sets `result`, then returns at the
bottom. Keeps return count under the lint limit while preserving
the early-exit semantics via `done`.
'''
result = (_STATE_LIST, last_shake_time, None, None)
done = False
if button_b.long_press:
fire_off(list_view.set_status)
done = True
elif button_a.short_count == 3:
toggle_silent(list_view.set_status)
done = True
elif button_a.short_count == 2:
command = list_view.selected_command
if command is not None:
fire_command(command, list_view.set_status)
# Listen mode sentinel - run_listen_mode() has already
# swapped the display to grid_view, so update state too.
if command[1] == b"LISTEN":
result = (_STATE_GRID, last_shake_time, None, None)
done = True
elif button_b.short_count == 2:
enter_grid()
result = (_STATE_GRID, last_shake_time, None, None)
done = True
if not done:
if button_a.short_count == 1:
list_view.scroll_up()
if button_b.short_count == 1:
list_view.scroll_down()
now = time.monotonic()
if (shake_magnitude() > _SHAKE_THRESHOLD
and now - last_shake_time > _SHAKE_COOLDOWN):
_cat_idx, command = pick_random_command()
enter_confirm(command[0])
result = (_STATE_CONFIRM, now, command, _STATE_LIST)
return result
def handle_confirm(pending, return_state, last_shake_time):
'''Input handling while the confirm modal is active.'''
if button_a.short_count == 2:
setter = list_view.set_status if return_state == _STATE_LIST else grid_view.set_status
if return_state == _STATE_LIST:
display.root_group = list_view.group
else:
display.root_group = grid_view.group
fire_command(pending, setter)
return return_state, last_shake_time, None, None
if button_b.short_count == 1:
if return_state == _STATE_LIST:
display.root_group = list_view.group
else:
display.root_group = grid_view.group
return return_state, last_shake_time, None, None
return _STATE_CONFIRM, last_shake_time, pending, return_state
state = _STATE_GRID
last_shake = 0.0
pending_command = None
pending_return = None
# Track when both A and B are pressed simultaneously for light sleep
# trigger. Requires a minimum hold time (~0.8s) so incidental button
# combos during normal use don't accidentally sleep the device.
_DUAL_HOLD_TRIGGER_S = 0.8
_dual_pressed_since = None
while True:
button_a.update()
button_b.update()
# Detect A+B held for deep sleep. Uses .value (stable debounced state)
# not .pressed (one-shot event). Hold both for _DUAL_HOLD_TRIGGER_S
# to commit the sleep action.
both_held = (not button_a.value) and (not button_b.value)
if both_held:
if _dual_pressed_since is None:
_dual_pressed_since = time.monotonic()
elif time.monotonic() - _dual_pressed_since >= _DUAL_HOLD_TRIGGER_S:
grid_view.set_status("Sleep...", 0xFF4080)
list_view.set_status("Sleep...", 0xFF4080)
# Wait for user to release BOTH buttons before starting
# light sleep. If we sleep while buttons are still held,
# the PinAlarm (level-triggered on value=False) would
# immediately fire and wake us right back up.
while not button_a.value or not button_b.value:
button_a.update()
button_b.update()
time.sleep(0.02)
time.sleep(0.15) # settle time to avoid bounce
enter_light_sleep()
# After wake, the button IO pins were deinit'd for PinAlarm
# and need to be re-established for normal polling.
btn_a_io = digitalio.DigitalInOut(board.BUTTON_A)
btn_a_io.switch_to_input(pull=digitalio.Pull.UP)
btn_b_io = digitalio.DigitalInOut(board.BUTTON_B)
btn_b_io.switch_to_input(pull=digitalio.Pull.UP)
button_a = Button(btn_a_io, value_when_pressed=False,
short_duration_ms=200, long_duration_ms=800)
button_b = Button(btn_b_io, value_when_pressed=False,
short_duration_ms=200, long_duration_ms=800)
# Restore the display and reset activity timer
display.brightness = DISPLAY_ACTIVE_BRIGHTNESS
_last_activity_time[0] = time.monotonic()
_display_sleeping[0] = False
grid_view.set_status("Awake!", 0x40C0FF)
list_view.set_status("Awake!", 0x40C0FF)
_dual_pressed_since = None
# Skip the rest of this frame so handlers don't see stale
# button state from the wake press
continue
# While both are held (but not yet DUAL_HOLD threshold), skip
# individual button handlers so they don't fire silent-toggle
# or similar from the collateral press.
check_display_sleep()
time.sleep(0.02)
continue
_dual_pressed_since = None
# Wake display on any button activity
if (button_a.short_count > 0 or button_b.short_count > 0
or button_a.long_press or button_b.long_press):
note_activity()
if state == _STATE_GRID:
state, last_shake, pending_command, pending_return = handle_grid(last_shake)
elif state == _STATE_LIST:
state, last_shake, pending_command, pending_return = handle_list(last_shake)
else:
state, last_shake, pending_command, pending_return = handle_confirm(
pending_command, pending_return, last_shake,
)
check_display_sleep()
time.sleep(0.02)
The remote is split across six files. code.py drives the menu state machine and main loop. command_library.py is the named catalog of all MagicBand+, wand, and ears-only commands. magicband_protocol.py is shared with the receiver and provides the build_* helpers that turn palette indices and timing values into raw byte payloads. ble_transmitter.py wraps _bleio to broadcast a payload as a BLE advert with the Disney CID. ui.py defines the four display views: a category grid, a scrollable command list, a confirm modal, and a listen capture view. boot.py handles the read-write filesystem flag for Listen Mode saves.
Plug your CLUE into your computer with a known-good USB cable. The CIRCUITPY drive should show up. Copy code.py, boot.py, command_library.py, magicband_protocol.py, ble_transmitter.py, and ui.py to the root of the CIRCUITPY drive. Then copy the contents of the bundle's lib folder to the lib folder on your CIRCUITPY drive.
The required libraries are adafruit_debouncer.mpy, adafruit_display_shapes, adafruit_display_text, and adafruit_lsm6ds (for shake detection).
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''Display views for the CLUE MagicBand+ remote.
Three views share the 240x240 TFT through root-group swaps:
- GridView: 2-col x 3-row grid of category tiles
- ListView: scrollable command list for a category
- ConfirmView: modal confirmation for shake-fired random commands
'''
# Target: Adafruit CLUE (nRF52840) - the BLE remote
import displayio
import terminalio
from adafruit_display_shapes.rect import Rect
from adafruit_display_text.label import Label
_W = 240
_H = 240
_TITLE_H = 24
_STATUS_H = 20
_BG = 0x000000
_FG = 0xFFFFFF
_DIM = 0x404040
_HIGHLIGHT = 0xFF00FF
_ACCENT = 0x00FFFF
class GridView:
'''Four-tile category grid displayed at startup.'''
def __init__(self, categories):
self._categories = categories
self._selected = 0
self._group = displayio.Group()
title = Label(
terminalio.FONT, text="MagicBand+",
color=_ACCENT, x=36, y=16,
)
title.scale = 2
self._group.append(title)
self._tile_rects = []
self._tile_labels = []
self._build_tiles()
self._status = Label(
terminalio.FONT, text="A/B: select 2xA: open",
color=_DIM, x=20, y=_H - 10,
)
self._group.append(self._status)
self._refresh()
@property
def group(self):
'''The displayio.Group root for this view.'''
return self._group
@property
def selected(self):
'''Index of the currently selected tile.'''
return self._selected
def next_tile(self):
'''Move highlight to the next tile (wraps).'''
self._selected = (self._selected + 1) % len(self._categories)
self._refresh()
def prev_tile(self):
'''Move highlight to the previous tile (wraps).'''
self._selected = (self._selected - 1) % len(self._categories)
self._refresh()
def set_tile(self, idx):
'''Set the highlighted tile by index.'''
if 0 <= idx < len(self._categories):
self._selected = idx
self._refresh()
def set_status(self, text, color=_DIM):
'''Update the bottom status line.'''
self._status.text = text
self._status.color = color
def _build_tiles(self):
# 2x2 grid with larger tiles now that we have 4 categories
cell_w = _W // 2
cell_h = (_H - _TITLE_H - _STATUS_H - 8) // 2
tile_inner_w = cell_w - 8
for idx, (name, _commands) in enumerate(self._categories):
col = idx % 2
row = idx // 2
x = col * cell_w + 4
y = _TITLE_H + 4 + row * (cell_h + 4)
rect = Rect(x, y, tile_inner_w, cell_h, outline=_DIM, stroke=2)
label = Label(terminalio.FONT, text=name, color=_FG)
# Pick the largest scale that fits horizontally with padding.
# terminalio.FONT is 6px wide per char at scale 1.
label_w_scale2 = len(name) * 12
if label_w_scale2 + 12 <= tile_inner_w:
label.scale = 2
label_w = label_w_scale2
else:
label.scale = 1
label_w = len(name) * 6
label.x = x + (tile_inner_w - label_w) // 2
label.y = y + cell_h // 2
self._tile_rects.append(rect)
self._tile_labels.append(label)
self._group.append(rect)
self._group.append(label)
def _refresh(self):
for idx, rect in enumerate(self._tile_rects):
if idx == self._selected:
rect.outline = _HIGHLIGHT
self._tile_labels[idx].color = _HIGHLIGHT
else:
rect.outline = _DIM
self._tile_labels[idx].color = _FG
class ListView:
'''Scrollable command list for a single category.'''
_VISIBLE_ROWS = 7
_ROW_H = 22
# Wide enough to fit scale=2 rendering of most names. Longer names
# automatically fall back to scale=1 to preserve right-side padding.
_MAX_CHARS_SCALE2 = 18
def __init__(self):
self._category_idx = 0
self._category_name = ""
self._commands = ()
self._selected = 0
self._scroll = 0
self._group = displayio.Group()
self._title = Label(
terminalio.FONT, text="", color=_ACCENT, x=8, y=12,
)
self._group.append(self._title)
self._rows = []
for i in range(self._VISIBLE_ROWS):
row = Label(
terminalio.FONT, text="", color=_FG,
x=12, y=_TITLE_H + 8 + i * self._ROW_H,
)
self._rows.append(row)
self._group.append(row)
self._status = Label(
terminalio.FONT, text="A/B scroll 2xA fire B-hold off",
color=_DIM, x=8, y=_H - 10,
)
self._group.append(self._status)
@property
def group(self):
'''The displayio.Group root for this view.'''
return self._group
@property
def selected_command(self):
'''The (name, payload, ping) tuple of the highlighted command.'''
if not self._commands:
return None
return self._commands[self._selected]
@property
def category_idx(self):
'''Index into CATEGORIES of the currently displayed list.'''
return self._category_idx
def load_category(self, idx, name, commands):
'''Populate the list with the commands of one category.'''
self._category_idx = idx
self._category_name = name
self._commands = commands
self._selected = 0
self._scroll = 0
self._title.text = f"{name} ({len(commands)})"
self._refresh()
def scroll_up(self):
'''Move selection up one row (wraps).'''
if not self._commands:
return
self._selected = (self._selected - 1) % len(self._commands)
self._adjust_scroll()
self._refresh()
def scroll_down(self):
'''Move selection down one row (wraps).'''
if not self._commands:
return
self._selected = (self._selected + 1) % len(self._commands)
self._adjust_scroll()
self._refresh()
def set_status(self, text, color=_DIM):
'''Update the bottom status line.'''
self._status.text = text
self._status.color = color
def _adjust_scroll(self):
if self._selected < self._scroll:
self._scroll = self._selected
elif self._selected >= self._scroll + self._VISIBLE_ROWS:
self._scroll = self._selected - self._VISIBLE_ROWS + 1
def _refresh(self):
for i, row in enumerate(self._rows):
cmd_idx = self._scroll + i
if cmd_idx >= len(self._commands):
row.text = ""
continue
name = self._commands[cmd_idx][0]
marker = ">" if cmd_idx == self._selected else " "
full = f"{marker}{name}"
row.scale = 2 if len(full) <= self._MAX_CHARS_SCALE2 else 1
row.text = full
row.color = _HIGHLIGHT if cmd_idx == self._selected else _FG
class ConfirmView:
'''Modal confirmation for shake-fired random commands.'''
def __init__(self):
self._group = displayio.Group()
self._group.append(Label(
terminalio.FONT, text="Shake! Fire this?",
color=_ACCENT, x=50, y=40,
))
self._command_label = Label(
terminalio.FONT, text="", color=_HIGHLIGHT,
x=20, y=110, scale=2,
)
self._group.append(self._command_label)
self._group.append(Label(
terminalio.FONT, text="2xA: Fire",
color=_FG, x=8, y=200,
))
self._group.append(Label(
terminalio.FONT, text="B: Cancel",
color=_FG, x=178, y=200,
))
@property
def group(self):
'''The displayio.Group root for this view.'''
return self._group
def set_command(self, name):
'''Set the command name shown in the confirm modal.'''
self._command_label.text = name
class ListenView:
'''BLE listening / capture view. Minimal to keep memory low.'''
def __init__(self):
self._group = displayio.Group()
title = Label(
terminalio.FONT, text="Listen Mode",
color=_ACCENT, x=36, y=16,
)
title.scale = 2
self._group.append(title)
# One label for all stats - updated less often than per-field labels
# to reduce bitmap allocation churn. Pre-allocated with worst-case
# length string so re-rendering reuses the same bitmap.
# 18 chars at scale 2 = 216px wide, fits on 240px display
self._stats_label = Label(
terminalio.FONT,
text=" ", # 18 chars padding
color=_FG, x=8, y=72,
)
self._stats_label.scale = 2
self._group.append(self._stats_label)
self._status = Label(
terminalio.FONT,
text=" ",
color=_DIM, x=8, y=_H - 10,
)
self._group.append(self._status)
self._status.text = "Hold B to stop"
@property
def group(self):
'''The displayio.Group root for this view.'''
return self._group
def update_stats(self, elapsed_s, total, unique, _last_rssi, _rate=None):
'''Update the stats label with the current capture summary.
last_rssi/rate are accepted for caller-API stability but not shown
on screen at scale 2 - the 240px display only fits the compact
"Ns U/T" format. Underscore prefix marks them as intentionally
unused for the linter.
'''
# Compact format that fits at scale 2 on the 240px display.
# 18 chars * 12px = 216px, leaves margin.
# Format: "{seconds}s {unique}/{total}" e.g. "47s 12/823"
text = f"{int(elapsed_s)}s {unique}/{total}"
# Pad to 18 chars to keep bitmap allocation stable
self._stats_label.text = f"{text:<18s}"
def set_status(self, text, color=_DIM):
'''Update the bottom status line.'''
# Status stays at scale 1 (smaller), pad to ~30 chars
self._status.text = f"{text:<30s}"
self._status.color = color
Three-View State Machine
The CLUE has a 240x240 TFT display. We use three view states swapped via display.root_group: a 4-tile category grid, a scrollable list of commands inside one category, and a confirm modal for shake-fired random commands. Each view is its own class in ui.py with its own group and set_status() method.
_STATE_GRID = 0 _STATE_LIST = 1 _STATE_CONFIRM = 2
The main loop calls one of three handler functions depending on the current state. Each handler reads button events, shake input, and returns the next state plus any pending command. The shape is (next_state, last_shake_time, pending_command, return_state). This lets the confirm modal know which view to return to after a yes/no decision.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''Named MagicBand+ command library organized into categories.
Each command is a (name, payload, needs_ping) tuple where needs_ping is
True if a 0.5s CC03 wake ping should be broadcast before the real command
to prime the band's receiver. Commands observed to latch reliably on first
try are marked False. The transmitter prepends the 0x0183 Disney CID to
payload bytes when advertising.
'''
# Target: Adafruit CLUE (nRF52840) - the BLE remote
from magicband_protocol import (
PALETTE_NAMES,
build_dual_color,
build_single_color,
)
# Wake-ping broadcast continuously by park beacons. Keeps the band's radio
# in high-response mode so the real command latches on the first shot.
PING_PAYLOAD = bytes.fromhex("cc03000000")
# OFF/cancel command. No pre-ping so cancellation is immediate.
OFF_COMMAND = ("Off", build_single_color(0x1D), False)
# Palette slots that look identical to another, produce no visible effect
# on the band, or duplicate other menu actions. 0x1D (Off) is redundant
# with the B-hold cancel shortcut; 0x1E (Unique) has no visible effect.
_SKIPPED_PALETTE = (0x09, 0x0C, 0x17, 0x18, 0x1C, 0x1D, 0x1E)
# Singles confirmed to latch first-try on a real band (no ping needed).
_RELIABLE_SINGLES = {0x02, 0x05}
SINGLE_COLOR = tuple(
(name, build_single_color(idx), idx not in _RELIABLE_SINGLES)
for idx, name in enumerate(PALETTE_NAMES)
if idx not in _SKIPPED_PALETTE
)
DUAL_COLOR = (
("Red & Blue", build_dual_color(0x15, 0x02), True),
("Orange & Cyan", build_dual_color(0x13, 0x16), False),
("Pink & Lime", build_dual_color(0x08, 0x12), True),
("Purple & Yellow", build_dual_color(0x01, 0x0F), True),
("Green & Red", build_dual_color(0x19, 0x15), False),
("Cyan & Orange", build_dual_color(0x16, 0x13), True),
("White & Blue", build_dual_color(0x1B, 0x02), True),
("Lavender & Pink", build_dual_color(0x06, 0x08), True),
)
# Combined Colors category - Dual Color pairs first (more visually striking)
# followed by Single Color palette entries.
COLORS = DUAL_COLOR + SINGLE_COLOR
# All captured park show codes latch first-try - they're the packets the
# band's firmware was specifically designed to recognize.
# The * suffix marks commands that trigger the band's vibration motor.
SHOW_FX = (
("Taste the Rainbow",
bytes.fromhex("e100e90c000f0f5d465bf005323748b0"), False),
("Blink White *",
bytes.fromhex("e100e90c000f0f5d465bf00532374895"), False),
# Orange Blink's timing byte (0xEF) has the always-on flag set, so this
# command runs indefinitely until another command or OFF is sent. The
# other E9 0C shows use timing 0x0F for ~29s runtime then auto-stop.
# Intentional: Orange Blink doubles as a persistent "alert mode" beacon.
("Orange Blink *",
bytes.fromhex("e100e90c00ef0f4f4f5bf0fb14374895"), False),
("5 Palette Cycle",
bytes.fromhex("e100e90c000f0fb1b9b5b1a2307b7db0"), False),
# DCL Rainbow - cloned from 5 Palette Cycle with DCL brand colors
# and long buzz. Navy / Yellow / Red / Navy / Yellow.
("DCL Rainbow *",
bytes.fromhex("e100e90c000f0fa3afb5a3af307b7db7"), False),
# Custom sub-protocol: Ears Battery shows the QT Py wearable's
# current battery level on its NeoPixel jewels (not visible on
# the CLUE itself - this is a remote trigger for the receiver).
("Ears Battery",
bytes.fromhex("aa4201"), False),
# Custom sub-protocol: cycle the QT Py ears through their
# brightness presets (dim / medium / bright). Useful between
# daytime and night usage without touching the headband.
("Ears Brightness",
bytes.fromhex("aa4203"), False),
# Custom sub-protocol: "Find Me" stroller/scooter beacon. Triggers
# a ~30 second high-visibility 3-phase animation on the wearable
# (strobe, rainbow chase, breathing) at maximum brightness so you
# can spot a parked stroller, wheelchair, or EV scooter from across
# a busy parking lot. Forces max brightness regardless of preset,
# then restores the preset after the animation ends.
("Find Me",
bytes.fromhex("aa4204"), False),
# Custom sub-protocol: preview the Fab 50 statue golden-swirl
# animation on demand. Same animation that real Magic Kingdom
# statue beacons trigger on the receiver - useful for video shoots
# and demos without needing a statue beacon nearby.
("Ears Statue",
bytes.fromhex("aa4205"), False),
# Sentinel entry: when fired, code.py recognizes the LISTEN_MODE
# marker and transitions to listen-mode UI instead of broadcasting.
# Captures all unique Disney 0x0183 packets to a file. Useful for
# reverse-engineering new park show packets (Spaceship Earth,
# Starlight Parade, etc.).
("Listen Mode",
b"LISTEN", False),
)
# Cross fades 3 and 5 use scaler=0 timing and latch first-try. The others
# use scaler=1 (3.1x multiplier) for long park-show durations and need
# the wake ping to prime the receiver.
CROSS_FADE = (
("Cyan to Pink",
bytes.fromhex("e100e911006f0f564858f44882d1460208d06500b0"), True),
("Blue to Yellow",
bytes.fromhex("e200e911004f0f444f58f44882d1460607d06543b0"), True),
("Pink to Green",
bytes.fromhex("e100e911000f0f485958f44882d146020dd06505b0"), False),
("Orange to Red",
bytes.fromhex("e200e911004f0f4f5558f44882d146022ad06501b0"), True),
("Lime to Purple",
bytes.fromhex("e100e91100010f5a475bf03134374894d13d0507b0"), False),
("Red to Off",
bytes.fromhex("e100e91100070f555d58f44882d1460508d06500b0"), True),
("Orange to Blue",
bytes.fromhex("e100e91100440f514258f44882d146050fd06500b0"), True),
)
ANIMATIONS = (
# Renamed from Circle w/ Vibration. Last byte changed B0 -> B8 to enable
# the 6-short-tap vibration pattern (same as working Animation 0F-1).
# The * suffix marks commands that trigger the band's vibration motor.
("Blue Circle *",
bytes.fromhex("e200e91200030fa2a2a4a4a230d037f4d2460064fcb8"), True),
("Purple Flash *",
bytes.fromhex("e100e90e00010fbda0a0bda059070048aeb5"), True),
# Crop Dust Fart as a 2-step sequence: the E9 0E tap animation runs
# for ~3.5s with its 0x8 rapid taps, then a 2-second long buzz (0x7)
# in orange punctuates the end. Orange finale matches the "gas cloud"
# theme without being jarring after the band's own color animation.
("Crop Dust Fart *",
((bytes.fromhex("e100e90e00110fbca7b9a7b959190248aeb8"), 3.5, (100, 80, 0)),
(bytes.fromhex("e100e90500090e13b7"), 2.5, (200, 100, 0))),
False),
("Blue & Orange *",
bytes.fromhex("e100e90f00110f4f425807488dd2462a0717b8"), True),
("Blue Sparkle",
bytes.fromhex("e100e91000134897d00ea0d146060f30d04e07b0"), True),
# E9 13 is a firmware-baked animation that renders as a purple pulse on
# real bands, despite its byte payload suggesting a multi-color mix.
("Purple Pulse",
bytes.fromhex("e100e9130002d037f0d23d0505000efa8983510ee7a0b0"), True),
("Holiday Flash",
bytes.fromhex("e200e91400420f555b58f44882d0651bd1462a02307b5db0"), False),
)
CATEGORIES = (
("Colors", COLORS),
("Show FX", SHOW_FX),
("Fades", CROSS_FADE),
("Animate", ANIMATIONS),
)
The Command Library
Every command the remote can broadcast lives as a tuple in command_library.py. The shape is (name, payload, needs_ping) where needs_ping tells the transmitter whether to send a 0.5-second wake-ping before the real command to prime the band's receiver.
SHOW_FX = (
("Taste the Rainbow",
bytes.fromhex("e100e90c000f0f5d465bf005323748b0"), False),
("DCL Rainbow *",
bytes.fromhex("e100e90c000f0fa3afb5a3af307b7db7"), False),
("Ears Battery",
bytes.fromhex("aa4201"), False),
("Ears Brightness",
bytes.fromhex("aa4203"), False),
("Ears Statue",
bytes.fromhex("aa4205"), False),
("Listen Mode",
b"LISTEN", False),
)Commands are grouped into four CATEGORIES: Colors, Show FX, Fades, and Animate. The grid view shows the category names. The list view shows the commands inside a category. To add a new captured packet to the menu, append a tuple to one of the category lists. The asterisk suffix on a command name marks ones that trigger the band's vibration motor - useful prep cue for the wearer.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''MagicBand+ BLE protocol constants and helpers.
Shared between the CLUE transmitter and the QT Py S3 receiver. Based on the
reverse-engineering work at:
https://emcot.world/Disney_MagicBand%2B_Bluetooth_Codes
All command payloads stored here are the manufacturer-data portion only (the
bytes after the 0x0183 Disney CID). The transmitter prepends the CID bytes
when building a BLE advertisement packet.
'''
# Target: shared between the Adafruit CLUE (BLE remote) and the Adafruit
# QT Py ESP32-S3 (BLE Beacon Ears) - copy this file to both boards.
# Disney's Bluetooth SIG company identifier.
DISNEY_CID = 0x0183
# 5-bit color palette. Values are RGB approximations calibrated for how the
# colors look on a NeoPixel Jewel at low brightness (~0.05). Green and blue
# channels look brighter per unit input than red on WS2812B LEDs, so cyan
# values have their red channel boosted to compensate, and blue hues get
# pushed toward their characteristic hue rather than a balanced RGB.
PALETTE_RGB = (
(80, 255, 255), # 0x00 cyan (red channel boosted so it's not pure teal)
(180, 0, 255), # 0x01 purple
(0, 0, 255), # 0x02 blue
(0, 20, 120), # 0x03 midnight blue (touch of green stops it looking black)
(40, 120, 255), # 0x04 blue 2
(200, 80, 255), # 0x05 bright purple
(200, 180, 255), # 0x06 lavender
(120, 0, 255), # 0x07 deep purple
(255, 60, 180), # 0x08 pink
(255, 70, 170), # 0x09 pink 2
(255, 80, 160), # 0x0A pink 3
(255, 90, 150), # 0x0B pink 4
(255, 110, 150), # 0x0C pink 5
(255, 130, 160), # 0x0D pink 6
(255, 160, 170), # 0x0E pink 7
(255, 180, 0), # 0x0F yellow orange
(255, 220, 0), # 0x10 off yellow
(255, 140, 20), # 0x11 yellow orange 2
(180, 255, 0), # 0x12 lime
(255, 90, 0), # 0x13 orange
(255, 40, 0), # 0x14 red orange
(255, 0, 0), # 0x15 red
(60, 255, 255), # 0x16 cyan 2 (red boost for distinctness from green)
(40, 240, 255), # 0x17 cyan 3
(20, 200, 255), # 0x18 cyan 4 (shifts more toward blue)
(0, 255, 0), # 0x19 green
(80, 255, 40), # 0x1A lime green
(255, 200, 180), # 0x1B white (warm white avoids blue cast at low levels)
(255, 200, 180), # 0x1C white 2
(0, 0, 0), # 0x1D off
(255, 140, 60), # 0x1E unique
(255, 0, 255), # 0x1F random / magenta
)
PALETTE_NAMES = (
"Cyan", "Purple", "Blue", "Midnight Blue",
"Blue 2", "Bright Purple", "Lavender", "Deep Purple",
"Pink", "Pink 2", "Pink 3", "Pink 4",
"Pink 5", "Pink 6", "Pink 7", "Yellow Orange",
"Off Yellow", "Yellow Orange 2", "Lime", "Orange",
"Red Orange", "Red", "Cyan 2", "Cyan 3",
"Cyan 4", "Green", "Lime Green", "White",
"White 2", "Off", "Unique", "Random",
)
# Mask palette: which of the 5 LEDs light up for a given 3-bit mask.
# Tuple order: (center, top_left, top_right, bottom_left, bottom_right)
MASK_LEDS = {
0b000: (1, 1, 1, 1, 1),
0b001: (0, 0, 1, 0, 0),
0b010: (0, 0, 0, 0, 1),
0b011: (0, 0, 0, 1, 0),
0b100: (0, 1, 0, 0, 0),
0b101: (1, 1, 1, 1, 1),
0b110: (0, 0, 1, 0, 0),
0b111: (1, 1, 1, 1, 1),
}
def decode_timing(byte):
'''Turn the timing byte into a dict of animation parameters.'''
scaler_b = bool(byte & 0x40)
time_val = byte & 0x0F
if scaler_b:
seconds = 3.1 * time_val + 5.5
else:
seconds = 1.5 * time_val + 6.5
return {
"always_on": bool(byte & 0x80),
"fade_code": (byte >> 4) & 0x03,
"seconds": seconds,
}
def build_single_color(palette_idx, mask=0, vibration=0, timing=0x09):
'''Build an E9 05 single-color-from-palette command payload.'''
color_byte = ((mask & 0x07) << 5) | (palette_idx & 0x1F)
vib_byte = 0xB0 | (vibration & 0x0F)
return bytes((0xE1, 0x00, 0xE9, 0x05, 0x00, timing, 0x0E,
color_byte, vib_byte))
def build_dual_color(inner_idx, outer_idx, vibration=0, timing=0x22):
'''Build an E9 06 dual-color command payload.
Note: the emcot wiki spec text says the top 3 bits of each color byte
should be 0b100, but the wiki's own example payloads use 0b010. Using
0b100 causes the top-left LED to be masked off (same bits the E9 05
mask palette uses for "top left only"). The correct working value
per the captured examples is 0b010 / 0x40.
'''
inner_byte = 0x40 | (inner_idx & 0x1F)
outer_byte = 0x40 | (outer_idx & 0x1F)
vib_byte = 0xB0 | (vibration & 0x0F)
return bytes((0xE2, 0x00, 0xE9, 0x06, 0x00, timing, 0x0F,
inner_byte, outer_byte, vib_byte))
def build_six_bit_color(red, green, blue, vibration=0, timing=0x0E):
'''Build an E9 08 raw 6-bit RGB command payload.'''
red_byte = (red & 0x3F) << 1
green_byte = (green & 0x3F) << 1
blue_byte = (blue & 0x3F) << 1
vib_byte = 0xB0 | (vibration & 0x0F)
return bytes((0xE1, 0x00, 0xE9, 0x08, 0x00, timing, 0xD2, 0x55,
red_byte, green_byte, blue_byte, vib_byte))
def build_five_color(center, top_left, bottom_left, bottom_right, top_right,
vibration=0, timing=0x0E):
'''Build an E9 09 five-color-palette command payload.
Each of the band's 5 LEDs gets its own palette slot. Order matches the
emcot wiki byte order: center, bottom-left, bottom-right, top-right,
top-left (reading outer ring counter-clockwise from top-left).
'''
def _color_byte(idx):
return 0xA0 | (idx & 0x1F)
vib_byte = 0xB0 | (vibration & 0x0F)
return bytes((0xE1, 0x00, 0xE9, 0x09, 0x00, timing, 0x0F,
_color_byte(top_left),
_color_byte(bottom_left),
_color_byte(bottom_right),
_color_byte(top_right),
_color_byte(center),
vib_byte))
# Starlight Bubble Wand BLE protocol (reverse-engineered April 2026).
# 13-byte packets. First 6 bytes are a fixed signature identifying the
# wand and the "cast color" command. Bytes 6-11 contain a rolling code
# (probably anti-replay authentication) that changes on every broadcast.
# Byte 12 is the palette index - same table as the MagicBand+ palette.
#
# We only check the first 6 bytes to recognize a wand packet. The rolling
# middle bytes cannot be replayed (they would fail the wand's own checks
# if sent back), so we read them but don't try to decode or broadcast
# them ourselves.
WAND_SIGNATURE = bytes.fromhex("cf0b00c42022")
WAND_PAYLOAD_LENGTH = 13
WAND_COLOR_INDEX = 12
def is_wand_packet(payload):
'''Return True if this payload is a Starlight Bubble Wand cast.'''
return (len(payload) == WAND_PAYLOAD_LENGTH
and bytes(payload[:len(WAND_SIGNATURE)]) == WAND_SIGNATURE)
def parse_wand(payload):
'''Decode a wand cast packet into a structured command dict.'''
if not is_wand_packet(payload):
return None
palette_idx = payload[WAND_COLOR_INDEX] & 0x1F
return {
"kind": "wand_cast",
"palette_idx": palette_idx,
"raw": bytes(payload),
}
# Fab 50 statue beacons. The Disney Fab 50 golden statues placed around
# Magic Kingdom broadcast 0xC4 packets to assist guest location services.
# Two sub-formats: C4 10 (18 bytes) and C4 15 (22 bytes). Both contain
# an ASCII 2-digit statue ID at offset 15-16 (e.g. "53", "40", "24").
# Triggering a golden-swirl animation when these are detected gives the
# wearable a thematic "the statue sees you" reaction.
_STATUE_PREFIX = bytes.fromhex("c4")
def _is_statue_beacon(payload):
'''Return True if this payload looks like a Fab 50 statue beacon.'''
if not payload or payload[0] != 0xC4:
return False
# Two known formats: C4 10 (18 bytes) and C4 15 (23 bytes)
return len(payload) in (18, 23)
def _parse_statue_beacon(payload):
'''Decode a statue beacon to extract its 2-digit ASCII identifier.'''
statue_id = "?"
# Statue ID is at offset 15-16 in both 18- and 22-byte variants
if len(payload) >= 17:
try:
statue_id = bytes(payload[15:17]).decode("ascii")
except (UnicodeError, ValueError):
statue_id = "?"
return {
"kind": "statue_beacon",
"statue_id": statue_id,
"raw": bytes(payload),
}
# Park show command opcodes - direct E9/EA family with no E1 00 wrapper.
# Captured from Disney park show infrastructure (Epcot, April 2026). These
# coexist with guest-fired E1/E2 commands but use a different byte layout.
# Long-format variants (E9 10, E9 13, EA 14) share a `f4 48 82` signature
# in the middle of the payload; their byte structure isn't fully decoded
# yet. The E9 08 short form decodes cleanly as a 5-slot palette command.
_SHOW_OPCODE_LABELS = {
(0xE9, 0x04): "E9 04",
(0xE9, 0x08): "E9 08 5-slot",
(0xE9, 0x10): "E9 10",
(0xE9, 0x13): "E9 13",
(0xEA, 0x14): "EA 14",
}
def _parse_show_command(payload):
'''Parse a direct E9/EA show packet captured from park infrastructure.'''
if len(payload) < 2:
return None
head = payload[0]
sub = payload[1]
label = _SHOW_OPCODE_LABELS.get((head, sub))
if label is None:
return None
# E9 08 short form is a 5-slot palette command. Bytes 5-9 are masked
# with 0x1F to extract palette indices, identical to the existing E9
# 09 five-color decode. Confirmed by capture 9 (blue green) decoding
# to Cyan/Blue 2/Green/Green/Blue 2 - matching the observed color.
slots = None
if (head == 0xE9 and sub == 0x08
and len(payload) >= 10 and payload[4] == 0x0F):
slots = [payload[5 + i] & 0x1F for i in range(5)]
return {
"kind": "show_command",
"label": label,
"head": head,
"sub": sub,
"slots": slots,
"raw": bytes(payload),
}
def _parse_by_head(payload):
'''Decode a payload that's not a wand cast or statue beacon.'''
head = payload[0]
if head == 0xCC:
return {"kind": "ping", "raw": payload}
if head in (0xE9, 0xEA):
show_cmd = _parse_show_command(payload)
if show_cmd is not None:
return show_cmd
if head in (0xE1, 0xE2):
return _parse_e1_e2(payload)
return {"kind": "unknown", "raw": payload}
def parse(payload):
'''Decode a manufacturer-data payload into a structured command dict.
Used by the QT Py receiver to interpret commands from MagicBands, the
CLUE remote, the Starlight Bubble Wand, and Disney park infrastructure
(Fab 50 statues, parade beacons).
'''
if not payload:
return None
# Wand packets have a distinctive 6-byte header signature
wand = parse_wand(payload)
if wand is not None:
return wand
# Fab 50 statue beacons (Magic Kingdom hub area)
if _is_statue_beacon(payload):
return _parse_statue_beacon(payload)
return _parse_by_head(payload)
def _parse_single_color(payload):
color_byte = payload[7]
return {
"kind": "single_color",
"mask": (color_byte >> 5) & 0x07,
"palette_idx": color_byte & 0x1F,
"timing": decode_timing(payload[5]),
"vibration": payload[8] & 0x0F,
}
def _parse_dual_color(payload):
return {
"kind": "dual_color",
"inner_idx": payload[7] & 0x1F,
"outer_idx": payload[8] & 0x1F,
"timing": decode_timing(payload[5]),
"vibration": payload[9] & 0x0F,
}
def _parse_six_bit(payload):
return {
"kind": "six_bit_color",
"red": (payload[8] >> 1) & 0x3F,
"green": (payload[9] >> 1) & 0x3F,
"blue": (payload[10] >> 1) & 0x3F,
"timing": decode_timing(payload[5]),
"vibration": payload[11] & 0x0F,
}
def _parse_five_color(payload):
'''E9 09 layout: TL BL BR TR C VIB starting at index 7.'''
return {
"kind": "five_color",
"top_left": payload[7] & 0x1F,
"bottom_left": payload[8] & 0x1F,
"bottom_right": payload[9] & 0x1F,
"top_right": payload[10] & 0x1F,
"center": payload[11] & 0x1F,
"timing": decode_timing(payload[5]),
"vibration": payload[12] & 0x0F,
}
# Function-code dispatch for E1/E2-wrapped payloads. Each entry maps
# the 2-byte function code (payload[2]<<8 | payload[3]) to (min_length,
# parser_or_kind). When the parser slot is a callable, it's invoked with
# the payload; when it's a string, a generic {"kind": ..., "raw": ...}
# dict is returned. Defined at module bottom so all _parse_* helpers
# already exist when this dict is built at import time.
_FUNC_CODE_DISPATCH = {
0xE905: (9, _parse_single_color),
0xE906: (10, _parse_dual_color),
0xE908: (12, _parse_six_bit),
0xE909: (13, _parse_five_color),
0xE90C: (5, "show_fx"),
0xE911: (5, "cross_fade"),
# Newer parade/show command not in our protocol docs. We can't
# decode the colors but still want the ears to react visibly.
0xCD07: (5, "parade_command"),
}
def _parse_e1_e2(payload):
'''Decode an E1/E2-wrapped payload by its function code.'''
if len(payload) < 5:
return {"kind": "unknown", "raw": payload}
func = (payload[2] << 8) | payload[3]
entry = _FUNC_CODE_DISPATCH.get(func)
if entry is None:
return {"kind": "animation", "func": func, "raw": payload}
min_len, handler = entry
if len(payload) < min_len:
return {"kind": "animation", "func": func, "raw": payload}
if callable(handler):
return handler(payload)
return {"kind": handler, "raw": payload}
Building a Color Command
Most commands are constructed at module load time using helper functions from magicband_protocol.py. The single-color and dual-color builders take palette indices and produce the raw bytes the band's firmware expects.
DUAL_COLOR = (
("Red & Blue", build_dual_color(0x15, 0x02), True),
("Orange & Cyan", build_dual_color(0x13, 0x16), False),
("Pink & Lime", build_dual_color(0x08, 0x12), True),
("Purple & Yellow", build_dual_color(0x01, 0x0F), True),
...
)Compare this with the firmware-baked SHOW_FX entries: those use raw bytes.fromhex() hex strings instead of helper builders, because their payloads are program IDs to specific firmware animations rather than synthesizable from palette indices.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''MagicBand+ BLE transmitter for the Adafruit CLUE.
Wraps _bleio.adapter to broadcast raw advertisement packets with Disney's
0x0183 manufacturer company identifier. Works directly with the BLE stack
on the nRF52840 without going through adafruit_ble's Advertisement classes.
'''
# Target: Adafruit CLUE (nRF52840) - the BLE remote
import time
import _bleio
from magicband_protocol import DISNEY_CID
# BLE advertising interval in seconds. CircuitPython requires this to be
# in the range 0.02-10.24. We use 0.025 instead of 0.02 because float
# precision can cause 0.02 to internally evaluate as slightly less than
# the minimum, raising "interval must be in range" ValueError.
_AD_INTERVAL = 0.025
# Default broadcast duration. MagicBands latch a command within the first
# ~second, but the timing byte in the payload controls the actual fade so
# we can stop advertising well before the animation finishes.
_BROADCAST_SECONDS = 3.0
def _build_advertisement(payload):
'''Assemble a 31-byte BLE advertisement packet with Disney manufacturer data.'''
cid_lo = DISNEY_CID & 0xFF
cid_hi = (DISNEY_CID >> 8) & 0xFF
mfr_field_len = 3 + len(payload)
return bytes((
0x02, 0x01, 0x06, # Flags AD: LE General Discoverable
mfr_field_len, 0xFF, cid_lo, cid_hi, # Manufacturer data header
)) + payload
def broadcast(payload, duration=_BROADCAST_SECONDS):
'''Advertise a MagicBand+ manufacturer-data payload for duration seconds.'''
packet = _build_advertisement(payload)
adapter = _bleio.adapter
if not adapter.enabled:
adapter.enabled = True
if adapter.advertising:
adapter.stop_advertising()
adapter.start_advertising(packet, connectable=False, interval=_AD_INTERVAL)
time.sleep(duration)
adapter.stop_advertising()
Broadcasting a BLE Advert
The ble_transmitter.py module wraps bleio.adapter directly to send raw 31-byte BLE adverts. We don't go through adafruitble's Advertisement classes because we need full control over the manufacturer-data bytes.
def broadcast(payload, duration=_BROADCAST_SECONDS):
packet = _build_advertisement(payload)
adapter = _bleio.adapter
if not adapter.enabled:
adapter.enabled = True
if adapter.advertising:
adapter.stop_advertising()
adapter.start_advertising(packet, connectable=False, interval=_AD_INTERVAL)
time.sleep(duration)
adapter.stop_advertising()The default broadcast duration is 3 seconds. MagicBands latch a command within the first second, but the timing byte in the payload controls the actual fade so we can stop advertising well before the animation finishes. The ad interval is 25 milliseconds, the closest valid value to the BLE minimum that doesn't trip a CircuitPython float-precision edge case.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''CLUE boot configuration: optional Python-writable filesystem.
CircuitPython by default mounts CIRCUITPY as read-only when USB is
connected, so Python can't write files. To capture BLE packets to a
file (Listen Mode in code.py) while USB is connected, we need to flip
that.
Two ways to enter capture mode:
1. **Marker file**: drop a file named `capture_mode.txt` onto the
CIRCUITPY drive while USB is connected, then reset. boot.py sees
the marker and remounts the filesystem as Python-writable.
2. **NVM flag**: code.py can request "next boot in capture mode" via
a byte in microcontroller.nvm. This survives reboots and works
regardless of who currently owns the filesystem. boot.py also
creates the marker file in this case so the user can see the
mode is engaged.
To exit capture mode: open the REPL and run
import os; os.remove("/capture_mode.txt")
then reset. The filesystem returns to host-writable.
'''
# Target: Adafruit CLUE (nRF52840) - the BLE remote
import os
import storage
import microcontroller
_MARKER = "/capture_mode.txt"
_NVM_FLAG_BYTE = 0 # NVM byte 0: 1 = request capture mode on this boot
marker_present = False
try:
os.stat(_MARKER)
marker_present = True
except OSError:
pass
# NVM-requested capture mode: code.py wrote 1 to byte 0 to ask for
# capture mode on this boot. Honor it by remounting writable and
# creating the marker file (which clears the NVM flag for next time).
nvm_request = microcontroller.nvm[_NVM_FLAG_BYTE] == 1
if marker_present:
storage.remount("/", readonly=False)
print("[boot] Capture mode (marker file present)")
elif nvm_request:
storage.remount("/", readonly=False)
# Create the marker file so user can SEE that capture mode is active.
# Content includes the literal REPL commands to undo it - paste-ready
# without leading whitespace, so users who open the file in any text
# editor can copy/paste directly into the serial REPL.
try:
with open(_MARKER, "w", encoding="utf-8") as f:
f.write(
"Capture mode active.\n"
"This file makes CIRCUITPY Python-writable so Listen Mode\n"
"can save captures. While this file exists, you CANNOT\n"
"drag-drop new code onto the drive.\n"
"\n"
"To return to dev mode (drag-drop), open the serial REPL,\n"
"press Ctrl+C to interrupt, then paste these lines:\n"
"\n"
"import os\n"
"os.remove(\"/capture_mode.txt\")\n"
"\n"
"Then reset the CLUE.\n"
)
# Clear the NVM flag - we honored it
microcontroller.nvm[_NVM_FLAG_BYTE] = 0
print("[boot] Capture mode (NVM-requested, marker created)")
except OSError as err:
print(f"[boot] Capture mode requested but write failed: {err}")
else:
print("[boot] Dev mode: USB host has filesystem write access")
Listen Mode
Holding A and B together for 0.8 seconds enters Listen Mode. The remote stops broadcasting, starts BLE scanning, and dedupes incoming Disney-CID packets into a dictionary keyed by raw payload bytes. When the user long-presses B to stop, the captures are written to /captures/listen_NNN.txt on the CIRCUITPY drive.
def _listen_capture_loop(seen):
...
while True:
button_b.update()
if button_b.long_press:
break
try:
for entry in adapter.start_scan(
interval=0.04, window=0.04,
minimum_rssi=-100, timeout=0.2):
payload = _extract_disney_payload(entry.advertisement_bytes)
if payload is None:
continue
key = bytes(payload)
if key in seen:
seen[key][1] += 1
else:
seen[key] = [time.monotonic() - start_time, 1, entry.rssi]
finally:
adapter.stop_scan()
...Filesystem writes from Python only work if boot.py remounted the CIRCUITPY drive as Python-writable. To switch into capture mode, drop a file named capture_mode.txt on the drive and reset the CLUE - or let Listen Mode itself set the NVM flag and ask you to reset. To switch back to dev mode, delete the marker file via the REPL and reset.
Shake-to-Fire
The CLUE's onboard LSM6DS33 accelerometer enables a fun shake-fire-random feature. The handler reads acceleration on every loop and triggers a confirm modal with a random command if the magnitude exceeds a threshold and a cooldown has elapsed.
def shake_magnitude():
x, y, z = accel.acceleration
return math.sqrt(x * x + y * y + z * z) - 9.81 # subtract gravity
if shake_magnitude() > _SHAKE_THRESHOLD and now - last_shake_time > _SHAKE_COOLDOWN:
_cat_idx, command = pick_random_command()
enter_confirm(command[0])The threshold is in meters per second squared after subtracting earth's gravity. Adjust _ SHAKE _THRESHOLD down if you want a softer shake to trigger, or up if you keep getting accidental fires while walking.
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.
There are two versions of this board: one with 8MB Flash/No PSRAM and one with 4MB Flash/2MB PSRAM.
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.
If after several tries, and verifying your USB cable is data-ready, you still cannot get to the bootloader, it is possible that the bootloader is missing or damaged. Check out the Factory Reset page for details on resolving this issue.
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.
The BOOT drive will disappear, and a new disk drive called CIRCUITPY will appear.
That's it!
QT Py Code
This is the firmware that runs on the QT Py ESP32-S3 inside the ear headband. It scans for Disney BLE adverts, decodes them with a shared protocol module, and renders matching animations on the two NeoPixel Jewels.
To program your QT Py for the Beacon Ears, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.
Copy Code
Plug your QT Py ESP32-S3 into your computer with a known-good USB-C cable. The CIRCUITPY drive should show up as a USB drive. Copy code.py, renderer.py, magicband_protocol.py, pixel_zones.py, and battery.py to the root of the CIRCUITPY drive. Then copy the contents of the bundle's lib folder to the lib folder on your CIRCUITPY drive.
The required libraries are adafruit_debouncer.mpy and neopixel.mpy.
How It Works
The firmware is split across five files. code.py is the main scan-and-render loop. magicband_protocol.py decodes Disney BLE packet bytes into structured command dicts. renderer.py turns those command dicts into per-frame animation states. pixel_zones.py abstracts the two NeoPixel Jewels as left and right ear zones. battery.py reads the LiPo voltage divider so the on-demand battery animation knows what to show.
Disney's BLE Manufacturer Adverts
Every MagicBand+, Starlight Bubble Wand, and Fab 50 statue at the parks broadcasts standard Bluetooth Low Energy advertisements with Disney's manufacturer company identifier (CID) 0x0183. Any phone with a BLE scanner app can see these. The receiver listens for that CID and pulls out the manufacturer-data payload.
DISNEY_CID = 0x0183
Most of the original codes were documented at the emcot.world wiki. We extended the catalog with new captures from Magic Kingdom and Epcot using the CLUE remote's Listen Mode.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''MagicBand+ BLE protocol constants and helpers.
Shared between the CLUE transmitter and the QT Py S3 receiver. Based on the
reverse-engineering work at:
https://emcot.world/Disney_MagicBand%2B_Bluetooth_Codes
All command payloads stored here are the manufacturer-data portion only (the
bytes after the 0x0183 Disney CID). The transmitter prepends the CID bytes
when building a BLE advertisement packet.
'''
# Target: shared between the Adafruit CLUE (BLE remote) and the Adafruit
# QT Py ESP32-S3 (BLE Beacon Ears) - copy this file to both boards.
# Disney's Bluetooth SIG company identifier.
DISNEY_CID = 0x0183
# 5-bit color palette. Values are RGB approximations calibrated for how the
# colors look on a NeoPixel Jewel at low brightness (~0.05). Green and blue
# channels look brighter per unit input than red on WS2812B LEDs, so cyan
# values have their red channel boosted to compensate, and blue hues get
# pushed toward their characteristic hue rather than a balanced RGB.
PALETTE_RGB = (
(80, 255, 255), # 0x00 cyan (red channel boosted so it's not pure teal)
(180, 0, 255), # 0x01 purple
(0, 0, 255), # 0x02 blue
(0, 20, 120), # 0x03 midnight blue (touch of green stops it looking black)
(40, 120, 255), # 0x04 blue 2
(200, 80, 255), # 0x05 bright purple
(200, 180, 255), # 0x06 lavender
(120, 0, 255), # 0x07 deep purple
(255, 60, 180), # 0x08 pink
(255, 70, 170), # 0x09 pink 2
(255, 80, 160), # 0x0A pink 3
(255, 90, 150), # 0x0B pink 4
(255, 110, 150), # 0x0C pink 5
(255, 130, 160), # 0x0D pink 6
(255, 160, 170), # 0x0E pink 7
(255, 180, 0), # 0x0F yellow orange
(255, 220, 0), # 0x10 off yellow
(255, 140, 20), # 0x11 yellow orange 2
(180, 255, 0), # 0x12 lime
(255, 90, 0), # 0x13 orange
(255, 40, 0), # 0x14 red orange
(255, 0, 0), # 0x15 red
(60, 255, 255), # 0x16 cyan 2 (red boost for distinctness from green)
(40, 240, 255), # 0x17 cyan 3
(20, 200, 255), # 0x18 cyan 4 (shifts more toward blue)
(0, 255, 0), # 0x19 green
(80, 255, 40), # 0x1A lime green
(255, 200, 180), # 0x1B white (warm white avoids blue cast at low levels)
(255, 200, 180), # 0x1C white 2
(0, 0, 0), # 0x1D off
(255, 140, 60), # 0x1E unique
(255, 0, 255), # 0x1F random / magenta
)
PALETTE_NAMES = (
"Cyan", "Purple", "Blue", "Midnight Blue",
"Blue 2", "Bright Purple", "Lavender", "Deep Purple",
"Pink", "Pink 2", "Pink 3", "Pink 4",
"Pink 5", "Pink 6", "Pink 7", "Yellow Orange",
"Off Yellow", "Yellow Orange 2", "Lime", "Orange",
"Red Orange", "Red", "Cyan 2", "Cyan 3",
"Cyan 4", "Green", "Lime Green", "White",
"White 2", "Off", "Unique", "Random",
)
# Mask palette: which of the 5 LEDs light up for a given 3-bit mask.
# Tuple order: (center, top_left, top_right, bottom_left, bottom_right)
MASK_LEDS = {
0b000: (1, 1, 1, 1, 1),
0b001: (0, 0, 1, 0, 0),
0b010: (0, 0, 0, 0, 1),
0b011: (0, 0, 0, 1, 0),
0b100: (0, 1, 0, 0, 0),
0b101: (1, 1, 1, 1, 1),
0b110: (0, 0, 1, 0, 0),
0b111: (1, 1, 1, 1, 1),
}
def decode_timing(byte):
'''Turn the timing byte into a dict of animation parameters.'''
scaler_b = bool(byte & 0x40)
time_val = byte & 0x0F
if scaler_b:
seconds = 3.1 * time_val + 5.5
else:
seconds = 1.5 * time_val + 6.5
return {
"always_on": bool(byte & 0x80),
"fade_code": (byte >> 4) & 0x03,
"seconds": seconds,
}
def build_single_color(palette_idx, mask=0, vibration=0, timing=0x09):
'''Build an E9 05 single-color-from-palette command payload.'''
color_byte = ((mask & 0x07) << 5) | (palette_idx & 0x1F)
vib_byte = 0xB0 | (vibration & 0x0F)
return bytes((0xE1, 0x00, 0xE9, 0x05, 0x00, timing, 0x0E,
color_byte, vib_byte))
def build_dual_color(inner_idx, outer_idx, vibration=0, timing=0x22):
'''Build an E9 06 dual-color command payload.
Note: the emcot wiki spec text says the top 3 bits of each color byte
should be 0b100, but the wiki's own example payloads use 0b010. Using
0b100 causes the top-left LED to be masked off (same bits the E9 05
mask palette uses for "top left only"). The correct working value
per the captured examples is 0b010 / 0x40.
'''
inner_byte = 0x40 | (inner_idx & 0x1F)
outer_byte = 0x40 | (outer_idx & 0x1F)
vib_byte = 0xB0 | (vibration & 0x0F)
return bytes((0xE2, 0x00, 0xE9, 0x06, 0x00, timing, 0x0F,
inner_byte, outer_byte, vib_byte))
def build_six_bit_color(red, green, blue, vibration=0, timing=0x0E):
'''Build an E9 08 raw 6-bit RGB command payload.'''
red_byte = (red & 0x3F) << 1
green_byte = (green & 0x3F) << 1
blue_byte = (blue & 0x3F) << 1
vib_byte = 0xB0 | (vibration & 0x0F)
return bytes((0xE1, 0x00, 0xE9, 0x08, 0x00, timing, 0xD2, 0x55,
red_byte, green_byte, blue_byte, vib_byte))
def build_five_color(center, top_left, bottom_left, bottom_right, top_right,
vibration=0, timing=0x0E):
'''Build an E9 09 five-color-palette command payload.
Each of the band's 5 LEDs gets its own palette slot. Order matches the
emcot wiki byte order: center, bottom-left, bottom-right, top-right,
top-left (reading outer ring counter-clockwise from top-left).
'''
def _color_byte(idx):
return 0xA0 | (idx & 0x1F)
vib_byte = 0xB0 | (vibration & 0x0F)
return bytes((0xE1, 0x00, 0xE9, 0x09, 0x00, timing, 0x0F,
_color_byte(top_left),
_color_byte(bottom_left),
_color_byte(bottom_right),
_color_byte(top_right),
_color_byte(center),
vib_byte))
# Starlight Bubble Wand BLE protocol (reverse-engineered April 2026).
# 13-byte packets. First 6 bytes are a fixed signature identifying the
# wand and the "cast color" command. Bytes 6-11 contain a rolling code
# (probably anti-replay authentication) that changes on every broadcast.
# Byte 12 is the palette index - same table as the MagicBand+ palette.
#
# We only check the first 6 bytes to recognize a wand packet. The rolling
# middle bytes cannot be replayed (they would fail the wand's own checks
# if sent back), so we read them but don't try to decode or broadcast
# them ourselves.
WAND_SIGNATURE = bytes.fromhex("cf0b00c42022")
WAND_PAYLOAD_LENGTH = 13
WAND_COLOR_INDEX = 12
def is_wand_packet(payload):
'''Return True if this payload is a Starlight Bubble Wand cast.'''
return (len(payload) == WAND_PAYLOAD_LENGTH
and bytes(payload[:len(WAND_SIGNATURE)]) == WAND_SIGNATURE)
def parse_wand(payload):
'''Decode a wand cast packet into a structured command dict.'''
if not is_wand_packet(payload):
return None
palette_idx = payload[WAND_COLOR_INDEX] & 0x1F
return {
"kind": "wand_cast",
"palette_idx": palette_idx,
"raw": bytes(payload),
}
# Fab 50 statue beacons. The Disney Fab 50 golden statues placed around
# Magic Kingdom broadcast 0xC4 packets to assist guest location services.
# Two sub-formats: C4 10 (18 bytes) and C4 15 (22 bytes). Both contain
# an ASCII 2-digit statue ID at offset 15-16 (e.g. "53", "40", "24").
# Triggering a golden-swirl animation when these are detected gives the
# wearable a thematic "the statue sees you" reaction.
_STATUE_PREFIX = bytes.fromhex("c4")
def _is_statue_beacon(payload):
'''Return True if this payload looks like a Fab 50 statue beacon.'''
if not payload or payload[0] != 0xC4:
return False
# Two known formats: C4 10 (18 bytes) and C4 15 (23 bytes)
return len(payload) in (18, 23)
def _parse_statue_beacon(payload):
'''Decode a statue beacon to extract its 2-digit ASCII identifier.'''
statue_id = "?"
# Statue ID is at offset 15-16 in both 18- and 22-byte variants
if len(payload) >= 17:
try:
statue_id = bytes(payload[15:17]).decode("ascii")
except (UnicodeError, ValueError):
statue_id = "?"
return {
"kind": "statue_beacon",
"statue_id": statue_id,
"raw": bytes(payload),
}
# Park show command opcodes - direct E9/EA family with no E1 00 wrapper.
# Captured from Disney park show infrastructure (Epcot, April 2026). These
# coexist with guest-fired E1/E2 commands but use a different byte layout.
# Long-format variants (E9 10, E9 13, EA 14) share a `f4 48 82` signature
# in the middle of the payload; their byte structure isn't fully decoded
# yet. The E9 08 short form decodes cleanly as a 5-slot palette command.
_SHOW_OPCODE_LABELS = {
(0xE9, 0x04): "E9 04",
(0xE9, 0x08): "E9 08 5-slot",
(0xE9, 0x10): "E9 10",
(0xE9, 0x13): "E9 13",
(0xEA, 0x14): "EA 14",
}
def _parse_show_command(payload):
'''Parse a direct E9/EA show packet captured from park infrastructure.'''
if len(payload) < 2:
return None
head = payload[0]
sub = payload[1]
label = _SHOW_OPCODE_LABELS.get((head, sub))
if label is None:
return None
# E9 08 short form is a 5-slot palette command. Bytes 5-9 are masked
# with 0x1F to extract palette indices, identical to the existing E9
# 09 five-color decode. Confirmed by capture 9 (blue green) decoding
# to Cyan/Blue 2/Green/Green/Blue 2 - matching the observed color.
slots = None
if (head == 0xE9 and sub == 0x08
and len(payload) >= 10 and payload[4] == 0x0F):
slots = [payload[5 + i] & 0x1F for i in range(5)]
return {
"kind": "show_command",
"label": label,
"head": head,
"sub": sub,
"slots": slots,
"raw": bytes(payload),
}
def _parse_by_head(payload):
'''Decode a payload that's not a wand cast or statue beacon.'''
head = payload[0]
if head == 0xCC:
return {"kind": "ping", "raw": payload}
if head in (0xE9, 0xEA):
show_cmd = _parse_show_command(payload)
if show_cmd is not None:
return show_cmd
if head in (0xE1, 0xE2):
return _parse_e1_e2(payload)
return {"kind": "unknown", "raw": payload}
def parse(payload):
'''Decode a manufacturer-data payload into a structured command dict.
Used by the QT Py receiver to interpret commands from MagicBands, the
CLUE remote, the Starlight Bubble Wand, and Disney park infrastructure
(Fab 50 statues, parade beacons).
'''
if not payload:
return None
# Wand packets have a distinctive 6-byte header signature
wand = parse_wand(payload)
if wand is not None:
return wand
# Fab 50 statue beacons (Magic Kingdom hub area)
if _is_statue_beacon(payload):
return _parse_statue_beacon(payload)
return _parse_by_head(payload)
def _parse_single_color(payload):
color_byte = payload[7]
return {
"kind": "single_color",
"mask": (color_byte >> 5) & 0x07,
"palette_idx": color_byte & 0x1F,
"timing": decode_timing(payload[5]),
"vibration": payload[8] & 0x0F,
}
def _parse_dual_color(payload):
return {
"kind": "dual_color",
"inner_idx": payload[7] & 0x1F,
"outer_idx": payload[8] & 0x1F,
"timing": decode_timing(payload[5]),
"vibration": payload[9] & 0x0F,
}
def _parse_six_bit(payload):
return {
"kind": "six_bit_color",
"red": (payload[8] >> 1) & 0x3F,
"green": (payload[9] >> 1) & 0x3F,
"blue": (payload[10] >> 1) & 0x3F,
"timing": decode_timing(payload[5]),
"vibration": payload[11] & 0x0F,
}
def _parse_five_color(payload):
'''E9 09 layout: TL BL BR TR C VIB starting at index 7.'''
return {
"kind": "five_color",
"top_left": payload[7] & 0x1F,
"bottom_left": payload[8] & 0x1F,
"bottom_right": payload[9] & 0x1F,
"top_right": payload[10] & 0x1F,
"center": payload[11] & 0x1F,
"timing": decode_timing(payload[5]),
"vibration": payload[12] & 0x0F,
}
# Function-code dispatch for E1/E2-wrapped payloads. Each entry maps
# the 2-byte function code (payload[2]<<8 | payload[3]) to (min_length,
# parser_or_kind). When the parser slot is a callable, it's invoked with
# the payload; when it's a string, a generic {"kind": ..., "raw": ...}
# dict is returned. Defined at module bottom so all _parse_* helpers
# already exist when this dict is built at import time.
_FUNC_CODE_DISPATCH = {
0xE905: (9, _parse_single_color),
0xE906: (10, _parse_dual_color),
0xE908: (12, _parse_six_bit),
0xE909: (13, _parse_five_color),
0xE90C: (5, "show_fx"),
0xE911: (5, "cross_fade"),
# Newer parade/show command not in our protocol docs. We can't
# decode the colors but still want the ears to react visibly.
0xCD07: (5, "parade_command"),
}
def _parse_e1_e2(payload):
'''Decode an E1/E2-wrapped payload by its function code.'''
if len(payload) < 5:
return {"kind": "unknown", "raw": payload}
func = (payload[2] << 8) | payload[3]
entry = _FUNC_CODE_DISPATCH.get(func)
if entry is None:
return {"kind": "animation", "func": func, "raw": payload}
min_len, handler = entry
if len(payload) < min_len:
return {"kind": "animation", "func": func, "raw": payload}
if callable(handler):
return handler(payload)
return {"kind": handler, "raw": payload}
The 32-Color Palette
MagicBand+ commands reference a fixed 5-bit palette built into the band's firmware. Most commands send palette indices rather than raw RGB values. We mirror the same palette in magicband_protocol.py with calibrated RGB values that look right on a NeoPixel Jewel at low brightness.
PALETTE_RGB = (
(80, 255, 255), # 0x00 cyan (red boost so it's not pure teal)
(180, 0, 255), # 0x01 purple
(0, 0, 255), # 0x02 blue
(0, 20, 120), # 0x03 midnight blue
...
(0, 255, 0), # 0x19 green
(80, 255, 40), # 0x1A lime green
(255, 200, 180), # 0x1B white (warm white)
...
(255, 0, 255), # 0x1F random / magenta
)Green and blue channels look brighter per unit input than red on WS2812B LEDs, so cyan values have their red channel boosted to compensate. White is biased warm to avoid a blue cast at low brightness levels. Edit PALETTE_RGB if you want to retune any colors for your specific Jewels.
Decoding a Packet
The parse() function in magicband_protocol.py takes a manufacturer-data payload and returns a dict describing what kind of command it is. The first byte of the payload selects the family. 0xCF is a Starlight Bubble Wand cast. 0xC4 is a Fab 50 statue beacon. 0xCC is the wake-ping that park beacons broadcast continuously. 0xE9 and 0xEA are park show packets like Epcot's stage lighting. 0xE1 and 0xE2 wrap the most common guest-facing animation commands with a function code in bytes 2 and 3.
def parse(payload):
if not payload:
return None
wand = parse_wand(payload)
if wand is not None:
return wand
if _is_statue_beacon(payload):
return _parse_statue_beacon(payload)
return _parse_by_head(payload)Each branch returns a dict like {"kind": "single_color", "palette_idx": 0x15, "mask": 0, "timing": ..., "vibration": 0}. The renderer never touches raw bytes - it dispatches on the kind field.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''Pixel zone abstraction for the BLE Beacon Ears project.
A "zone" represents one ear. Each ear is driven by its own 7-pixel
Jewel on an independent data pin so the renderer can output stereo
effects (left-leads-right rotations, out-of-phase breathing).
The API is intentionally minimal:
zone.fill(rgb) - solid color across all pixels
zone.set_led(idx, rgb) - write a specific pixel
zone.count - number of pixels in the zone
zone.show() - flush to hardware (no-op if auto_write)
The renderer writes to both zones each frame and calls show() once at frame
end. Double-buffering is not needed at 15fps - a torn frame would be
invisible to the eye.
'''
# Target: Adafruit QT Py ESP32-S3 - the BLE Beacon Ears
import neopixel
class StereoJewels:
'''Production mode: two 7-pixel Jewels on independent data pins.
Supports an idle-skip optimization: once both jewels have been shown
as all-black, subsequent show() calls are no-ops until pixel data
actually changes. This avoids unnecessary data-stream activity and
lets the WS2812 chips stay in their lowest-current latched state.
'''
def __init__(self, left_pin, right_pin, brightness=0.1):
self._left = neopixel.NeoPixel(
left_pin, 7, brightness=brightness, auto_write=False)
self._right = neopixel.NeoPixel(
right_pin, 7, brightness=brightness, auto_write=False)
self._left.fill((0, 0, 0))
self._right.fill((0, 0, 0))
self._left.show()
self._right.show()
self._last_shown_black = True
def make_zones(self):
'''Return (left_zone, right_zone) wrapping each Jewel separately.'''
return _JewelZone(self._left), _JewelZone(self._right)
def set_brightness(self, brightness):
'''Change the brightness of both jewels at runtime.'''
self._left.brightness = brightness
self._right.brightness = brightness
self._last_shown_black = False # force next show() to push new values
def _all_black(self):
'''Return True if every pixel on both jewels is currently (0,0,0).'''
for i in range(7):
if self._left[i] != (0, 0, 0):
return False
if self._right[i] != (0, 0, 0):
return False
return True
def show(self):
'''Flush both Jewel buffers to hardware, with idle-skip optimization.
If we've already shown all-black once and nothing has changed to
non-black since, skip the data stream to save power. The WS2812
chips latch their last color state and stay in quiescent mode
until new data arrives.
'''
if self._all_black():
if self._last_shown_black:
return
self._left.show()
self._right.show()
self._last_shown_black = True
else:
self._left.show()
self._right.show()
self._last_shown_black = False
class _JewelZone:
'''Zone backed by a dedicated 7-pixel Jewel (StereoJewels mode).'''
count = 7
def __init__(self, pixel_obj):
self._pixel = pixel_obj
def fill(self, rgb):
'''Set every pixel on this Jewel to the given color.'''
self._pixel.fill(rgb)
def set_led(self, idx, rgb):
'''Write a specific pixel index (0-6) on this Jewel.'''
if 0 <= idx < 7:
self._pixel[idx] = rgb
Stereo Ear Zones
The pixel_zones.py module wraps the two NeoPixel Jewels as separate "zones" with the same minimal API.
zone.fill(rgb) # solid color across all pixels zone.set_led(idx, rgb) # write a specific pixel zone.count # number of pixels in the zone zone.show() # flush to hardware
The renderer writes to both zones each frame and calls show() once at the end. Animations apply a stereo phase offset between the two zones so static colors get a gentle out-of-phase breathing animation and rotations get a left-leads-right sweep.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''Animation renderer for MagicBand+ commands.
Given a parsed command dict from magicband_protocol, produces an
AnimationState that the game loop can render over time. The state is a
small dict holding:
started_at (seconds) - time.monotonic() when command received
duration_s (float or None) - how long until animation stops; None = forever
render(zones, t) - callback that paints a frame at time t
Design rules:
- Renderer NEVER blocks. All timing is derived from t (time since start).
- All palette colors are looked up via magicband_protocol.PALETTE_RGB.
- Stereo effects are applied at render time via per-zone phase offset.
- When no active animation exists, zones are filled black.
'''
# Target: Adafruit QT Py ESP32-S3 - the BLE Beacon Ears
import math
import magicband_protocol
# Breathing envelope - applied to static colors to add life.
# Amplitude 0.45 means brightness oscillates between 55% and 100% of target.
# This is more pronounced than a subtle 25% amplitude would be - on 7-pixel
# jewels with full color, you need more contrast to read as "breathing."
_BREATH_PERIOD_S = 2.5
_BREATH_AMPLITUDE = 0.45
# Rotation period for animations without explicit timing cues.
_ROTATE_PERIOD_S = 2.0
# Default duration when a command has no parseable timing byte.
_DEFAULT_DURATION_S = 10.0
# Cross-fade period for dual-color alternation on pixel prototype.
_DUAL_ALTERNATE_PERIOD_S = 1.2
def _scale_rgb(rgb, factor):
'''Multiply each channel by factor (0.0-1.0) and clamp.'''
return (
min(255, max(0, int(rgb[0] * factor))),
min(255, max(0, int(rgb[1] * factor))),
min(255, max(0, int(rgb[2] * factor))),
)
def _breath_factor(t, phase=0.0):
'''Return 0..1 brightness multiplier that breathes gently over time.'''
# Sinusoid, output range (1 - amp) to 1.0.
wave = 0.5 + 0.5 * math.sin(
2 * math.pi * (t / _BREATH_PERIOD_S + phase))
return (1.0 - _BREATH_AMPLITUDE) + _BREATH_AMPLITUDE * wave
def for_command(command):
'''Build an AnimationState from a parsed command dict.'''
kind = command.get("kind", "unknown")
handler = _COMMAND_HANDLERS.get(kind)
if handler is None:
return None
if kind == "ping":
return handler()
return handler(command)
def _state_parade_command(_command):
'''CD 07 / parade beacon - colors not decoded.
Newer Disney park show commands (Starlight Parade etc.) use formats
we haven't reverse-engineered. Rather than ignoring them and being
silent during the show, render a generic rainbow rotation so the
ears at least respond visibly. The wearer sees that the show IS
triggering them, just not in the exact same way as a real band.
'''
rainbow_colors = (
(255, 0, 0), # red
(255, 100, 0), # orange
(255, 220, 0), # yellow
(0, 255, 0), # green
(0, 120, 255), # blue
(180, 0, 255), # purple
)
def render(zones, t):
# Smooth rotation over 2 seconds per cycle
phase = t / 2.0
n_colors = len(rainbow_colors)
for zone_idx, zone in enumerate(zones):
zone_offset = 0.5 if zone_idx == 1 else 0.0
outer_count = max(1, zone.count - 1)
# Center pixel cycles slowly
center_slot = int(phase) % n_colors
zone.set_led(0, _scale_rgb(rainbow_colors[center_slot], 0.4))
for led_idx in range(1, zone.count):
angle_frac = (led_idx - 1) / outer_count
color_phase = phase + zone_offset + angle_frac
slot = int(color_phase * n_colors) % n_colors
zone.set_led(led_idx, rainbow_colors[slot])
return {"duration_s": 5.0, "render": render, "label": "PARADE"}
def _state_statue_beacon(command):
'''Fab 50 statue detected - golden swirl with sparkles and pulse.
Disney's Fab 50 golden statues at Magic Kingdom broadcast continuous
location beacons. We trigger this animation to acknowledge "the
statue sees you" when one is detected nearby. The original 2-second
version felt too brief - now 4.0s with two pulse "beats" so the
surprise lasts long enough to register.
Visual: warm gold pixels swirl around the outer ring, with random
bright white sparkles popping in. Sparkles get easier to trigger
on pulse peaks. A breathing brightness envelope creates two beats
over the 4-second span; each beat peaks mid-rotation, so the swirl
waxes bright at ~1s and ~3s with a softer dip between them.
'''
statue_id = command.get("statue_id", "?")
gold_bright = (255, 180, 30)
gold_dim = (120, 80, 10)
sparkle_white = (255, 255, 200)
def _statue_pixel_for(led_idx, ring_idx, zone_idx, t, phase, envelope, pulse):
'''Compute the RGB for one outer-ring LED in the statue swirl.'''
# Sparkle: pseudo-random per LED + time. Threshold gets
# slightly easier on pulse peaks so sparkles cluster
# rhythmically with the beat instead of feeling random.
sparkle_phase = t * 12.0 + led_idx * 1.7 + zone_idx * 0.5
if math.sin(sparkle_phase) > 0.88 - 0.06 * pulse:
return _scale_rgb(sparkle_white, envelope)
zone_offset = phase + (0.3 if zone_idx == 1 else 0.0)
outer_count = 6
head_pos = (zone_offset * outer_count) % outer_count
distance = (head_pos - ring_idx) % outer_count
if distance < 1.0:
return _scale_rgb(gold_bright, envelope)
if distance < 3.0:
fade = 1.0 - (distance / 3.0)
return _scale_rgb(gold_dim, envelope * fade)
return (0, 0, 0)
def render(zones, t):
# Outer envelope: 0-0.3s fade in, 3.7-4.0s fade out, flat between.
if t < 0.3:
fade_envelope = t / 0.3
elif t > 3.7:
fade_envelope = max(0.0, (4.0 - t) / 0.3)
else:
fade_envelope = 1.0
# Two-beat pulse: dim at t=0, peak at t=1, dim at t=2, peak at
# t=3, dim at t=4. Cosine-shifted sine keeps range tight (0.5..1.0)
# so the swirl never disappears entirely between peaks.
pulse = 0.75 + 0.25 * math.sin(
2 * math.pi * t / 2.0 - math.pi / 2)
envelope = fade_envelope * pulse
# Continuous rotation - no reset between beats. ~3 full
# revolutions over the 4s span at 1.5 rev/s base rate.
phase = t * 1.5
for zone_idx, zone in enumerate(zones):
# Center pixel: steady warm gold modulated by envelope
zone.set_led(0, (int(gold_bright[0] * envelope * 0.6),
int(gold_bright[1] * envelope * 0.6),
int(gold_bright[2] * envelope * 0.6)))
for led_idx in range(1, zone.count):
zone.set_led(led_idx, _statue_pixel_for(
led_idx, led_idx - 1, zone_idx, t, phase,
envelope, pulse))
return {
"duration_s": 4.0,
"render": render,
"label": f"STATUE #{statue_id}",
}
# Find Me beacon - 3-phase high-visibility animation for locating a
# stroller, wheelchair, or EV scooter in a busy parking lot. Triggered
# by the CLUE remote's "Find Me" command. The main loop also forces
# the pixel brightness to maximum during this animation regardless of
# the user's preset, then restores their preset after.
_FIND_STROBE_S = 3.0 # Phase 1: attention-grabbing strobe
_FIND_CHASE_S = 15.0 # Phase 2: rainbow chase (motion + color)
_FIND_BREATHE_S = 12.0 # Phase 3: rainbow breathing (steady glow)
FIND_MODE_DURATION_S = _FIND_STROBE_S + _FIND_CHASE_S + _FIND_BREATHE_S
def _state_find_me(_command):
'''3-phase high-visibility "find me" animation.
Phase 1: Strobe - rapid full-white + saturated color flashes to
catch eyes from across a parking lot.
Phase 2: Rainbow chase - bright pixels rotating around the ring with
rainbow color trail. Easy to spot at distance, indicates motion
/ liveness.
Phase 3: Rainbow breathing - steady saturated rainbow at slower
breath rate. Less alarming once located, easy to home in on.
'''
# Vivid colors used throughout (full saturation for visibility)
rainbow = (
(255, 0, 0), # red
(255, 80, 0), # orange
(255, 200, 0), # yellow
(0, 255, 0), # green
(0, 80, 255), # blue
(180, 0, 255), # purple
)
white = (255, 255, 255)
def _phase_strobe(zones, t):
'''Phase 1: strobe at 5 Hz alternating white and rainbow color.'''
strobe_idx = int(t * 10) # 10 strobes/sec
if strobe_idx % 2 == 0:
color = white
else:
color = rainbow[(strobe_idx // 2) % len(rainbow)]
for zone in zones:
zone.fill(color)
def _phase_chase(zones, phase_t):
'''Phase 2: rainbow chase rotating around the ring.'''
rotation = phase_t * 1.5 # ~1.5 revolutions per second
n = len(rainbow)
for zone_idx, zone in enumerate(zones):
zone_offset = rotation + (0.3 if zone_idx == 1 else 0.0)
zone.set_led(0, rainbow[int(rotation * 0.5) % n])
outer_count = max(1, zone.count - 1)
for led_idx in range(1, zone.count):
angle = (led_idx - 1) / outer_count
zone.set_led(led_idx,
rainbow[int((zone_offset + angle) * n) % n])
def _phase_breathe(zones, phase_t):
'''Phase 3: rainbow breathing - all pixels rainbow with envelope.'''
breath = 0.5 + 0.5 * math.sin(2 * math.pi * phase_t / 2.0)
n = len(rainbow)
for zone_idx, zone in enumerate(zones):
zone_offset = 0.5 if zone_idx == 1 else 0.0
zone.set_led(0, _scale_rgb(
rainbow[int(phase_t / 2.0) % n], breath))
outer_count = max(1, zone.count - 1)
for led_idx in range(1, zone.count):
angle = (led_idx - 1) / outer_count
zone.set_led(led_idx, _scale_rgb(
rainbow[int((angle + zone_offset) * n) % n], breath))
def render(zones, t):
if t < _FIND_STROBE_S:
_phase_strobe(zones, t)
elif t < _FIND_STROBE_S + _FIND_CHASE_S:
_phase_chase(zones, t - _FIND_STROBE_S)
else:
_phase_breathe(zones, t - _FIND_STROBE_S - _FIND_CHASE_S)
return {
"duration_s": FIND_MODE_DURATION_S,
"render": render,
"label": "FIND ME",
}
def _lighten(rgb, amount=0.4):
'''Return rgb shifted toward white by `amount` (0.0-1.0).'''
return (
min(255, int(rgb[0] + (255 - rgb[0]) * amount)),
min(255, int(rgb[1] + (255 - rgb[1]) * amount)),
min(255, int(rgb[2] + (255 - rgb[2]) * amount)),
)
def _state_wand_cast(command):
'''Starlight Wand cast - a multi-phase comet animation.
Narrative:
1. Comet swirls on the LEFT ear twice (outer ring), tail fading behind.
2. Comet "crosses over" briefly (both ears show a quick trail).
3. Comet swirls on the RIGHT ear twice.
4. Both ears sparkle with a lighter shade of the cast color.
5. Both ears settle into a slow breathing glow of the cast color,
holding for 30 seconds or until the next command arrives.
Each outer-ring pixel on a 7-pixel Jewel is indexed 1..6 with pixel 0
being the center. The comet moves around the 6 outer pixels; the
center pixel glows at a modest fraction to anchor the swirl.
'''
palette_idx = command["palette_idx"]
rgb = magicband_protocol.PALETTE_RGB[palette_idx]
sparkle_rgb = _lighten(rgb, 0.55)
name = magicband_protocol.PALETTE_NAMES[palette_idx]
# Phase timings (seconds from t=0): swirl_left_end, crossover_end,
# swirl_right_end, sparkle_end, total_duration.
timings = (0.9, 1.1, 2.0, 2.8, 30.0)
# Comet shape: outer_count, tail_len, tail_falloff (per-step dimming).
comet_shape = (6, 4, 0.55)
def _comet_on_zone(zone, head_position, color_bright):
'''Draw a comet with tail on the outer ring of one zone.
head_position is a float 0..outer_count-1 indicating where the
comet "head" sits on the ring. Tail trails behind it at decreasing
brightness. Center pixel (idx 0) anchors at a modest glow.
'''
outer_count, tail_len, tail_falloff = comet_shape
# Fade the center so it's subtle but present
zone.set_led(0, _scale_rgb(color_bright, 0.3))
# Tail effect - for each outer ring pixel, compute its distance
# back from the head and set brightness accordingly.
head_int = int(head_position) % outer_count
for ring_idx in range(outer_count):
led_idx = ring_idx + 1 # skip center
# Distance back from head (always positive, wrapping around)
distance = (head_int - ring_idx) % outer_count
if distance > tail_len:
zone.set_led(led_idx, (0, 0, 0))
elif distance == 0:
# Bright head
zone.set_led(led_idx, color_bright)
else:
factor = tail_falloff ** distance
zone.set_led(led_idx, _scale_rgb(color_bright, factor))
def _dark_zone(zone):
for i in range(zone.count):
zone.set_led(i, (0, 0, 0))
def render(zones, t):
left, right = zones[0], zones[1]
swirl_left_end, crossover_end, swirl_right_end, sparkle_end, _ = timings
outer_count = comet_shape[0]
if t < swirl_left_end:
_wand_phase_left(left, right, t, swirl_left_end,
outer_count, rgb, _comet_on_zone, _dark_zone)
elif t < crossover_end:
_wand_phase_crossover(left, right, t, swirl_left_end,
crossover_end, outer_count, rgb,
_comet_on_zone)
elif t < swirl_right_end:
_wand_phase_right(left, right, t, crossover_end,
swirl_right_end, outer_count, rgb,
_comet_on_zone, _dark_zone)
elif t < sparkle_end:
_wand_phase_sparkle(zones, t, rgb, sparkle_rgb)
else:
_wand_phase_breathe(left, right, t - sparkle_end, rgb)
return {
"duration_s": timings[4],
"render": render,
"label": f"WAND {name}",
}
def _wand_phase_left(left, right, t, swirl_left_end, outer_count,
rgb, comet_fn, dark_fn):
'''Phase 1: left ear comet, 2 full swirls. Right ear dark.'''
progress = t / swirl_left_end
comet_fn(left, progress * outer_count * 2, rgb)
dark_fn(right)
def _wand_phase_crossover(left, right, t, swirl_left_end, crossover_end,
outer_count, rgb, comet_fn):
'''Phase 2: crossover - left fades, right starts.'''
fade_progress = (t - swirl_left_end) / (crossover_end - swirl_left_end)
comet_fn(left, outer_count * 2 - 1,
_scale_rgb(rgb, 1.0 - fade_progress))
comet_fn(right, 0, _scale_rgb(rgb, fade_progress))
def _wand_phase_right(left, right, t, crossover_end, swirl_right_end,
outer_count, rgb, comet_fn, dark_fn):
'''Phase 3: right ear comet, 2 full swirls. Left ear dark.'''
progress = (t - crossover_end) / (swirl_right_end - crossover_end)
dark_fn(left)
comet_fn(right, progress * outer_count * 2, rgb)
def _wand_phase_sparkle(zones, t, rgb, sparkle_rgb):
'''Phase 4: sparkle burst - both ears scatter light shimmers.'''
for zone_idx, zone in enumerate(zones):
for led_idx in range(zone.count):
phase = t * 22.0 + led_idx * 1.3 + zone_idx * 0.7
twinkle = abs(math.sin(phase * 2 * math.pi))
if twinkle > 0.75:
zone.set_led(led_idx, sparkle_rgb)
elif twinkle > 0.4:
zone.set_led(led_idx, _scale_rgb(rgb, twinkle))
else:
zone.set_led(led_idx, _scale_rgb(rgb, 0.2))
def _wand_phase_breathe(left, right, t_breath, rgb):
'''Phase 5: settled breathing - slow, gentle pulse on both ears.'''
# 4-second period, 30% amplitude (between 70% and 100%). Right ear
# is offset by half a cycle so the two ears breathe out of phase.
left_f = 0.85 + 0.15 * math.sin(2 * math.pi * t_breath / 4.0)
right_f = 0.85 + 0.15 * math.sin(
2 * math.pi * (t_breath / 4.0 + 0.5))
left.fill(_scale_rgb(rgb, left_f))
right.fill(_scale_rgb(rgb, right_f))
def _state_ping():
# Wake-ping packets (CC03) are fired by the CLUE remote right before
# commands flagged needs_ping=True. They're a meta-signal meant for
# the band receiver, not something the ears should visualize. Return
# None so the game loop ignores it entirely.
return None
def _state_single_color(command):
palette = command["palette_idx"]
rgb = magicband_protocol.PALETTE_RGB[palette]
name = magicband_protocol.PALETTE_NAMES[palette]
duration = None if command["timing"]["always_on"] else command["timing"]["seconds"]
def render(zones, t):
# Stereo breathing: left and right out of phase by half a cycle.
left_rgb = _scale_rgb(rgb, _breath_factor(t, phase=0.0))
right_rgb = _scale_rgb(rgb, _breath_factor(t, phase=0.5))
zones[0].fill(left_rgb)
zones[1].fill(right_rgb)
return {
"duration_s": duration,
"render": render,
"label": f"SINGLE {name}",
}
def _state_dual_color(command):
inner = magicband_protocol.PALETTE_RGB[command["inner_idx"]]
outer = magicband_protocol.PALETTE_RGB[command["outer_idx"]]
inner_name = magicband_protocol.PALETTE_NAMES[command["inner_idx"]]
outer_name = magicband_protocol.PALETTE_NAMES[command["outer_idx"]]
duration = None if command["timing"]["always_on"] else command["timing"]["seconds"]
def render(zones, t):
# Stereo assignment: left = inner, right = outer. Each still breathes
# to stay lively. Out-of-phase breathing keeps the two ears feeling
# alive rather than identical.
zones[0].fill(_scale_rgb(inner, _breath_factor(t, phase=0.0)))
zones[1].fill(_scale_rgb(outer, _breath_factor(t, phase=0.5)))
return {
"duration_s": duration,
"render": render,
"label": f"DUAL {inner_name}/{outer_name}",
}
def _state_five_color(command):
# Each of the 5 band LEDs has its own palette slot. We pick a
# representative color per zone: left zone uses top-left + bottom-left,
# right zone uses top-right + bottom-right, each zone's center lights up
# the average. For single-pixel prototype we flatten further.
tl = magicband_protocol.PALETTE_RGB[command["top_left"]]
bl = magicband_protocol.PALETTE_RGB[command["bottom_left"]]
tr = magicband_protocol.PALETTE_RGB[command["top_right"]]
br = magicband_protocol.PALETTE_RGB[command["bottom_right"]]
center = magicband_protocol.PALETTE_RGB[command["center"]]
duration = (None if command["timing"]["always_on"]
else command["timing"]["seconds"])
def _avg(a, b, c):
return ((a[0] + b[0] + c[0]) // 3,
(a[1] + b[1] + c[1]) // 3,
(a[2] + b[2] + c[2]) // 3)
left_rgb = _avg(tl, bl, center)
right_rgb = _avg(tr, br, center)
def render(zones, t):
zones[0].fill(_scale_rgb(left_rgb, _breath_factor(t, phase=0.0)))
zones[1].fill(_scale_rgb(right_rgb, _breath_factor(t, phase=0.5)))
return {"duration_s": duration, "render": render, "label": "FIVE"}
def _state_six_bit(command):
# E9 08 gives raw 6-bit RGB. Expand to 8-bit and use it directly.
rgb = (command["red"] << 2, command["green"] << 2, command["blue"] << 2)
duration = (None if command["timing"]["always_on"]
else command["timing"]["seconds"])
def render(zones, t):
zones[0].fill(_scale_rgb(rgb, _breath_factor(t, phase=0.0)))
zones[1].fill(_scale_rgb(rgb, _breath_factor(t, phase=0.5)))
return {"duration_s": duration, "render": render, "label": "RGB6"}
# Firmware-baked E9 0C animations. These have known payload signatures
# whose bytes are NOT raw 5-slot palette indices but rather animation
# program selectors. We map them to approximate visual color sequences
# matching how real MagicBand+ hardware actually plays them.
#
# Key: first 12 bytes of payload (signature prefix, excludes timing/vib/vib)
# Value: (label, ordered color RGB list)
_BAKED_ANIMATIONS = {
# Taste the Rainbow - full rainbow rotation
bytes.fromhex("e100e90c000f0f5d465bf005"): (
"Rainbow",
[
(255, 0, 0), # red
(255, 90, 0), # orange
(255, 220, 0), # yellow
(0, 255, 0), # green
(0, 120, 255), # blue
(180, 0, 255), # purple
],
),
# Blink White - white strobe
bytes.fromhex("e100e90c000f0f5d465bf005"): (
"Blink White",
[(255, 220, 200), (0, 0, 0)],
),
# Orange Blink - orange pulse
bytes.fromhex("e100e90c00ef0f4f4f5bf0fb"): (
"Orange Blink",
[(255, 90, 0), (50, 20, 0)],
),
}
def _lookup_baked_animation(raw):
'''Return (label, slots) if raw matches a known firmware animation.'''
# Taste the Rainbow and Blink White share the same 12-byte prefix but
# differ in tail bytes. Disambiguate by comparing tail too.
prefix = bytes(raw[:12])
tail = bytes(raw[12:]) if len(raw) > 12 else b""
# Taste the Rainbow full: e100e90c000f0f5d465bf005 32 37 48 b0
# Blink White full: e100e90c000f0f5d465bf005 32 37 48 95
# Distinguished by last byte: b0=no vibration (TTR), 95=other (Blink White)
if prefix == bytes.fromhex("e100e90c000f0f5d465bf005"):
if len(tail) >= 4 and tail[-1] == 0x95:
return ("Blink White",
[(255, 220, 200), (0, 0, 0)])
# Default this prefix to Taste the Rainbow
return ("Rainbow",
[(255, 0, 0), (255, 90, 0), (255, 220, 0),
(0, 255, 0), (0, 120, 255), (180, 0, 255)])
# Orange Blink
if prefix == bytes.fromhex("e100e90c00ef0f4f4f5bf0fb"):
return ("Orange Blink",
[(255, 90, 0), (50, 20, 0)])
return None
def _decode_5slot_palette(raw):
'''Decode bytes 7..11 of a 5-slot E9 0C payload into colors and label.
Returns (slots, label) where slots is a list of RGB tuples and label
is a short summary of the distinct color names involved.
'''
slot_bytes = raw[7:12] if len(raw) >= 12 else raw[7:]
slots = []
slot_names = []
for byte in slot_bytes:
idx = byte & 0x1F
slots.append(magicband_protocol.PALETTE_RGB[idx])
slot_names.append(magicband_protocol.PALETTE_NAMES[idx])
if not slots:
slots = [(255, 255, 255)]
slot_names = ["White"]
distinct = []
for name in slot_names:
short = name.split()[0][:4]
if not distinct or distinct[-1] != short:
distinct.append(short)
return slots, f"SHOW {'>'.join(distinct)}"
def _state_show_fx(command):
'''E9 0C captured park animations.
Some E9 0C payloads are firmware-baked animation programs (Taste the
Rainbow, Blink White, Orange Blink) where the bytes are program IDs,
not 5-slot palettes. We recognize those by signature and use hardcoded
color sequences matching their real visual appearance.
Other E9 0C payloads (5 Palette Cycle, DCL Rainbow, future clones) are
true 5-slot palette cycles - for those we extract colors from bytes
7-11 as palette indices.
'''
raw = command["raw"]
baked = _lookup_baked_animation(raw)
if baked is not None:
label_suffix, slots = baked
label = f"SHOW {label_suffix}"
else:
slots, label = _decode_5slot_palette(raw)
duration = _DEFAULT_DURATION_S
if len(raw) >= 6:
timing = magicband_protocol.decode_timing(raw[5])
duration = None if timing["always_on"] else timing["seconds"]
def render(zones, t):
n_slots = len(slots)
phase = t / _ROTATE_PERIOD_S
for zone_idx, zone in enumerate(zones):
zone_offset = 0.5 if zone_idx == 1 else 0.0
center_slot = int(phase) % n_slots
zone.set_led(0, slots[center_slot])
outer_count = max(1, zone.count - 1)
for led_idx in range(1, zone.count):
angle_frac = (led_idx - 1) / outer_count
color_phase = phase + zone_offset + angle_frac
slot = int(color_phase * n_slots) % n_slots
zone.set_led(led_idx, slots[slot])
return {"duration_s": duration, "render": render, "label": label}
def _state_cross_fade(command):
# E9 11 cross fade between two palette colors. The two endpoint colors
# are encoded in bytes 7 (from) and 8 (to) of the payload. Bytes 9+
# appear to be fade timing and repeat parameters, not additional color
# slots.
raw = command["raw"]
slot_a_idx = (raw[7] if len(raw) > 7 else 0) & 0x1F
slot_b_idx = (raw[8] if len(raw) > 8 else 0) & 0x1F
slot_a = magicband_protocol.PALETTE_RGB[slot_a_idx]
slot_b = magicband_protocol.PALETTE_RGB[slot_b_idx]
name_a = magicband_protocol.PALETTE_NAMES[slot_a_idx]
name_b = magicband_protocol.PALETTE_NAMES[slot_b_idx]
duration = _DEFAULT_DURATION_S * 2
if len(raw) >= 6:
timing = magicband_protocol.decode_timing(raw[5])
duration = None if timing["always_on"] else timing["seconds"]
def _mix(a, b, f):
return (
int(a[0] * (1 - f) + b[0] * f),
int(a[1] * (1 - f) + b[1] * f),
int(a[2] * (1 - f) + b[2] * f),
)
def render(zones, t):
# Slow sinusoidal cross fade between a and b. Left leads right.
f_left = 0.5 + 0.5 * math.sin(2 * math.pi * t / 4.0)
f_right = 0.5 + 0.5 * math.sin(2 * math.pi * (t - 1.0) / 4.0)
zones[0].fill(_mix(slot_a, slot_b, f_left))
zones[1].fill(_mix(slot_a, slot_b, f_right))
return {
"duration_s": duration,
"render": render,
"label": f"FADE {name_a}<>{name_b}",
}
def _show_command_slots_path(slots):
'''5-slot palette path for show_command. Returns a state dict.'''
slot_colors = [magicband_protocol.PALETTE_RGB[i] for i in slots]
slot_names = [magicband_protocol.PALETTE_NAMES[i] for i in slots]
distinct = []
for name in slot_names:
short = name.split()[0][:4]
if not distinct or distinct[-1] != short:
distinct.append(short)
full_label = f"SHOW5 {'>'.join(distinct)}"
def render_slots(zones, t):
n_slots = len(slot_colors)
phase = t / _ROTATE_PERIOD_S
for zone_idx, zone in enumerate(zones):
zone_offset = 0.5 if zone_idx == 1 else 0.0
zone.set_led(0, slot_colors[int(phase) % n_slots])
outer_count = max(1, zone.count - 1)
for led_idx in range(1, zone.count):
angle_frac = (led_idx - 1) / outer_count
color_phase = phase + zone_offset + angle_frac
zone.set_led(led_idx,
slot_colors[int(color_phase * n_slots) % n_slots])
return {"duration_s": _DEFAULT_DURATION_S, "render": render_slots,
"label": full_label}
def _show_command_generic_path(raw, label):
'''Generic park-show pulse for un-decoded long-format packets.
Uses a position-weighted polynomial hash to derive a deterministic
primary palette index per capture - simple XOR collapsed multiple
captures into the same bucket, which defeats the "tell captures
apart on camera" goal.
'''
seed = 0
for byte in raw:
seed = (seed * 31 + byte) & 0xFFFF
palette_size = len(magicband_protocol.PALETTE_RGB)
primary_idx = seed % palette_size
# Skip the "Off" palette entry so the primary is never black.
if magicband_protocol.PALETTE_RGB[primary_idx] == (0, 0, 0):
primary_idx = (primary_idx + 1) % palette_size
primary_name = magicband_protocol.PALETTE_NAMES[primary_idx]
# Anchor the primary plus three accents spaced around the palette.
accents = (
magicband_protocol.PALETTE_RGB[primary_idx],
magicband_protocol.PALETTE_RGB[(primary_idx + 6) % palette_size],
magicband_protocol.PALETTE_RGB[(primary_idx + 12) % palette_size],
magicband_protocol.PALETTE_RGB[(primary_idx + 18) % palette_size],
)
def render_generic(zones, t):
# 1.2s rotation - faster than the 2.0s used for guest commands,
# gives the show pulse a more energetic feel.
phase = t / 1.2
n_slots = 4
for zone_idx, zone in enumerate(zones):
zone_offset = 0.5 if zone_idx == 1 else 0.0
zone.set_led(0, accents[int(phase) % n_slots])
outer_count = max(1, zone.count - 1)
for led_idx in range(1, zone.count):
angle_frac = (led_idx - 1) / outer_count
color_phase = phase + zone_offset + angle_frac
zone.set_led(led_idx,
accents[int(color_phase * n_slots) % n_slots])
return {"duration_s": _DEFAULT_DURATION_S, "render": render_generic,
"label": f"{label} hue={primary_name}"}
def _state_show_command(command):
'''Park-show packet renderer (Epcot light show, etc.).
Two paths depending on whether the payload decodes:
- If `slots` is set (E9 08 short form), render as a 5-slot palette
rotation matching firmware show_fx output.
- Otherwise the long-format payloads (E9 10, E9 13, EA 14) aren't
fully decoded yet, so render a generic park-show pulse with a
primary hue derived from the payload bytes. Different captured
payloads produce visibly different primary colors, so multiple
captures can be told apart on camera even though we can't decode
their internal structure.
'''
slots = command.get("slots")
if slots is not None:
return _show_command_slots_path(slots)
return _show_command_generic_path(
command["raw"], command.get("label", "SHOW"))
def _state_animation(command):
# Generic animation (E9 0B, E9 0E, E9 0F, etc.). The jewels have 7
# pixels each (1 center + 6 outer ring), so we can do real spatial
# rotation: cycle the outer ring through the palette slots while
# keeping the center a fixed color. Reads as a proper "color wheel"
# effect like the real bands.
raw = command["raw"]
slots = []
slot_names = []
# Most animation payloads have color bytes after the 7-byte header.
# Skip the last byte (vibration) when collecting colors.
for byte in raw[7:-1]:
idx = byte & 0x1F
if idx < 0x1F: # skip obvious non-color bytes like vibration codes
slots.append(magicband_protocol.PALETTE_RGB[idx])
slot_names.append(magicband_protocol.PALETTE_NAMES[idx])
if not slots:
slots = [(100, 100, 100)]
slot_names = ["Gray"]
func = command.get("func", 0)
# Label lists distinct color short names for at-a-glance recognition.
distinct = []
for name in slot_names:
short = name.split()[0][:4]
if not distinct or distinct[-1] != short:
distinct.append(short)
# Cap label length so serial output stays readable
label_colors = '>'.join(distinct[:4])
if len(distinct) > 4:
label_colors += '...'
label = f"ANIM 0x{func:04X} {label_colors}"
def render(zones, t):
n_slots = len(slots)
# How fast the color wheel rotates - one full revolution per period
rotations_per_s = 1.0 / _ROTATE_PERIOD_S
# Global phase advances linearly with time
phase = t * rotations_per_s
for zone_idx, zone in enumerate(zones):
zone_offset = 0.5 if zone_idx == 1 else 0.0 # right trails left
# Center LED gets the "middle" slot as an anchor color
center_slot = int(phase) % n_slots
zone.set_led(0, slots[center_slot])
# Outer ring LEDs (1-6) each get a color at their angular
# position, offset by the rotating phase. count=7 means
# indices 1..6 are outer pixels for the NeoPixel Jewel.
outer_count = max(1, zone.count - 1)
for led_idx in range(1, zone.count):
# Each outer pixel's color index walks around the palette
# based on its physical angular position + the rotation phase
angle_frac = (led_idx - 1) / outer_count
color_phase = phase + zone_offset + angle_frac
slot = int(color_phase * n_slots) % n_slots
zone.set_led(led_idx, slots[slot])
return {
"duration_s": _DEFAULT_DURATION_S,
"render": render,
"label": label,
}
# Dispatch table for for_command(). Defined after the _state_* functions
# so all references resolve. Handlers all take a command dict, except
# _state_ping which takes no args.
_COMMAND_HANDLERS = {
"ping": _state_ping,
"wand_cast": _state_wand_cast,
"single_color": _state_single_color,
"dual_color": _state_dual_color,
"five_color": _state_five_color,
"six_bit_color": _state_six_bit,
"show_fx": _state_show_fx,
"cross_fade": _state_cross_fade,
"animation": _state_animation,
"parade_command": _state_parade_command,
"show_command": _state_show_command,
"statue_beacon": _state_statue_beacon,
"find_me": _state_find_me,
}
def render_idle(zones):
'''Default renderer when no animation is active. Blanks both zones.'''
zones[0].fill((0, 0, 0))
zones[1].fill((0, 0, 0))
Animation State Pattern
Every command renders through the same pattern in renderer.py. for_command() takes a parsed command dict and returns an animation state with three fields: a duration_s, a render(zones, t) function, and a label string for the serial log.
def _state_dual_color(command):
inner_rgb = magicband_protocol.PALETTE_RGB[command["inner_idx"]]
outer_rgb = magicband_protocol.PALETTE_RGB[command["outer_idx"]]
duration_s = command["timing"]["seconds"]
def render(zones, t):
for zone_idx, zone in enumerate(zones):
zone.set_led(0, inner_rgb)
phase_offset = 0.5 if zone_idx == 1 else 0.0
...
return {"duration_s": duration_s, "render": render, "label": "DUAL ..."}The main loop calls render(zones, t) every frame, where t is seconds since the animation started. When t exceeds duration_s, the animation expires and the ears go idle until the next packet arrives.
The Remote Command Sub-Protocol
The CLUE remote can fire four "ears-only" commands that don't render anything on real bands or wands - they only the ears recognize. The packet format uses the Disney CID with a custom 0xAA 0x42 prefix, followed by one byte that selects the command. Real bands and wands ignore packets they don't recognize, so this is safe to broadcast alongside MagicBand+ traffic.
REMOTE_COMMANDS = {
bytes.fromhex("aa4201"): "battery",
bytes.fromhex("aa4203"): "brightness",
bytes.fromhex("aa4204"): "find",
bytes.fromhex("aa4205"): "statue",
}
def remote_command(payload):
return REMOTE_COMMANDS.get(bytes(payload[:3]))Add another command by appending an entry to the dict and a matching branch in the if/elif handler in code.py.
Render Priority Chain
Each frame, the loop picks one source to render from a priority chain. Find Me beats everything because it's the most user-critical animation. Solo enter/exit indicators beat brightness flashes, which beat the battery-unavailable yellow pulse, which beats the battery display, which beats the active animation, which beats Solo Mode cycling, which beats idle. A real BLE packet can preempt Solo Mode mid-showpiece for park interaction without the user juggling modes.
if frame_t < find_mode_until:
...
elif frame_t < solo_indicator_until:
...
elif frame_t < brightness_flash_until:
...
elif frame_t < battery_display_until:
...
elif active_state is not None:
...
elif solo_mode:
...
else:
renderer.render_idle(zones)3D Printing
3MF files for 3D printing are oriented and ready to print on FDM machines using PLA filament. Original design source files may be downloaded using the links below.
CLUE +QT Py / Lipo BFF Cases 3MF
The dropdown on the Fusion 360 site allows you to pick your preferred 3D file format like STEP, STL, etc.
Slice with settings for PLA material
The parts were sliced using BambuStudio using the slice settings below.
PLA filament 220c extruder
0.2 layer height
10% gyroid infill
200mm/s print speed
Tree Supports
60 C heated bed
The QT Py band is printed in TPU filament.
Design Source Files
The project assembly was designed in Fusion 360. Once opened in Fusion 360, It can be exported 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.
Assemble QT Py + NeoPixels
Short Headers and Sockets
We used short strips of socket headers on the QT Py to keep the circuit low-profile and project compact.
When cutting headers to fit the QT Py boards, be sure to use eye protection. See the guide below about soldering headers.
How To Solder Headers
By Erin St Blaine
Plan Wires
You'll want to measure out your wire connections depending on where you want to install the NeoPixel LEDs and the QT Py. You can cut down on some of the wiring by sharing power and ground connections across the two NeoPixel Jewels with a ribbon silicone cable.
Mickey Ear Wires Lengths:
Left ear: 24.5 cm
Right ear (QT Py Case Side): 18.5 cm
Solder Connections
The wired connections are soldered to the top of the QT Py with them running underneath the PCB.
Sew NeoPixels
The two mounting holes on the NeoPixel Jewels are then sewn to the fabric with a needle and thread.
Be careful using hobby knives to avoid cuts. Younger makers should ask older folks to assist.
LiPo Pocket
You can create a pocket for the LiPo battery by measuring and cutting a small slit on the side of the headbands with a hobby knife. These were big enough to fit a 1200mAh LiPo battery.
LiPo JST
You can route the battery cable in between the QTPy and LiPo BFF before plugging it into the JST port.
Assemble QT Py case
The QT Py case snap fits together and features a cutout for connecting a flexible TPU band into the side of the case. The TPU band can be wrapped around the headband to attach them together.
Place Slide Switch
A slide switch add-on piece lets you easily turn the circuit on and off.
Press fit case
Align the wires to the cutouts on the case and press fit the two side together.
A piece of twisty tie wire can help to keep the cable taught to the headband.
Assemble Remote
The case for the CLUE features cutouts for the on-board buttons and display.
Plug the LiPo battery JST into the port on the Clue.
Place LiPo in Case
Align the LiPo cable to the cutout inside the case.
LiPo Battery Retainer
The retainer keeps the LiPo battery mounted in place. Align the corner cutouts to the cable and carefully press into the walls in the case.
Place CLUE
Gently guide the LiPo cable to the left side of the case, avoiding the button presser on the case.
Attach Cover
Peel the protective film off of the display.
Align the button cutout to the cover to press fit to the case.
Usage
Using the Beacon Ears
The Beacon Ears system is two boards working together. The headband ears listen passively for Disney BLE adverts at home or in the parks. The CLUE remote sends commands to the ears and to nearby MagicBands, wands, and Fab 50 statues - or scoops up packets in Listen Mode for reverse-engineering new park show codes.
Quick Reference - Beacon Ears (QT Py)
BOOT single press: cycle brightness (3 levels)
BOOT double-press (Solo Mode only): skip to next showpiece
BOOT triple-press: toggle Solo Mode on or off
BOOT long-press (0.5s): show battery level
Boot Up
Plug a LiPo battery into the JST connector on each board or run them off USB. The Beacon Ears blink briefly through their idle frame and start listening immediately.
Brightness Levels
Short press the BOOT button on the QT Py to cycle through three brightness presets: dim, medium, bright. Each press shows a quick confirmation flash on the outer rings - one lit pixel for dim, two for medium, three for bright. The current preset persists until you press again or until the next reset.
Battery Level
Long-press the BOOT button on the QT Py for half a second to show the current battery level. On USB power, both Jewels animate a swirling fill: a leading-edge pixel sweeps around the outer ring, leaving lit pixels behind it equal to the battery level. Five lit pixels means full, one means nearly empty. The animation holds with a gentle pulse, then fades to black.
On battery power, the same long press shows a brief yellow center pulse instead. WS2812 LED timing is unreliable when the battery is sagging under animation current draw - the yellow pulse signals "plug in to USB to check level reliably" and saves the user from misreading a corrupted color.
Solo Mode
Triple-press the BOOT button to enter Solo Mode. The ears flash a brief white burst, then start cycling through a curated reel of showpiece animations. The reel mixes synthesized highlights with real packets captured from Epcot's stage lighting - five-color rainbow rotations, dual-color combos, deep "Disney blue" 6-bit RGB, plus a handful of decoded park show captures.
Double-press BOOT during Solo Mode to skip the current showpiece and pick a new random one. Triple-press again to exit Solo Mode - the ears flash a cool blue pulse and return to listening.
A real Disney BLE packet preempts Solo Mode cleanly. If you walk past a Fab 50 statue or another guest's MagicBand fires while you're in Solo Mode, the ears interrupt the showpiece, render the park animation, then resume Solo cycling on the next pick. You don't have to manage modes manually for park interaction to work.
Quick Reference - CLUE Remote
A single press: scroll up in list view
A double press: select / fire
A triple-press: toggle silent mode
B single press: scroll down in list view
B double press: open category / back to grid
B long-press (0.6s): cancel / send Off command
A and B held together (0.8s): light sleep
Shake: fire a random command (with confirm prompt)
Boot Up
The CLUE shows the four-tile category grid: Colors, Show FX, Fades, Animate. The grid is the home view - everything starts here.
The CLUE Display Grid
The grid view is the home screen. Four tiles - Colors, Show FX, Fades, Animate - cover the catalog of every command the remote can broadcast. The currently selected tile pulses magenta. Press A once to move the highlight forward, B once to move backward.
Double-press A to open the highlighted category. The display switches to a scrollable list view of every command in that category. Double-press B to go back to the grid.
The CLUE Display List
The list view shows commands one per row at scale 2 if they fit, scale 1 if longer. The selected row is highlighted with a leading caret and magenta text. Press A or B once to scroll up or down. Press A twice to fire the selected command.
Some command names end with an asterisk - these trigger the band's haptic vibration motor and announce a brief buzz on the wearer's wrist. The asterisk is a heads-up to the remote operator that the command will physically vibrate any band that latches it.
Long-press B to broadcast the Off command and cancel any latched animation. Off has no pre-ping so the cancellation is immediate.
Shake to Fire
The CLUE's onboard accelerometer detects a sharp shake and pops up a confirmation modal with a random command picked from the catalog. Double-press A to fire, B to cancel and return to the previous view. The shake threshold is calibrated for a deliberate motion - a normal walking pace won't trigger it, but a sharp wrist flick will.
Find Me
Send the Find Me command from the CLUE to start a 30-second high-visibility beacon animation on the ears. The phases are a strobe at 5 Hz alternating white with rainbow colors, a rainbow chase rotating around each Jewel, and a slow rainbow breathing pulse. Brightness is forced to maximum for the duration regardless of the current preset, then restored when the animation ends. We think this works well for spotting a stroller, scooter, or wheelchair across a crowded parking lot.
Display Sleep
The CLUE's TFT backlight auto-sleeps after 30 seconds of no button activity. Any button press wakes it instantly. The display sleep is the largest power saver on the CLUE - the backlight pulls 25 to 35 mA when on, dropping to near-zero when off.
Light Sleep
Hold A and B together on the CLUE for 0.8 seconds to enter light sleep. The TFT backlight turns off, the speaker silences, and the BLE radio shuts down. Press either button to wake. Light sleep preserves Python state, so the selected category, silent mode, and any other settings persist through the sleep cycle.
Statue Preview
Send the Statue command from the CLUE to fire the same golden swirl animation the ears play when they detect a real Fab 50 statue beacon. This is a four-second swirl with two breathing pulse beats and white sparkles tinted gold. Useful for video shoots, demos, or just enjoying the surprise without hunting down a statue.
Silent Mode
Triple-press the A button on the CLUE to toggle silent mode. With silent mode on, the chime that plays after every command fire is suppressed - the ears still react to broadcasts, just without the audible feedback from the remote. Useful in quiet spaces or when filming.
Listen Mode
Listen Mode is a sentinel command in the Show FX category. Selecting it switches the CLUE from broadcasting to BLE scanning. The capture view shows the elapsed seconds, the count of unique payloads seen, and the total payload count. Every unique Disney-CID packet within the receive window gets logged with a first-seen timestamp, count, and last RSSI.
Long-press B to stop the capture. If the CIRCUITPY drive is mounted Python-writable, the captures save to /captures/listen_NNN.txt - tap B once more to dismiss the save confirmation. If the drive is host-mounted (the default when USB is connected), the CLUE sets a flag in non-volatile memory and asks for a reset to switch into capture mode for the next boot.
To switch back to dev mode after capturing, open the serial REPL and run import os; os.remove("/capture_mode.txt"), then reset the CLUE. The drive returns to host-writable so you can drag-drop new code onto it.

