We built this interactive voting station from neopixels and have been testing it this week.
The way it works is a coin is dropped through a chute where a beam break sensor picks up the coins signature. There are 24 stations. The sensors are LOW triggered.
Every now and then, one of the stations will randomly start counting up without input, and recently, station 11 doesn’t read anything half of the time. I’m gonna crawl under it with an oscilloscope, but maybe someone has an idea what this could be? The Mega is getting 12V and the sensors are getting 5V from external source, all grounds are tied together.
/*
6 strips × (8 bars × 24 px) = 6 × 192 = 1152 LEDs total
24 sensors: one per section (6 strips × 4 sections)
Each strip’s 4 sections map to bar pairs: (0,1), (2,3), (4,5), (6,7)
Progress per section: 0..24. Unfilled LEDs are OFF by default.
*/
#include <Adafruit_NeoPixel.h>
// -------------------------- USER CONFIG --------------------------
#define NUM_STRIPS 6
#define NUM_BARS 8
#define PIXELS_PER_BAR 24
#define LEDS_PER_STRIP (NUM_BARS * PIXELS_PER_BAR)
// NeoPixel type: try NEO_GRB first; if colors are wrong, try NEO_RGB / NEO_BRG / NEO_RBG / NEO_GBR / NEO_BGR
#define NEO_TYPE (NEO_RGB + NEO_KHZ800) // Prefer 800 kHz parts. 400 kHz will double blind time.
// Set your 6 strip pins here:
uint8_t STRIP_PINS[NUM_STRIPS] = {2, 3, 4, 5, 6, 7};
// Set your 24 sensor pins here (one per section: strip0 sec0..3, strip1 sec0..3, ...):
uint8_t COIN_PINS[24] = {
22,23,24,25, // strip 0 sections 0..3
26,27,28,29, // strip 1
30,31,32,33, // strip 2
34,35,36,37, // strip 3
38,39,40,41, // strip 4
42,43,44,45 // strip 5
};
// LED frame cadence (global). We refresh at most ONE strip per frame.
const uint16_t FRAME_MS = 20; // ~50 Hz global cadence
// Sensor signal polarity (with INPUT_PULLUP, broken = LOW)
const uint8_t ACTIVE_LEVEL = LOW;
const uint16_t DEBOUNCE_MS = 3; // short debounce on the digital input WAS 3
const uint16_t MIN_ACTIVE_MS = 5; // need >=12ms LOW to count (tune 8..20)
const uint16_t QUIET_BEFORE_MS = 8; // require >=8ms stable before a new break
static uint32_t lastStableMs[24]; // last time state became stable
static uint32_t activeStartMs[24]; // when LOW (broken) became stable
static uint32_t lastCountMs[24]; // when we last counted a coin
// ---- PIR motion settings ----
#define NUM_PIRS 3
uint8_t PIR_PINS[NUM_PIRS] = {8, 9, 10}; // <-- set your PIR pins here
// Most PIR modules output HIGH on motion. If yours is inverted, set PIR_ACTIVE_LEVEL = LOW.
const uint8_t PIR_ACTIVE_LEVEL = HIGH;
const uint32_t INACTIVITY_MS = 15UL * 60UL * 1000UL; // 15 minutes
//const uint32_t INACTIVITY_MS = 15UL * 1000UL; // 15 seconds
static volatile uint32_t lastMotionMs = 0;
static bool lightsOff = false;
// ------------------------ END USER CONFIG ------------------------
// ------------------------- LED OBJECTS ---------------------------
Adafruit_NeoPixel strips[NUM_STRIPS] = {
Adafruit_NeoPixel(LEDS_PER_STRIP, STRIP_PINS[0], NEO_TYPE),
Adafruit_NeoPixel(LEDS_PER_STRIP, STRIP_PINS[1], NEO_TYPE),
Adafruit_NeoPixel(LEDS_PER_STRIP, STRIP_PINS[2], NEO_TYPE),
Adafruit_NeoPixel(LEDS_PER_STRIP, STRIP_PINS[3], NEO_TYPE),
Adafruit_NeoPixel(LEDS_PER_STRIP, STRIP_PINS[4], NEO_TYPE),
Adafruit_NeoPixel(LEDS_PER_STRIP, STRIP_PINS[5], NEO_TYPE),
};
// ---------------------- GRADIENT / RENDER ------------------------
struct RGB { uint8_t r,g,b; };
struct Gradient { RGB c0, c1; };
static inline RGB lerpRGB(const RGB& a, const RGB& b, float t) {
RGB o;
o.r = (uint8_t)(a.r + (b.r - a.r) * t + 0.5f);
o.g = (uint8_t)(a.g + (b.g - a.g) * t + 0.5f);
o.b = (uint8_t)(a.b + (b.b - a.b) * t + 0.5f);
return o;
}
// Per-strip, per-section gradient and direction
Gradient sectionGrad[NUM_STRIPS][4];
bool sectionLeftToRight[NUM_STRIPS][4];
// Map section -> bar index (paired)
static inline int barFromSection(int section, bool secondInPair) {
return section * 2 + (secondInPair ? 1 : 0);
}
static inline int ledIndex(int bar, int pixel) {
return bar * PIXELS_PER_BAR + pixel;
}
// Renders ONE section on ONE strip, mirroring its paired bar
void renderSection(int si, int section, int value) {
if (value < 0) value = 0;
if (value > PIXELS_PER_BAR) value = PIXELS_PER_BAR;
const Gradient& grad = sectionGrad[si][section];
Adafruit_NeoPixel& strip = strips[si];
// build gradient buffer for the filled region
RGB buf[PIXELS_PER_BAR];
for (int i = 0; i < PIXELS_PER_BAR; ++i) {
if (i < value) {
float t = (PIXELS_PER_BAR == 1) ? 0.0f : (float)i / (float)(PIXELS_PER_BAR - 1);
buf[i] = lerpRGB(grad.c0, grad.c1, t);
} else {
buf[i] = {0,0,0}; // unfilled = off
}
}
int barA = barFromSection(section, false);
int barB = barFromSection(section, true);
bool ltr = sectionLeftToRight[si][section];
for (int i = 0; i < PIXELS_PER_BAR; ++i) {
int pix = ltr ? i : (PIXELS_PER_BAR - 1 - i);
uint32_t c = strip.Color(buf[pix].r, buf[pix].g, buf[pix].b);
strip.setPixelColor(ledIndex(barA, i), c);
strip.setPixelColor(ledIndex(barB, i), c);
}
}
void renderStrip(int si, const uint8_t sectionMask /*4-bit: which sections to render*/) {
for (int s = 0; s < 4; ++s) {
if (sectionMask & (1u << s)) {
extern int progressVals[NUM_STRIPS][4];
renderSection(si, s, progressVals[si][s]);
}
}
}
// ---------------------- PROGRESS / DIRTY -------------------------
int progressVals[NUM_STRIPS][4]; // current bar fill values 0..24
int counts[24]; // sensor-driven counts (one per section: 6×4)
static int lastCounts[24]; // for change detection
static uint8_t dirtyStrip[NUM_STRIPS]; // strip needs show
static uint8_t sectionDirty[NUM_STRIPS]; // 4-bit mask per strip: which sections changed
inline void mapCountsAndMarkDirty() {
for (int si = 0; si < NUM_STRIPS; ++si) {
for (int s = 0; s < 4; ++s) {
int i = 4*si + s;
int v = counts[i];
if (v != lastCounts[i]) {
lastCounts[i] = v;
progressVals[si][s] = v;
dirtyStrip[si] = 1;
sectionDirty[si] |= (1u << s);
}
}
}
}
// -------------------------- SENSORS ------------------------------
static uint8_t rawState[24], stableState[24], lastStable[24];
static uint32_t lastChangeMs[24];
static volatile uint8_t brokeLatch[24]; // set on first stable ACTIVE edge
void sensorsBegin() {
for (int i = 0; i < 24; ++i) {
pinMode(COIN_PINS[i], INPUT_PULLUP); // or INPUT if module is push-pull
uint8_t r = digitalRead(COIN_PINS[i]);
rawState[i] = stableState[i] = lastStable[i] = r;
lastChangeMs[i] = lastStableMs[i] = millis();
activeStartMs[i] = lastCountMs[i] = 0;
}
}
inline void pollSensors() {
uint32_t now = millis();
for (int i = 0; i < 24; ++i) {
uint8_t r = digitalRead(COIN_PINS[i]);
// Debounce to a stable value
if (r != rawState[i]) {
rawState[i] = r;
lastChangeMs[i] = now;
continue;
}
if (now - lastChangeMs[i] < DEBOUNCE_MS) continue;
// Stable; check for transition relative to previous stable
if (stableState[i] != r) {
uint32_t prevStableAt = lastStableMs[i]; // <-- capture BEFORE updating
uint8_t prev = stableState[i];
stableState[i] = r;
lastStableMs[i] = now; // <-- update AFTER using prevStableAt
// Transition to ACTIVE (broken)
if (r == ACTIVE_LEVEL && prev != ACTIVE_LEVEL) {
// Require some quiet time before we start a candidate break
if ((now - lastCountMs[i] >= QUIET_BEFORE_MS) &&
(now - prevStableAt >= QUIET_BEFORE_MS)) {
activeStartMs[i] = now; // candidate break begins
} else {
activeStartMs[i] = 0; // too chatty; ignore
}
}
// Transition to INACTIVE (unbroken): evaluate duration
else if (r != ACTIVE_LEVEL && prev == ACTIVE_LEVEL) {
if (activeStartMs[i]) {
uint32_t dur = now - activeStartMs[i];
if (dur >= MIN_ACTIVE_MS) {
// count 0..24 (24 pixels, inclusive)
counts[i] += 1;
if (counts[i] > PIXELS_PER_BAR) counts[i] = 0;
lastCountMs[i] = now;
}
activeStartMs[i] = 0;
}
}
}
}
}
// Consume latches: increment count mod 24
inline void consumeLatches() {
for (int i = 0; i < 24; ++i) {
if (brokeLatch[i]) {
brokeLatch[i] = 0;
counts[i] = (counts[i] + 1) % 25;
Serial.println(i);
}
}
}
// ---------------------- STAGGERED REFRESH ------------------------
static uint32_t lastFrameMs = 0;
static int nextStrip = 0;
static uint32_t lastAnyShowMs = 0;
const uint32_t KEEPALIVE_MS = 600; // force a refresh occasionally so you know it's alive
inline void staggeredRefresh() {
uint32_t now = millis();
// Keep-alive: nudge the next strip occasionally (in case nothing changes for a long time)
if (now - lastAnyShowMs >= KEEPALIVE_MS) {
dirtyStrip[nextStrip] = 1;
}
if (now - lastFrameMs < FRAME_MS) return;
lastFrameMs = now;
// Find one dirty strip to render + show this frame
for (int attempts = 0; attempts < NUM_STRIPS; ++attempts) {
int si = nextStrip;
nextStrip = (nextStrip + 1) % NUM_STRIPS;
if (dirtyStrip[si]) {
uint8_t mask = sectionDirty[si] ? sectionDirty[si] : 0x0F; // if unknown, render all 4
renderStrip(si, mask);
sectionDirty[si] = 0;
// Do the (only) show this frame
strips[si].show();
dirtyStrip[si] = 0;
lastAnyShowMs = now;
break;
}
}
}
// PIR STUFF
inline bool anyMotionNow() {
for (int i = 0; i < NUM_PIRS; ++i) {
if (digitalRead(PIR_PINS[i]) == PIR_ACTIVE_LEVEL) return true;
}
return false;
}
inline void turnOffAllLights() {
// Clear all strips immediately (don’t stagger—just shut off)
for (int si = 0; si < NUM_STRIPS; ++si) {
for (int p = 0; p < LEDS_PER_STRIP; ++p) {
strips[si].setPixelColor(p, 0);
}
strips[si].show(); // do show per strip here so it goes dark right away
}
}
inline void wakeDisplay() {
// Force full repaint on wake
for (int si = 0; si < NUM_STRIPS; ++si) {
sectionDirty[si] = 0x0F;
dirtyStrip[si] = 1;
}
}
// --------------------------- SETUP -------------------------------
void setup() {
Serial.begin(115200);
// Init LEDs
for (int si = 0; si < NUM_STRIPS; ++si) {
strips[si].begin();
strips[si].setBrightness(255); // adjust if needed
strips[si].show(); // clear
// Default gradients & directions per strip/section
// sectionGrad[si][0] = { {255, 0, 0}, {0, 0, 100} }; // red -> blue
// sectionGrad[si][1] = { { 0, 100, 0}, { 0, 100, 50} }; // green -> cyan
// sectionGrad[si][2] = { { 0, 0, 100}, {255, 0, 100} }; // blue -> magenta
// sectionGrad[si][3] = { {255, 100, 100}, {0, 100, 100} }; // white -> violet
for (int s = 0; s < 4; ++s) sectionLeftToRight[si][s] = true;
}
sectionGrad[0][0] = { {255, 10, 10}, {255, 10, 10} }; // strawberry
sectionGrad[0][1] = { {255, 0, 0}, {255, 0, 0} }; // cherry
sectionGrad[0][2] = { {255, 0, 0}, {255, 10, 0} }; // Tropical Punch
sectionGrad[0][3] = { {255, 5, 0}, {255, 25, 0} }; // peach mango
sectionGrad[1][0] = { {255, 10, 0}, {255, 10, 0} }; // orange
sectionGrad[1][1] = { {255, 5, 0}, {255, 5, 0} }; // tangerine
sectionGrad[1][2] = { {255, 5, 0}, {255, 5, 0} }; // root beer (brown is physically impossible)
sectionGrad[1][3] = { {255, 25, 0}, {255, 80, 0} }; // pina pineapple
sectionGrad[2][0] = { {255, 25, 0}, {255, 80, 0} }; // sunshine punch
sectionGrad[2][1] = { {255, 80, 0}, {255, 80, 00} }; // lemonade
sectionGrad[2][2] = { {255, 120, 0}, {255, 120, 0} }; // lemon-lime
sectionGrad[2][3] = { {0, 100, 0} , {255, 0, 0} }; // strawberry kiwi
sectionGrad[3][0] = { {0, 100, 0}, {0, 100, 0} }; // jamaica
sectionGrad[3][1] = { { 0, 100, 10}, { 0, 100, 10} }; // green apple
sectionGrad[3][2] = { { 0, 100, 100}, {255, 0, 50} }; // sharkleberry fin
sectionGrad[3][3] = { {0, 100, 100}, {0, 100, 100} }; // great bluedini
sectionGrad[4][0] = { {0, 0, 100}, {255,80, 0} }; // blue raspberry lemonade
sectionGrad[4][1] = { { 0, 0, 100}, { 0, 0, 100} }; // blast off blue moon berry
sectionGrad[4][2] = { { 0, 0, 10}, {0, 0, 100} }; // ghoul aid blackberry
sectionGrad[4][3] = { {0, 0, 100}, {255, 0, 50} }; // magic twists switchin secret
sectionGrad[5][0] = { {0, 0, 50}, {255, 0, 30} }; // grape
sectionGrad[5][1] = { {255, 0, 50}, { 255, 0, 50} }; // purplesaurus rex
sectionGrad[5][2] = { {255, 0, 80}, {255, 0, 10} }; // fruit ts wildberry tea
sectionGrad[5][3] = { {255, 0, 40}, {255, 0, 40} }; // raspberry
// Init counters / dirties
for (int i = 0; i < 24; ++i) { counts[i] = 0; lastCounts[i] = -1; } // -1 forces initial paint
for (int si = 0; si < NUM_STRIPS; ++si) { dirtyStrip[si] = 1; sectionDirty[si] = 0x0F; }
sensorsBegin();
// Initial full render so you see something immediately
for (int si = 0; si < NUM_STRIPS; ++si) {
for (int s = 0; s < 4; ++s) progressVals[si][s] = counts[4*si + s];
renderStrip(si, 0x0F);
strips[si].show();
}
lastAnyShowMs = millis();
lastFrameMs = 0; // so first stagger tick can occur soon
// ---- PIR init ----
for (int i = 0; i < NUM_PIRS; ++i) {
pinMode(PIR_PINS[i], INPUT); // Most PIR breakouts are push-pull. If needed, use INPUT_PULLUP and flip PIR_ACTIVE_LEVEL.
}
lastMotionMs = millis();
}
// ---------------------------- LOOP -------------------------------
void loop() {
// 0) Motion handling: update lastMotionMs and handle sleep/wake
uint32_t now = millis();
if (anyMotionNow()) {
lastMotionMs = now;
Serial.println(lastMotionMs);
if (lightsOff) { // wake from sleep
lightsOff = false;
wakeDisplay(); // mark everything dirty so it repaints
}
} else {
if (!lightsOff && (now - lastMotionMs >= INACTIVITY_MS)) {
lightsOff = true;
turnOffAllLights(); // immediate blackout
}
}
// 1) Poll sensors quickly and latch broken edges
pollSensors();
// 2) Consume latches -> update counts (mod 24)
//consumeLatches();
// 3) Map counts to progress & mark dirty only where changed
mapCountsAndMarkDirty();
// 4) Staggered refresh: at most ONE .show() per frame
//Serial.println(lightsOff);
if (!lightsOff) {
staggeredRefresh(); // your one-strip-per-frame updater
}
// Optional: small delay to keep loop friendly; not required
// delay(0);
}
// ---------------------- OPTIONAL HELPERS -------------------------
// Call to tweak a gradient on the fly
void setSectionGradient(int stripIndex, int section, RGB startRGB, RGB endRGB) {
if (stripIndex < 0 || stripIndex >= NUM_STRIPS) return;
if (section < 0 || section > 3) return;
sectionGrad[stripIndex][section].c0 = startRGB;
sectionGrad[stripIndex][section].c1 = endRGB;
// Mark dirty for redraw
sectionDirty[stripIndex] |= (1u << section);
dirtyStrip[stripIndex] = 1;
}
// Flip direction for wiring/layout quirks
void setSectionDirection(int stripIndex, int section, bool leftToRight) {
if (stripIndex < 0 || stripIndex >= NUM_STRIPS) return;
if (section < 0 || section > 3) return;
sectionLeftToRight[stripIndex][section] = leftToRight;
sectionDirty[stripIndex] |= (1u << section);
dirtyStrip[stripIndex] = 1;
}
Thanks!
J


