A modular LED indicator system for visualizing system status and metrics. Combines ESP8266 firmware driving 3×12 WS2812B LED rings with a Python web controller that communicates over USB serial.
- Microcontroller: WeMos D1 Mini (ESP8266)
- LEDs: 3 × WS2812B addressable LED rings (12 LEDs each)
- Communication: USB Serial via CH340 (115200 baud)
┌─────────────────┐ USB Serial (JSON) ┌──────────────────┐
│ ESP8266 │ ◄─────────────────► │ Python Controller│
│ │ │ │
│ ┌────────────┐ │ │ ┌─────────────┐ │
│ │ Module │ │ │ │ FastAPI + │ │
│ │ Registry │ │ │ │ HTMX UI │ │
│ └─────┬──────┘ │ │ └──────┬──────┘ │
│ │ │ │ │ │
│ ┌─────┴──────┐ │ │ ┌─────┴──────┐ │
│ │ Built-in │ │ │ │ Modules: │ │
│ │ Modules │ │ │ │ - manual │ │
│ │ - manual │ │ │ │ - llama.cpp│ │
│ │ - status │ │ │ └────────────┘ │
│ └─────┬──────┘ │ │ │
│ │ │ │ │
│ ┌─────┴──────┐ │ │ │
│ │ FastLED │ │ │ │
│ │ Ring 0-2 │ │ │ │
│ └────────────┘ │ │ │
└─────────────────┘ └──────────────────┘
| Ring | LEDs | Purpose |
|---|---|---|
| 0 (Green) | 12 | Prompt throughput — pulse intensity maps to tokens/s |
| 1 (Yellow) | 12 | Generation activity — chase animation, speed + color shift |
| 2 (White) | 12 | Slot utilization — per-slot status (free/busy/error) |
5V POWER SUPPLY (5V / 3A min)
┌─────────────────────────────┐
│ VCC ───────────────────────┼──→ 5V (all rings)
│ GND ───────────────────────┼──→ GND (all rings + WeMos)
└─────────────────────────────┘
WE MOS D1 MINI LED RINGS (WS2812B, 12 LEDs each)
┌───────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ │ Ring 0 │ │ Ring 1 │ │ Ring 2 │
│ D1 (GPIO5) ──┼──────────────────┼→ DIN │ │ DIN │ │ DIN │
│ │ │ │ │ │ │ │
│ 5V │──────────────────┼──────────┼────┼──────────┼────┼──────────┤
│ GND ─────────┼──────────────────┼──────────┼────┼──────────┼────┼──────────┤
│ │ │ │ │ │ │ │
│ │ │ DOUT ────┼──→│ │ │ │
│ │ └──────────┘ │ DOUT ───┼──→│ │
└───────────────┘ └──────────┘ └──────────┘
Key points:
-
External 5V supply required — 36 LEDs × 60mA = ~2.16A max. The WeMos USB cannot power the LEDs. Use a 5V/3A power supply (e.g., phone charger).
-
Daisy-chain data: D1 (WeMos) → Ring 0 DIN → Ring 0 DOUT → Ring 1 DIN → Ring 1 DOUT → Ring 2 DIN. Ring 2 DOUT is left unconnected.
-
Common ground: All grounds must be tied together (WeMos + power supply + all rings).
-
Pin mapping: Data on D1 (GPIO5). 3 rings × 12 LEDs, GRB color order.
-
Level shifter (optional but recommended): WS2812B expects 5V logic on DIN; the ESP8266 outputs 3.3V. It often works without a level shifter, but for reliability add a 74HCT245 or simple MOSFET level shifter on the data line.
-
Decoupling capacitor: Place a 470–1000µF capacitor across 5V/GND at the first ring to handle power spikes.
Newline-delimited JSON over serial at 115200 baud. Commands include a command field and optional id for request/response matching.
Example command:
{"cmd":"led.set","ring":0,"index":5,"r":255,"g":0,"b":0}Example response:
{"cmd":"led.set","status":"ok","id":1}# Option 1: pip (recommended)
pip install platformio
# Option 2: Homebrew (macOS/Linux)
brew install platformio
# Option 3: Standalone installer
curl -fsSL https://raw.githubusercontent.com/platformio/platformio/master/scripts/remote-install.py | python3Verify installation:
pio --versioncd firmware
pio runFirst build will download the ESP8266 Arduino framework and dependencies (FastLED, ArduinoJson). Subsequent builds are faster.
# Flash to connected ESP8266
pio run --target upload
# Flash to specific port
pio run --target upload --environment esp01 --upload-port /dev/ttyUSB0If upload fails, put the ESP8266 in boot mode: hold BOOT, press RESET, release RESET then release BOOT, then retry.
pio device monitor --baud 115200Managed automatically by PlatformIO via platformio.ini:
- FastLED — LED driver library
- ArduinoJson — JSON serialization (v6.x)
- Python 3.10+
piporuvfor package management
cd controller
python3 -m venv .venv
source .venv/bin/activate
pip install -e .Edit config.yaml to set your serial port, server address, and module settings:
serial:
port: "/dev/ttyUSB0"
baudrate: 115200
server:
host: "0.0.0.0"
port: 8081
# Auto-activate a module on startup (e.g. "manual", "llama_cpp", or null)
default_module: null
llama_cpp:
server_url: "http://127.0.0.1:8080"
poll_interval: 1.0Copy config.example.yaml as a starting point:
cp config.example.yaml config.yaml# Start with default config.yaml
python -m main
# Override config file
python -m main --config /path/to/custom.yaml
# Override host/port
python -m main --host 0.0.0.0 --port 8081The controller reads the serial port from config.yaml and attempts to connect on startup. If the device is not yet connected, it will retry every 5 seconds until successful. A background loop watches for disconnections and reconnects automatically.
Install the service file and start the controller:
# Edit the service file to match your paths and user
# controller/andon-lights.service
# Copy to system (or use --user for user-level service)
sudo cp andon-lights.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now andon-lights.service
# Check status
sudo systemctl status andon-lights.service
# View logs
sudo journalctl -u andon-lights.service -fFor a user-level service (no sudo needed):
mkdir -p ~/.config/systemd/user
cp andon-lights.service ~/.config/systemd/user/
# Edit to remove User/Group lines and adjust paths
systemctl --user daemon-reload
systemctl --user enable --now andon-lights.service- FastAPI — Web framework
- Uvicorn — ASGI server
- Jinja2 — HTML templating
- HTMX — Dynamic UI without JavaScript SPA
- pyserial-asyncio — Async serial communication
- httpx — HTTP client (for llama.cpp module polling)
Access at http://localhost:8081
- Dashboard (
/) — Connection status, module list, LED preview - Manual (
/manual) — Per-LED control, ring effects, brightness, color presets - Llama.cpp (
/llama-cpp) — Server config, live metrics display, slot visualization
| Method | Path | Description |
|---|---|---|
| GET | /api/status |
Connection and module status |
| GET | /api/ports |
Detect available serial ports |
| POST | /api/connect |
Connect to serial port |
| POST | /api/disconnect |
Disconnect from serial port |
| POST | /api/module/activate |
Activate a module by name |
| POST | /api/manual/led |
Set individual LED color |
| POST | /api/manual/ring |
Set entire ring color |
| POST | /api/manual/effect |
Apply effect to ring |
| POST | /api/manual/brightness |
Set global brightness |
| POST | /api/manual/all |
Set all LEDs to same color |
| POST | /api/manual/clear |
Turn off all LEDs |
| POST | /api/llama-cpp/config |
Configure llama.cpp settings |
| GET | /api/preview/manual |
Get manual preview state |
| GET | /api/preview/llama-cpp |
Get llama.cpp preview state |
Both firmware and controller use a pluggable module architecture. Each module defines:
onInit()— One-time initializationonActivate()— Called when module becomes activeonUpdate()/tick()— Periodic update looponCommand()— Handle incoming commandsgetName()— Module identifiergetConfig()— Module configuration
Manual — Direct LED control for testing and debugging
- Set individual LEDs, rings, or all LEDs
- Apply effects (chase, breathe, pulse, strobe, rainbow, gradient)
- Adjust brightness and color
Llama.cpp — Visualize llama.cpp server metrics
- Polls
/slotsendpoint for slot utilization - Polls
/metricsendpoint for performance data - Maps prompt speed to Ring 0, generation activity to Ring 1, slot status to Ring 2
open-andon-lights/
├── firmware/
│ ├── platformio.ini
│ └── src/
│ ├── config.h # Hardware configuration
│ ├── main.cpp # Entry point
│ ├── leds.h / leds.cpp # FastLED driver
│ ├── effects.h / effects.cpp # LED effects
│ ├── protocol.h / protocol.cpp # JSON-over-serial
│ ├── modules.h / modules.cpp # Module registry
│ └── builtins/
│ ├── manual_module.h / manual_module.cpp
│ └── status_module.h / status_module.cpp
└── controller/
├── pyproject.toml
├── config.yaml # Active configuration
├── config.example.yaml # Example configuration
├── andon-lights.service # SystemD service file
├── main.py # FastAPI application
├── modules/
│ ├── base.py # SerialDevice, ModuleRegistry, ABC
│ ├── config.py # YAML config loader
│ ├── manual.py # Manual control module
│ └── llama_cpp.py # Llama.cpp metrics module
└── web/
├── static/
│ └── style.css # Dark theme styles
└── templates/
├── index.html # Dashboard
├── manual.html # Manual controls
└── llama_cpp.html # Llama.cpp metrics
- Ensure the CH340 driver is installed (
sudo apt install ch341-udev-ruleson Debian/Ubuntu) - Check permissions:
ls -la /dev/ttyUSB* - Add user to dialout group:
sudo usermod -aG dialout $USER - The controller will retry connection every 5 seconds. Connect the ESP8266 and wait.
- Check that the serial port in
config.yamlmatches the detected port:ls /dev/ttyUSB* - If the port changes (e.g.,
/dev/ttyUSB0→/dev/ttyUSB1), updateconfig.yamland restart the service - For SystemD:
sudo systemctl restart andon-lights.service
- Hold the BOOT button while pressing RESET, release RESET then BOOT
- Verify correct port:
ls /dev/ttyUSB*
- Verify power supply can handle LED current (max ~60mA per LED at full brightness)
- Check data line connection (GPIO5/D1 on WeMos D1 Mini)
- Use the manual module to test with a simple solid color