Skip to content

Commit 6755083

Browse files
committed
first commit juggling ball and bridge code
1 parent ecc17f1 commit 6755083

File tree

3 files changed

+664
-0
lines changed

3 files changed

+664
-0
lines changed

ESP-NOW_Juggling/boot.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# SPDX-FileCopyrightText: 2025 John Park for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
"""
5+
ESP-NOW MIDI Juggling Bridge
6+
boot.py - Minimal configuration for USB MIDI only
7+
"""
8+
9+
import usb_hid
10+
import usb_midi
11+
12+
# Disable everything except MIDI
13+
usb_hid.disable() # No HID devices
14+
usb_midi.enable() # Only MIDI
15+
16+
print("Minimal USB MIDI configuration loaded")
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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:
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 e:
127+
print(f"Accelerometer read error: {e}")
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 ex:
142+
print(f"Send error: {ex}")
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 ex:
159+
print(f"Battery report send error: {ex}")
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:
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

Comments
 (0)