From b082922ec2860f7c03283c8ddbaf5a5a57c41de3 Mon Sep 17 00:00:00 2001 From: videopixil Date: Thu, 7 May 2026 18:08:36 -0400 Subject: [PATCH 1/2] clue ble beacon clue ble remote code --- CLUE_BLE_Beacon_Remote/ble_transmitter.py | 49 ++ CLUE_BLE_Beacon_Remote/boot.py | 78 +++ CLUE_BLE_Beacon_Remote/code.py | 693 +++++++++++++++++++ CLUE_BLE_Beacon_Remote/command_library.py | 161 +++++ CLUE_BLE_Beacon_Remote/magicband_protocol.py | 373 ++++++++++ CLUE_BLE_Beacon_Remote/ui.py | 310 +++++++++ 6 files changed, 1664 insertions(+) create mode 100644 CLUE_BLE_Beacon_Remote/ble_transmitter.py create mode 100644 CLUE_BLE_Beacon_Remote/boot.py create mode 100644 CLUE_BLE_Beacon_Remote/code.py create mode 100644 CLUE_BLE_Beacon_Remote/command_library.py create mode 100644 CLUE_BLE_Beacon_Remote/magicband_protocol.py create mode 100644 CLUE_BLE_Beacon_Remote/ui.py diff --git a/CLUE_BLE_Beacon_Remote/ble_transmitter.py b/CLUE_BLE_Beacon_Remote/ble_transmitter.py new file mode 100644 index 000000000..6396eba4c --- /dev/null +++ b/CLUE_BLE_Beacon_Remote/ble_transmitter.py @@ -0,0 +1,49 @@ +# 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() diff --git a/CLUE_BLE_Beacon_Remote/boot.py b/CLUE_BLE_Beacon_Remote/boot.py new file mode 100644 index 000000000..a2e3f2602 --- /dev/null +++ b/CLUE_BLE_Beacon_Remote/boot.py @@ -0,0 +1,78 @@ +# 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") diff --git a/CLUE_BLE_Beacon_Remote/code.py b/CLUE_BLE_Beacon_Remote/code.py new file mode 100644 index 000000000..2fd9b2914 --- /dev/null +++ b/CLUE_BLE_Beacon_Remote/code.py @@ -0,0 +1,693 @@ +# 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 B-release then a fresh B-press to dismiss save confirm. + + Polls the raw debounced .value rather than waiting for short_count + so dismissal feels instant. button_b.value reflects the LAST debounced + state, so update() must be called each loop iteration. + ''' + # First: wait for release (B may still be held from the long-press + # that ended capture). + while True: + button_b.update() + if button_b.value: # True = not pressed (active-low) + break + time.sleep(0.05) + time.sleep(0.1) # debounce gap + # Then: wait for next fresh press. + while True: + button_b.update() + if not button_b.value: + break + time.sleep(0.05) + + +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. +# Fallback: directly drive board.TFT_LITE low via digitalio if the +# brightness property doesn't fully kill the backlight. +_last_activity_time = [time.monotonic()] +_display_sleeping = [False] + +# Try to grab a direct handle to the backlight pin as a fallback. The +# main display object has this pin claimed, so this may fail with +# ValueError - that's fine, we fall back to display.brightness only. +_backlight_pin = None +try: + _backlight_pin = digitalio.DigitalInOut(board.TFT_LITE) + _backlight_pin.switch_to_output(value=True) +except (AttributeError, ValueError, RuntimeError): + # Display module has claimed TFT_LITE - fine, we'll use + # display.brightness alone to control the backlight. + pass + + +def note_activity(): + '''Mark the current moment as user-active - wake display if sleeping.''' + _last_activity_time[0] = time.monotonic() + if _display_sleeping[0]: + if _backlight_pin is not None: + _backlight_pin.value = True + _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 + if _backlight_pin is not None: + _backlight_pin.value = False + _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 + if _backlight_pin is not None: + _backlight_pin.value = False + _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 + if _backlight_pin is not None: + _backlight_pin.value = True + _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) diff --git a/CLUE_BLE_Beacon_Remote/command_library.py b/CLUE_BLE_Beacon_Remote/command_library.py new file mode 100644 index 000000000..a8b60a241 --- /dev/null +++ b/CLUE_BLE_Beacon_Remote/command_library.py @@ -0,0 +1,161 @@ +# 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), +) diff --git a/CLUE_BLE_Beacon_Remote/magicband_protocol.py b/CLUE_BLE_Beacon_Remote/magicband_protocol.py new file mode 100644 index 000000000..08e259534 --- /dev/null +++ b/CLUE_BLE_Beacon_Remote/magicband_protocol.py @@ -0,0 +1,373 @@ +# 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} diff --git a/CLUE_BLE_Beacon_Remote/ui.py b/CLUE_BLE_Beacon_Remote/ui.py new file mode 100644 index 000000000..e54ae629c --- /dev/null +++ b/CLUE_BLE_Beacon_Remote/ui.py @@ -0,0 +1,310 @@ +# 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 From 77a62b0bfc144562ea4a515bb2631b4803a6d71f Mon Sep 17 00:00:00 2001 From: videopixil Date: Fri, 8 May 2026 21:19:26 -0400 Subject: [PATCH 2/2] code.py format fixes formatting issue fixes in code.py --- CLUE_BLE_Beacon_Remote/code.py | 153 +++++++++++++-------------------- 1 file changed, 61 insertions(+), 92 deletions(-) diff --git a/CLUE_BLE_Beacon_Remote/code.py b/CLUE_BLE_Beacon_Remote/code.py index 2fd9b2914..40930b702 100644 --- a/CLUE_BLE_Beacon_Remote/code.py +++ b/CLUE_BLE_Beacon_Remote/code.py @@ -42,18 +42,18 @@ _CHIME = ((523, 0.08), (659, 0.08), (784, 0.14)) # Direct hardware access - skips loading adafruit_clue's full sensor suite. -_i2c = board.I2C() +i2c = board.I2C() try: from adafruit_lsm6ds.lsm6ds33 import LSM6DS33 - _accel = LSM6DS33(_i2c) + accel = LSM6DS33(i2c) except (OSError, RuntimeError, ImportError): from adafruit_lsm6ds.lsm6ds3trc import LSM6DS3TRC - _accel = LSM6DS3TRC(_i2c) + 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 +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 @@ -63,16 +63,16 @@ # 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)) +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) +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) +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() @@ -81,11 +81,11 @@ confirm_view = ui.ConfirmView() listen_view = ui.ListenView() -_display.root_group = grid_view.group +display.root_group = grid_view.group -button_a = Button(_btn_a_io, value_when_pressed=False, +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, +button_b = Button(btn_b_io, value_when_pressed=False, short_duration_ms=250, long_duration_ms=600) @@ -97,17 +97,17 @@ def play_chime(): if _silent_mode[0]: return for freq, dur in _CHIME: - _speaker.frequency = int(freq) - _speaker.duty_cycle = 0x8000 + speaker.frequency = int(freq) + speaker.duty_cycle = 0x8000 time.sleep(dur) - _speaker.duty_cycle = 0 + 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) + pixel.fill(display_color) ble_transmitter.broadcast(payload) - _pixel.fill((0, 0, 0)) + pixel.fill((0, 0, 0)) def fire_command(command, status_setter): @@ -131,7 +131,7 @@ def fire_command(command, status_setter): status_setter(f"Firing: {name}", 0x00FF00) play_chime() if needs_ping: - _pixel.fill((30, 30, 30)) + pixel.fill((30, 30, 30)) ble_transmitter.broadcast( command_library.PING_PAYLOAD, duration=0.5, ) @@ -140,7 +140,7 @@ def fire_command(command, status_setter): status_setter(f"Playing: {name}", 0x00FFFF) play_chime() if needs_ping: - _pixel.fill((30, 30, 30)) + pixel.fill((30, 30, 30)) ble_transmitter.broadcast( command_library.PING_PAYLOAD, duration=0.5, ) @@ -148,9 +148,9 @@ def fire_command(command, status_setter): for i, step in enumerate(payload): step_bytes, hold, color = step status_setter(f"{name} {i + 1}/{total}", 0x00FFFF) - _pixel.fill(color) + pixel.fill(color) ble_transmitter.broadcast(step_bytes, duration=hold) - _pixel.fill((0, 0, 0)) + pixel.fill((0, 0, 0)) status_setter("Ready", 0x404040) @@ -220,26 +220,17 @@ def _save_capture_with_fallback(seen, elapsed): def _wait_for_dismiss_press(): - '''Wait for B-release then a fresh B-press to dismiss save confirm. + '''Wait for the next short B-press to dismiss the save confirmation. - Polls the raw debounced .value rather than waiting for short_count - so dismissal feels instant. button_b.value reflects the LAST debounced - state, so update() must be called each loop iteration. + 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. ''' - # First: wait for release (B may still be held from the long-press - # that ended capture). while True: button_b.update() - if button_b.value: # True = not pressed (active-low) - break - time.sleep(0.05) - time.sleep(0.1) # debounce gap - # Then: wait for next fresh press. - while True: - button_b.update() - if not button_b.value: - break - time.sleep(0.05) + if button_b.short_count > 0: + return def run_listen_mode(): @@ -250,7 +241,7 @@ def run_listen_mode(): ''' # Aggressively free memory before allocating capture state. gc.collect() - _display.root_group = listen_view.group + display.root_group = listen_view.group note_activity() if supervisor.runtime.usb_connected: listen_view.set_status("USB - hold B to stop", 0xFF8000) @@ -323,9 +314,9 @@ def fire_off(status_setter): gc.collect() status_setter("Off", 0xFF4040) play_chime() - _pixel.fill((40, 40, 40)) + pixel.fill((40, 40, 40)) ble_transmitter.broadcast(payload, duration=1.5) - _pixel.fill((0, 0, 0)) + pixel.fill((0, 0, 0)) status_setter("Ready", 0x404040) @@ -369,36 +360,20 @@ def toggle_silent(status_setter): # --- 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. +# 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. -# Fallback: directly drive board.TFT_LITE low via digitalio if the -# brightness property doesn't fully kill the backlight. _last_activity_time = [time.monotonic()] _display_sleeping = [False] -# Try to grab a direct handle to the backlight pin as a fallback. The -# main display object has this pin claimed, so this may fail with -# ValueError - that's fine, we fall back to display.brightness only. -_backlight_pin = None -try: - _backlight_pin = digitalio.DigitalInOut(board.TFT_LITE) - _backlight_pin.switch_to_output(value=True) -except (AttributeError, ValueError, RuntimeError): - # Display module has claimed TFT_LITE - fine, we'll use - # display.brightness alone to control the backlight. - pass - def note_activity(): '''Mark the current moment as user-active - wake display if sleeping.''' _last_activity_time[0] = time.monotonic() if _display_sleeping[0]: - if _backlight_pin is not None: - _backlight_pin.value = True - _display.brightness = _DISPLAY_ACTIVE_BRIGHTNESS + display.brightness = DISPLAY_ACTIVE_BRIGHTNESS _display_sleeping[0] = False print(f"[DISPLAY] wake at t={time.monotonic():.1f}s") @@ -408,10 +383,8 @@ def check_display_sleep(): if _display_sleeping[0]: return idle_s = time.monotonic() - _last_activity_time[0] - if idle_s >= _DISPLAY_SLEEP_TIMEOUT_S: - _display.brightness = 0.0 - if _backlight_pin is not None: - _backlight_pin.value = False + 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)") @@ -433,20 +406,18 @@ def enter_light_sleep(): reassignment which complicates the function signature). ''' # Fade out chime speaker if it was running (shouldn't be, but safe) - _speaker.duty_cycle = 0 + speaker.duty_cycle = 0 # Turn off onboard status pixel - _pixel.fill((0, 0, 0)) + pixel.fill((0, 0, 0)) # Turn off display backlight - if _backlight_pin is not None: - _backlight_pin.value = False - _display.brightness = 0.0 + 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() + # 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 @@ -504,7 +475,7 @@ def pick_random_command(): def shake_magnitude(): '''Return the current accelerometer magnitude in m/s^2.''' - a_x, a_y, a_z = _accel.acceleration + a_x, a_y, a_z = accel.acceleration return (a_x * a_x + a_y * a_y + a_z * a_z) ** 0.5 @@ -513,18 +484,18 @@ def enter_list(cat_idx): gc.collect() name, commands = _PALETTE[cat_idx] list_view.load_category(cat_idx, name, commands) - _display.root_group = list_view.group + display.root_group = list_view.group def enter_grid(): '''Switch back to the grid view.''' - _display.root_group = grid_view.group + 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 + display.root_group = confirm_view.group def handle_grid(last_shake_time): @@ -597,16 +568,16 @@ def handle_confirm(pending, return_state, last_shake_time): 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 + display.root_group = list_view.group else: - _display.root_group = grid_view.group + 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 + display.root_group = list_view.group else: - _display.root_group = grid_view.group + display.root_group = grid_view.group return return_state, last_shake_time, None, None return _STATE_CONFIRM, last_shake_time, pending, return_state @@ -648,18 +619,16 @@ def handle_confirm(pending, return_state, last_shake_time): 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, + 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, + 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 - if _backlight_pin is not None: - _backlight_pin.value = True + display.brightness = DISPLAY_ACTIVE_BRIGHTNESS _last_activity_time[0] = time.monotonic() _display_sleeping[0] = False grid_view.set_status("Awake!", 0x40C0FF)