Festival vote totem — build guide
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.
The whole build in one screen. Click any part of the diagram below to see what it does.
Two separate electronic units, tied together by one plain wire.
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.
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.
| Qty | Item | PID | Each | Total |
|---|---|---|---|---|
| 1 | QT Py ESP32-S3 (8 MB Flash, No PSRAM) — the vote-counting brain | 5426 | $12.50 | $12.50 |
| 1 | LED Arcade Button 1x4 — STEMMA QT breakout — reads the buttons, drives their LEDs | 5296 | $9.95 | $9.95 |
| 1 | STEMMA QT cable, 100 mm — QT Py → arcade board, snaps in, zero solder | 4210 | $0.95 | $0.95 |
| 1 | Arcade button with LED, 30 mm translucent Green | 3487 | $2.50 | $2.50 |
| 1 | Arcade button with LED, 30 mm translucent Red | 3489 | $2.50 | $2.50 |
| 1 | Arcade button quick-connect wire pairs, 0.11" — 10-pack (uses 4, rest are spares) | 1152 | $4.95 | $4.95 |
| Subtotal | $33.35 |
| Qty | Item | Each | Total |
|---|---|---|---|
| 4 | Elecrow 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 |
| Qty | Item | PID | Each | Total |
|---|---|---|---|---|
| 1 | Adafruit 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 |
| 1 | Ultra 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 pair | 4613 | $11.95 | $11.95 |
| Subtotal | $15.90 |
| Qty | Item | Notes |
|---|---|---|
| ~6 | Plain female-to-female jumper wire | Any 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. |
| 1 | Small USB power bank for the Vote Box | Low current draw (one small board and the button LEDs) — almost any bank works, even a small one. |
| 1 | USB power bank/hub for the ticker box to finalize | Feeds 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. |
| Qty | Item | Notes |
|---|---|---|
| 2 | Dry-erase board, ~8×10" | One per side, mounted back-to-back. |
| 2 | Printed image, laminated | Mounted back-to-back on foam board. Lamination = rain-proof. |
| 1 | Foam board or black coroplast sheet | Builds the ticker box and stiffens the panels. |
| 1 | Small plastic project box, ~4×3×2" | Vote Box enclosure. Drill one 30 mm hole in each of two opposite faces for the arcade buttons. |
| 1 | Pole: telescoping painter's pole or 1" PVC, 6–8 ft | Painter's pole collapses for transit. |
| — | Velcro straps, zip ties, 3M VHB tape, electrical tape | Mounting everything. |
| 2 | Dry-erase markers + string tether | You will lose untethered markers by sundown. |
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).
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.
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.
Two sketches: VoteBox.ino for the QT Py, and DisplayPanel.ino flashed identically to all 4 CrowPanel boards. Both ship alongside this document.
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.
// ─────────────────────────────────────────────────────────────
// 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();
}
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++;
}
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonBench-test everything flat on a table before building the enclosure.
| Symptom | Cause & fix |
|---|---|
| Vote Box NeoPixel blinks red | The button board (0x3A) isn't found — re-seat the STEMMA QT cable at both ends; it should click. |
| A panel never updates | Check 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 disagree | Shouldn'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 lights | LED polarity — unplug that button's LED quick-connect, flip it, replug. |
| Ghosting / faint shadow of old numbers | Expected between full refreshes — clears automatically every 10 updates (FULL_REFRESH_EVERY). Lower that constant if it bothers you sooner. |
| A press counts double | Raise DEBOUNCE_MS in VoteBox.ino to 40. |
| Lights won't toggle, or a vote gets added when tapping both buttons | The 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 all | Check 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 on | The 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 battery | Confirm 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. |