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 adafruit_ble'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.
Page last edited May 12, 2026
Text editor powered by tinymce.