The code running all this isn’t terribly interesting.

I started with the Adafruit NeoPixel library but then modified it slightly. I didn’t really like the API of the original library and I eventually wanted to try swapping it out with a different implementation that didn’t take up so much CPU time. The most significant change was making some parts of the library compile time. For example, the pixel layout (RGB vs BGR vs with-white etc) was changed to a template parameter instead of a runtime variable.

Low Level Code

enum class NeoPixelColorLayout {
    RGB, RBG, GRB, GBR, BRG, BGR,
    WRGB, WRBG, WGRB, WGBR, WBRG, WBGR,
    RWGB, RWBG, GWRB, GWBR, BWRG, BWGR,
    RGWB, RBWG, GRWB, GBWR, BRWG, BGWR,
    RGBW, RBGW, GRBW, GBRW, BRGW, BGRW,
};

template <NeoPixelColorLayout ColorLayout>
struct NeoPixelColorLayoutTraits
{
    //static constexpr  uint8_t  red_offset;
    //static constexpr  uint8_t  green_offset;
    //static constexpr  uint8_t  blue_offset;
    //static constexpr  uint8_t  white_offset;
    //static constexpr  bool     has_white;
};

#define DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(color_layout, red_offset_, green_offset_, blue_offset_, white_offset_, has_white_)   \
template <>                                                                                                                     \
struct NeoPixelColorLayoutTraits<NeoPixelColorLayout::color_layout> {                                                           \
    static constexpr  uint8_t  red_offset   = red_offset_;;                                                                     \
    static constexpr  uint8_t  green_offset = green_offset_;                                                                    \
    static constexpr  uint8_t  blue_offset  = blue_offset_;;                                                                    \
    static constexpr  uint8_t  white_offset = white_offset_;;                                                                   \
    static constexpr  bool     has_white    = has_white_;                                                                       \
};
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(RGB,  0, 1, 2, 0, false)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(RBG,  0, 2, 1, 0, false)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(GRB,  1, 0, 2, 0, false)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(GBR,  2, 0, 1, 0, false)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(BRG,  0, 2, 1, 0, false)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(BGR,  2, 1, 0, 0, false)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(WRGB, 1, 2, 3, 0, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(WRBG, 1, 3, 2, 0, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(WGRB, 2, 1, 3, 0, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(WGBR, 3, 1, 2, 0, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(WBRG, 2, 3, 1, 0, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(WBGR, 3, 2, 1, 0, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(RWGB, 0, 2, 3, 1, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(RWBG, 0, 3, 2, 1, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(GWRB, 2, 0, 3, 1, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(GWBR, 3, 0, 2, 1, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(BWRG, 2, 3, 0, 1, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(BWGR, 3, 2, 0, 1, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(RGWB, 0, 1, 3, 2, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(RBWG, 0, 3, 1, 2, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(GRWB, 1, 0, 3, 2, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(GBWR, 3, 0, 1, 2, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(BRWG, 1, 3, 0, 2, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(BGWR, 3, 1, 0, 2, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(RGBW, 0, 1, 2, 3, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(RBGW, 0, 2, 1, 3, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(GRBW, 1, 0, 2, 3, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(GBRW, 2, 0, 1, 3, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(BRGW, 1, 2, 0, 3, true)
DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT(BGRW, 2, 1, 0, 3, true)
#undef DEFINE_NEOPIXEL_COLOR_LAYOUT_TRAIT

This first block of code sets up “layout_traits”, so later we can use an enum to represent which pixel layout we want to use.

struct Color
{
    uint8_t     r;
    uint8_t     g;
    uint8_t     b;
    uint8_t     w;
    
    Color() : r(0), g(0), b(0), w(0) {};
    Color(uint8_t r_, uint8_t g_, uint8_t b_) : r(r_), g(g_), b(b_), w(0) {};
    Color(uint8_t r_, uint8_t g_, uint8_t b_, uint8_t w_) : r(r_), g(g_), b(b_), w(w_) {};
    
    static Color HSV(uint16_t hue, uint8_t sat = 255, uint8_t val = 255)
    {
        // Remap 0-65535 to 0-1529. Pure red is CENTERED on the 64K rollover;
        // 0 is not the start of pure red, but the midpoint...a few values above
        // zero and a few below 65536 all yield pure red (similarly, 32768 is the
        // midpoint, not start, of pure cyan). The 8-bit RGB hexcone (256 values
        // each for red, green, blue) really only allows for 1530 distinct hues
        // (not 1536, more on that below), but the full unsigned 16-bit type was
        // chosen for hue so that one's code can easily handle a contiguous color
        // wheel by allowing hue to roll over in either direction.
        hue = (hue * 1530L + 32768) / 65536;
        
        // Because red is centered on the rollover point (the +32768 above,
        // essentially a fixed-point +0.5), the above actually yields 0 to 1530,
        // where 0 and 1530 would yield the same thing. Rather than apply a
        // costly modulo operator, 1530 is handled as a special case below.
        
        // So you'd think that the color "hexcone" (the thing that ramps from
        // pure red, to pure yellow, to pure green and so forth back to red,
        // yielding six slices), and with each color component having 256
        // possible values (0-255), might have 1536 possible items (6*256),
        // but in reality there's 1530. This is because the last element in
        // each 256-element slice is equal to the first element of the next
        // slice, and keeping those in there this would create small
        // discontinuities in the color wheel. So the last element of each
        // slice is dropped...we regard only elements 0-254, with item 255
        // being picked up as element 0 of the next slice. Like this:
        // Red to not-quite-pure-yellow is:        255,   0, 0 to 255, 254,   0
        // Pure yellow to not-quite-pure-green is: 255, 255, 0 to   1, 255,   0
        // Pure green to not-quite-pure-cyan is:     0, 255, 0 to   0, 255, 254
        // and so forth. Hence, 1530 distinct hues (0 to 1529), and hence why
        // the constants below are not the multiples of 256 you might expect.
        
        // Convert hue to R,G,B (nested ifs faster than divide+mod+switch):
        uint8_t r;
        uint8_t g;
        uint8_t b;
        if (hue < 510) {            // Red to Green-1
            b = 0;
            if (hue < 255) {        //   Red to Yellow-1
                r = 255;
                g = hue;            //     g = 0 to 254
            } else {                //   Yellow to Green-1
                r = 510 - hue;      //     r = 255 to 1
                g = 255;
            }
        }
        else if (hue < 1020) {      // Green to Blue-1
            r = 0;
            if (hue <  765) {       //   Green to Cyan-1
                g = 255;
                b = hue - 510;      //     b = 0 to 254
            } else {                //   Cyan to Blue-1
                g = 1020 - hue;     //     g = 255 to 1
                b = 255;
            }
        }
        else if(hue < 1530) {       // Blue to Red-1
            g = 0;
            if (hue < 1275) {       //   Blue to Magenta-1
                r = hue - 1020;     //     r = 0 to 254
                b = 255;
            } else {                //   Magenta to Red-1
                r = 255;
                b = 1530 - hue;     //     b = 255 to 1
            }
        } else {                    // Last 0.5 Red (quicker than % operator)
            r = 255;
            g = b = 0;
        }
        
        // Apply saturation and value to R,G,B, pack into 32-bit result:
        uint32_t v1 =   1 + val; // 1 to 256; allows >>8 instead of /255
        uint16_t s1 =   1 + sat; // 1 to 256; same reason
        uint8_t  s2 = 255 - sat; // 255 to 0
        return Color{
            ((((r * s1) >> 8) + s2) * v1) >> 8,
            ((((g * s1) >> 8) + s2) * v1) >> 8,
            ((((b * s1) >> 8) + s2) * v1) >> 8
        };
    }
    
    Color  scale(uint8_t brightness) const
    {
        return {
            uint8_t((this->r * brightness) >> 8),
            uint8_t((this->g * brightness) >> 8),
            uint8_t((this->b * brightness) >> 8),
            uint8_t((this->w * brightness) >> 8),
        };
    }
};

The Color class represents a color triplet (or quadruplet since there’s a white channel). The HSV() function generates a Color object based on a Hue-Saturation-Value triplet (taken from Adafruit code). The scale() function scales a color by a scalar value (taken from the Adafruit code).

template <NeoPixelColorLayout neopixel_layout>
class NeoPixel final {
    private:
        using NeoPixelLayoutTrait = NeoPixelColorLayoutTraits<neopixel_layout>;
    private:
        uint16_t const  num_leds;
        uint8_t const   pin_num;
        uint8_t         brightness;
        std::vector<uint8_t>    data;
        uint32_t    end_time;    ///< Latch timing reference
    
    public:
        NeoPixel(uint8_t pin_num_, uint16_t num_leds_, uint8_t brightness_ = 0x20)
          : num_leds(num_leds_)
          , pin_num(pin_num_)
          , brightness(brightness_)
          , data(this->num_leds * (NeoPixelLayoutTrait::has_white ? 4 : 3), 0x00)
          , end_time(micros())
        {
            pinMode(this->pin_num, OUTPUT);
            digitalWrite(this->pin_num, LOW);
        }
        
        uint16_t getNumLeds() const
        {
            return this->num_leds;
        }
        
        void  setRaw(uint8_t n, uint8_t r, uint8_t g, uint8_t b, uint8_t w = 0)
        {
            size_t const base = n * (NeoPixelLayoutTrait::has_white ? 4 : 3);
            this->data[base + NeoPixelLayoutTrait::red_offset  ] = r;
            this->data[base + NeoPixelLayoutTrait::green_offset] = g;
            this->data[base + NeoPixelLayoutTrait::blue_offset ] = b;
            this->data[base + NeoPixelLayoutTrait::white_offset] = w;
        }
        void  set(uint8_t n, Color const c)
        {
            auto const sc = c.scale(this->brightness);
            this->setRaw(n, sc.r, sc.g, sc.b, sc.w );
        }
        
        void  setAll(Color const c)
        {
            for (auto n = 0 ; n < this->num_leds ; n++){
                this->set(n, c);
            }
        }
        
        bool  canShow() const
        {
            return (micros() - this->end_time) >= 300L;
        }
        void  show()
        {
            while(!this->canShow());
            
            noInterrupts(); // Need 100% focus on instruction timing
            
            uint8_t const   portNum = g_APinDescription[this->pin_num].ulPort;
            uint32_t const  pinMask = 1ul << g_APinDescription[this->pin_num].ulPin;
            uint8_t const *         ptr     = this->data.data();
            uint8_t const * const   end     = ptr + this->data.size();
            uint8_t                 p       = *ptr++;
            uint8_t  bitMask =  0x80;
            
            volatile uint32_t *  set = &(PORT->Group[portNum].OUTSET.reg);
            volatile uint32_t *  clr = &(PORT->Group[portNum].OUTCLR.reg);
            
            while (ptr < end){
                *set = pinMask;
                asm("nop; nop; nop; nop; nop; nop; nop; nop;");
                if (p & bitMask) {
                    asm("nop; nop; nop; nop; nop; nop; nop; nop;nop; nop; nop; nop; nop; nop; nop; nop;nop; nop; nop; nop;");
                    *clr = pinMask;
                } else {
                    *clr = pinMask;
                    asm("nop; nop; nop; nop; nop; nop; nop; nop;nop; nop; nop; nop; nop; nop; nop; nop;nop; nop; nop; nop;");
                }
                if (bitMask >>= 1) {
                    asm("nop; nop; nop; nop; nop; nop; nop; nop; nop;");
                } else {
                    p       = *ptr++;
                    bitMask = 0x80;
                }
            }
            
            this->end_time = micros(); // Save EOD time for latch on next call
            interrupts();
        }
};

This is the meat of the NeoPixel class. Most of it (especially the show() function) is based on the original Arduino code. The difference is the pixel layout from the traits class introduced before - this moves more of the “work” to compile time and lets the compiler more effectively optimize the get/set code (at least I think it does, I have not profiled).

At some point I should use a similar trait system to abstract the different ways to do the show() action - it varies from chip to chip.

enum class TittyRing { Outer, Middle, Inner, Center };
static constexpr uint8_t NP_Outer = 24;
static constexpr uint8_t NP_Middle = 12;
static constexpr uint8_t NP_Inner = 6;
class NeoTitty final {
    private:
        NeoPixel<NeoPixelColorLayout::GRBW>  inner;
        NeoPixel<NeoPixelColorLayout::GRBW>  outer;
        uint8_t const   inner_offset;
        uint8_t const   middle_offset;
        uint8_t const   outer_offset;
    
    public:
        NeoTitty(uint8_t inner_pin_num, uint8_t outer_pin_num, uint8_t inner_offset_ = 0, uint8_t middle_offset_ = 0, uint8_t outer_offset_ = 0)
          : inner( inner_pin_num, NP_Middle + NP_Inner + 1 )
          , outer( outer_pin_num, NP_Outer )
          , inner_offset(inner_offset_)
          , middle_offset(middle_offset_)
          , outer_offset(outer_offset_)
        {}
        
        void  set(TittyRing tr, uint8_t n, Color const c)
        {
            switch (tr){
                case TittyRing::Outer:
                    n = (n + this->outer_offset) % NP_Outer;
                    this->outer.set(n, c);
                    return;
                case TittyRing::Middle:
                    n = (n + this->middle_offset) % NP_Middle;
                    this->inner.set(n, c);
                    return;
                case TittyRing::Inner:
                    n = (n + this->inner_offset) % NP_Inner;
                    this->inner.set(NP_Middle + 1 + n, c);
                    return;
                case TittyRing::Center:
                    this->inner.set(NP_Middle + 0, c);
                    return;
            }
        }
        
        void  set(TittyRing tr, Color const c)
        {
            switch (tr){
                case TittyRing::Outer:  this->outer.setAll(c); return;
                case TittyRing::Middle: for (auto i = 0 ; i < NP_Middle ; i++){ this->inner.set(i, c); }; return;
                case TittyRing::Inner:  for (auto i = 0 ; i < NP_Inner ; i++){ this->inner.set(NP_Middle + 1 + i, c); }; return;
                case TittyRing::Center: this->inner.set(NP_Middle + 0, c); return;
            }
        }
        
        void  set(Color const c)
        { 
            this->inner.setAll(c);
            this->outer.setAll(c);
        }
        
        void  show()
        {
            this->inner.show();
            this->outer.show();
        }
};

This is the class (named by Rosie) that encapsulates the specifics of the LED bra hardware: two rings plus a center board.

Instead of a simple string of 42 pixels, it’s an “outer ring” of 24, a “middle ring” of 12, an “inner ring” of 5 and finally a singular “center” pixel. This makes it easier to program for certain patterns without having to have “positioning” constants everywhere.

class NeoBra final {
    private:
        NeoTitty CR2;
        NeoTitty right;
    
    public:
        NeoTitty(uint8_t left_inner_pin_num, uint8_t left_outer_pin_num,
                 uint8_t right_inner_pin_num, uint8_t right_outer_pin_num,
                 uint8_t left_inner_offset = 0, uint8_t left_middle_offset = 0, uint8_t left_outer_offset = 0,
                 uint8_t right_inner_offset = 0, uint8_t right_middle_offset = 0, uint8_t right_outer_offset = 0)
          : left(left_inner_pin_num, left_outer_pin_num, left_inner_offset, left_middle_offset, left_outer_offset)
          , right(right_inner_pin_num, right_outer_pin_num, right_inner_offset, right_middle_offset, right_outer_offset)
        {}
        
        void  set(TittyRing tr, uint8_t n, Color const c)
        {
            this->left.set(tr, n, c);
            this->right.set(tr, n, c);
        }
        
        void  set(TittyRing tr, Color const c)
        {
            this->left.set(tr, c);
            this->right.set(tr, c);
        }
        
        void  set(Color const c)
        { 
            this->left.set(c);
            this->right.set(c);
        }
        
        void  show()
        {
            this->left.show();
            this->right.show();
        }
};

This class simply wraps two NeoTittys: it has the same API but it forwards the calls to both instances. At some point I may want to add more overloads that lets you select left vs right (instead of always doing both).

High Level Code

So now the top level can concern itself with lighting up the bra.

All of these classes follow this interface:

class Foo {
    public:
        Foo(NeoBra &nb, ...);
        void step();
}

With the idea being that they can be constructed as global variables (or in the Arduino setup()) and then their step() function called inside the Arduino loop() function.
Most of the classes end up busy-waiting in their step() function to do timing, but it’s usually fast enough that it wouldn’t matter (if you’re doing other things in loop() like switching between lighting modes).

The first class to look at is RainbowSpinner. This class lights up the circles like a color wheel and then spins it over time. The speed of rotation is configurable.

class RainbowSpinner
{
    private:
        NeoBra &        nb;
        uint32_t const  stepsize;
        uint16_t        h;
    
    public:
        RainbowSpinner(NeoBra &nb_, uint32_t const stepsize_) : nb(nb_), stepsize(stepsize_), h(0) {};
        RainbowSpinner(NeoBra &nb_) : RainbowSpinner(nb_, 20) {};
        
        void  step()
        {
            for (auto n = 0 ; n < NP_Inner ; n++){
                nb.set(TittyRing::Inner, n, Color::HSV(this->h + (n * (65535 / NP_Inner)), 255, 150));
            }
            for (auto n = 0 ; n < NP_Middle ; n++){
                nb.set(TittyRing::Middle, n, Color::HSV(this->h + (n * (65535 / NP_Middle)), 255, 200));
            }
            for (auto n = 0 ; n < NP_Outer ; n++){
                nb.set(TittyRing::Outer, n, Color::HSV(this->h + (n * (65535 / NP_Outer))));
            }
            nb.set(TittyRing::Center, Color::HSV(this->h*2, 255, 100));
            nb.show();
            this->h += this->stepsize;
        }
};

The next class, Quadrature, sets up a 4-color pattern like the BMW logo and spins it over time. The colors are configurable while the speed currently is not.

class Quadrature
{
    private:
        NeoBra &                nb;
        std::array<Color,4>     colors;
        uint8_t                 h;
    
    public:
        Quadrature(NeoBra &nb_, std::array<Color,4> colors_) : nb(nb_), colors(colors_), h(0) {};
        
        void step()
        {
            for (auto n = 0 ; n < NP_Outer ; n++){
                nb.set(TittyRing::Outer, n, this->colors[((n+h) / 6) % 4] );
            }
            for (auto n = 0 ; n < NP_Middle ; n++){
                nb.set(TittyRing::Middle, n, this->colors[((n+(h/2)) / 3) % 4] );
            }
            for (auto n = 0 ; n < NP_Inner ; n++){
                //nb.set(TittyRing::Inner, n, this->colors[((n+(h/4)) / 1) % 4] );
            }
            nb.show();
            delay(80);
            this->h += 1;
        }
};

I have another class, Pulsar, but it currently doesn’t work and I’ve forgotten what it’s supposed to do! 🤐

So there you have it, an LED cosplay bra from the ground up - and a framework to easily add more color patterns in the future.