|
| 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() |
0 commit comments