Skip to content

Commit 93d2025

Browse files
Merge pull request #930 from adafruit/python-goggles
Add CircuitPython_Goggles code
2 parents a490bc5 + 172d6a0 commit 93d2025

File tree

2 files changed

+379
-0
lines changed

2 files changed

+379
-0
lines changed

CircuitPython_Goggles/code.py

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
# pylint: disable=import-error
2+
3+
"""
4+
NeoPixel goggles code for CircuitPython
5+
6+
With a rotary encoder attached (pins are declred in the "Initialize
7+
hardware" section of the code), you can select animation modes and
8+
configurable attributes (color, brightness, etc.). TAP the encoder
9+
button to switch between modes/settings, HOLD the encoder button to
10+
toggle between PLAY and CONFIGURE states.
11+
12+
With no rotary encoder attached, you can select an animation mode
13+
and configure attributes in the "Configurable defaults" section
14+
(including an option to auto-cycle through the animation modes).
15+
16+
Things to Know:
17+
- FancyLED library is NOT used here because it's a bit too much for the
18+
Trinket M0 to handle (animation was very slow).
19+
- Animation modes are all monochromatic (single color, varying only in
20+
brightness). More a design decision than a technical one...of course
21+
NeoPixels can be individual colors, but folks like customizing and the
22+
monochromatic approach makes it easier to select a color. Also keeps the
23+
code a bit simpler, since Trinket space & performance is limited.
24+
- Animation is monotonic time driven; there are no sleep() calls. This
25+
ensures that animation is constant-time regardless of the hardware or
26+
CircuitPython performance over time, or other goings on (e.g. garbage
27+
collection), only the frame rate (smoothness) varies; overall timing
28+
remains consistent.
29+
"""
30+
31+
from math import modf, pi, sin
32+
from random import getrandbits
33+
from time import monotonic
34+
from digitalio import DigitalInOut, Direction
35+
from richbutton import RichButton
36+
from rotaryio import IncrementalEncoder
37+
import adafruit_dotstar
38+
import board
39+
import neopixel
40+
41+
# Configurable defaults
42+
43+
PIXEL_HUE = 0.0 # Red at start
44+
PIXEL_BRIGHTNESS = 0.4 # 40% brightness at start
45+
PIXEL_GAMMA = 2.6 # Controls brightness linearity
46+
RING_1_OFFSET = 10 # Alignment of top pixel on 1st NeoPixel ring
47+
RING_2_OFFSET = 10 # Alignment of top pixel on 2nd NeoPixel ring
48+
RING_2_FLIP = True # If True, reverse order of pixels on 2nd ring
49+
CYCLE_INTERVAL = 0 # If >0 auto-cycle through play modes @ this interval
50+
SPEED = 1.0 # Initial animation speed for modes that use it
51+
XRAY_BITS = 0x0821 # Initial bit pattern for "X-ray" mode
52+
53+
# Things you probably don't want to change, unless adding new modes
54+
55+
PLAY_MODE_SPIN = 0 # Revolving pulse
56+
PLAY_MODE_XRAY = 1 # Watchmen-inspired "X-ray goggles"
57+
PLAY_MODE_SCAN = 2 # Scanline effect
58+
PLAY_MODE_SPARKLE = 3 # Random dots
59+
PLAY_MODES = 4 # Number of PLAY modes
60+
PLAY_MODE = PLAY_MODE_SPIN # Initial PLAY mode
61+
62+
CONFIG_MODE_COLOR = 0 # Setting color (hue)
63+
CONFIG_MODE_BRIGHTNESS = 1 # Setting brightness
64+
CONFIG_MODE_ALIGN = 2 # Top pixel indicator
65+
CONFIG_MODES = 3 # Number of CONFIG modes
66+
CONFIG_MODE = CONFIG_MODE_COLOR # Initial CONFIG mode
67+
CONFIGURING = False # NOT configuring at start
68+
# CONFIG_MODE_ALIGN is only used to test the values of RING_1_OFFSET and
69+
# RING_2_OFFSET. The single lit pixel should appear at the top of each ring.
70+
# If it does not, adjust each of those two values (integer from 0 to 15)
71+
# until the pixel appears at the top (or physically reposition the rings).
72+
# Some of the animation modes rely on the two rings being aligned a certain
73+
# way. Once adjusted, you can reduce the value of CONFIG_MODES and this
74+
# mode will be skipped in config state.
75+
76+
# Initialize hardware - PIN DEFINITIONS APPEAR HERE
77+
78+
# Turn off onboard DotStar LED
79+
DOTSTAR = adafruit_dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1)
80+
DOTSTAR.brightness = 0
81+
82+
# Turn off onboard discrete LED
83+
LED = DigitalInOut(board.D13)
84+
LED.direction = Direction.OUTPUT
85+
LED.value = 0
86+
87+
# Declare NeoPixels on pin D0, 32 pixels long. Set to max brightness because
88+
# on-the-fly brightness slows down NeoPixel lib, so we'll do our own here.
89+
PIXELS = neopixel.NeoPixel(board.D0, 32, brightness=1.0, auto_write=False)
90+
91+
# Declare rotary encoder on pins D4 and D3, and click button on pin D2.
92+
# If encoder behaves backwards from what you want, swap pins here.
93+
ENCODER = IncrementalEncoder(board.D4, board.D3)
94+
ENCODER_BUTTON = RichButton(board.D2)
95+
96+
97+
def set_pixel(pixel_num, brightness):
98+
"""Set one pixel in both 16-pixel rings. Pass in pixel index (0 to 15)
99+
and relative brightness (0.0 to 1.0). Actual resulting brightness
100+
will be a function of global brightness and gamma correction."""
101+
# Clamp passed brightness to 0.0-1.0 range,
102+
# apply global brightness and gamma correction
103+
brightness = max(min(brightness, 1.0), 0.0) * PIXEL_BRIGHTNESS
104+
brightness = pow(brightness, PIXEL_GAMMA) * 255.0
105+
# local_color is adjusted brightness applied to global PIXEL_COLOR
106+
local_color = (
107+
int(PIXEL_COLOR[0] * brightness + 0.5),
108+
int(PIXEL_COLOR[1] * brightness + 0.5),
109+
int(PIXEL_COLOR[2] * brightness + 0.5))
110+
# Roll over pixel_num as needed to 0-15 range, then store color
111+
pixel_num_wrapped = (pixel_num + RING_1_OFFSET) & 15
112+
PIXELS[pixel_num_wrapped] = local_color
113+
# Determine corresponding pixel for second ring. Mirror direction if
114+
# configured for such, correct for any rotational difference, then
115+
# perform similar roll-over as above before storing color.
116+
if RING_2_FLIP:
117+
pixel_num = 15 - pixel_num
118+
pixel_num_wrapped = 16 + ((pixel_num + RING_2_OFFSET) & 15)
119+
PIXELS[pixel_num_wrapped] = local_color
120+
121+
122+
def triangle_wave(pos, peak=0.5):
123+
"""Return a brightness level (0.0 to 1.0) corresponding to a position
124+
(0.0 to 1.0) within a triangle wave (spanning 0.0 to 1.0) with wave's
125+
peak brightness at a given position (0.0 to 1.0) within its span.
126+
Positions outside the wave's span return 0.0."""
127+
if 0.0 <= pos < 1.0:
128+
if pos <= peak:
129+
return pos / peak
130+
return (1.0 - pos) / (1.0 - peak)
131+
return 0.0
132+
133+
134+
def hue_to_rgb(hue):
135+
"""Given a hue value as a float, where the fractional portion
136+
(0.0 to 1.0) indicates the actual hue (starting from red at 0,
137+
to green at 1/3, to blue at 2/3, and back to red at 1.0),
138+
return an RGB color as a 3-tuple with values from 0.0 to 1.0."""
139+
hue = modf(hue)[0]
140+
sixth = (hue * 6.0) % 6.0
141+
ramp = modf(sixth)[0]
142+
if sixth < 1.0:
143+
return (1.0, ramp, 0.0)
144+
if sixth < 2.0:
145+
return (1.0 - ramp, 1.0, 0.0)
146+
if sixth < 3.0:
147+
return (0.0, 1.0, ramp)
148+
if sixth < 4.0:
149+
return (0.0, 1.0 - ramp, 1.0)
150+
if sixth < 5.0:
151+
return (ramp, 0.0, 1.0)
152+
return (1.0, 0.0, 1.0 - ramp)
153+
154+
155+
def random_bits():
156+
"""Generate random bit pattern, avoiding adjacent set bits (w/wrap)"""
157+
pattern = getrandbits(16)
158+
pattern |= (pattern & 1) << 16 # Replicate bit 0 at bit 16
159+
return pattern & ~(pattern >> 1) # Mask out adjacent set bits
160+
161+
162+
# Some last-minute state initialization
163+
164+
POS = 0 # Initial swirl animation position
165+
PIXEL_COLOR = hue_to_rgb(PIXEL_HUE) # Initial color
166+
ENCODER_PRIOR = ENCODER.position # Initial encoder position
167+
TIME_PRIOR = monotonic() # Initial time
168+
LAST_CYCLE_TIME = TIME_PRIOR # For mode auto-cycling
169+
SPARKLE_BITS_PREV = 0 # First bits for sparkle animation
170+
SPARKLE_BITS_NEXT = 0 # Next bits for sparkle animation
171+
PREV_WEIGHT = 2 # Force initial sparkle refresh
172+
173+
174+
# Main loop
175+
176+
while True:
177+
ACTION = ENCODER_BUTTON.action()
178+
if ACTION is RichButton.TAP:
179+
# Encoder button tapped, cycle through play or config modes:
180+
if CONFIGURING:
181+
CONFIG_MODE = (CONFIG_MODE + 1) % CONFIG_MODES
182+
else:
183+
PLAY_MODE = (PLAY_MODE + 1) % PLAY_MODES
184+
elif ACTION is RichButton.DOUBLE_TAP:
185+
# DOUBLE_TAP not currently used, but this is where it would go.
186+
pass
187+
elif ACTION is RichButton.HOLD:
188+
# Encoder button held, toggle between PLAY and CONFIG modes:
189+
CONFIGURING = not CONFIGURING
190+
elif ACTION is RichButton.RELEASE:
191+
# RELEASE not currently used (play/config state changes when HOLD
192+
# is detected), but this is where it would go.
193+
pass
194+
195+
# Process encoder input. Code always uses the ENCODER_CHANGE value
196+
# for relative adjustments.
197+
ENCODER_POSITION = ENCODER.position
198+
ENCODER_CHANGE = ENCODER_POSITION - ENCODER_PRIOR
199+
ENCODER_PRIOR = ENCODER_POSITION
200+
201+
# Same idea, but for elapsed time (so time-based animation continues
202+
# at the next position, it doesn't jump around as when multiplying
203+
# monotonic() by SPEED.
204+
TIME_NOW = monotonic()
205+
TIME_CHANGE = TIME_NOW - TIME_PRIOR
206+
TIME_PRIOR = TIME_NOW
207+
208+
if CONFIGURING:
209+
# In config mode, different pixel patterns indicate which
210+
# adjustment is being made (e.g. alternating pixels = hue mode).
211+
if CONFIG_MODE is CONFIG_MODE_COLOR:
212+
PIXEL_HUE = modf(PIXEL_HUE + ENCODER_CHANGE * 0.01)[0]
213+
PIXEL_COLOR = hue_to_rgb(PIXEL_HUE)
214+
for i in range(0, 16):
215+
set_pixel(i, i & 1) # Turn on alternating pixels
216+
elif CONFIG_MODE is CONFIG_MODE_BRIGHTNESS:
217+
PIXEL_BRIGHTNESS += ENCODER_CHANGE * 0.025
218+
PIXEL_BRIGHTNESS = max(min(PIXEL_BRIGHTNESS, 1.0), 0.0)
219+
for i in range(0, 16):
220+
set_pixel(i, (i & 2) >> 1) # Turn on pixel pairs
221+
elif CONFIG_MODE is CONFIG_MODE_ALIGN:
222+
C = 1 # First pixel on
223+
for i in range(0, 16):
224+
set_pixel(i, C)
225+
C = 0 # All other pixels off
226+
else:
227+
# In play mode. Auto-cycle animations if CYCLE_INTERVAL is set.
228+
if CYCLE_INTERVAL > 0:
229+
if TIME_NOW - LAST_CYCLE_TIME > CYCLE_INTERVAL:
230+
PLAY_MODE = (PLAY_MODE + 1) % PLAY_MODES
231+
LAST_CYCLE_TIME = TIME_NOW
232+
233+
if PLAY_MODE is PLAY_MODE_XRAY:
234+
# In XRAY mode, encoder selects random bit patterns
235+
if abs(ENCODER_CHANGE) > 1:
236+
XRAY_BITS = random_bits()
237+
# Unset bits pulsate ever-so-slightly
238+
DIM = 0.42 + sin(monotonic() * 2) * 0.08
239+
for i in range(16):
240+
if XRAY_BITS & (1 << i):
241+
set_pixel(i, 1.0)
242+
else:
243+
set_pixel(i, DIM)
244+
else:
245+
# In all other modes, encoder adjusts speed/direction
246+
SPEED += ENCODER_CHANGE * 0.05
247+
SPEED = max(min(SPEED, 4.0), -4.0)
248+
POS += TIME_CHANGE * SPEED
249+
if PLAY_MODE is PLAY_MODE_SPIN:
250+
for i in range(16):
251+
frac = modf(POS + i / 15.0)[0] # 0.0-1.0 around ring
252+
if frac < 0:
253+
frac = 1.0 + frac
254+
set_pixel(i, triangle_wave(frac, 0.5 - SPEED * 0.125))
255+
elif PLAY_MODE is PLAY_MODE_SCAN:
256+
if POS >= 0:
257+
S = 2.0 - modf(POS)[0] * 4.0
258+
else:
259+
S = 2.0 - (1.0 + modf(POS)[0]) * 4.0
260+
for i in range(16):
261+
Y = sin((i / 7.5 + 0.5) * pi) # Pixel Y coord
262+
D = 0.5 - abs(Y - S) * 0.6 # Distance to scanline
263+
set_pixel(i, triangle_wave(D))
264+
elif PLAY_MODE is PLAY_MODE_SPARKLE:
265+
NEXT_WEIGHT = modf(abs(POS * 2.0))[0]
266+
if SPEED < 0:
267+
NEXT_WEIGHT = 1.0 - NEXT_WEIGHT
268+
if NEXT_WEIGHT < PREV_WEIGHT:
269+
SPARKLE_BITS_PREV = SPARKLE_BITS_NEXT
270+
while True:
271+
SPARKLE_BITS_NEXT = random_bits()
272+
if not SPARKLE_BITS_NEXT & SPARKLE_BITS_PREV:
273+
break # No bits in common, good!
274+
PREV_WEIGHT = 1.0 - NEXT_WEIGHT
275+
for i in range(16):
276+
bit = 1 << i
277+
if SPARKLE_BITS_PREV & bit:
278+
result = PREV_WEIGHT
279+
elif SPARKLE_BITS_NEXT & bit:
280+
result = NEXT_WEIGHT
281+
else:
282+
result = 0
283+
set_pixel(i, result)
284+
PREV_WEIGHT = NEXT_WEIGHT
285+
PIXELS.show()

CircuitPython_Goggles/richbutton.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# pylint: disable=import-error, too-many-instance-attributes, too-few-public-methods
2+
3+
"""Glorified button class with debounced tap, double-tap, hold and release"""
4+
5+
from time import monotonic
6+
from digitalio import DigitalInOut, Direction, Pull
7+
8+
class RichButton:
9+
"""
10+
A button class handling more than basic taps: adds debounced tap,
11+
double-tap, hold and release.
12+
"""
13+
14+
TAP = 0
15+
DOUBLE_TAP = 1
16+
HOLD = 2
17+
RELEASE = 3
18+
19+
def __init__(self, pin, *, debounce_period=0.05, hold_period=0.75,
20+
double_tap_period=0.3):
21+
"""
22+
Constructor for RichButton class.
23+
24+
Arguments:
25+
pin (int) : Digital pin connected to button
26+
(opposite leg to GND). Pin will be
27+
configured as INPUT with pullup.
28+
Keyword arguments:
29+
debounce_period (float) : interval, in seconds, in which multiple
30+
presses are ignored (debounced)
31+
(default = 0.05 seconds).
32+
hold_period (float) : interval, in seconds, when a held
33+
button will return a HOLD value from
34+
the action() function (default = 0.75).
35+
double_tap_period (float): interval, in seconds, when a double-
36+
tap can be sensed (vs returning
37+
a second single-tap) (default = 0.3).
38+
Longer double-tap periods will make
39+
single-taps less responsive.
40+
"""
41+
self.in_out = DigitalInOut(pin)
42+
self.in_out.direction = Direction.INPUT
43+
self.in_out.pull = Pull.UP
44+
self._debounce_period = debounce_period
45+
self._hold_period = hold_period
46+
self._double_tap_period = double_tap_period
47+
self._holding = False
48+
self._tap_time = -self._double_tap_period
49+
self._press_time = monotonic()
50+
self._prior_state = self.in_out.value
51+
52+
def action(self):
53+
"""
54+
Process pin input. This MUST be called frequently for debounce, etc.
55+
to work, since interrupts are not available.
56+
Returns:
57+
None, TAP, DOUBLE_TAP, HOLD or RELEASE.
58+
"""
59+
new_state = self.in_out.value
60+
if new_state != self._prior_state:
61+
# Button state changed since last call
62+
self._prior_state = new_state
63+
if not new_state:
64+
# Button initially pressed (TAP not returned until debounce)
65+
self._press_time = monotonic()
66+
else:
67+
# Button initially released
68+
if self._holding:
69+
# Button released after hold
70+
self._holding = False
71+
return self.RELEASE
72+
if (monotonic() - self._press_time) >= self._debounce_period:
73+
# Button released after valid debounce time
74+
if monotonic() - self._tap_time < self._double_tap_period:
75+
# Followed another recent tap, reset double timer
76+
self._tap_time = 0
77+
return self.DOUBLE_TAP
78+
# Else regular debounced release, maybe 1st tap, keep time
79+
self._tap_time = monotonic()
80+
else:
81+
# Button is in same state as last call
82+
if self._prior_state:
83+
# Is not pressed
84+
if (self._tap_time > 0 and
85+
(monotonic() - self._tap_time) > self._double_tap_period):
86+
# Enough time since last tap that it's not a double
87+
self._tap_time = 0
88+
return self.TAP
89+
elif (not self._holding and
90+
(monotonic() - self._press_time) >= self._hold_period):
91+
# Is pressed, and has been for the holding period
92+
self._holding = True
93+
return self.HOLD
94+
return None

0 commit comments

Comments
 (0)