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