Coding tips & tricks for LED ring lighting effects - Embedded.com

Coding tips & tricks for LED ring lighting effects

Before we start, I should remind you that I am a hardware design engineer by trade — I am in no way a paid-up member of the software cognoscenti — so when it comes to creating code, I'm largely reduced to making things up as I go along.

Of course, this sometimes ends up with my feeling as though I'm on the losing end of a birling competition, but I love the adrenalin rush of excitement and anticipation when I finally track a recalcitrant bug — typically in the form of a stupid mistake on my part — down to its lair.

The point is that, even though I do walk the path of hardware righteousness, I have picked up a few tricks from the software dark-side along the way, and I'm quite proud of some of these little rascals, so I thought I'd dare to share them here.

Now, if you are one who strides the corridors of power in the software domain, you'll probably see this article as an amphigory describing paltry parlor tricks of no account, in which case may I bid you good day (don’t let the door bang into you on the way out). We hardware guys and gals have enough on our plates wrestling the underlying systems into submission, so when it comes to subduing scoundrelly software, all we can do is do the best we can with what we've got.

The topic of this article is implementing interesting effects on LED rings — in particular, the NeoPixel-based rascals from Adafruit that I've been using in my Vetinari Clock and Cunning Chronograph projects (see also Using WS2812-based NeoPixels in embedded systems).

In the case of the Vetinari Clock, we're working with a 16-element NeoPixel ring. For the purposes of simplicity, however, let's pretend it's an 8-element ring, and that these elements are numbered as follows (I've shown element 0 as being a darker color for future reference):


(Source: Max Maxfield/Embedded.com)

Let's assume that we've declared our ring and — because we don’t have much imagination — we've called it myRing . Let's also assume that we've already defined our 8-bit RGB values, which we will refer to as R , G , and B in the code (I hope this naming convention won't prove to be too confusing 🙂

Now, let's suppose that we wish to light up the elements forming our ring one after the other, starting with element 0 and working out way up to element 7, with a 100 ms delay between each of the elements turning on. After this, we wish to turn them off again in the same order. The code we use to do this could be as follows:

for (i = 0; i < 8; i++) {  myRing.setPixelColor(i,R,G,B);  myRing.show();  delay(100);}for (i = 0; i < 8; i++) {  myRing.setPixelColor(i,0,0,0);  myRing.show();  delay(100);}

The way Adafruit's NeoPixel library works is that the setPixelColor() function stores the RGB data in an array in memory (we might visualize this as a two-dimensional array of bytes called myRing[8][3] , where the first dimension reflects the number of elements forming the ring and the second dimension refers to the three RGB values associated with each element). Later, we use the show() function to stream this data out of memory and into the ring. (Note that the 8 and 3 values in the array declaration result in elements numbered 0-to-7 and 0-to-2, respectively.)

OK so far, but now let's suppose that every time we light up a new element (i ), we also wish to turn off the previous element (i - 1 ), perhaps using something like the following:

for (i = 0; i < 8; i++) {  myRing.setPixelColor(i,R,G,B);  myRing.setPixelColor(i-1,0,0,0);  myRing.show();  delay(100);}

The idea is that if we were to call this snippet of code over and over again, we would hope to see a single element racing round and round the ring. The problem is that the code shown above won’t work (cue "sad sound" effect). The underlying theory is reasonable enough, but it's always the "end conditions" that bite you in the nether regions when you're least expecting it. In order to see what the issue is, consider the following table:


(Source: Max Maxfield/Embedded.com)

As we see, the problem occurs right at the beginning when we illuminate element 0 and attempt to turn off the previous element. We actually wish to turn off element 7 (00000111 in binary). The problem is that when i = 0 , our i - 1 operation will result in -1 (11111111 in binary).

If you aren’t sure where this 11111111 value came from, but you really wish to know, then you need to perform a Google search on "Two's Complement." For the purposes of this column, however, let's simply assume that -1 in our computer is represented using an all 1s value.

Now, we could perform a test to see if we are working with bit 0, and address the situation accordingly. Consider the following snippet of code, for example:

for (i = 0; i < 8; i++) {  myRing.setPixelColor(i,R,G,B);  if (i == 0)    myRing.setPixelColor(7,0,0,0);  else    myRing.setPixelColor(i-1,0,0,0);  myRing.show();  delay(100);}

This is certainly a serviceable solution, but I regard it as being messy and aesthetically unpleasing. Fortunately, there's a cunning trick we can employ as follows:

for (i = 0; i < 8; i++) {  myRing.setPixelColor(i,R,G,B);  myRing.setPixelColor((i-1) & 0x7 ,0,0,0);  myRing.show();  delay(100);}

The & is known as the bitwise AND operator. This operator performs a logical AND operation on each pair of corresponding bits in the values on either side of the operator. If either of the bits is 0, the associated output bit is 0; it's only when both bits are 1 that the output is 1. Consider what would happen if we were to perform the bitwise AND on two 8-bit variables called a and b containing binary values of 00001111 and 01010101, respectively:


(Source: Max Maxfield/Embedded.com)

If we look at the two digits in the bit 7 positions, we have 0 & 0 = 0. The two digits in bit 6 give us 0 & 1 = 0; the two digits in bit 3 give us 1 & 0 = 0; and the two digits in bit 2 give us 1 & 1 = 1.

Returning to our code snippet above, we're using the & 0x7 as a "mask" to clear the most-significant (MS) bits to 0 and to only return the values in the three LS bits (where 0x7 in hexadecimal represents 00000111 in binary).

What this means in practice is that when i = 1 through 7 (00000001 through 00000111 in binary), and i - 1 = 0 through 6 (00000000 through 00000110 in binary), then the & 0x7 has no effect whatsoever. However, when i = 0 (00000000 in binary), and i - 1 = -1 (11111111 in binary), then using the bitwise AND (& 0x7 , or 00000111 in binary) results in 00000111, which equates to element 7, which is what we wanted in the first place (cue "happy music").

Our new solution also works "the other way round," as it were. In the examples above, our illuminated element has been racing round in a clockwise direction, but suppose we wished to make it rotate the other way round. Once again, we might start with the following code:

for (i = 7 ; i >= 0 ; i-- ) {  myRing.setPixelColor(i,R,G,B);  myRing.setPixelColor(i+1 ,0,0,0);  myRing.show();  delay(100);}

And, once again, this won’t work due to a problem with the end condition as illustrated below:


(Source: Max Maxfield/Embedded.com)

Happily, the same solution we came up with to address the clockwise end condition problem also works for its anticlockwise counterpart:

for (i = 7; i >= 0; i--) {  myRing.setPixelColor(i,R,G,B);  myRing.setPixelColor((i+1) & 0x7 ,0,0,0);  myRing.show();  delay(100);}

In this case, when i = 6 through 0 (00000110 through 00000000 in binary), and i + 1 = 7 through 1 (00000111 through 00000001 in binary), then the & 0x7 has no effect. However, when i = 7 (00000111 in binary), and i + 1 = 8 (00001000 in binary), then using the bitwise AND (& 0x7 , or 00000111 in binary) results in 00000000, which equates to element 0, which -- once again -- is what we wanted in the first place.

This is the point when I feel like enthusiastically exclaiming "Tra-la" in strident tones; but wait, because there's more (Oh, so much more)...

To Page 2 >

Using cross-reference tables
Let’s assume that we are still playing with an 8-element ring and that we've spent countless yonks developing copious amounts of capriciously cunning code. Unfortunately, something dire happens to our ring and we have to replace it. Even worse, when we come to "light up" our new ring, we discover that we've inserted it incorrectly such that it's rotated clockwise by 90° as illustrated below.


(Source: Max Maxfield/Embedded.com)

In this case, the red numbers outside the ring are the actual indices to the physical pixels, while the green numbers inside the ring reflect the indices our code is expecting to use. It probably goes without saying that this is the time when one is prone to murmur something like "Oh dear, I wish I'd listened to my dear old mother and refrained from supergluing this little scamp into the system" (or words to that effect).

Of course, this won't really matter if all we want to do is have one element chasing itself around and around the ring ad infinitum , but it's going to be a real pain if we wish to control specific elements and showcase sophisticated sequences (I tell you, the words are just tripping and trilling off my tongue today). One solution is to create a cross-reference table (array) as illustrated below:

int xref[8] = {6,7,0,1,2,3,4,5};

Now, let's take our original "single element chasing itself clockwise around the ring" code snippet as an example:

for (i = 0; i < 8; i++) {  myRing.setPixelColor(i,R,G,B);  myRing.setPixelColor((i-1) & 0x7,0,0,0);  myRing.show();  delay(100);}

All we have to do is to modify any references to i in our setPixelColor() function call such that they are redirected by our cross-reference array as illustrated below:

for (i = 0; i < 8; i++) {  myRing.setPixelColor(xref[i] ,R,G,B);  myRing.setPixelColor((xref[i-1] ) & 0x7,0,0,0);  myRing.show();  delay(100);}

Well, I don’t know about you, but I think this is pretty nifty!

Handling rings that don’t have 2^n elements The reason the & 0x7 solution worked in our previous examples is that our rings had eight elements, and eight is a power of two (2^3 = 8). Similar solutions can be employed for any other power of two; for example, 2^4 = 16, so & 0xF (00001111 in binary) would work with a 16-element ring.

Unfortunately, our solution will not work with rings that don’t have 2^n elements. Consider a ring with only six elements, as shown below, for example:


(Source: Max Maxfield/Embedded.com)

In this case, the only legal values we have to address our elements are 0 through 5 (00000000 through 00000101 in binary). In the case of a clockwise rotation, when we are lighting element i = 0 , our & 0x7 solution to extinguish element i -1 would return 7 (00000111), not the 5 (00000101) we desire.

Once again, our cross-reference table (array) comes to the rescue. In this case, we could use the following array to support clockwise rotations:

int xref[7] = {5,  0,1,2,3,4,5};

Observe that we've created a 7-element array, even though our ring boasts only 6 elements. This is because we've added an extra entry at the beginning of the array to handle the i - 1 case for when we are illuminating element 0 and extinguishing element 5. In this case, our code snippet would look like the following:

for (i = 1; i < 7; i++) {  myRing.setPixelColor(xref[i],R,G,B);  myRing.setPixelColor(xref[i-1],0,0,0);  myRing.show();  delay(100);}

Note especially that we now use i = 1; i < 7; in our for loop, as opposed to the i = 0; i < 6; we might have used if we hadn’t added the extra element into the beginning of the array.

Similarly, if we wish to rotate the illuminated element in an anticlockwise fashion, we would add one more element onto the end of our cross reference array as follows:

int xref[8] = {5,0,1,2,3,4,5,0  };

Well, things are looking pretty sweet so far, but I'm about to drop a wrench into the works. The above solutions are great if all we wish to do is light up one pixel and extinguish its adjacent companion, but what happens if we wish to do more?

Let's suppose, for example, that we are performing a clockwise rotation on a ring containing eight elements. Furthermore, let's suppose that, when we light up element i with RGB values of 255, we also wish to assign element i - 1 RGB values of 127, element i - 2 RGB values of 63, element i - 3 RGB values of 31, and that we wish to extinguish element i - 4 by assigning it RGB values of 0.

The overall effect we're trying to achieve is something like a vapor trail fading away. We could, of course, extend our cross-reference array to look like the following:

int xref[12] = {4,5,6,7,  0,1,2,3,4,5,6,7};

And then we could change our corresponding code snippet to look like the following:

for (i = 4; i < 12; i++) {  myRing.setPixelColor(xref[i],  255,255,255);  myRing.setPixelColor(xref[i-1],127,127,127);  myRing.setPixelColor(xref[i-2], 63, 63, 63);  myRing.setPixelColor(xref[i-3], 31, 31, 31);  myRing.setPixelColor(xref[i-4],  0,  0,  0);  myRing.show();  delay(100);}

I think you'll agree, however, that things are starting to get a little frayed around the edges. Can you think of a solution to this conundrum? What we're looking for is an approach that will let us work with arbitrary numbers of arbitrarily-sized rings (i.e., varying number of elements), and that will facilitate our implementing effects such as having arbitrarily-long "vapor trails," for example.

By some strange quirk of fate, I happen to have just such a solution. Let's take a look, shall we...

To Page 3 >

A plan so cunning...
What we are talking about here is a cunning plan. Indeed, "A plan so cunning we could pin a tail on it and call it a weasel," as Black Adder might say. Now, a real programmer would probably have come up with something like this solution right from the get-go, and a real programmer would probably have decided to use pointers.

Let's start by assuming we are working with a single ring and that this ring has six elements numbered 0 through 5. Based on this, our hypothetical programmer might have defined a structure containing "previous" and "next" pointers, along with any other desired data fields. Our software guru might subsequently have instantiated a bunch of these structures and connected them together as illustrated below:


(Source: Max Maxfield/Embedded.com)

Using this construct, it would be possible to implement all sorts of engaging effects relatively easily. Sad to relate, however, there are several problems with this approach, not the least being the fact that I no longer remember how to use pointers. (I used to be a diva with these little rascals 30+ years ago, but a lot of Pooh Sticks have flowed down the river of time since those halcyon days.)

Another issue is memory utilization. I'm not sure how big a pointer is in an Arduino Uno -- these things are system dependent -- but it's got to be at least two bytes. This isn’t really an issue if you are playing with a single ring containing only six elements, but things start to mount up when you are working with something like my Cunning Chronograph.

For all these reasons, my solution is to implement something like the structure shown above, but using arrays of bytes instead of pointers. (The reason for using 8-bit bytes as opposed to the Arduino Uno's 16-bit integers is that it saves memory. Each byte can happily represent values from 0 to 255, and I have only 96 elements in my Cunning Chronograph -- a 60-pixel ring, a 24-pixel ring, and a 12-pixel ring.)

Once again, let's assume we are dealing with a single 6-element NeoPixel ring, which we can think of as being referenced as myRing[6] . Now let's suppose we use a multi-dimensional array to create a structure looking something like the one shown in the illustration above. In this case, however, we will have only one data value, and this data value will be something we can use as an index into our myRing[] array.

// One ring; six pixels numbered 0 through 5// Three fields: previous, next, & indexconst byte chains[6][3] = {  {5,1,0},{0,2,1},{1,3,2},{2,4,3},{3,5,4},{4,0,5}};

The reason I call this array chains[][] is that I think of these things as being "chains of pixels." Now, this can take a bit of wrapping one's brain around, but it's easy enough once you've got the hang of it. Let's take the first triad {5,1,0} which is associated with element 0 on our ring. The '5' says that the previous (anticlockwise) element in the chain was number 5, the 1 says that the next (clockwise) element in the chain is number 1, and the 0 is the index into our myRing[] array that will hold the NeoPixel data (we'll return to the reason why we actually need this index shortly).

Let's also assume that we've defined the following:

#define pPrevious 0  // 'p' = pointer#define pNext     1  // 'p' = pointer#define dIndex    2  // 'd' = data#define sRing     0  // 1st element in ring

Also, for reasons that will become clearer in a moment, let's assume that we have a higher-level function called controlRing() and a lower-level function called loadRing() (and remember that we've previously instantiated our NeoPixel ring, which we think of as myRing[] ).

Now suppose we wish to flash all of the elements in our ring red, then green, then blue. At a minimum, our code might look something like the following:

void controlRing() {  loadRing (sRing,255,0,0);  myRing.show();  delay(100);  loadRing (sRing,0,255,0);  myRing.show();  delay(100);  loadRing (sRing,0,0,255);  myRing.show();  delay(100);}void loadRing (byte pRing, byte R, byte G, byte B) {  byte tRing = pRing;     do {    myRing.setPixelColor     (chains[tRing][dIndex],R,G,B);    tRing = chains[tRing][pNext];  } while (tRing != pRing)}

Observe that we no longer need to use a for (i = 0; i < n; i++) type control loop in our loadRing() function; instead, we just keep on rolling around the chain until we return to the beginning, at which point we exit the function.

Now, you may be thinking that this is an overly complex way to do things, but the only tricky part is setting-up the original data structures. Once that's done, the world's your lobster (or crustacean of your choice).

You may also be wondering about the fact that the dIndex data values are the same as their corresponding elements in the chains[][] array, which are themselves the same as the corresponding elements in the myRing[] array.

Well, suppose we mess-up and position our ring such that its pixels aren't where we expect them to be (similar to the problem we discussed earlier when we rotated our ring 90@deg; clockwise). In such a case, all we have to do us update the dIndex data values in our chains[][] array and we'll be ready to rock and roll again.

And things get better and better. Suppose we have three rings, each comprising six pixels. We could decide to treat these as completely independent elements and to control each ring via its own microcontroller pin. Indeed, this may be preferable if we only ever wish to update one ring at a time. However, if we wish to update all of the rings simultaneously, then it's more efficient in terms of time and memory to daisy-chain them together. This approach also has the added advantage of using only a single microcontroller pin to drive all three rings (see also Cunning Chronograph: One Ring vs. Three Rings?).


(Source: Max Maxfield/Embedded.com)

In this case, where we first instantiate our NeoPixel array, we would do so with 18 elements (i.e., code>myRings[18] . Furthermore, our chains[][] array will now be as follows:

// Three rings, each with six pixels 0 through 5// Three fields: previous, next, & indexconst byte chains[18][3] = {  {5,1,0},{0,2,1},{1,3,2},{2,4,3},{3,5,4},{4,0,5},  {11,7,6},{6,8,7},{7,9,8},{8,10,9},{9,11,10},{10,6,11},  {17,13,12},{12,14,13},{13,15,14},{14,16,15},{15,17,16},{16,12,17}};

Let's also assume that we've defined the following:

#define pPrevious 0  // 'p' = pointer#define pNext     1  // 'p' = pointer#define dIndex    2  // 'd' = data#define sRing1    0  // 1st element ring 1#define sRing2    6  // 1st element ring 2#define sRing3   12  // 1st element ring 3

Purely for the sake of example, suppose we wish to light the first ring all red, the second all green, and the third all blue; in this case, we could achieve this as follows:

void controlRings() {  loadRings (sRing1 ,255,0,0);  loadRings (sRing2 ,0,255,0);  loadRings (sRing3 ,0,0,255);  myRings.show();  delay(100);}void loadRings (byte pRing, byte R, byte G, byte B) {  byte tRing = pRing;     do {    myRings.setPixelColor     (chains[tRing][dIndex],R,G,B);    tRing = chains[tRing][pNext];  } while (tRing != pRing)}

Observe that the main change in the controlRings() function is that we vary the starting element parameter that we pass into the loadRings() function calls. Meanwhile, the loadRings() function itself remains unchanged.

So, just how powerful is this technique? Well, as we previously discussed, in the case of my Cunning Chronograph project, I have a 60-element ring, a 24-element ring, and a 12-element ring, thereby giving me 96 elements in all. I've daisy-chained all three rings together, and I'm using the technique discussed above to control everything.

Even though the main function of the Cunning Chronograph is to act as a clock, I want to take my little beauty one step further, so I've constructed an audio spectrum analyzer card of the type I used in my BADASS Display and I'm using this little scamp to drive the Cunning Chronograph.


(Source: Max Maxfield/Embedded.com)

The image above shows all of the rings in their original starting positions. The three base frequency bands are represented using the 60-pixel outer ring, while the two mid-range and two treble frequency bands are represented on the central and inner rings, respectively.

Can you imagine all of these colors pulsing in time with the music? Now imagine the rings slowly rotating. Maybe we want them all to rotate in the same direction, or maybe we want the outer and inner rings to be rotating clockwise while the central ring rotates in a widdershins fashion. Surely achieving this would require code of such complexity as to make one's eyes water. Well, you tell me, because here's the function I use to load the color data into the 96-element array associated with my three NeoPixel rings:

// Load the music color datavoid loadNeoArray (byte sRing, byte xRing) {  byte tsRing = sRing; // sRing = Original start position  byte txRing = xRing; // xRing = Rotated start position    do {       rings.setPixelColor(chains[txRing][qIndex],           tColorsMusic[chains[tsRing][qBands]][iR],           tColorsMusic[chains[tsRing][qBands]][iG],           tColorsMusic[chains[tsRing][qBands]][iB]);          tsRing = chains[tsRing][qClk];      txRing = chains[txRing][qClk];  } while (txRing != xRing);    }

This really isn't all that bad, is it? The sRing parameter passed into this function by the higher-level control routine represents the original (12 O'clock) starting position of the ring in question, while the xRing parameter represents the new (rotated) start position.

If we commence with the controlling routine setting the value of sRing to be the starting value for the 60-pixel ring, and also setting the value of xRing to be the same as for sRing , then the outer ring will appear as shown in the photograph above. By comparison, if the controlling routine sets xRing to point to the next clockwise element in the chain, the result will be to rotate the entire outer ring clockwise by one pixel element.

Similarly, if the controlling routine sets the value of sRing to be the starting value for the 24-pixel ring, and it sets the value of xRing to be the next anticlockwise element in this chain, the result will be to rotate then entire center ring anticlockwise by one pixel element.

I should perhaps note that I haven't actually tested the rotating part of the code yet (I only wrote it last night), but I have every confidence that it will work as planned (LOL). I'm currently racing to clear my desk before heading out to Denmark tomorrow (see Embedded is everywhere, but especially in Denmark). On my return, I'll be working furiously to add things like antialiasing before taking my Cunning Chronograph on the road to show it off at the Awesome Arduino Show & Tell session at the forthcoming ESC Minneapolis.

As soon as I do get things working, I'll write another article and make all of the code available for anyone who is interested to peruse, ponder, and play with. Until then, I welcome all questions and comments (especially nice ones) about the coding ideas presented herein.

19 thoughts on “Coding tips & tricks for LED ring lighting effects

  1. “I think there's a problem with your code using the xref[] arrays….nnInstead of using xref[i]-1 I think you need to use xref[i-1]nnAside from that, it is a reasonably clever solution. I need to take a closer look at your code for the fading tail. Or

    Log in to Reply
  2. “Arrgggh — you are of course correct — I've changed all the “xref[i]-1” type expressions to their “xref[i-1]” equivalents. That's what happens when you write code for an article without compiling/running it. Thanks for catching this.”

    Log in to Reply
  3. “I agree — it's not fancy — but it works. Take the trick about using the xref[] array, for example. I'd started using this to address one of my own problems, then a friend emailed me to say that he had somehow messed things up with his clock — one of th

    Log in to Reply
  4. “You are putting a lot of effort into re-calculating the “previous” pixel, so you can turn it off. Since you have 100ms to work with, why not simply pass through all the pixels in the ring and turn them _all_ off, then light the one you want just before

    Log in to Reply
  5. “Nice work Max! Looking forward to seeing it in Minneapolis next week.nBased on what you wrote here, I'll see if I can add some kind of wrap or rotate function into the XLR8 NeoPixel library that I'm architecting now.”

    Log in to Reply
  6. “For the fade-out effect, getPixelColor() returns the RGB colors all packed into a uint32_t. You could unpack them, divide each by 2, and then call setPixelColor to re-load. Or get really tricky and do something like this one-liner:nring.setPixelColor(i,

    Log in to Reply
  7. “Nice work!nAll contributions to problem solving are welcome. Yours is correct and didactic, besides very humorous.nBut, in the sheet two, where there is the codenmyRing.setPixelColor((xref[i-1]) & 0x7,0,0,0);nshouldn't it benmyRing.setPixelColor((xre

    Log in to Reply
  8. “With the rotate function — you might also want to add an antialiasing capability — I'll show you what I'm working on when I see you at ESC Minneapolis”

    Log in to Reply
  9. “Actually — I think you are both right … and wrong LOLnnAs you say — I wrote:n …((xref[i-1]) & 0x7,0,0,0);nnWhat I intended to write was (as you suggested):n …((xref[i-1] & 0x7),0,0,0);nnIn reality, however, I believe either option work

    Log in to Reply
  10. “The problem is when i=0, xref[i-1] is going to try accessing a piece of memory it shouldn't be looking at. You need to whack the top bits of i-1 before using it to index the xref array.”

    Log in to Reply
  11. “Nope — check page 2 again — by the time we get to this point we've added an extra entry into the beginning of the xref[] array to address this issue (Ha! One for me! :-)”

    Log in to Reply
  12. “Forgive me, Max, but you said I suggested the snippet of coden…((xref[i-1] & 0x7),0,0,0);nwhen I really had suggested nmyRing.setPixelColor((xref[(i-1) & 0x7]),0,0,0);nwith the logical operation '& 0x7' inside the brackets in order to prevent inadeq

    Log in to Reply
  13. “Ah — now I see what you mean — but I'll have to take a few moments to wrap my brain around this — I'll get back to you once I've mulled it over.”

    Log in to Reply
  14. “Nice work Max! The demonstration looks very beautiful. I was wondering could you share the complete code with me? I am very interested to play with those lighting patterns. Thanks in advance.”

    Log in to Reply
  15. “Hi Tiger — I wish I had them, but this article was from 3.5 years ago and I have no idea where the source code may be (sad face) — on the bright side, I've been having a lot of fun with NeoPixels — see my latest hobby project: Awesome Audio-Reactive Ar

    Log in to Reply

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.