|
| 1 | +# SPDX-FileCopyrightText: 2025 John Park for Adafruit Industries |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | +""" |
| 5 | +ESP-NOW MIDI Juggling Ball |
| 6 | +communicates to Feather TFT connected to computer/synth via USB |
| 7 | +""" |
| 8 | + |
| 9 | +import time |
| 10 | +import wifi |
| 11 | +import espnow |
| 12 | +import board |
| 13 | +import neopixel |
| 14 | +import analogio |
| 15 | +import adafruit_lis3dh |
| 16 | + |
| 17 | +# CONFIGURATION: Set this for each device |
| 18 | +DEVICE_ID = "ball_A" # Options: "ball_A", "ball_B", "ball_C" |
| 19 | + |
| 20 | +# Sleep configuration -- light sleep, will wake on tap detection |
| 21 | +SLEEP_AFTER = 30 # Seconds of inactivity before sleep turns off NeoPixels/radio |
| 22 | + |
| 23 | +# Device list |
| 24 | +DEVICES = ["ball_A", "ball_B", "ball_C"] |
| 25 | + |
| 26 | +ALL_COLORS = [0xEE0010, 0x00FF00, 0x0010EE] # indexed to DEVICES |
| 27 | + |
| 28 | +# Current color state (starts with device's default) |
| 29 | +current_color_index = DEVICES.index(DEVICE_ID) |
| 30 | +CURRENT_COLOR = ALL_COLORS[current_color_index] |
| 31 | + |
| 32 | +NUM_PIX = 2 # 2 for PCB version, however many you want for BFF |
| 33 | +# Set up NeoPixel and I2C for accelerometer |
| 34 | +pixel = neopixel.NeoPixel(board.A0, NUM_PIX) # board.A0 for PCB, A3 for BFF |
| 35 | +pixel.brightness = 1.0 |
| 36 | +pixel.fill(CURRENT_COLOR) |
| 37 | + |
| 38 | +# Set up battery monitoring |
| 39 | +voltage_pin = analogio.AnalogIn(board.A2) |
| 40 | + |
| 41 | + |
| 42 | +def get_battery_voltage(): |
| 43 | + """Read battery voltage from A2 pin""" |
| 44 | + # Take the raw voltage pin value, and convert it to voltage |
| 45 | + voltage = (voltage_pin.value / 65536) * 2 * 3.3 |
| 46 | + return voltage |
| 47 | + |
| 48 | + |
| 49 | +# Initialize I2C and LIS3DH accelerometer |
| 50 | +try: |
| 51 | + # i2c = board.STEMMA_I2C() # use this if connecting to STEMMA QT |
| 52 | + i2c = board.I2C() |
| 53 | + try: |
| 54 | + lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c, address=0x18) |
| 55 | + print("LIS3DH address: 0x18") |
| 56 | + except ValueError: |
| 57 | + lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c, address=0x19) |
| 58 | + print("LIS3DH address: 0x19") |
| 59 | + lis3dh.range = adafruit_lis3dh.RANGE_2_G |
| 60 | + lis3dh.set_tap(1, 90) |
| 61 | + has_accelerometer = True |
| 62 | + print("LIS3DH accelerometer initialized with tap detection") |
| 63 | +except Exception as e: # pylint:disable=broad-except |
| 64 | + print(f"Accelerometer init failed: {e}") |
| 65 | + has_accelerometer = False |
| 66 | + |
| 67 | + |
| 68 | +# Channel switching hack |
| 69 | +wifi.radio.start_ap(" ", "", channel=6, max_connections=0) |
| 70 | +wifi.radio.stop_ap() |
| 71 | + |
| 72 | + |
| 73 | +def format_mac(mac_bytes): |
| 74 | + return ":".join(f"{b:02x}" for b in mac_bytes) |
| 75 | + |
| 76 | + |
| 77 | +def get_my_mac(): |
| 78 | + return format_mac(wifi.radio.mac_address) |
| 79 | + |
| 80 | + |
| 81 | +def cycle_color(): |
| 82 | + """Cycle to the next color in the list""" |
| 83 | + global current_color_index, CURRENT_COLOR # pylint: disable=global-statement |
| 84 | + current_color_index = (current_color_index + 1) % len(ALL_COLORS) |
| 85 | + CURRENT_COLOR = ALL_COLORS[current_color_index] |
| 86 | + pixel.fill(CURRENT_COLOR) |
| 87 | + print(f"Color changed to: {CURRENT_COLOR:06X}") |
| 88 | + |
| 89 | + |
| 90 | +def flash(): |
| 91 | + """Quick flash for tap feedback""" |
| 92 | + pixel.fill((200, 200, 200)) |
| 93 | + time.sleep(0.15) |
| 94 | + pixel.fill(CURRENT_COLOR) |
| 95 | + |
| 96 | + |
| 97 | +def enter_sleep(): |
| 98 | + """Enter low-power sleep mode""" |
| 99 | + global is_sleeping # pylint: disable=global-statement |
| 100 | + # print("Entering sleep mode - turning off NeoPixels and radio") |
| 101 | + pixel.fill((0, 0, 0)) # Turn off all NeoPixels |
| 102 | + pixel.brightness = 0 |
| 103 | + wifi.radio.stop_ap() # Turn off radio |
| 104 | + is_sleeping = True |
| 105 | + |
| 106 | + |
| 107 | +def wake_up(): |
| 108 | + """Wake from sleep mode""" |
| 109 | + global is_sleeping, last_activity_time # pylint: disable=global-statement |
| 110 | + # print("Waking up - restoring NeoPixels and radio") |
| 111 | + pixel.brightness = 1.0 |
| 112 | + pixel.fill(CURRENT_COLOR) |
| 113 | + # Restart radio |
| 114 | + wifi.radio.start_ap(" ", "", channel=6, max_connections=0) |
| 115 | + wifi.radio.stop_ap() |
| 116 | + is_sleeping = False |
| 117 | + last_activity_time = time.monotonic() |
| 118 | + |
| 119 | + |
| 120 | +def check_tap(): |
| 121 | + """Check if accelerometer detects tap""" |
| 122 | + if not has_accelerometer: |
| 123 | + return False |
| 124 | + try: |
| 125 | + return lis3dh.tapped |
| 126 | + except Exception as er: # pylint:disable=broad-except |
| 127 | + print(f"Accelerometer read error: {er}") |
| 128 | + return False |
| 129 | + |
| 130 | + |
| 131 | +def send_trigger_message(trigger_type="tap"): |
| 132 | + """Send trigger message with current device color""" |
| 133 | + current_time = time.monotonic() # pylint: disable=redefined-outer-name |
| 134 | + message = f"TRIGGER|{DEVICE_ID}|{trigger_type}|{current_time:.1f}" # pylint: disable=redefined-outer-name |
| 135 | + |
| 136 | + try: |
| 137 | + e.send(message, broadcast_peer) |
| 138 | + # print(f"TX: {message}") |
| 139 | + flash() |
| 140 | + return True |
| 141 | + except Exception as exc: # pylint:disable=broad-except |
| 142 | + print(f"Send error: {exc}") |
| 143 | + return False |
| 144 | + |
| 145 | + |
| 146 | +def send_battery_report(): |
| 147 | + """Send battery voltage report to bridge with current color""" |
| 148 | + voltage = get_battery_voltage() |
| 149 | + current_time = time.monotonic() # pylint: disable=redefined-outer-name |
| 150 | + message = ( # pylint: disable=redefined-outer-name |
| 151 | + f"BATTERY|{DEVICE_ID}|{voltage:.2f}|{CURRENT_COLOR:06X}|{current_time:.1f}" |
| 152 | + ) |
| 153 | + |
| 154 | + try: |
| 155 | + e.send(message, broadcast_peer) |
| 156 | + print(f"TX Battery: {message} ({voltage:.2f}V, color: {CURRENT_COLOR:06X})") |
| 157 | + return True |
| 158 | + except Exception as exb: # pylint:disable=broad-except |
| 159 | + print(f"Battery report send error: {exb}") |
| 160 | + return False |
| 161 | + |
| 162 | + |
| 163 | +# Initialize ESP-NOW |
| 164 | +e = espnow.ESPNow() |
| 165 | +broadcast_peer = espnow.Peer(mac=b"\xff\xff\xff\xff\xff\xff", channel=6) |
| 166 | +e.peers.append(broadcast_peer) |
| 167 | + |
| 168 | +my_mac = get_my_mac() |
| 169 | +print(f"{DEVICE_ID} ball starting - MAC: {my_mac}, Color: {CURRENT_COLOR:06X}") |
| 170 | + |
| 171 | +# Clear accelerometer startup noise |
| 172 | +print("Initializing accelerometer...") |
| 173 | +time.sleep(0.5) |
| 174 | +if has_accelerometer: |
| 175 | + print("Clearing startup tap artifacts...") |
| 176 | + for i in range(10): |
| 177 | + try: |
| 178 | + tap_state = lis3dh.tapped |
| 179 | + if tap_state: |
| 180 | + print(f"Cleared startup tap {i + 1}") |
| 181 | + time.sleep(0.1) |
| 182 | + except Exception: # pylint:disable=broad-except |
| 183 | + pass |
| 184 | + print("Accelerometer ready for tap detection") |
| 185 | + |
| 186 | +# Timing variables |
| 187 | +last_tap_time = 0 |
| 188 | +tap_debounce = 0.3 |
| 189 | +startup_time = time.monotonic() |
| 190 | +startup_protection = 0.5 |
| 191 | + |
| 192 | +# Sleep/wake tracking |
| 193 | +last_activity_time = time.monotonic() |
| 194 | +is_sleeping = False |
| 195 | + |
| 196 | +while True: |
| 197 | + current_time = time.monotonic() # pylint: disable=redefined-outer-name |
| 198 | + |
| 199 | + # Check for sleep timeout |
| 200 | + if not is_sleeping and (current_time - last_activity_time > SLEEP_AFTER): |
| 201 | + enter_sleep() |
| 202 | + |
| 203 | + # Check for tap (primary trigger and wake-up source) |
| 204 | + if has_accelerometer: |
| 205 | + # Only check for taps after startup protection period |
| 206 | + if current_time - startup_time > startup_protection: |
| 207 | + if check_tap(): |
| 208 | + # If sleeping, wake up first |
| 209 | + if is_sleeping: |
| 210 | + wake_up() |
| 211 | + |
| 212 | + # Check debounce for actual trigger |
| 213 | + if current_time - last_tap_time > tap_debounce: |
| 214 | + send_trigger_message("tap") |
| 215 | + last_tap_time = current_time |
| 216 | + last_activity_time = current_time |
| 217 | + else: |
| 218 | + # During startup protection, clear any false tap detections |
| 219 | + if check_tap(): |
| 220 | + print("Ignoring tap during startup protection period") |
| 221 | + |
| 222 | + # Check for incoming packets from bridge (only if not sleeping) |
| 223 | + if not is_sleeping and e: |
| 224 | + packet = e.read() |
| 225 | + sender_mac = format_mac(packet.mac) |
| 226 | + |
| 227 | + if sender_mac != my_mac: |
| 228 | + message = packet.msg.decode("utf-8") # pylint:disable=redefined-outer-name |
| 229 | + last_activity_time = current_time # Any message counts as activity |
| 230 | + |
| 231 | + # Handle color change commands from bridge |
| 232 | + if message.startswith("COLOR|"): |
| 233 | + parts = message.split("|") |
| 234 | + if len(parts) >= 3: |
| 235 | + target_device = parts[1] |
| 236 | + command = parts[2] |
| 237 | + |
| 238 | + # Only respond if this message is for us |
| 239 | + if target_device == DEVICE_ID and command == "next": |
| 240 | + cycle_color() |
| 241 | + # Send battery report when color changes |
| 242 | + send_battery_report() |
| 243 | + |
| 244 | + # Handle trigger messages from other balls for visual feedback |
| 245 | + # elif message.startswith("TRIGGER|"): |
| 246 | + # parts = message.split("|") |
| 247 | + # if len(parts) >= 4: |
| 248 | + # sender_device = parts[1] |
| 249 | + # trigger_type = parts[2] |
| 250 | + # print(f"Trigger from {sender_device} ({trigger_type})") |
| 251 | + # # Brief red flash for other ball triggers |
| 252 | + # flash() |
| 253 | + |
| 254 | + time.sleep(0.01) # Fast polling for responsive tap detection |
0 commit comments