Eye-D Badge

by AahanSharma in Circuits > LEDs

4311 Views, 9 Favorites, 0 Comments

Eye-D Badge

21.gif
24.gif
IMG_20260719_092311.jpg
IMG_20260719_091901.jpg

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

  1. ESP32-C6 dev board
  2. 8×16 WS2812B RGB LED matrix panel
  3. LiPo battery
  4. 3D printed Case
  5. Slide Switch

HARDWARE- CUSTOM RGB MATRIX

IMG_0124.JPG
01.JPG
02.JPG

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

IMG_E0125.JPG

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

  1. Microcontroller: Espressif ESP32-C6
  2. CPU: 32-bit RISC-V processor, up to 160 MHz
  3. Board Size: 21 × 17.8 mm
  4. Wireless Connectivity:
  5. Wi-Fi 6 (2.4 GHz, IEEE 802.11 b/g/n/ax)
  6. Bluetooth 5 (Low Energy)
  7. Zigbee
  8. Thread
  9. Matter compatible
  10. SRAM: 512 KB
  11. Flash Memory: 4 MB
  12. USB: USB Type-C for programming and power
  13. GPIO: 11 multifunction GPIO pins
  14. Antenna: Onboard PCB antenna with U.FL connector for an external antenna
  15. Battery Support: Built-in LiPo battery charging circuitry
  16. Low Power: Deep sleep current in the microamp range
  17. LED Matrix Interface: Single data pin (D0) controls all 128 RGB LEDs

POWER SOURCE

IMG_E0126.JPG

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

Screenshot 2026-07-09 172139.jpg
Screenshot 2026-07-09 172416.jpg
Screenshot 2026-07-09 172430.jpg
Screenshot 2026-07-09 172528.jpg
Screenshot 2026-07-09 172544.jpg
Screenshot 2026-07-09 172612.jpg
Screenshot 2026-07-09 172634.jpg

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.

3D PRINTED PARTS

02.gif

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.

Downloads

MATRIX & XIAO ASSEMBLY

01.gif

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

IMG_0133.JPG
DEMO

This is code for this project and it's a simple one.

#include <Adafruit_NeoPixel.h>
#ifdef __AVR__
#include <avr/power.h> // Required for 16 MHz Adafruit Trinket
#endif
#define PIN 0 // On Trinket or Gemma, suggest changing this to 1
#define NUMPIXELS 128 // Popular NeoPixel ring size

Adafruit_NeoPixel pixels(NUMPIXELS, PIN, NEO_GRB + NEO_KHZ800);
#define DELAYVAL 80 // Time (in milliseconds) to pause between pixels

void setup() {
#if defined(__AVR_ATtiny85__) && (F_CPU == 16000000)
clock_prescale_set(clock_div_1);
#endif
pixels.begin(); // INITIALIZE NeoPixel strip object (REQUIRED)
}

void loop() {
pixels.clear(); // Set all pixel colors to 'off'
for(int i=0; i<NUMPIXELS; i++) { // For each pixel...
pixels.setPixelColor(i, pixels.Color(0, 150, 0));
pixels.show(); // Send the updated pixel colors to the hardware.
delay(DELAYVAL); // Pause before next pass through loop
}
}

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

03.gif
04.gif
05.gif
06.gif
  1. The matrix and frame assembly begins by passing the XIAO from the top side to the bottom of the frame.
  2. 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

07.gif
08.gif

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

09.gif
10.gif
11.gif
12.gif
13.gif
  1. 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.
  2. The LiPo cell's positive terminal is soldered to the XIAO's battery positive pad.
  3. 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.
  4. Finally, place the XIAO back into its mounting position.

FINAL ASSEMBLY

14.gif
15.gif
16.gif

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`)

```cpp
#define DATA_PIN 0 // GPIO the matrix data wire is on
#define WIDTH 8 // logical width, as worn (portrait)
#define HEIGHT 16 // logical height, as worn
#define PANEL_W 16 // how the panel is physically wired
#define PANEL_H 8
#define NUM_LEDS (PANEL_W * PANEL_H) // 128
#define SERPENTINE false // true if odd rows are wired right-to-left
#define ROTATE_180 false // flip everything if mounted upside-down
#define NAME_TEXT "YOURNAME" // what "name" mode scrolls
#define SCROLL_MS 70 // ms per scroll step (lower = faster)
#define EYES_DWELL_MS 8000 // how long eyes show between name scrolls
```

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

```cpp
CRGB leds[NUM_LEDS]; // the frame buffer: 128 RGB pixels FastLED pushes out
CRGB fgColor = ...; // current badge color (changed by C: command)
uint8_t brightness = 30; // global brightness (changed by B: command)
```


Mode State

```cpp
enum Mode { MODE_NAME, MODE_EYES, MODE_TEXT, MODE_RAIN, ... MODE_NYAN, MODE_DRAW };
volatile Mode mode = MODE_EYES; // badge boots into the eye
String bleText = ""; // last text received over BLE
volatile bool newBleMsg = false; // flag: "a BLE message is waiting"
String bleBuf = ""; // the waiting message itself
```

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

```cpp
uint16_t XY(int8_t x, int8_t y) {
if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return NUM_LEDS; // off-grid guard
#if ROTATE_180
x = WIDTH - 1 - x; y = HEIGHT - 1 - y; // optional 180° flip
#endif
int8_t px = HEIGHT - 1 - y; // rotate portrait 90° onto the landscape panel
int8_t py = x;
if (SERPENTINE && (py & 1)) // zigzag panels: odd rows reversed
return py * PANEL_W + (PANEL_W - 1 - px);
return py * PANEL_W + px; // straight-wired panels
}
```


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)

```cpp
void setPx(int8_t x, int8_t y, CRGB c) {
uint16_t i = XY(x, y);
if (i < NUM_LEDS) leds[i] = c;
}
```

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.

```cpp
uint8_t fontCol(char c, uint8_t col) {
if (c >= 'a' && c <= 'z') c -= 32; // lowercase → uppercase
if (c < 32 || c > 90) c = '?'; // anything unknown prints as ?
return pgm_read_byte(&font5x7[c - 32][col]);
}
```

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.

```cpp
int scrollOffset; // how far the fall has gone
int textPixelHeight(const String &s) { return s.length() * 8; } // 7px letter + 1 gap

bool drawScrollFrame(const String &s) {
FastLED.clear();
for (uint16_t ci = 0; ci < s.length(); ci++) {
int cy = scrollOffset - (ci + 1) * 8; // char 0 leads the fall
if (cy <= -8 || cy >= HEIGHT) continue; // skip chars not on screen
for (uint8_t col = 0; col < 5; col++) {
uint8_t bits = fontCol(s[ci], col);
for (uint8_t row = 0; row < 7; row++)
if (bits & (1 << row)) setPx(1 + col, cy + row, fgColor);
}
}
FastLED.show();
scrollOffset++;
if (scrollOffset > textPixelHeight(s) + HEIGHT) { scrollOffset = 0; return true; }
return false;
}
```

`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


```cpp
int8_t pupilX, pupilTargetX; // where the pupil is / wants to be (-1..1)
uint8_t blinkPhase; // 0 = open, 1..12 = mid-blink frame counter
uint32_t nextBlinkAt, nextLookAt; // when to blink / glance next
```

Each Frame

  1. 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
  2. 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.
  3. Draw the outline - with an ellipse equation.
```cpp
const float cx = 3.5f, cy = 7.5f, a = 4.0f; // center and horizontal radius
float b = 0.6f + 3.2f * openness / 6.0f; // vertical radius follows the blink
...
float d = dx*dx + dy*dy; // normalized distance from center
if (d <= 1.0f && d >= 0.45f) setPx(x, y, fgColor);
```

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

  1. 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

  1. 'fadeToBlackBy(leds, Num_LEDS, n)' - dims the whole frame a step drawing on top of that every frame creates motion trails for free.
  2. 'sin8/beatsin8/`inoise8'—FastLED's fast integer wave noice generators, used instead of slow floationg point.
  3. 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.
  4. 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().
  5. 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.
  6. Sparkle ('drawSparkleFrame') - fade, then with some probability drop one 'fgColour' dot and occasionally a white one at random positions. Glitter.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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.
  12. Comet (`drawCometFrame`) - a full height bar sweeping side to side on 'beatsin8' a smooth sine bounce, hue slowly rotating, trails from the fade.
  13. 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.
  14. Pulse (`drawPulseFrame`) - the whole panel breathes `beatsin8(16, 8, 160)` give a slow sine between dim and bright, applied to 'fgColour'
  15. Nyan (`drawNyanFrame`) - a hand drawn 8x9 sprite in a 'PROGMEM' table where each cells
```cpp
int8_t nyanWob(uint32_t t, uint8_t y) {
uint8_t s = sin8((uint8_t)(t / 6 + y * 32)); // a sine over time, phase-shifted per row
return (s > 170) ? 1 : (s < 86 ? -1 : 0); // quantized to -1 / 0 / +1
}
```

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

```cpp
#define NUS_SERVICE "6E400001-..." // the service
#define NUS_RX "6E400002-..." // phone writes commands here
#define NUS_TX "6E400003-..." // badge could notify back (unused for now)
```

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.

```cpp
if (v[0]=='P' && v[1]==':') { // draw command → handle RIGHT NOW
// P:xyRRGGBB → parse x, y (one hex digit each) and the 6-digit color
setPx(x, y, CRGB(...)); // paint straight into the frame buffer
// P:C → FastLED.clear()
drawDirty = true; // tell loop() "something changed, show it"
return;
}
bleBuf = v; newBleMsg = true; // everything else waits for loop()
```


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.

```cpp
if (newBleMsg) handleBleMsg(); // apply any pending command first
switch (mode) { ... } // then draw one frame of the current mode
```
  1. MODE_NAME - scroll `NAME_TEXT`; when a pass completes, switch to eyes for
  2. EYES_DWELL_MS - then back that's the idle alternation.
  3. 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.
  4. MODE_TEXT - scroll whatever BLE text arrived (defaults to "HELLO").
  5. MODE_CYCLE - a sub-mode list and a 10-second timer; `runAnim(subs[sub])` plays the current one, timer advances to the next.
  6. 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)`
  7. 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'

  1. header - tittle plus a status dot + 'standby / linked' label
  2. <canvas id="cv"> - the 8x16 LED preview 320x640 = 40 px per LED
  3. connect button - the only thing active before you link
  4. controls - everything else, blurred out ('hidden") until connected:
  5. text input + send
  6. mode buttons (generated from JS list)
  7. draw section: slider toggle, swatch palette, custom color, clear
  8. brightness slider
  9. color swatches

log - online status / debug readout at the bottom

The BLE section

```js
const NUS_SERVICE='6e400001-...', NUS_RX='6e400002-...';
let device=null, rxChar=null; // the connected device and its RX characteristic
```


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

```js
device = await navigator.bluetooth.requestDevice({
filters: [{ namePrefix: 'EyeD' }], // only badges in the picker
optionalServices: [NUS_SERVICE]
});
const sv = await (await device.gatt.connect()).getPrimaryService(NUS_SERVICE);
rxChar = await sv.getCharacteristic(NUS_RX); // grab the write pipe
```

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.

  1. sendCmd(cmd) - encodes the strings to bytes and writes it to 'rxChar'
  2. 20-byte chunks - the safe payload size for a default BLE connection.
  3. '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.
```js
const MODES=[['name',0,'scroll'],['eyes',1,'eyes'],...];
```


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.

```js
const W=8, H=16;
let buf = new Float32Array(W*H*3); // the JS "frame buffer", RGB 0..1 floats
function setP(x,y,r,g,b) { ... } // = firmware setPx
function fade(f) { ... } // = fadeToBlackBy
function clearBuf() { ... } // = FastLED.clear
```


  1. FDATA — the same 5×7 font as the firmware, as a JS object.
  2. 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.
  3. S.breathe - the standby state clears the buffer = all LEDs dark.
  4. simSet(mode, label) - switches the simulated mode and updates the little label under the canvas

The Render Loop - ('frame()' + 'draw()'), driven by 'requestAnimationFrame'

  1. 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

EyeD Badge
17.gif
20.gif
21.gif
22.gif
23.gif
24.gif
25.gif
26.gif
27.gif
PHOTO-2026-07-19-09-32-37.jpg

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!