Overview
Sister Night NeoPixel Goggles
In this project, we're building NeoPixel goggles inspired by HBO's television series Watchmen. Control the NeoPixel LED animations with a rotary encoder. Display up to four different modes with control like speed, colors and brightness of the NeoPixels. The 3D printed parts fit over costume goggles and snap fit together!
CircuitPython Code
The code was written in Circuit Python so it makes customizing much easier. This project uses the Trinket Adafruit M0 which is one of the tiniest boards supported in CircuitPython. This makes projects easier to maintain because it works like a USB drive so all of your code and libraries are easily accessible.
Wearable Cosplay Goggles
You can actually see through them so they’re safe to wear. The 3D printed covers actually keep the light away from your eyes. An elastic headband makes it much more comfortable to wear and can be adjusted so they’ll stay nice and tight.
The following the parts are used to build this project.
Page last edited March 08, 2024
Text editor powered by tinymce.
Circuit Diagram
The diagram below provides a visual reference for wiring of the components. This diagrams was created using the software package Fritzing.
Adafruit Library for Fritzing
Use Adafruit's Fritzing parts library to create circuit diagrams for your projects. Download the library or just grab individual parts. Get the library and parts from GitHub - Adafruit Fritzing Parts.
Power
The Trinket M0 is powered by 400mAh battery via the Lipo Backpack. This allows the USB port from the Trinket M0 to charge the 400mAh battery. A slide switch is connected to the two switch pin on the Lipo Back.
- BAT from Lipo Backpack to BAT on Trinket M0
- Ground from Lipo Backpack to GND on Trinket M0
- 5V from Lipo Backpack to USB on Trinket M0
Rotary Encoder
A rotary encoder is wired to the Trinket M0. This requires five wired connections. The rotary encoder also features a built-in button switch. Follow the circuit diagram above for the connecting the correct pins to Trinket M0.
Page last edited March 08, 2024
Text editor powered by tinymce.
Code
Setup Trinket M0 with CircuitPython
We'll need to get our board setup so we can run the CircuitPython code. Let's walk through these steps to get the latest version of CircuitPython onto your board.
The Mu Python Editor
Mu is a simple Python editor that works with Adafruit CircuitPython hardware. It's written in Python and works on Windows, MacOS, Linux and Raspberry Pi. The serial console is built right in so you get immediate feedback from your board's serial output! While you can use any text editor with your code, Mu makes it super simple.
Installing or upgrading CircuitPython
You should ensure you have CircuitPython 5.0 or 8 on your board. Plug your board in with a known good data + power cable (not the cheesy USB cable that comes with USB power packs, they are power only). You should see a new flash drive pop up.
If the drive is CIRCUITPY, then open the boot_out.txt file to ensure the version number is 5.0 or 8.
Adafruit CircuitPython 5.0.0-alpha.5 on 2019-11-04; Adafruit Trinket M0 with samd21e18
If the version is less than 5 -or- you only get a drive named TRINKETBOOT then follow the Trinket M0 guide on installing CircuitPython.
Download the Adafruit CircuitPython Library Bundle
In order to run the code, we'll need to download a few libraries. Libraries contain code to help interface with hardware a lot easier for us.
Use the Trinket M0 page on Installing Libraries to get the library that matches the major version of CircuitPython you are using noted above.
To run the code for this project, we need the two libraries in the Required Libraries list below. Unzip the library bundle and search for the libraries. Drag and drop the files into a folder named lib on the CIRCUITPY drive (create the folder if it is not already on the Trinkey M0).
Once we have all the files we need, a directory listing will look similar to below as far as files and directories.
Upload Code
This project offers two different code.py sketches. Both programs can be used with either display.
Click on the download link below to grab the main code directly from GitHub. Rename the file to code.py and drop it onto the CIRCUITPY main (root) directory. The code will run properly when all of the files have been uploaded including libraries.
Use any text editor or favorite IDE to modify the code. We suggest using Mu as noted above.
# SPDX-FileCopyrightText: 2019 Phillip Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT
# pylint: disable=import-error
# NeoPixel goggles code for CircuitPython
#
# With a rotary encoder attached (pins are declred in the "Initialize
# hardware" section of the code), you can select animation modes and
# configurable attributes (color, brightness, etc.). TAP the encoder
# button to switch between modes/settings, HOLD the encoder button to
# toggle between PLAY and CONFIGURE states.
#
# With no rotary encoder attached, you can select an animation mode
# and configure attributes in the "Configurable defaults" section
# (including an option to auto-cycle through the animation modes).
#
# Things to Know:
# - FancyLED library is NOT used here because it's a bit too much for the
# Trinket M0 to handle (animation was very slow).
# - Animation modes are all monochromatic (single color, varying only in
# brightness). More a design decision than a technical one...of course
# NeoPixels can be individual colors, but folks like customizing and the
# monochromatic approach makes it easier to select a color. Also keeps the
# code a bit simpler, since Trinket space & performance is limited.
# - Animation is monotonic time driven; there are no sleep() calls. This
# ensures that animation is constant-time regardless of the hardware or
# CircuitPython performance over time, or other goings on (e.g. garbage
# collection), only the frame rate (smoothness) varies; overall timing
# remains consistent.
from math import modf, pi, sin
from random import getrandbits
from time import monotonic
from digitalio import DigitalInOut, Direction
from richbutton import RichButton
from rotaryio import IncrementalEncoder
import adafruit_dotstar
import board
import neopixel
# Configurable defaults
PIXEL_HUE = 0.0 # Red at start
PIXEL_BRIGHTNESS = 0.4 # 40% brightness at start
PIXEL_GAMMA = 2.6 # Controls brightness linearity
RING_1_OFFSET = 10 # Alignment of top pixel on 1st NeoPixel ring
RING_2_OFFSET = 10 # Alignment of top pixel on 2nd NeoPixel ring
RING_2_FLIP = True # If True, reverse order of pixels on 2nd ring
CYCLE_INTERVAL = 0 # If >0 auto-cycle through play modes @ this interval
SPEED = 1.0 # Initial animation speed for modes that use it
XRAY_BITS = 0x0821 # Initial bit pattern for "X-ray" mode
# Things you probably don't want to change, unless adding new modes
PLAY_MODE_SPIN = 0 # Revolving pulse
PLAY_MODE_XRAY = 1 # Watchmen-inspired "X-ray goggles"
PLAY_MODE_SCAN = 2 # Scanline effect
PLAY_MODE_SPARKLE = 3 # Random dots
PLAY_MODES = 4 # Number of PLAY modes
PLAY_MODE = PLAY_MODE_SPIN # Initial PLAY mode
CONFIG_MODE_COLOR = 0 # Setting color (hue)
CONFIG_MODE_BRIGHTNESS = 1 # Setting brightness
CONFIG_MODE_ALIGN = 2 # Top pixel indicator
CONFIG_MODES = 3 # Number of CONFIG modes
CONFIG_MODE = CONFIG_MODE_COLOR # Initial CONFIG mode
CONFIGURING = False # NOT configuring at start
# CONFIG_MODE_ALIGN is only used to test the values of RING_1_OFFSET and
# RING_2_OFFSET. The single lit pixel should appear at the top of each ring.
# If it does not, adjust each of those two values (integer from 0 to 15)
# until the pixel appears at the top (or physically reposition the rings).
# Some of the animation modes rely on the two rings being aligned a certain
# way. Once adjusted, you can reduce the value of CONFIG_MODES and this
# mode will be skipped in config state.
# Initialize hardware - PIN DEFINITIONS APPEAR HERE
# Turn off onboard DotStar LED
DOTSTAR = adafruit_dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1)
DOTSTAR.brightness = 0
# Turn off onboard discrete LED
LED = DigitalInOut(board.D13)
LED.direction = Direction.OUTPUT
LED.value = 0
# Declare NeoPixels on pin D0, 32 pixels long. Set to max brightness because
# on-the-fly brightness slows down NeoPixel lib, so we'll do our own here.
PIXELS = neopixel.NeoPixel(board.D0, 32, brightness=1.0, auto_write=False)
# Declare rotary encoder on pins D4 and D3, and click button on pin D2.
# If encoder behaves backwards from what you want, swap pins here.
ENCODER = IncrementalEncoder(board.D4, board.D3)
ENCODER_BUTTON = RichButton(board.D2)
def set_pixel(pixel_num, brightness):
"""Set one pixel in both 16-pixel rings. Pass in pixel index (0 to 15)
and relative brightness (0.0 to 1.0). Actual resulting brightness
will be a function of global brightness and gamma correction."""
# Clamp passed brightness to 0.0-1.0 range,
# apply global brightness and gamma correction
brightness = max(min(brightness, 1.0), 0.0) * PIXEL_BRIGHTNESS
brightness = pow(brightness, PIXEL_GAMMA) * 255.0
# local_color is adjusted brightness applied to global PIXEL_COLOR
local_color = (
int(PIXEL_COLOR[0] * brightness + 0.5),
int(PIXEL_COLOR[1] * brightness + 0.5),
int(PIXEL_COLOR[2] * brightness + 0.5))
# Roll over pixel_num as needed to 0-15 range, then store color
pixel_num_wrapped = (pixel_num + RING_1_OFFSET) & 15
PIXELS[pixel_num_wrapped] = local_color
# Determine corresponding pixel for second ring. Mirror direction if
# configured for such, correct for any rotational difference, then
# perform similar roll-over as above before storing color.
if RING_2_FLIP:
pixel_num = 15 - pixel_num
pixel_num_wrapped = 16 + ((pixel_num + RING_2_OFFSET) & 15)
PIXELS[pixel_num_wrapped] = local_color
def triangle_wave(pos, peak=0.5):
"""Return a brightness level (0.0 to 1.0) corresponding to a position
(0.0 to 1.0) within a triangle wave (spanning 0.0 to 1.0) with wave's
peak brightness at a given position (0.0 to 1.0) within its span.
Positions outside the wave's span return 0.0."""
if 0.0 <= pos < 1.0:
if pos <= peak:
return pos / peak
return (1.0 - pos) / (1.0 - peak)
return 0.0
def hue_to_rgb(hue):
"""Given a hue value as a float, where the fractional portion
(0.0 to 1.0) indicates the actual hue (starting from red at 0,
to green at 1/3, to blue at 2/3, and back to red at 1.0),
return an RGB color as a 3-tuple with values from 0.0 to 1.0."""
hue = modf(hue)[0]
sixth = (hue * 6.0) % 6.0
ramp = modf(sixth)[0]
if sixth < 1.0:
return (1.0, ramp, 0.0)
if sixth < 2.0:
return (1.0 - ramp, 1.0, 0.0)
if sixth < 3.0:
return (0.0, 1.0, ramp)
if sixth < 4.0:
return (0.0, 1.0 - ramp, 1.0)
if sixth < 5.0:
return (ramp, 0.0, 1.0)
return (1.0, 0.0, 1.0 - ramp)
def random_bits():
"""Generate random bit pattern, avoiding adjacent set bits (w/wrap)"""
pattern = getrandbits(16)
pattern |= (pattern & 1) << 16 # Replicate bit 0 at bit 16
return pattern & ~(pattern >> 1) # Mask out adjacent set bits
# Some last-minute state initialization
POS = 0 # Initial swirl animation position
PIXEL_COLOR = hue_to_rgb(PIXEL_HUE) # Initial color
ENCODER_PRIOR = ENCODER.position # Initial encoder position
TIME_PRIOR = monotonic() # Initial time
LAST_CYCLE_TIME = TIME_PRIOR # For mode auto-cycling
SPARKLE_BITS_PREV = 0 # First bits for sparkle animation
SPARKLE_BITS_NEXT = 0 # Next bits for sparkle animation
PREV_WEIGHT = 2 # Force initial sparkle refresh
# Main loop
while True:
ACTION = ENCODER_BUTTON.action()
if ACTION is RichButton.TAP:
# Encoder button tapped, cycle through play or config modes:
if CONFIGURING:
CONFIG_MODE = (CONFIG_MODE + 1) % CONFIG_MODES
else:
PLAY_MODE = (PLAY_MODE + 1) % PLAY_MODES
elif ACTION is RichButton.DOUBLE_TAP:
# DOUBLE_TAP not currently used, but this is where it would go.
pass
elif ACTION is RichButton.HOLD:
# Encoder button held, toggle between PLAY and CONFIG modes:
CONFIGURING = not CONFIGURING
elif ACTION is RichButton.RELEASE:
# RELEASE not currently used (play/config state changes when HOLD
# is detected), but this is where it would go.
pass
# Process encoder input. Code always uses the ENCODER_CHANGE value
# for relative adjustments.
ENCODER_POSITION = ENCODER.position
ENCODER_CHANGE = ENCODER_POSITION - ENCODER_PRIOR
ENCODER_PRIOR = ENCODER_POSITION
# Same idea, but for elapsed time (so time-based animation continues
# at the next position, it doesn't jump around as when multiplying
# monotonic() by SPEED.
TIME_NOW = monotonic()
TIME_CHANGE = TIME_NOW - TIME_PRIOR
TIME_PRIOR = TIME_NOW
if CONFIGURING:
# In config mode, different pixel patterns indicate which
# adjustment is being made (e.g. alternating pixels = hue mode).
if CONFIG_MODE is CONFIG_MODE_COLOR:
PIXEL_HUE = modf(PIXEL_HUE + ENCODER_CHANGE * 0.01)[0]
PIXEL_COLOR = hue_to_rgb(PIXEL_HUE)
for i in range(0, 16):
set_pixel(i, i & 1) # Turn on alternating pixels
elif CONFIG_MODE is CONFIG_MODE_BRIGHTNESS:
PIXEL_BRIGHTNESS += ENCODER_CHANGE * 0.025
PIXEL_BRIGHTNESS = max(min(PIXEL_BRIGHTNESS, 1.0), 0.0)
for i in range(0, 16):
set_pixel(i, (i & 2) >> 1) # Turn on pixel pairs
elif CONFIG_MODE is CONFIG_MODE_ALIGN:
C = 1 # First pixel on
for i in range(0, 16):
set_pixel(i, C)
C = 0 # All other pixels off
else:
# In play mode. Auto-cycle animations if CYCLE_INTERVAL is set.
if CYCLE_INTERVAL > 0:
if TIME_NOW - LAST_CYCLE_TIME > CYCLE_INTERVAL:
PLAY_MODE = (PLAY_MODE + 1) % PLAY_MODES
LAST_CYCLE_TIME = TIME_NOW
if PLAY_MODE is PLAY_MODE_XRAY:
# In XRAY mode, encoder selects random bit patterns
if abs(ENCODER_CHANGE) > 1:
XRAY_BITS = random_bits()
# Unset bits pulsate ever-so-slightly
DIM = 0.42 + sin(monotonic() * 2) * 0.08
for i in range(16):
if XRAY_BITS & (1 << i):
set_pixel(i, 1.0)
else:
set_pixel(i, DIM)
else:
# In all other modes, encoder adjusts speed/direction
SPEED += ENCODER_CHANGE * 0.05
SPEED = max(min(SPEED, 4.0), -4.0)
POS += TIME_CHANGE * SPEED
if PLAY_MODE is PLAY_MODE_SPIN:
for i in range(16):
frac = modf(POS + i / 15.0)[0] # 0.0-1.0 around ring
if frac < 0:
frac = 1.0 + frac
set_pixel(i, triangle_wave(frac, 0.5 - SPEED * 0.125))
elif PLAY_MODE is PLAY_MODE_SCAN:
if POS >= 0:
S = 2.0 - modf(POS)[0] * 4.0
else:
S = 2.0 - (1.0 + modf(POS)[0]) * 4.0
for i in range(16):
Y = sin((i / 7.5 + 0.5) * pi) # Pixel Y coord
D = 0.5 - abs(Y - S) * 0.6 # Distance to scanline
set_pixel(i, triangle_wave(D))
elif PLAY_MODE is PLAY_MODE_SPARKLE:
NEXT_WEIGHT = modf(abs(POS * 2.0))[0]
if SPEED < 0:
NEXT_WEIGHT = 1.0 - NEXT_WEIGHT
if NEXT_WEIGHT < PREV_WEIGHT:
SPARKLE_BITS_PREV = SPARKLE_BITS_NEXT
while True:
SPARKLE_BITS_NEXT = random_bits()
if not SPARKLE_BITS_NEXT & SPARKLE_BITS_PREV:
break # No bits in common, good!
PREV_WEIGHT = 1.0 - NEXT_WEIGHT
for i in range(16):
bit = 1 << i
if SPARKLE_BITS_PREV & bit:
result = PREV_WEIGHT
elif SPARKLE_BITS_NEXT & bit:
result = NEXT_WEIGHT
else:
result = 0
set_pixel(i, result)
PREV_WEIGHT = NEXT_WEIGHT
PIXELS.show()
Page last edited March 08, 2024
Text editor powered by tinymce.
3D Printing
The parts for this project are designed to be 3D printed with FDM based machines. STL files are oriented to print "as is". Parts require tight tolerances that might need adjustment of slice settings. Reference the suggested settings below.
CAD Files
The parts can further be separated into small pieces for fitting on printers with smaller build volumes. Note: a STEP file is included for other 3D surface modeling programs such as Onshape, Solidworks and Rhino.
Custom Supports Plugin for CURA
We suggest installing the custom supports plugin for CURA. This provides a tool for adding custom supports to models. To install the plugin, click on the Marketplace button, top right of the app window. Search for "custom supports" and download the plugin. Make sure to have the latest version of CURA installed.
TPU Flexible Parts
The goggle case and cover are designed to be printed in TPU filament. The goggle-case.stl part requires support material to print properly. The included .3mf file contains pre-configured slice settings for CURA slicing software. Support material is included and does not need to be added with the .3mf project file.
Rotary Case
The rotary-case-left.stl and rotary-case-right.stl parts requires support material as well. This part should be printed in PLA, not ninjaflex. The .3mf project file contains pre-configured slice settings and custom supports. Both parts feature mounting hole for the rotary encoder. You can choose which side to mount the rotary encoder.
Page last edited March 08, 2024
Text editor powered by tinymce.
Assembly
Orient Rings
Rotate the rings as shown to optimize wire lengths.
Measure the left NeoPixel Ring (input) 120mm long with a 4 wire ribbon cable.
The right NeoPixel Ring (output) wires measured 90mm long.
Separate Wires
Carefully separate the wires so they can be hidden behind the back of the NeoPixel PCB.
Silicon ribbon wires are easy to bend and form around the ring.
Solder Input Ring
Apply solder to the 5V, ground, data Input and data out pins. Tweezers can be used to hold each wire while reheating the soldered points and gently pushing each wire into the through pins.
Solder Output Ring
Solder the 5V, ground and data input pins in the same manner.
Solder Ring input to output
Cut and place a small pice of heat shrink before soldering the input and output wires together from the left and right NeoPixel rings.
Solder Y-Cable for Power
Measure a 25mm long 2-wire ribbon cable to combine both power and ground connections from both rings. This will make it easy to connect to the Trinket M0.
Add another small piece of heat shrink to the short ribbon cable before soldering together.
Use tweezers to hold both power wires close while soldering to small wires as shown in the picture.
Use hot air or the upper side of the solder iron to set the heat shrink over the wire connections.
Solder Y wire for ground
Solder the the ground connections in the same way, making sure to add the heat shrink before soldering all three ground wires together.
Lipo Backpack header
Use the included header with the LiPo Backpack to connect it to the Trinket. The BAT and G pins align together but the 5V on the BackPack will need to connect to the USB pin on the Trinket. Use a small wire to connect to make the connection.
Remove the third pin on the header and solder to the Lipo Backpack with the plastic part of the header on the bottom of the Lipo Backpack.
Cut trace
Prepare the Lipo Backpack to connect the slide switch. Cut the trace between the two through holes that have the rectangle around them.
Use flush cutters to remove the trace by placing one of the blades on the mounting hole while the other blade slices through the trace.
Solder switch
Measure a two wire ribbon cable 55mm long and solder to each through hole on the Lipo Backpack.
Trim the pins on the slide switch to make it easier to mount inside the case. We won't need the third pin, so you can remove one of the third pins on one of the sides. Solder both wires to each of the two pins.
Trinket M0 Data Wires
Solder the data wire from the NeoPixel ring to the Trinket M0. Connect the Data In wire to Pin #0.
Solder Power and Ground
Solder the power and ground wires to the positive(+) and negative(-) pads on the bottom of the Trinket M0.
Backpack USB to 5v
Measure and cut the wire to be 20mm in length. Connect it to the USB pad on the Trinket to the 5v pad on the Lipo Backpack.
Align BackPack to Trinket
Place the LipoBack over the Trinket and solder the USB wire to the 5v pin on the Lipo Backpack.
Solder Backpack
Line up the header on the LiPo Backpack and solder the header pins to the Trinket M0.
Rotary Prep
Measure out a 4-wire ribbon cable to be 145mm in length. Reference the circuit diagram and solder the pins to the rotary encoder.
Solder Rotary to Trinket
Tin pins number 2, 3 and 4 on the Trinket M0 and reference the circuit diagram to solder the wires from the rotary encoder.
Goggle Nose connector
Remove the rubber nose connector on the goggles and replace it with the 3D printed case.
Unscrew both lens covers to reveal the slot on the side of the goggles.
Twist and pull each side of the nose connector to remove it from the goggles.
Temporally Remove Elastic Bands
Remove the elastic band. This will make it easier to mount all of the components. Start by carefully bending back the metal clasps on the ends. Pull out the elastic bands to remove them from the goggles.
Attach printed Nose connector case
Reference the photo and line up the case with goggles so it fits on the opposite side of the nose bridge.
Insert the nubs from the case into the slits on goggles. Twist to lock them into place.
Remove Tinted Lens
The included tinted lenses stack on top of the clear lenses. Remove them to fit the 3D printed NeoPixel holders on top.
Place the 3D printed ring holders over the clear lens. The holder is held in place with the housing screwed back on.
Place side case
Fit one of the rotary cases over an eye and screw on the lens cover to fit it in place. Repeat this for the second eye.
Press fit rings
With the lens covers secured, press fit the NeoPixel rings into the 3D printed holders. If the tolerances are too tight for fitting, file down the NeoPixel Ring PCBs to remove any leftover rough areas ("mouse bites").
Insert Battery
Be sure to remove the support material inside the 3D printed case.
Connect the 400mah lipo battery to the JST connector on the Lipo Backpack. Carefully insert the battery at an angle.
Attach Slide Switch
The slide switch is fitted above the lipo battery inside the walls around the cutout.
Insert Trinket
With the battery and slide switch mounted, place the Trinket M0 above the battery with the Lipo Backpack facing up.
Prep Rotary
The rotary encoder is held in place with the included washer and hex nut. Remove the knob, washer and hex nut before panel mounting. The wires can be threaded through the slits on the case.
Attach Rotary knob case
Line up the rotary encoder so it sits flush against the case. Fasten the washer and screw back on.
Feed the wires up and over the goggle lens and hold the side case with one hand while screwing the lens holder back on to the goggles.
Page last edited March 08, 2024
Text editor powered by tinymce.
Modes and Functions
Using the rotary encoder (the black spinney knob), you can switch between the different play mode animations, adjust the speed of those animations, as well as change the color and brightness of the pixels.
Play modes
There are 4 "play modes" for the goggles. These are the different animations that the goggles run.
Briefly press down the rotary encoder to toggle between each mode.
Play mode 1: pixels spin
Adjust the speed of the spinning by twisting the rotary encoder. Can speed up or slow down the rate at which the lights move around the goggles.
Play mode 2: "Watchmen" mode
Adjust location of bright pixels with encoder.
Play mode 3: pixels spin and collide
Adjust speed with rotary encoder.
Play mode 4: random pixel glow
Adjust speed with rotary encoder.
Transition between play and configuration modes
To change the color and brightness of the NeoPixels, you will have to change the configuration settings. These settings can be accessed by pressing and holding down the encoder until the configuration state is reached.
Config modes
There are 3 config modes. Switch between them the same way as the play modes are switched, by briefly pressing down the encoder.
Config mode 1: color
To change the color of the NeoPixels, adjust the rotary encoder. Whatever color you end up on will be the color saved for all modes moving forward.
Config mode 2: brightness
Use the encoder to adjust the NeoPixel brightness.
Config mode 3: NeoPixel alignment
This config mode is for verifying the alignment and placement of the NeoPixel rings. The leds should be at the top of the ring and symmetrical to each other. If they aren't, either reposition the rings or change the values of ring_1_offset and/or ring_2_offset in the code.
Example
Putting this all together, here's an example of how to change the NeoPixel color from red (default) to green as well as increase the brightness. Then, how to go back to the play modes and watch the new color in action.
Page last edited March 08, 2024
Text editor powered by tinymce.