A Very NeoPixel Christmas
The Christmas season brings decorations and lights. My neighborhood has an assortment of fixed multi-color incandescent and LED lights. Then there is one house with a big light show, complete with low-power FM transmission of a soundtrack to sync with the lights.
While I don't want to invest the time and expense in setting up a complete light and sound show, I thought I could do a bit better than common off-the-shelf lighting. I came up with an inexpensive solution that uses an ESP32, some strings of NeoPixels, and the Moddable SDK. The video below shows some of the effects.
NeoPixels
The Moddable SDK includes a NeoPixels API designed for low-cost microcontrollers like the ESP32. Developers can write applications to control long strings of LEDs and create custom animations with any color.
Amazon offers a Prime deal to send strings of NeoPixels the next day. There are many vendors of these lights; search for "WS2811 string lights" and you're sure to find a string for less than $20. The LEDs on reel are fun too, but you are pretty much constrained to straight runs. Make sure you get the ones that are wired and weatherproof if you are planning on putting them on your house.
If you don't already have an ESP32 module, you can order one of those for under $10 too.
Power
The LED strings come in 5V and 12V models. Either will work - just make sure you match the power supply with the source voltage. Label them if you're working with 12V strings, especially if you're swapping development between 12V and 5V strings.
Long strings of RGB LEDs can draw a lot of power. Check the specs and calculate the power draw so you can provide enough amperage to the circuit. In my strings, the specifications say 0.6W/LED, so a 50 LED segment will run 30W. At 12V that comes in at 2.5 amps for the worst case with all red, green and blue LEDs at full brightness.
If you are using many strings wired together, you may need to inject power along the path to combat the effects of resistance. I was able to run 4 strings of 50 (200 lights) on a 12V 6A power supply without a noticeable drop in brightness. With effects, the brightness and colors of the lights are changing often so any drop in brightness will be not be noticed.
Many ESP32 modules come with a voltage regulator capable of 12V input so you can drive the module with the same power supply as the LEDs. That makes things a lot easier. I'm not sure how long the module's onboard regulator will last converting 12V to 3V for extended periods. Using 5V strips eliminates that concern.
Pigtail connections to hook up to your LED string if it is so configured. Many strips and strings come with the matching pigtail. This also simplifies the hookup.
Hookup
Three wires to the ESP32 is all it takes:
- Power to VIN on the ESP32 module and LED strip.
- Ground to GND on the ESP32 and the LED strip.
- Signal from the LED strip to a GPIO on the ESP32. I used GPIO 22.
My strings have a 3 prong JST connector on either end and additional power taps at either end for power injection.
Don't fry your module!
Since I've used 12V power supply, I make sure I disconnect the power before I connect the USB port. I'm uncertain whether the USB 5V power is directly connected to VIN on the module and I don't want to run 12V to USB 5V. I'm not willing to sacrifice a module or my USB bus to try it.
Consider yourself warned.
On a 12V system, it is a simple precaution to disconnect the power from the LED string before connecting USB to the module. And don't forget to remove the USB before reconnecting the power.
Programming
This is the fun part!
Standard Marquee lights, rainbow fades and patterns, and random ROYGBIV blinking works with almost any layout, but with addressable LEDs you can create some interesting effects like grid fades and wipes with colors simply unattainable by your normal Christmas lights.
I subclassed the NeoPixel class and created a new NeoStrand class to add effects and schemes.
A scheme is a set of effects to apply to a strand. A scheme has one or more effects applied to the strand in the specified order, allowing modifier effects to alter the colors and brightness of segments of the strand.
An effect is an operation that is called on a regular basis to change the red, green, and blue colors of the LED pixels in a segment of the strip.
Setup
First we need to import some base modules and the NeoStrand module:
import Timer from "timer";
import Digital from "pins/digital";
import Monitor from "pins/digital/monitor";
import NeoStrand from "neostrand";
Note: if you notice flicker, you may have to adjust the timing for your strip. Check your specification sheet. The durations are in 50ns increments. NeoPixel has been extended to allow custom timings.
const Timing_WS2811 = {
mark: { level0: 1, duration0: 800, level1: 0, duration1: 450, },
space: { level0: 1, duration0: 400, level1: 0, duration1: 800, },
reset: { level0: 0, duration0: 30000, level1: 0, duration1: 30000, }
};
We're using 3 strands of 50 LEDs in this example, so the length is 150. NeoStrand is subclass of NeoPixel, so we use the same constructor dictionary to set the pin connected to the DataIn signal on the LED strip:
const LEN = 150;
const strand = new NeoStrand({length: LEN, pin: 22, order: "RGB", timing: Timing_WS2811});
Note: You may want to tone down your lights during development so you don't blind yourself.
strand.brightness = 10;
This diminishes your color range, so use with care. The default brightness is 64 and maximum is 255.
Make sure you test at least once with your desired brightness as the high-powered leds can look different at bright levels.
Defining schemes
A scheme is a set of effects to apply to a strand. A scheme can have multiple effects which are applied to the strand in order.
let myEffect = new NeoStrand.HueSpan({ strand, start: 0, end: (strand.length/2 - 1) });
let myEffect2 = new NeoStrand.Marquee({ strand, start: (strand.length/2), end: strand.length, reverse: 1 });
let myScheme = [ myEffect, myEffect2 ];
This defines the parameters to create a HueSpan effect on the left side and a Marquee on the right side. The effects and their parameters are described later in this post.
Starting it up
Now we just have to set the scheme and start.
strand.setScheme(myScheme);
strand.start(50);
The parameter sent to start
defines the frequency that the effect engine is run. It is the number of milliseconds between calls to the effect function. Reducing the number may cause some effects to render more smoothly at the expense of CPU load.
Changing schemes
With the extraordinary flexibility that NeoStrand provides, you may not be satisfied using a single scheme. The Moddable SDK provides a number of ways to assist you in making that happen. There are three methods outlined here:
- Using a GPIO trigger (button press)
- Using a Timer
- Using an HTTP Server fetch
The list of schemes is an array that can be constructed and pushed/popped to.
let manySchemes = [
[ new NeoStrand.HueSpan({ strand }) ],
[ new NeoStrand.Marquee({ strand }) ],
myScheme,
];
let currentScheme = 0;
Using a GPIO trigger
The Monitor
class is used to watch for a button press. Pin 0 on my ESP32 module is connected to a "user" button. When the button is pressed, the next scheme in the array is set. The new scheme takes effect immediately.
let monitor = new Monitor({pin: 0, mode: Digital.InputPullUp, edge: Monitor.Falling});
monitor.onChanged = function() {
currentScheme = (currentScheme + 1) % manySchemes.length;
strand.setScheme(manySchemes[currentScheme]);
}
Using a Timer
A repeating timer can be used to cycle through the set of schemes:
let effectRunTime = 10000; // 10 seconds
Timer.repeat(() => {
currentScheme = (currentScheme + 1) % manySchemes.length;
strand.setScheme(manySchemes[currentScheme]);
}, effectRunTime);
Using an HTTP fetch
Since this is a ESP32 with Wi-Fi, let's take advantage of that. The Moddable SDK makes it super easy.
We'll use MDNS to advertise http://lights.local. When you hit that page from your web browser the scheme will change.
That sounds like a lot of code. But with Moddable, it's just a few lines. To advertise with mDNS, set up an HTTP server, and configure Wi-Fi, take the following steps:
-
Advertise with mDNS
The following code claims the name lights so that a request to http://lights.local
on the local network will be handled by this device.
import MDNS from "mdns";
import {Server} from "http";
let hostName = "lights";
new MDNS({hostName});
-
Start an HTTP server
The HTTP server will handle the request and change the scheme.
let server = new Server({port: 80});
server.callback = function(message, value) {
if (2 == message)
this.path = value;
if (8 == message) {
if (this.path == "/") {
// change the scheme
currentScheme = (currentScheme + 1) % manySchemes.length;
strand.setScheme(manySchemes[currentScheme]);
// send a response
let body = "Scheme changed to: ";
for (let i=0; i<strand.scheme.length; i++)
body += strand.scheme[i].name + "\n";
trace(body);
return {headers: ["Content-Type", "text/plain"], body};
}
else
this.status = 404;
}
}
-
Configure Wi-Fi
When building an app that uses WiFi, you can simply add the ssid
and password
parameters to the mcconfig build line:
$ mcconfig -m -p esp32 ssid=<myssid> password=<mypassword>
Once the app is built and deployed, accessing the web page http://lights.local will change the scheme.
Enjoy your amazing lights.
Tell me about the effects
Effects are what make up a scheme. They usually cause one or more of the LEDs to change state over time. The effect function can do just about anything though - it's just JavaScript. You can get and set GPIOs, play a sound, trigger a cannon - the sky's the limit.
The following video shows some of the effects included in the NeoStrand API.
Effects are subclassed from NeoStrandEffect
and take a dictionary of configuration parameters. The configuration dictionary must contain the strand
to which this effect will be applied.
let baseDictionary = { strand, duration: 5000, loop: 1 };
Other parameters are defined by each effect. Some effects take a start
and end
parameter which define the span of the strand to use for that effect. Multiple effects can be applied to the same strand simultaneously (a scheme) so you can tailor the effects to your particular physical configuration.
A fragment of the documentation describing the Marquee and Hue Span effects is included below.
Marquee
The Marquee
effect is the common ant-march lighting effect.
A sizeA
and sizeB
parameters define the pattern. For example { sizeA: 1, sizeB: 3 }
will produce the repeating pattern: [ A, B, B, B, A, B, B, B, A ... ]
Parameter |
Default Value |
Range |
Description |
strand |
(none) |
|
The NeoStrand to which this effect applied |
start |
0 |
[0..strand.length] |
The index of the first pixel of effect |
end |
strand.length |
[0..strand.length] |
The index of the last pixel of effect |
reverse |
0 |
[0, 1] |
Set to 1 to run the effect in reverse, i.e. run the timeline of the effect backwards |
loop |
1 |
[0, 1] |
Set to 1 to loop the effect |
duration |
1000 |
|
The duration of one complete cycle of the pattern, in ms |
sizeA |
1 |
[1..strand.length] |
The number of pixels in the A part of pattern |
sizeB |
3 |
[1..strand.length] |
The number of pixels in the B part of pattern |
rgbA |
{ r: 0, g: 0, b: 0x13 } |
|
RGB color elements for the A part of the pattern |
rgbB |
{ r:0xff, g:0xff, b:0xff } |
|
RGB color elements for the B part of the pattern |
Hue Span
The HueSpan
effect is a smooth fade between colors. Like a color-wheel, it cycles through the hue in HSV color space.
Parameter |
Default Value |
Range |
Description |
strand |
(none) |
|
The NeoStrand to which this effect is applied |
start |
0 |
[0..strand.length] |
The index of the first pixel of effect |
end |
strand.length |
[0..strand.length] |
The index of the last pixel of effect |
size |
strand.length |
[0..strand.length] |
The length of one hue cycle, in pixels |
duration |
1000 |
|
The duration of one complete color wheel cycle, in ms |
reverse |
0 |
[0, 1] |
Set to 1 to run the effect in reverse, i.e. run the timeline of the effect backwards |
loop |
1 |
[0, 1] |
Set to 1 to loop the effect |
speed |
1.0 |
|
The speed multiplier |
position |
0 |
[0..1] |
The starting HSV hue position |
saturation |
1.0 |
[0..1] |
The HSV saturation |
value |
1.0 |
[0..1] |
The HSV value |
Lots more...
Please check out the documentation for a current list of the effects and their parameters including:
Make your own effect
To make your own effect, it is often easiest to pick an existing one that most closely matches your vision as a starting point.
Let's make a random color effect. It will set size
pixels to a random color and change colors every duration
ms.
Parameter |
Default Value |
Range |
Description |
strand |
(none) |
|
The NeoStrand to which this effect is applied |
start |
0 |
[0..strand.length] |
The index of the first pixel of effect |
end |
strand.length |
[0..strand.length] |
The index of the last pixel of effect |
duration |
1000 |
|
The time between color changes, in ms |
size |
5 |
[0..strand.length] |
The size of each color |
max |
127 |
[0..255] |
The maximum value of the random RGB component |
Using Pattern
as a starting point, we'll change the class name and constructor, set up the timeline in activate
and provide a setter for the changing effect value. The loopPrepare
function will be called before a looping effect starts or restarts.
class RandomColor extends NeoStrandEffect {
constructor(dictionary) {
super(dictionary);
this.name = "RandomColor"
this.size = dictionary.size ? dictionary.size : 5;
this.max = dictionary.max ? dictionary.max : 127;
this.loop = 1; // force loop
}
loopPrepare(effect) {
effect.colors_set = 0;
}
activate(effect) {
effect.timeline.on(effect, { effectValue: [ 0, effect.dur ] }, effect.dur, null, 0);
effect.reset(effect);
}
set effectValue(value) {
if (0 == this.colors_set) {
for (let i=this.start; i<this.end; i++) {
if (0 == (i % this.size)) {
this.color = this.strand.makeRGB(Math.random() * this.max, Math.random() * this.max, Math.random() * this.max);
this.strand.set(i, this.color, this.start, this.end);
}
this.colors_set = 1;
}
}
}
}
Then create and add the effect your scheme list and give it a try.
let randomColorScheme = [ new RandomColor({ strand }) ];
manySchemes.push( randomColorScheme );
It will be the last scheme to be played.
Lessons Learned
I had an error that was driving me batty for a bit:
NeoStrand.prototype.add: not enough IDs!
The code in question:
let p = this.pixels[idx];
After struggling with this for a while, I noticed in xsbug that the index idx
to the array was a floating point number. I received this explanation:
Using floating point numbers as an array index is the same as using a string. These are equivalent...
foo["1.23"]
foo[1.23]
...When the string is an integer, it is a array index... so these are equivalent:
foo["1"]
foo[1]
An easy trick to truncate a float to an integer is to use logical or. It is faster and more compact than using Math.floor. These are equivalent:
foo["1.23" | 0]
foo[1.23 | 0]
foo[1]
Tell us about it
Tweet pictures or movies of your beautiful color creations! You can also offer your new effects to the community on our GitHub repository.
This is just the beginning of the possibilities. I can imagine the embedded server publishing a webpage of parameters to modify. I can imagine a companion app with a Moddable Zero, using the touch-screen with controls to make modifications to the parameters. I can imagine posting a scheme to the embedded http server so I can test new schemes without re-flashing the device. Using music or other sensor based data to influence the effects. There are so many possibilities.