Festival vote totem — build guide

Tally Totem

A double-sided festival totem that asks a whiteboard question, takes YES / NO votes with two glowing arcade buttons, and shows the running tally on four sunlight-readable e-paper panels — a mirrored pair for YES, a mirrored pair for NO.

Display4× e-paper, sunlight readable
Wiringzero soldering
Linkwired broadcast, no WiFi
Memorysurvives power loss

00 TL;DR

The whole build in one screen. Click any part of the diagram below to see what it does.

PRINTED IMAGE laminated, one per side YOUR QUESTION dry-erase, one per side YES 053 NO 004 VOTE BOX QT Py + buttons
Tap a part
Click or tap any piece of the totem in the diagram to see what it does and how it fits together.

01 How it works

Two separate electronic units, tied together by one plain wire.

PRINTED IMAGE laminated, one copy each side YOUR QUESTION dry-erase, one board each side YES 053 NO 004 VOTE BOX QT Py + buttons USB BANK rigid foam-board sandwich write the question fresh each day e-paper ticker box: 2 panels per face (YES left, NO right), lit at night by a toggleable warm-white strip — tap both buttons quickly Vote Box: reads buttons, broadcasts votes down one wire to all 4 panels one button faces each side Vote Box's own small power bank pole: painter's pole or 1" PVC

Front view. The back is identical — the back-YES panel always matches the front-YES panel, and likewise for NO, because both receive the same broadcast.

02 Shopping list

Electronics prices and stock verified July 17, 2026. One item is flagged to finalize — a real part exists, it just wasn't bench-verified in this pass.

Vote Box — $33.35

QtyItemPIDEachTotal
1QT Py ESP32-S3 (8 MB Flash, No PSRAM) — the vote-counting brain5426$12.50$12.50
1LED Arcade Button 1x4 — STEMMA QT breakout — reads the buttons, drives their LEDs5296$9.95$9.95
1STEMMA QT cable, 100 mm — QT Py → arcade board, snaps in, zero solder4210$0.95$0.95
1Arcade button with LED, 30 mm translucent Green3487$2.50$2.50
1Arcade button with LED, 30 mm translucent Red3489$2.50$2.50
1Arcade button quick-connect wire pairs, 0.11" — 10-pack (uses 4, rest are spares)1152$4.95$4.95
Subtotal$33.35

Display panels — $107.60

QtyItemEachTotal
4Elecrow CrowPanel ESP32 4.2" E-Paper — 400×300, built-in ESP32-S3, integrated brain + display in one board. 2 become the YES pair (front+back), 2 become the NO pair — identical firmware, set by one jumper wire (see Firmware).$26.90$107.60
Subtotal$107.60

Night lights — $15.90

QtyItemPIDEachTotal
1Adafruit MOSFET Driver — switches the light strip on/off from a plain signal wire; screw terminals for power and the strip, handles up to 1.5 A (plenty for two short strip segments)5648$3.95$3.95
1Ultra Flexible White LED Strip, Warm White ~3000K, 480 LED/m, 1m — cut into two short segments (a few cm each is plenty), one grazing each face's panel pair4613$11.95$11.95
Subtotal$15.90
The one place this build isn't zero-solder The arcade board's spare port 3 (unused — only ports 1/2 drive the buttons) is the signal source for the lights, but its LED output connector (JST-XH) and the MOSFET driver's signal input (JST-PH) are different connector families that don't mate directly. Bridge them with a spare quick-connect wire pair (already in the Vote Box parts list — you'll have 6 left over after wiring the two buttons) soldered or twisted onto the driver's 0.1" breakout header. It's two wires, no precision needed — a twist-on wire connector works fine if you'd rather not solder at all.
Simpler alternative: one battery push-light per panel, no MOSFET at all If you'd rather skip the driver, strip, and its one soldered joint entirely, mount one small battery-powered LED puck light (like a $2–3 keychain or push-touch light) at the top edge of each of the 4 panels, angled to graze the display. Turn them on by hand before dusk, off (or just leave them, next event) when you pack up. When one's battery dies, pull it off its Velcro/tape mount and press in a fresh one — no wiring, no driver, no firmware signal to debug. The trade-off versus the strip: no quick-tap toggle from the arcade board, and four separate lights to remember to switch instead of one gesture.
Alternative panel count: 1× 6" panel per face instead of 2× 4.2" This guide's 4-panel layout exists because each face needs to show both YES and NO side by side, and the 4.2" CrowPanel only has room for one number. A single larger e-paper panel (~6" class, comparable resolution needed to fit two legible numbers) per face would cut the panel count from 4 to 2 — and, if you go with the push-light option above, from 4 lights to 2. It's not a drop-in swap: firmware would need to draw both tallies on one canvas instead of picking YES/NO by a jumper pin, and 6" e-paper SPI boards aren't as commonly available with an integrated ESP32-S3 as the CrowPanel, so the "brain + display in one board" convenience of this parts list may not carry over directly. Worth pricing out if you'd rather build 2 enclosures than 4, but treat it as a redesign, not a substitution.

Wiring & power

QtyItemNotes
~6Plain female-to-female jumper wireAny breadboard jumper wire pack (~$5 for 20). Carries the Vote Box's broadcast (2 wires: signal + ground) to each of the 4 panels, plus one short jumper per NO panel for its role pin, plus the MOSFET driver's signal wire (see below) — all running the same short distance between the co-located Vote Box and ticker box. Pushes onto the CrowPanel's 0.1"-pitch header — no tool needed.
1Small USB power bank for the Vote BoxLow current draw (one small board and the button LEDs) — almost any bank works, even a small one.
1USB power bank/hub for the ticker box to finalizeFeeds the 4 display panels and, via the MOSFET driver's power terminal, the two light strips — all physically co-located there. E-paper draws power mainly during a refresh, and the strips only draw current while lit, but total draw wasn't bench-verified in this pass. A 4-port USB hub off one bank, or 2 small banks, both work; confirm current draw once you have hardware in hand.

Everything else — hardware store / Amazon, ~$60–80

QtyItemNotes
2Dry-erase board, ~8×10"One per side, mounted back-to-back.
2Printed image, laminatedMounted back-to-back on foam board. Lamination = rain-proof.
1Foam board or black coroplast sheetBuilds the ticker box and stiffens the panels.
1Small plastic project box, ~4×3×2"Vote Box enclosure. Drill one 30 mm hole in each of two opposite faces for the arcade buttons.
1Pole: telescoping painter's pole or 1" PVC, 6–8 ftPainter's pole collapses for transit.
Velcro straps, zip ties, 3M VHB tape, electrical tapeMounting everything.
2Dry-erase markers + string tetherYou will lose untethered markers by sundown.
Keep the electronics co-located The Vote Box's broadcast wire and the 4 panels' role jumpers are plain point-to-point wire, not a connector system — that's only "simple" if the ticker box (4 panels) and the Vote Box sit close together, within a foot or so, not stretched across a collapsing pole joint. Build them as one rigid assembly; let the pole extend below that assembly, not through it.

03 Wiring

Two separate wiring jobs: the Vote Box's internal buttons (STEMMA QT, zero solder), and the broadcast wire fanning out to the 4 display panels (plain jumper wire, zero solder).

USB POWER BANK (Vote Box only) USB-C QT Py ESP32-S3 PID 5426 · Vote Box STEMMA QT 100mm LED ARCADE 1x4 QT PID 5296 · addr 0x3A port 1 = YES · port 2 = NO YES NO quick-connect wires, PID 1152, no solder port 3 signal — JST-XH → JST-PH, solder or twist MOSFET DRIVER PID 5648 · power from ticker box bank LIGHT STRIPS ×2 — front + back, PID 4613, wired in parallel Vote Box broadcast → all 4 panels (shared, not addressed) "TX" pin + GND, plain jumper wire YES · FRONT 053 role pin: floating GPIO19 = RX NO · FRONT 004 role pin: → GND GPIO19 = RX YES · BACK 053 role pin: floating GPIO19 = RX NO · BACK 004 role pin: → GND GPIO19 = RX 4× Elecrow CrowPanel 4.2" — identical firmware, each with its own USB-C power "role pin: → GND" is the only wire that differs — present only on the 2 NO panels

All 4 panels tap the same two wires (signal + ground) from the Vote Box. Nothing is addressed — it's a broadcast, so wiring order doesn't matter. The only wire that differs between a YES panel and a NO panel is the tiny role jumper.

Why front and back can never disagree

The front-YES and back-YES panels receive the exact same broadcast and run identical firmware — there's no per-panel state to drift out of sync. If you ever want to double-check a panel's role, unplug its role jumper: it should immediately start showing YES on its next update.

04 Firmware

Two sketches: VoteBox.ino for the QT Py, and DisplayPanel.ino flashed identically to all 4 CrowPanel boards. Both ship alongside this document.

One thing left to finish before flashing DisplayPanel.ino Elecrow ships their own e-paper driver for this exact board (not the more common GxEPD2 library), with functions named EPD_GPIOInit(), Paint_NewImage(), and EPD_Display_Part(). Their existence and the chip's partial-refresh support are confirmed, but their exact call signatures weren't independently verified in this pass — rather than guess and hand you a maybe-wrong function call, the sketch below has a clearly marked // TODO where those calls go, sourced from Elecrow's own example sketch. Everything else in the file — role detection, the wire protocol, ghosting prevention — is complete.

VoteBox.ino complete

// ─────────────────────────────────────────────────────────────
//  TALLY TOTEM — Vote Box
//  Board: Adafruit QT Py ESP32-S3 (No PSRAM), PID 5426
//  Buttons: Adafruit LED Arcade Button 1x4 STEMMA QT, PID 5296
//           snapped onto the QT Py's STEMMA QT port — zero solder
//
//  Job: read the two buttons, hold the authoritative YES/NO counts,
//  save them to flash on every vote, and broadcast the current
//  counts out over a single wire (the pin labeled TX on the board)
//  to all four display panels. No WiFi, no radio — this is a plain
//  wired serial broadcast, immune to festival RF interference.
//
//  Broadcast format (plain ASCII, one line):
//    Y:053,N:004\n
//  Sent immediately after every vote, and again every ~2s as a
//  heartbeat so any display panel that resets catches up fast.
//
//  Both buttons together control two different things, by how long
//  you hold them — both read from the moment they're BOTH down:
//    quick press + release (under 1.2s)  -> toggle the night lights
//    hold 5 seconds                      -> reset the vote counts
//  Between 1.2s and 5s, both button LEDs flash fast as a warning
//  that you've passed the quick-toggle window and a reset is coming
//  if you keep holding; let go during the flash and nothing happens.
//
//  Night lights: a warm-white LED strip grazing each face, switched
//  by an Adafruit MOSFET Driver (PID 5648). Signal comes from the
//  arcade board's PORT 3 — otherwise unused, since only ports 1/2
//  drive the actual buttons — so the lights are just a third "button
//  channel" with no light attached, controlled the same way the
//  button LEDs are.
//
//  Status NeoPixel (built into the QT Py):
//    yellow = starting   green = running   blinking red = the
//    button board (0x3A) wasn't found on the STEMMA QT bus
// ─────────────────────────────────────────────────────────────
#include <Wire.h>
#include <Adafruit_seesaw.h>
#include <Adafruit_NeoPixel.h>
#include <Preferences.h>

// ---- tweakables ----
const uint32_t LIGHT_TOGGLE_MS = 1200;   // release both buttons within this window to toggle the lights
const uint32_t RESET_HOLD_MS   = 5000;   // hold both buttons this long to reset
const uint32_t DEBOUNCE_MS     = 25;
const uint16_t MAX_COUNT       = 999;    // 3-digit cap — matches the display panels' layout
const uint32_t BROADCAST_MS    = 2000;   // heartbeat re-send interval
const uint32_t BAUD            = 115200;

// ---- LED Arcade 1x4 QT (seesaw) ----
const uint8_t ADDR_BTN  = 0x3A;
const uint8_t SW_YES    = 18;   // port 1 switch
const uint8_t SW_NO     = 19;   // port 2 switch
const uint8_t LED_YES   = 12;   // port 1 LED
const uint8_t LED_NO    = 13;   // port 2 LED
const uint8_t LIGHTS_PIN = 0;   // port 3 LED output — drives the MOSFET driver's signal input, not a button

Adafruit_seesaw   btns(&Wire1);   // the STEMMA QT connector is Wire1 on the QT Py
Adafruit_NeoPixel px(1, PIN_NEOPIXEL, NEO_GRB + NEO_KHZ800);
Preferences       prefs;

uint16_t yesCount = 0, noCount = 0;
bool     lightsOn = true;

struct Btn {
  uint8_t  pin;
  bool     down      = false;
  bool     lastRaw   = false;
  uint32_t lastFlip  = 0;
  bool     countable = false;
};
Btn yesBtn = {SW_YES}, noBtn = {SW_NO};

bool     resetArmed      = false;
uint32_t bothDownAt      = 0;
bool     waitBothRelease = false;
uint32_t lastBroadcast   = 0;

void status(uint32_t color) { px.setPixelColor(0, color); px.show(); }

void save() {
  prefs.putUShort("yes", yesCount);
  prefs.putUShort("no",  noCount);
}

void broadcast() {
  Serial1.printf("Y:%03u,N:%03u\n", yesCount, noCount);
  lastBroadcast = millis();
}

void halt(const char *msg) {
  Serial.println(msg);
  while (true) { status(0xFF0000); delay(250); status(0); delay(250); }
}

void setup() {
  Serial.begin(115200);
  Serial1.begin(BAUD);   // uses the pins labeled TX / RX on the QT Py silkscreen

  px.begin();
#ifdef NEOPIXEL_POWER
  pinMode(NEOPIXEL_POWER, OUTPUT);
  digitalWrite(NEOPIXEL_POWER, HIGH);
#endif
  status(0x202000);  // yellow = starting

  Wire1.begin(SDA1, SCL1);
  if (!btns.begin(ADDR_BTN)) halt("Button board (0x3A) not found - check the STEMMA QT cable");

  btns.pinMode(SW_YES, INPUT_PULLUP);
  btns.pinMode(SW_NO,  INPUT_PULLUP);

  prefs.begin("tally");
  yesCount = prefs.getUShort("yes", 0);
  noCount  = prefs.getUShort("no",  0);
  lightsOn = prefs.getBool("lightsOn", true);
  btns.analogWrite(LIGHTS_PIN, lightsOn ? 255 : 0);

  broadcast();
  status(0x001500);  // green = running
}

bool poll(Btn &b) {
  bool raw = !btns.digitalRead(b.pin);  // pressed = LOW
  uint32_t now = millis();
  if (raw != b.lastRaw) { b.lastRaw = raw; b.lastFlip = now; }
  if (raw != b.down && now - b.lastFlip >= DEBOUNCE_MS) { b.down = raw; return true; }
  return false;
}

void loop() {
  uint32_t now = millis();
  bool yesEdge = poll(yesBtn);
  bool noEdge  = poll(noBtn);

  // ---- both-buttons gesture: quick tap toggles lights, 5s hold resets ----
  if (yesBtn.down && noBtn.down) {
    if (!resetArmed) { resetArmed = true; bothDownAt = now; }
    yesBtn.countable = noBtn.countable = false;  // a two-handed squeeze is never a vote
    if (!waitBothRelease) {
      if (now - bothDownAt >= RESET_HOLD_MS) {
        yesCount = noCount = 0;
        save();
        broadcast();
        waitBothRelease = true;
      }
    }
  } else if (resetArmed && !yesBtn.down && !noBtn.down) {
    if (!waitBothRelease && now - bothDownAt < LIGHT_TOGGLE_MS) {
      lightsOn = !lightsOn;
      prefs.putBool("lightsOn", lightsOn);
      btns.analogWrite(LIGHTS_PIN, lightsOn ? 255 : 0);
    }
    resetArmed = false;
    waitBothRelease = false;
  }

  // ---- votes count on release, so a reset squeeze never adds votes ----
  if (!resetArmed && !waitBothRelease) {
    if (yesEdge) {
      if (yesBtn.down) yesBtn.countable = true;
      else if (yesBtn.countable) {
        yesBtn.countable = false;
        if (yesCount < MAX_COUNT) yesCount++;
        save(); broadcast();
      }
    }
    if (noEdge) {
      if (noBtn.down) noBtn.countable = true;
      else if (noBtn.countable) {
        noBtn.countable = false;
        if (noCount < MAX_COUNT) noCount++;
        save(); broadcast();
      }
    }
  }

  // ---- button glow: breathing idle, full-bright while pressed, fast flash during reset warning ----
  static uint32_t lastGlow = 0;
  if (now - lastGlow >= 33) {
    lastGlow = now;
    uint8_t level;
    if (resetArmed && now - bothDownAt >= LIGHT_TOGGLE_MS) {
      level = ((now / 150) % 2) ? 255 : 0;               // fast flash = "past the light toggle, resetting soon"
    } else {
      level = 40 + (uint8_t)(35.0 * (1.0 + sin(now / 900.0)));  // gentle breathing
    }
    btns.analogWrite(LED_YES, yesBtn.down ? 255 : level);
    btns.analogWrite(LED_NO,  noBtn.down  ? 255 : level);
  }

  // ---- heartbeat re-broadcast so any panel that reset catches up within ~2s ----
  if (now - lastBroadcast >= BROADCAST_MS) broadcast();
}

DisplayPanel.ino one TODO remaining

Flash this identical sketch to all four CrowPanel boards. What differs between them is one physical jumper wire (GPIO21 → GND on the two NO panels only), not the code.

// ─────────────────────────────────────────────────────────────
//  TALLY TOTEM — Display Panel
//  Board: Elecrow CrowPanel ESP32 4.2" E-Paper (SSD1683, 400x300)
//  Flash this SAME sketch to all four panels. Which counter each
//  panel shows is set by ONE jumper wire, not by different code:
//
//    ROLE_PIN (GPIO21) jumpered to GND  -> this panel shows NO
//    ROLE_PIN left unconnected          -> this panel shows YES
//
//  Wiring in: two wires from the Vote Box, shared across all four
//  panels in parallel (a broadcast, not a bus — no addressing):
//    Vote Box "TX" pin  -> this panel's GPIO19 (RX)
//    Vote Box GND       -> this panel's GND
//  Both are plain push-on jumper wire into the CrowPanel's 2x10
//  GPIO header (0.1" pitch, GND pins are broken out there too).
//
//  Confirmed control pins (cross-checked against Elecrow's own
//  GitHub repo, not just a blog writeup):
//    CS=45  DC=46  RST=47  BUSY=48  PWR=7  SCK=12  MOSI=11
//
//  *** ONE THING LEFT TO FILL IN ***
//  Elecrow ships their own e-paper driver (not GxEPD2) with
//  functions named EPD_GPIOInit(), Paint_NewImage(), and
//  EPD_Display_Part(). I've confirmed those functions exist and
//  that this chip (SSD1683) supports partial refresh, but I have
//  NOT independently verified their exact call signatures — I'd
//  rather flag that plainly than hand you a guessed function
//  call. Before flashing: open Elecrow's official example sketch
//  for this exact board (linked in the build guide) and copy its
//  display-init and draw-text calls into renderPanel() below,
//  replacing the placeholder. Everything else in this file —
//  role detection, the wire protocol, ghosting prevention — is
//  complete and does not depend on that library's specifics.
// ─────────────────────────────────────────────────────────────

// ---- tweakables ----
const uint32_t BAUD              = 115200;
const uint8_t  FULL_REFRESH_EVERY = 10;   // do a full (flashing) refresh every N partial updates, to clear ghosting

// ---- role selection ----
const uint8_t ROLE_PIN = 21;   // GPIO21 — jumper to GND for a "NO" panel, leave floating for "YES"
enum Role { ROLE_YES, ROLE_NO };
Role myRole;

// ---- broadcast in ----
const uint8_t RX_PIN = 19;     // GPIO19 — shared wire from the Vote Box's TX pin

// ---- e-paper control pins (verified against Elecrow's GitHub) ----
#define EPD_CS   45
#define EPD_DC   46
#define EPD_RST  47
#define EPD_BUSY 48
#define EPD_PWR  7
#define EPD_SCK  12
#define EPD_MOSI 11

uint16_t lastShown   = 0xFFFF;   // force a draw on first boot
uint8_t  updateCount = 0;

void setup() {
  Serial.begin(115200);

  pinMode(ROLE_PIN, INPUT_PULLUP);
  myRole = (digitalRead(ROLE_PIN) == LOW) ? ROLE_NO : ROLE_YES;

  Serial1.begin(BAUD, SERIAL_8N1, RX_PIN, -1);   // receive-only

  pinMode(EPD_PWR, OUTPUT);
  digitalWrite(EPD_PWR, HIGH);   // power the panel before touching SPI

  // TODO: copy Elecrow's official EPD_GPIOInit() / display.init()
  // sequence here, using the pin numbers above.

  renderPanel(0, true);   // draw "0" / blank state at boot, full refresh
}

void loop() {
  static String line;
  while (Serial1.available()) {
    char c = Serial1.read();
    if (c == '\n') {
      parseAndRender(line);
      line = "";
    } else if (line.length() < 32) {
      line += c;
    }
  }
}

// Parses "Y:053,N:004" and pulls out just this panel's own number.
void parseAndRender(const String &line) {
  int yIdx = line.indexOf("Y:");
  int nIdx = line.indexOf("N:");
  if (yIdx < 0 || nIdx < 0) return;   // malformed line, ignore — next heartbeat will fix it

  uint16_t yVal = line.substring(yIdx + 2, line.indexOf(',', yIdx)).toInt();
  uint16_t nVal = line.substring(nIdx + 2).toInt();
  uint16_t mine = (myRole == ROLE_YES) ? yVal : nVal;

  if (mine == lastShown) return;   // no change, skip the refresh entirely
  renderPanel(mine, false);
}

void renderPanel(uint16_t value, bool forceFull) {
  bool fullRefresh = forceFull || (updateCount % FULL_REFRESH_EVERY == 0);
  const char *label = (myRole == ROLE_YES) ? "YES" : "NO";

  // TODO: draw here using Elecrow's Paint_NewImage() / text-draw
  // calls, then push with EPD_Display_Part() for a partial update
  // or their full-refresh equivalent when fullRefresh is true:
  //   1. Paint_NewImage(...)
  //   2. Paint_Clear(WHITE)
  //   3. draw `label` near the top, `value` large and centered
  //   4. fullRefresh ? full-refresh call : EPD_Display_Part(...)

  lastShown = value;
  updateCount++;
}

One-time computer setup

  1. Install the Arduino IDE.
  2. Settings → Additional boards manager URLs, add:
    https://espressif.github.io/arduino-esp32/package_esp32_index.json
  3. Boards Manager → install esp32 by Espressif Systems.
  4. Manage Libraries → install Adafruit seesaw Library and Adafruit NeoPixel (for VoteBox.ino).
  5. Download Elecrow's example sketch/library for the 4.2" panel from their GitHub repo — this is where the DisplayPanel.ino TODO gets filled in.
  6. QT Py: Tools → Board → esp32 → Adafruit QT Py ESP32-S3 No PSRAM. CrowPanel: select the matching ESP32-S3 board entry from Elecrow's board package instructions.

05 Physical build

Bench-test everything flat on a table before building the enclosure.

  1. Bench test the Vote Box. Wire the arcade board and buttons per the diagram, flash VoteBox.ino, open the Serial Monitor, press buttons, confirm the broadcast line prints and counts climb correctly. Test the 5-second reset and the quick-tap light toggle separately, and confirm the setting survives a power cycle.
  2. Bench test one display panel. Wire GPIO19 + GND to a second QT Py (or the real Vote Box) sending test broadcast lines, flash DisplayPanel.ino (with the TODO filled in), confirm it draws the right number and that leaving the role pin floating shows YES while jumpering it to GND shows NO.
  3. Night lights. Cut the LED strip into two short segments and wire both to the MOSFET driver's load terminals in parallel. Wire the driver's power terminal to the ticker box's bank, and its signal input to the arcade board's port 3 (the one non-connector-matched joint in the build — see the Night Lights callout in the shopping list). Confirm the quick-tap gesture switches both segments together before mounting anything.
  4. Ticker box. Build a shallow box from foam board / coroplast holding 2 panels per face (YES left, NO right — matching the whiteboard's own YES/NO layout), cut a window per panel sized to the CrowPanel's active area. Wire all 4 panels' broadcast + role pins per the diagram, and mount the two light-strip segments to graze each face's panel pair.
  5. Vote Box enclosure. Drill two 30 mm holes in opposite faces of the small project box for the arcade buttons; the QT Py and arcade board ride inside. Mount just below the ticker box, close enough that the broadcast and light-signal wires stay short.
  6. Panel sandwich. Laminated print ×2 back-to-back on foam board; dry-erase boards ×2 back-to-back below it. Zip-tie both to the pole through drilled holes.
  7. Stack it: image → whiteboard → ticker box → Vote Box → power bank(s), all as one rigid assembly, with the collapsible pole extending below.

06 Pre-festival test checklist

07 Troubleshooting

SymptomCause & fix
Vote Box NeoPixel blinks redThe button board (0x3A) isn't found — re-seat the STEMMA QT cable at both ends; it should click.
A panel never updatesCheck its GPIO19 (RX) and GND jumpers are seated. Open its own Serial output — if nothing is arriving, the fault is the wire, not the firmware.
A panel shows the wrong counter (YES instead of NO or vice versa)Its role jumper (GPIO21 → GND) is missing or connected when it shouldn't be. Check the specific two panels meant to be NO.
Front and back disagreeShouldn't be possible — both receive the same broadcast. If it happens, check the failing panel's RX wire first, then its role jumper.
A button LED never lightsLED polarity — unplug that button's LED quick-connect, flip it, replug.
Ghosting / faint shadow of old numbersExpected between full refreshes — clears automatically every 10 updates (FULL_REFRESH_EVERY). Lower that constant if it bothers you sooner.
A press counts doubleRaise DEBOUNCE_MS in VoteBox.ino to 40.
Lights won't toggle, or a vote gets added when tapping both buttonsThe quick-tap window is LIGHT_TOGGLE_MS (1.2s) — you're likely releasing too slowly. Aim for a snappy tap; the button LEDs staying in their normal breathing pattern (not flashing) confirms you're still inside the window.
Lights don't come on at allCheck the MOSFET driver's power terminal is wired to the ticker box's bank, and its signal wire is actually reaching the arcade board's port 3 (not port 1 or 2, which are the buttons). Confirm with the Serial Monitor: lightsOn toggling in firmware doesn't guarantee the physical wire is connected.
Only one light segment comes onThe two strip segments should be wired in parallel to the same MOSFET driver load terminals — check the second segment's connection, not the driver or firmware.
Totem dies after a few minutes on batteryConfirm the power bank isn't auto-sleeping on the Vote Box's small load — most banks handle e-paper's low draw fine, but cheap ones can be picky below ~50mA.
Click me.