Eye-D Badge
Hello World, and Meet the EyeD Badge
I wanted to make a conference badge that people would actually notice, so I built the EyeD Badge, a wearable 8×16 RGB LED smart badge powered by an ESP32-C6.
Instead of a boring laminated ID card, I replaced it with a 128-pixel RGB LED matrix. The entire badge is powered by a small LiPo battery, making it comfortable enough to wear throughout a full day at a conference, hackathon, or maker event.
One of my favourite features is the startup animation. As soon as the badge powers on, a giant animated eye appears, looks around the room, and occasionally blinks at people. That's where the name EyeD comes from, it's an ID badge with an eye that watches back.
Of course, the eye is just the idle animation.
I also added Bluetooth Low Energy support, allowing the badge to be controlled from any phone, tablet, or laptop. There's no app to install—just open a web page in your browser and connect.
From the web interface, you can scroll your name, social handle, or any custom message across the display. I also added more than 15 built-in animations, including Matrix rain, fire, plasma effects, and even a full Nyan Cat animation complete with its rainbow trail.
For something more creative, I added a Draw Mode that lets you create pixel art directly on the badge, one pixel at a time, in any colour. The web interface also includes a live preview of the LED matrix, so you can always see what the badge is displaying without looking down.
The goal behind the EyeD Badge was to turn a simple conference ID into something interactive and fun. Instead of just showing your name, it becomes a wearable display that grabs attention, starts conversations, and lets you customize it however you like.
Supplies
- ESP32-C6 dev board
- 8×16 WS2812B RGB LED matrix panel
- LiPo battery
- 3D printed Case
- Slide Switch
HARDWARE- CUSTOM RGB MATRIX
For the LED matrix, I wanted something a little out of the ordinary.
Luckily, I came across an project by Arnov Sharma, whose design was exactly what I was looking for. I reached out to him to see if he could provide a fully assembled sample, and I got one for this build.
This Matrix is in 16x8 configuration and is laid out in an OXPLOW layout, OXPLOW is a matrix type in which LEDs go one way in one row, and then backward in the next row, and so on, this layout is also called boustrophedon.
There's also another layout which is the serpentine Layout and LEDs in this layout are laid out in a continuous chain like a snake thus the name serpentine.
This Matrix is controlled via the FAST LED Library but can be operated with a bunch of existing libraries like ADAFRUIT's Neopixel Library or Smart Matrix Library etc.
HARDWARE- XIAO ESP32 C6
Powering the EyeD Badge is the Seeed Studio XIAO ESP32-C6, an incredibly compact development board measuring just 21 × 17.8 mm, about the size of a thumbnail. Despite its tiny footprint, it packs everything needed for a connected wearable. At its core is Espressif's ESP32-C6, featuring a 32-bit RISC-V processor running at up to 160 MHz, along with modern wireless connectivity including Wi-Fi 6, Bluetooth 5 (LE), Zigbee, and Thread, making it Matter-ready for future IoT applications.
For this project, the hardware setup couldn't be simpler. The entire 8×16 RGB LED matrix is driven using a single GPIO pin (D0) for the data signal, alongside power and ground. Thanks to the board's integrated LiPo battery charging circuit and excellent low-power performance, the badge can run all day from a small rechargeable battery without requiring any additional power management hardware.
Key Specifications
- Microcontroller: Espressif ESP32-C6
- CPU: 32-bit RISC-V processor, up to 160 MHz
- Board Size: 21 × 17.8 mm
- Wireless Connectivity:
- Wi-Fi 6 (2.4 GHz, IEEE 802.11 b/g/n/ax)
- Bluetooth 5 (Low Energy)
- Zigbee
- Thread
- Matter compatible
- SRAM: 512 KB
- Flash Memory: 4 MB
- USB: USB Type-C for programming and power
- GPIO: 11 multifunction GPIO pins
- Antenna: Onboard PCB antenna with U.FL connector for an external antenna
- Battery Support: Built-in LiPo battery charging circuitry
- Low Power: Deep sleep current in the microamp range
- LED Matrix Interface: Single data pin (D0) controls all 128 RGB LEDs
POWER SOURCE
To power the EyeD Badge, I chose a 3.7 V 650 mAh LiPo battery. It was an easy choice since the Seeed Studio XIAO ESP32-C6 has built-in support for single-cell LiPo batteries, including onboard charging circuitry.
The XIAO provides dedicated battery pads on the back of the board, allowing the LiPo cell to be soldered directly without needing an external charging module or power management board.
BADGE DESIGN
From the very beginning, my goal was to make the EyeD Badge feel like a real conference ID badge rather than just another electronics project. That meant designing an enclosure with the same overall shape and proportions as a typical ID card, complete with a mounting loop at the top for attaching a standard lanyard or badge strap.
I started by modelling all of the major components in Fusion 360, including the 8×16 RGB LED matrix, the 650 mAh LiPo Cell, the Seeed Studio XIAO ESP32-C6, and the slide power switch. Having accurate models made it much easier to design a compact enclosure that fit everything neatly.
The front of the enclosure is essentially a frame that holds the LED matrix securely in place. The matrix is mounted from the front using four M2 screws, giving it a clean appearance while keeping it firmly attached.
On the back, I designed dedicated mounting locations for each component. The LiPo battery sits behind the LED matrix, while the XIAO ESP32-C6 is mounted near the bottom of the enclosure. Beside it is a slot for the slide switch, allowing the badge to be turned on or off from the bottom edge without opening the enclosure.
To finish the assembly, I designed a removable rear cover that encloses all of the electronics and protects them during everyday use. Finally, a reinforced loop at the top of the enclosure allows a standard ID card strap or lanyard to be attached, completing the look and functionality of a wearable conference badge.
Downloads
3D PRINTED PARTS
After finalizing the model, Both parts were exported as mesh files and then 3D printed using Sage Green PLA.
Both parts were printed with a 0.4 mm nozzle, 0.2 mm layer height, 25% infill, and we use tree support for parts that needed support.
MATRIX & XIAO ASSEMBLY
The assembly process of the XIAO MCU and LED Matrix board was super simple. Here, we connected the LED Matrix's VCC terminal to the XIAO's 3.3V pin, GND to GND, and the DIN pin of the LED Matrix to the XIAO's D0 pin.
DEMO RUN
This is code for this project and it's a simple one.
This demo sketch is the "hello world" of addressable LEDs, using Adafruit's NeoPixel Library - I ran it first just to prove the wiring works before loading real firmware.
MATRIX & FRAME ASSEMBLY
- The matrix and frame assembly begins by passing the XIAO from the top side to the bottom of the frame.
- Next, position the LED Matrix in place from the top side, align it with its mounting holes, and use four M2 screws to secure the board in place.
XIAO ESP32 C6 PLACEMENT
The XIAO is positioned in place, and the wires connecting the XIAO to the LED Matrix are neatly aligned, with all three wires tucked inside the frame body for a clean and compact assembly.
POWER SOURCE ASSEMBLY
- We place a piece of double-sided tape near the mounting area for the LiPo cell. Remove the tape cover using a pair of tweezers, then place the LiPo cell in position. Gently press the battery to secure it in place.
- The LiPo cell's positive terminal is soldered to the XIAO's battery positive pad.
- Next, place the slide switch in its designated position right next to the XIAO. Solder the negative terminal of the LiPo cell to the common (COM) terminal of the slide switch. Then connect the NC terminal of the slide switch to the XIAO's battery negative pad. Here, we have essentially added a power switch between the battery's negative terminal and the XIAO's battery negative input.
- Finally, place the XIAO back into its mounting position.
FINAL ASSEMBLY
For the final assembly, we simply take the lid part and place it on the back side of the enclosure. Just press it into place, and thanks to the designed tolerances and clearances, it fits tightly and securely without any additional fasteners.
This concludes the assembly process, and we now have a solid, fully assembled LED Matrix badge ready to use.
MAIN CODE
Two files make the whole project.
badge_firmware.ino runs on the ESP32-C6, drives the less listen on bluetooth
index.html the control page connests over web bluetooth and simulates the panel on screen
PART 1 — The Firmware (`badge_firmware.ino`)
the key idea the panel is a physically a 16 wide x 8 tall strip chain, but the badge is worn vertically so all the drawing code thinks in a logical 8x16 portrait canvas, and one function 'XY'
below translates to the physical wiring. change hardware details here never in animation code
Mode State
volatile matters these all written from the bluetooth callback which runs on a diffrents task and read in loop() it tells he compiler this can change behind your back, dont cache it
the enum numbers are exactly the numbers the webpage sent 'M:16' = index 16 = 'Mode_NYAN'
XY(x, y) coordinate translator
most important 10 line in the file. everything drains in portrait coordinates this converts one logical (x,y) into the index of the physical led in the chain
off grid coordinate return "num_leds' an index just past the buffer which the next function safely rejects so animations can draw partially off the edge without crashing. the 90° rotation is why text can fall down a panel thats electrically sideways
SetPx(x, y, Colour)
the only safe way to light a pixel translate coordinates check the guard write into the frame buffer. nothing appears on the physical lens until someone calls 'FastLED.show()'
The font
'font5x7[]' and 'fontCol()'
font5x7[] is a table of 5 byte entries, one per ASCII character from space to 'Z' each byte is one column of the character each bit in the byte is one row. bit 0 = top, so the letter A is 5 column x 7 rows of on/off bits.
PROGMEM / 'pgm_read_byte' keep the table in flash instead of RAM,
The scroller 'drawScrollFrame()'
draws one frame of vertically-falling text and advances it by one pixel.
`cy = scrollOffset - (ci+1)*8` is the trick that makes the first letter of the message enter first and lead the fall, with each next layer stacked above it so you read the message in order as it drops.
letters are drawn at 'x= 1..5' centred in the 8 pixel width.
Returns 'true' when one full pass finished name mode uses that to switch back to the eyes.
The eye — `drawEyesFrame()`
The idle animation one big Eye. three little state machine run at once
Each Frame
- Blink scheduler - if the time has passed 'nextBlinkAt' start a blink. 'blinkPhase' counts 1 to 12s. openness goes 6 to 8s closing then 0-6s opening then a new random blink time 1.5 to 5.5s away is chosen
- Glance scheduler - every 0.9 to 3.1s pick a new 'pupilTargetX' -1, 0 or 1. the pupil steps one pixel per frame towards it smooth moment, not teleporting.
- Draw the outline - with an ellipse equation.
point with 'd ≤ 1' are inside the ellipse, requiring `d ≥ 0.45` keeps only a ring, the outline. Shrinking 'b' squashes the ellipse flat = the eyelid closing
- Iris + pupil - rounded 4x4 patch of dimmed 'fgColour' (`iris.nscale8_video(80)` = 30% brightness. around a 2x2 red pupil drawn only when the eye is open enough (`openness >= 3`).
The Animation
each one draws a single frame 'loop()' calls it over and over. common tricks
- 'fadeToBlackBy(leds, Num_LEDS, n)' - dims the whole frame a step drawing on top of that every frame creates motion trails for free.
- 'sin8/beatsin8/`inoise8'—FastLED's fast integer wave noice generators, used instead of slow floationg point.
- Rain (`drawRainFrame`) — one "drop head" per column (`rainHead[x]`). each frame fade everything, draw the head white and the pixel behind it in 'fgColour' move the head down. past the bottom, the head is set to a negative number = an offscreen countdown, so column restarts at random staggered times.
- Plasma (`drawPlasmaFrame`) - for each pixel, add three sine waves of position and time, use the sum as a hue flowing colour blobs. No states at all. its pure math of 'millis().
- Fire (`drawFireFrame`) - the classic heat simulation. A 'heat [x] [y]' grid every cell cells a random bit, heat drifts upward each cell becomes a weighted average of the cell below it, random sparks ignite at the bottom row, and 'HeatColour() map heat 0-255 to black-red-orange-white.
- Sparkle ('drawSparkleFrame') - fade, then with some probability drop one 'fgColour' dot and occasionally a white one at random positions. Glitter.
- Shapes (`drawShapesFrame`) - every 0.3s to 0.9s stamp a random outline rectangle, cross,diamond at a random spot in a random hue the global fade melts them away.
- Game of Life (`drawLifeFrame`) - real Conway rules on a wrap-around grid count each cell's 8 neighbours, live cell survives with 2-3, empty cell births with exactly 3. reseeds automatically when the poplation dies out or after 120 genrations to escaoe frozen pattern. dying cells leave fading trails because the frame only half-fades.
- Bounce (`drawBounceFrame`) - two balls with float position and velocities each frame add velocity, flips its sign on a wall hit. One ball white, one 'fgColour' both leaving traits.
- Equalizer (`drawEqFrame`) - per column, 'inoise8' smooth random noise picks a target bar height the bar moves one pixel per frame toward it that easing is what makes it look like music. Colour by height green base, amber mids, red peaks.
- Radar (`drawRadarFrame`) - up to 3 'ripples' each has a center and a radius that grows every frame pixels whose squared distance falls between `(r-1)²` and `r²` form the expanding ring.
- Comet (`drawCometFrame`) - a full height bar sweeping side to side on 'beatsin8' a smooth sine bounce, hue slowly rotating, trails from the fade.
- Stars (`drawStarsFrame`) - 14 stars, each with position, brightness, and a brightness velocity. Rise to max start dimming hit zero respawn at a new random spot. slightly ble-tinted white.
- Pulse (`drawPulseFrame`) - the whole panel breathes `beatsin8(16, 8, 160)` give a slow sine between dim and bright, applied to 'fgColour'
- Nyan (`drawNyanFrame`) - a hand drawn 8x9 sprite in a 'PROGMEM' table where each cells
because the phase depends on 'y' , the offset ripples down the trail the wave the cat itself set the offset of the top trail row, so it bobs in sync. sprinkles swap color on a 250 ms timer = twinkling.
Bluetooth Whole BLE section
The badge speaks Nordic UART Service NUS. a de-facto standard serial port over BLE with fixed UUIDs. any BLE terminal app can talk to it, not just our web page
hexVal(c) turns one hex character ('0'-'9', 'a'-'f', 'A'-'F') into its number. used to parse draw commands.
RxCallback::onWrite runs the instant a BLE write lands.
why two paths? normal commands are rare (one button press), so a single message buffer is fine. but drawing sends a burst of messages while you drag a finger, if each had other wait for 'loop()' to pick up, some would overwrite 'bleBuf' before being read and pixel would drop. painting directly in the call back makes strikes lossless
SrvCallback::onDisconnect - one job restarts advertising when the phone disconnects, so the badge is immediately discoverable again
setupBLE() - boots the ratio sets the name (EyeD Badge), creates the NUS service with its RX write and Tx notify characteristics, attaches the callback, starts advertising
handleBleMsg() - called from 'loop' when 'newBleMsg' is set. parses the command:
|Message | Meaning |
|---------------- |---------|
| `B:40` | brightness 1-255 |
| `C:FF0044` | set `fgColor` (hex RGB) |
| `M:5` | switch to mode 5; `M:17` also clears the draw canvas |
| `P:xyRRGGBB` | (handled earlier in the callback) paint pixel x,y |
| `P:C` | clear the draw canvas |
| anything else | treat as text → scroll it (mode 2) |
setup() and loop()
setup() - run once. register the LED strip with FastLED ('WS2812B'),'GRB' color order), set brightness, start BLE, seed the random generator from hardware entropy, randomise the rain columns.
loop() - the heartbeat.
- MODE_NAME - scroll `NAME_TEXT`; when a pass completes, switch to eyes for
- EYES_DWELL_MS - then back that's the idle alternation.
- MODE_EYES` - eye frame; only auto-returns to name if it came from name mode (`eyesUntil != 0`). Booting straight into eyes stays in eyes forever.
- MODE_TEXT - scroll whatever BLE text arrived (defaults to "HELLO").
- MODE_CYCLE - a sub-mode list and a 10-second timer; `runAnim(subs[sub])` plays the current one, timer advances to the next.
- MODE_DRAW - does no drawing of its own. The canvas lives in `leds[]` when the BLE callback set `drawDirty`, push it out with one `show()` This is why your drawing persists — nothing ever clears it until you leave. everything else `runAnim(mode)`
- runAnim(m) - lookup that calls the right 'draw...Frame()' plus its per-mode frame delay (plasma run at 30 ms, Game of Life at a Chill 140 ms.. Shared by the normal path and cycle mode so the timings live in one place
The Web page 'index.html'
One self contained file - styles, markup, and three JS sections ( BLE, simulator, draw), No frameworks, no build step.
Page structure 'HTML'
- header - tittle plus a status dot + 'standby / linked' label
- <canvas id="cv"> - the 8x16 LED preview 320x640 = 40 px per LED
- connect button - the only thing active before you link
- controls - everything else, blurred out ('hidden") until connected:
- text input + send
- mode buttons (generated from JS list)
- draw section: slider toggle, swatch palette, custom color, clear
- brightness slider
- color swatches
log - online status / debug readout at the bottom
The BLE section
setUI(connected) - flips every visual between standby and linked states button text, glow dot, unblurring the controls, On disconnect it also turns draw mode off and blanks the preview.
toggleConnect() - the whole connection dance
also registers a 'gattserverdisconnected' listener so the UI resets if the badge goes out of range. If the browser has no 'navigator.bluetooth' (iPhone Safari), it logs the ' use Bluefy' hint instead.
- sendCmd(cmd) - encodes the strings to bytes and writes it to 'rxChar'
- 20-byte chunks - the safe payload size for a default BLE connection.
- 'sendText()' / 'uiBrightness()' / 'pickMode()' / 'pickColor()' - thin wrappers read the input, send the matching command (`M:`, `B:`, `C:` or raw text), update which button is highlighted and mirror the change in the local simulator so the preview matches the badge.
each entry = button label firmware mode number, simulator animation name the buttons are generated from this list add a mode in firmware, add one line here done.
The LED simulator
the preview isn't a video - its a reimplementation of every firmware animation in javascript, drawing into the same kind of buffer.
- FDATA — the same 5×7 font as the firmware, as a JS object.
- S— an object of animation functions: `S.scroll`, `S.eyes`, `S.rain`, `S.fire`, `S.nyan`, ... each a line-for-line translation of its firmware twin (same constants, same wave math), so preview and the badge stay in sync visually.
- S.breathe - the standby state clears the buffer = all LEDs dark.
- simSet(mode, label) - switches the simulated mode and updates the little label under the canvas
The Render Loop - ('frame()' + 'draw()'), driven by 'requestAnimationFrame'
- IV - maps each mode to its frame intervel(mirroring the firmware's per-mode but re-render the canvas the full 60 fps.
That's the entire interface between the two files — six message shapes.
Link to the Code - here
Downloads
RESULT
Here's the Result, the EyeD Badge, watching everyone back.
Day to day, it just hangs on my lanyard doing its eye thing, wandering, blinking, and occasionally creeping someone out at exactly the right moment.
When someone asks about it (they always do), I pull out my phone, connect in two taps, and either have their name scrolling across the badge or open Draw Mode and let them paint pixels on my chest. That party trick hasn't missed once.
Fire, Matrix Rain, the Equalizer, and Nyan Cat with its waving rainbow trail handle the rest of the attention.
Let me know what else I can add to this project in Version 2. Perhaps I could design a version that integrates the LEDs, the XIAO, and everything else onto a single custom PCB. I'm currently learning PCB design, and it would definitely be a challenge I'd love to tackle.
But that's it for this project. Once again, a special thanks to Arnov Sharma for providing the LED Matrix used in this build, and thank you to everyone who made it this far.
Bye!