Initial commit — Amirine Cosplay Lights

Arduino Uno + WS2812B LED strip controller with a text-based lightshow
system. Shows are defined as .txt files (hex color + fade duration per step),
converted to PROGMEM headers by convert_all.py, and navigated at runtime
via a debounced button (tap/double-tap/hold). BSD 2-Clause license.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 10:16:56 +02:00
commit 11eb2584ef
24 changed files with 1320 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Arduino IDE build output
build/
*.elf
*.hex
*.map
# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
# Generated file — recreate with convert.py
# Commented out so the example is tracked in version control:
# arduino/cosplay_lights/lightshow.h
# OS
.DS_Store
Thumbs.db
+154
View File
@@ -0,0 +1,154 @@
# Architecture Details — Amirine Cosplay Lights
For anyone who wants to understand or modify how the project works.
SPDX-License-Identifier: BSD-2-Clause
---
## How show playback works
The Arduino sketch maintains a small amount of playback state:
| Variable | Type | What it stores |
|---|---|---|
| `s_show_index` | `uint8_t` | Which show is active (index into `SHOWS[]`) |
| `s_show` | `ShowDef` | Active show descriptor (read from PROGMEM once on load) |
| `s_step_index` | `uint16_t` | Current step within the active show |
| `s_step_start` | `uint32_t` | `millis()` when the current step began |
| `s_from_color` | `CRGB` | Color the strip was showing when this step started |
On every `loop()` call (~60 times per second):
1. Check the button for events; switch show if needed (`load_show()`).
2. Read the current step from `SHOW[].steps` in PROGMEM.
3. Compute progress `t = elapsed / duration_ms`, clamped to 0.01.0.
4. Blend `s_from_color` toward the target color by `t`.
5. Push the blended color to the LEDs.
6. If `t >= 1.0`, advance to the next step (loops at end of show).
To **hold** a color, add two steps with the same color — the second transition is from the color to itself, which is invisible.
### Why PROGMEM?
The Arduino Uno has only 2KB of RAM. Storing all show data in normal arrays would exhaust it quickly. `PROGMEM` stores data in flash memory (32KB), which the Uno has much more of. Both the `Step` arrays and the `ShowDef` index (`SHOWS[]`) live in PROGMEM; the `read_step()` and `read_show_def()` helpers in `lightshow_format.h` extract values safely.
Each `Step` is 5 bytes (3 bytes RGB + 2 bytes duration). A `ShowDef` is 4 bytes (2-byte pointer + 2-byte length). The sketch currently uses 21% of flash — there is room for many more shows.
---
## How the button works
`button.h/.cpp` implements a state machine with software debouncing:
1. **Debounce** — the raw pin state must be stable for `DEBOUNCE_MS` (50ms) before the debounced state updates. This filters contact bounce.
2. **Hold detection** — if the debounced state stays pressed for `HOLD_MS` (800ms), `BTN_HOLD` fires immediately (doesn't wait for release). Any pending tap count is discarded.
3. **Tap counting** — each clean release increments a tap counter and records the release time.
4. **Tap resolution** — after `DOUBLE_TAP_MS` (400ms) of silence, the accumulated tap count is resolved: 1 tap → `BTN_TAP`, 2+ taps → `BTN_DOUBLE_TAP`. This introduces a 400ms delay on single taps (acceptable for show navigation).
Timing constants are at the top of `button.cpp` and can be adjusted.
---
## File generation
`shows.h` and `show_*.h` are **generated files** — they are the output of `converter/convert_all.py`. The source of truth is the `.txt` files in `converter/shows/`.
The generation pipeline:
1. Reads every `.txt` in `converter/shows/`.
2. Converts `blue_pulse.txt` first (always index 0), then all others alphabetically.
3. Writes one `show_<name>.h` per file into `arduino/cosplay_lights/`.
4. Regenerates `shows.h` with updated `#include` lines and `SHOWS[]` table.
Running `make shows` triggers this. `make upload` runs `make shows` first automatically.
---
## Adding a new LED pattern
The current `ACTIVE_PATTERN` is `PATTERN_SOLID` — all LEDs show the same blended color.
To add, for example, a chase pattern:
**Step 1** — Add a `#define` in `config.h`:
```cpp
#define PATTERN_SOLID 0
#define PATTERN_CHASE 1
#define ACTIVE_PATTERN PATTERN_CHASE
```
**Step 2** — Add the pattern logic in `led_controller.cpp`:
```cpp
#elif ACTIVE_PATTERN == PATTERN_CHASE
// One lit LED chases along the strip using the blended show color.
static uint16_t head = 0;
fill_solid(leds, NUM_LEDS, CRGB::Black);
leds[head] = color;
head = (head + 1) % NUM_LEDS;
```
The `color` parameter is always the show's current blended color, so patterns automatically follow lightshow transitions.
---
## Changing the LED strip type
Edit `config.h`:
```cpp
// WS2812B (default)
#define LED_TYPE WS2812B
#define COLOR_ORDER GRB
#define LED_DATA_PIN 6
// APA102 / Dotstar (two-wire)
// #define LED_TYPE APA102
// #define COLOR_ORDER BGR
// #define LED_DATA_PIN 6
// #define LED_CLOCK_PIN 7
```
For APA102, also change the `FastLED.addLeds` call in `led_controller.cpp` to the two-pin form:
```cpp
FastLED.addLeds<LED_TYPE, LED_DATA_PIN, LED_CLOCK_PIN, COLOR_ORDER>(leds, NUM_LEDS);
```
---
## Multiple strips
To drive two strips with the same show, add a second array and register it in `led_controller.cpp`:
```cpp
static CRGB leds_a[NUM_LEDS];
static CRGB leds_b[NUM_LEDS];
void leds_begin() {
FastLED.addLeds<LED_TYPE, 6, COLOR_ORDER>(leds_a, NUM_LEDS);
FastLED.addLeds<LED_TYPE, 5, COLOR_ORDER>(leds_b, NUM_LEDS);
FastLED.setBrightness(MAX_BRIGHTNESS);
FastLED.clear(true);
}
void leds_apply_color(CRGB color) {
fill_solid(leds_a, NUM_LEDS, color);
fill_solid(leds_b, NUM_LEDS, color);
}
```
`FastLED.show()` pushes all registered strips at once — `leds_show()` needs no changes.
---
## Power budget reference
| Scenario | Current draw (approx.) |
|----------|----------------------|
| 60 LEDs, full white, brightness 255 | ~3.6A |
| 60 LEDs, full white, brightness 150 | ~2.1A |
| 60 LEDs, single color, brightness 150 | ~0.7A |
| 60 LEDs, all off | ~20mA (Arduino only) |
Always use a 5V supply rated for at least **1A more** than your calculated draw, and add a 1000µF capacitor on the supply lines near the strip connector.
+24
View File
@@ -0,0 +1,24 @@
BSD 2-Clause License
Copyright (c) 2026, Amirine Cosplay Lights Contributors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+53
View File
@@ -0,0 +1,53 @@
# Amirine Cosplay Lights — Build & Upload
# SPDX-License-Identifier: BSD-2-Clause
#
# Usage:
# make shows — convert all .txt shows and regenerate shows.h
# make build — compile the sketch
# make upload — convert shows, compile, and upload (auto-detects port)
# make upload PORT=/dev/ttyUSB1 — upload to a specific port
# make port — list connected Arduino-compatible devices
# make clean — remove build artifacts
SKETCH := arduino/cosplay_lights
FQBN := arduino:avr:uno
BUILD_DIR := build
# Auto-detect the first connected Arduino Uno port.
# Override on the command line: make upload PORT=/dev/ttyUSB1
PORT ?= $(shell arduino-cli board list 2>/dev/null | awk '/arduino:avr:uno/{print $$1}' | head -1)
# ---- Targets -----------------------------------------------------------
.PHONY: all shows build upload port clean
all: build
## Convert all .txt show files and regenerate shows.h.
shows:
python converter/convert_all.py
## Compile the sketch.
build:
arduino-cli compile --fqbn $(FQBN) --build-path $(BUILD_DIR) $(SKETCH)
## Convert shows, compile, and upload to the Arduino.
upload: shows build
@if [ -z "$(PORT)" ]; then \
echo ""; \
echo " No Arduino found. Plug in the USB cable and try again,"; \
echo " or specify the port manually: make upload PORT=/dev/ttyUSB0"; \
echo ""; \
exit 1; \
fi
arduino-cli upload --fqbn $(FQBN) --port $(PORT) $(SKETCH)
@echo ""
@echo " Uploaded to $(PORT). The show starts immediately."
## List connected Arduino-compatible devices.
port:
@arduino-cli board list
## Remove build artifacts.
clean:
rm -rf $(BUILD_DIR)
+65
View File
@@ -0,0 +1,65 @@
# Project Plan — Amirine Cosplay Lights
This file records the original design brief and decisions so future contributors can understand why things are built the way they are.
---
## Goals
1. **Light show player** — load `.txt` files that define a sequence of colors with transition durations. Steps fade from the previous color to the next over the specified time. Setting duration to `0ms` gives an instant switch; high values (e.g. `5000ms`) give a slow fade.
2. **Multi-show navigation** — all shows are compiled into the firmware. A single button lets you cycle through them:
- 1 tap — next show
- 2 taps — previous show
- Hold — reset to show 0 (slow blue pulse)
3. **Arduino + WS2812B** — runs on an Arduino Uno with a WS2812B (NeoPixel) LED strip.
4. **Open source** — BSD 2-Clause license. Free for any use, personal or commercial.
5. **Designed for expansion** — the LED controller layer (`led_controller.h/.cpp`) is structured so that new patterns (chase, sparkle, gradient) can be added later without touching show playback logic.
---
## Hardware confirmed
- **Board:** Arduino Uno
- **LED strip:** WS2812B (NeoPixel), data pin 6, ~60 LEDs, GRB color order
- **Button:** momentary push button on pin 2, wired to GND (INPUT_PULLUP)
- **Color format:** hex codes (`#RRGGBB`)
- **Show loading:** compiled into firmware via Arduino IDE / arduino-cli
---
## Workflow
```
Write/edit .txt files in converter/shows/
make upload
(runs make shows → make build → arduino-cli upload)
Show starts immediately, button navigates
```
---
## Design principles
- **Small, clearly named functions** — each function does one thing.
- **Comments explain the why** — not the what.
- **PROGMEM for all show data** — Arduino Uno has only 2KB RAM; all `Step` arrays and the `SHOWS[]` index live in flash.
- **Single config file** — all hardware values are in `config.h`; porting to a different board or strip type requires edits in one place.
- **generate-don't-maintain** — `shows.h` and `show_*.h` are generated files; the source of truth is the `.txt` files in `converter/shows/`.
---
## Future ideas (not yet implemented)
- Per-LED patterns (chase, sparkle, gradient segments)
- Multiple strip support
- Battery power optimization (auto-dim, sleep)
- SD card loading (no recompile needed for new shows)
- WiFi loading via ESP32 (browser-based show upload)
- Multiple buttons for direct show selection
- Show name displayed on a small OLED
+161
View File
@@ -0,0 +1,161 @@
# Amirine Cosplay Lights
Arduino-powered LED strip controller for cosplay costumes.
Write simple `.txt` files to define light shows — fades, instant switches, pulses — then upload them to the Arduino. A single button lets you cycle through all loaded shows.
SPDX-License-Identifier: BSD-2-Clause
---
## What you need
| Item | Notes |
|------|-------|
| Arduino Uno or Nano | Any clone works |
| WS2812B LED strip | NeoPixel-compatible |
| 5V power supply | See power warning below |
| Micro USB cable | For uploading code |
| Momentary push button | Any normally-open tactile button |
| 470Ω resistor | On the LED data line — protects the first LED |
| 1000µF capacitor | Across LED power lines — prevents voltage spikes |
---
## Wiring
### LED strip
```
Arduino WS2812B strip
─────── ─────────────
GND ──────────────────── GND
D6 ──── [470Ω] ──────── DIN (data in)
```
Power the strip separately — see the power warning below.
### Button
```
Arduino Button
─────── ──────
D2 ──────────────────── leg 1
GND ──────────────────── leg 2
```
Uses the internal pull-up resistor. No external resistor needed. Pin 2 can be changed in `config.h`.
### Button behaviour
| Press | Action |
|-------|--------|
| 1 tap | Next show |
| 2 taps | Previous show |
| Hold (~0.8s) | Reset to show 0 (slow blue pulse) |
### Power warning — read this
At full brightness, each WS2812B LED draws up to 60mA.
A 60-LED strip at full white = **3.6A** — far more than the Arduino's 5V pin can supply.
**Power the strip directly from a 5V supply** (phone charger, power bank, or battery pack):
- Connect the supply's **5V** and **GND** to the strip's **VCC** and **GND**.
- Also connect the supply's **GND** to the Arduino's **GND** (shared ground).
- Do **not** connect the supply's 5V to the Arduino's 5V pin.
- Place a **1000µF capacitor** between VCC and GND near the strip connector.
`MAX_BRIGHTNESS` in `config.h` is set to 150 (out of 255) to limit current draw. Lower it further for battery builds.
---
## Quick start
### 1. Install the FastLED library
Arduino IDE: **Sketch → Include Library → Manage Libraries** → search `FastLED` → Install.
### 2. Configure your hardware
Edit `arduino/cosplay_lights/config.h`:
- `LED_DATA_PIN` — pin connected to the strip's DIN (default: 6)
- `BUTTON_PIN` — pin the button is wired to (default: 2)
- `NUM_LEDS` — total LED count on your strip (default: 60)
- `MAX_BRIGHTNESS` — global brightness cap (default: 150)
### 3. Write or edit show files
Show files live in `converter/shows/`. Four are included:
| File | Description |
|------|-------------|
| `blue_pulse.txt` | Slow blue breathing — always show 0 (home/reset) |
| `example_fade.txt` | Slow color cycle |
| `example_party.txt` | Fast rainbow flashes |
| `example_pulse.txt` | Red heartbeat |
**Show file format:**
```
// Lines starting with // are comments.
#FF0000, 0 // snap to red instantly (0ms = no fade)
#00FF00, 3000 // 3-second fade to green
#0000FF, 0 // snap to blue
```
Each line: a hex color and how long (in ms) to fade from the previous color.
**Tip — holding a color:** add a second step with the same color, duration = hold time:
```
#FF0000, 0 // snap to red
#FF0000, 2000 // hold red for 2 seconds
#0000FF, 1500 // fade to blue
```
### 4. Build and upload
```bash
make upload
```
This converts all `.txt` shows, compiles the sketch, and uploads it. The show starts immediately and loops forever. Use the button to switch shows.
If you have multiple USB devices and the wrong port is detected:
```bash
make port # see what's connected
make upload PORT=/dev/ttyUSB1 # target a specific port
```
---
## Adding your own shows
1. Create a `.txt` file in `converter/shows/` using the format above.
2. Run `make upload` — your new show is automatically included.
The order in which shows appear when cycling through with the button is: `blue_pulse` first, then all others sorted alphabetically by filename.
---
## File overview
```
Amirine_Cosplay_Lights/
├── arduino/cosplay_lights/
│ ├── cosplay_lights.ino — main sketch (upload this)
│ ├── config.h — hardware settings
│ ├── button.h / .cpp — button debounce and event detection
│ ├── led_controller.h / .cpp — LED abstraction layer
│ ├── lightshow_format.h — Step and ShowDef structs
│ ├── shows.h — master show index (regenerated by make shows)
│ └── show_*.h — individual show data (regenerated by make shows)
├── converter/
│ ├── convert_all.py — converts all shows and regenerates shows.h
│ ├── convert.py — converts a single show file (for testing)
│ └── shows/ — .txt show files (edit these)
├── Makefile — build, upload, and show conversion targets
├── README.md — this file
├── DETAILS.md — architecture and modification guide
├── PLAN.md — original project brief
└── LICENSE — BSD 2-Clause
```
+103
View File
@@ -0,0 +1,103 @@
/*
* button.cpp — Debounced button with single-tap, double-tap, and hold detection.
* SPDX-License-Identifier: BSD-2-Clause
*/
#include "button.h"
#include <Arduino.h>
// ---- Timing constants --------------------------------------------------
static const uint16_t DEBOUNCE_MS = 50; // minimum stable time before accepting a state change
static const uint16_t HOLD_MS = 800; // hold this long to emit BTN_HOLD
static const uint16_t DOUBLE_TAP_MS = 400; // second tap must arrive within this window
// ---- State -------------------------------------------------------------
static uint8_t s_pin;
// Debounce state
static bool s_raw_state; // last raw digitalRead result
static bool s_debounced; // stable debounced state (true = pressed)
static uint32_t s_debounce_time; // millis() when raw state last changed
// Tap counting
static uint8_t s_tap_count; // taps accumulated in the current sequence
static uint32_t s_last_release_ms; // millis() of the most recent release
// Hold detection
static uint32_t s_press_start_ms; // millis() when current press began
static bool s_hold_fired; // true after BTN_HOLD has been emitted for this press
// ---- Helpers -----------------------------------------------------------
// Returns true when the button is physically pressed.
// INPUT_PULLUP means LOW = pressed.
static inline bool pin_is_pressed() {
return digitalRead(s_pin) == LOW;
}
// ---- Public API --------------------------------------------------------
void button_begin(uint8_t pin) {
s_pin = pin;
s_raw_state = false;
s_debounced = false;
s_debounce_time = 0;
s_tap_count = 0;
s_last_release_ms = 0;
s_press_start_ms = 0;
s_hold_fired = false;
pinMode(pin, INPUT_PULLUP);
}
ButtonEvent button_update() {
uint32_t now = millis();
bool raw = pin_is_pressed();
// ---- Debounce ----
// Reset the debounce timer whenever the raw reading changes.
if (raw != s_raw_state) {
s_raw_state = raw;
s_debounce_time = now;
}
// Only update the debounced state after the signal has been stable.
bool prev = s_debounced;
if ((now - s_debounce_time) >= DEBOUNCE_MS) {
s_debounced = raw;
}
bool just_pressed = ( s_debounced && !prev);
bool just_released = (!s_debounced && prev);
// ---- Press start ----
if (just_pressed) {
s_press_start_ms = now;
s_hold_fired = false;
}
// ---- Hold detection (fires once while the button is held) ----
if (s_debounced && !s_hold_fired && (now - s_press_start_ms) >= HOLD_MS) {
s_hold_fired = true;
s_tap_count = 0; // discard any pending taps so hold doesn't also trigger a tap
return BTN_HOLD;
}
// ---- Count taps on each release (only if hold didn't fire) ----
if (just_released && !s_hold_fired) {
s_tap_count++;
s_last_release_ms = now;
}
// ---- Resolve tap count after the double-tap window expires ----
// Wait until the button is up AND the window has passed before deciding.
if (s_tap_count > 0 && !s_debounced && (now - s_last_release_ms) >= DOUBLE_TAP_MS) {
uint8_t taps = s_tap_count;
s_tap_count = 0;
if (taps == 1) return BTN_TAP;
if (taps >= 2) return BTN_DOUBLE_TAP;
}
return BTN_NONE;
}
+34
View File
@@ -0,0 +1,34 @@
/*
* button.h — Debounced button with single-tap, double-tap, and hold detection.
*
* Wiring: connect one leg of the button to the configured pin, the other to GND.
* Uses INPUT_PULLUP — no external resistor needed.
*
* Timing (adjust in button.cpp if needed):
* DEBOUNCE_MS — ignores bounces shorter than this (default 50ms)
* HOLD_MS — how long to hold before BTN_HOLD fires (default 800ms)
* DOUBLE_TAP_MS — window to catch a second tap (default 400ms)
*
* Note: single taps are confirmed after DOUBLE_TAP_MS of silence so the
* code can tell them apart from the start of a double-tap.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <stdint.h>
enum ButtonEvent : uint8_t {
BTN_NONE, // nothing happened
BTN_TAP, // one short press, released, no second press within window
BTN_DOUBLE_TAP, // two presses within DOUBLE_TAP_MS of each other
BTN_HOLD, // button held longer than HOLD_MS
};
// Initialize the button. Call once in setup().
// pin: the Arduino pin the button is wired to (other end to GND).
void button_begin(uint8_t pin);
// Poll for button events. Call once per loop().
// Returns BTN_NONE most of the time; returns an event exactly once when detected.
ButtonEvent button_update();
+47
View File
@@ -0,0 +1,47 @@
#pragma once
// SPDX-License-Identifier: BSD-2-Clause
// ============================================================
// Hardware Configuration — Amirine Cosplay Lights
// Adjust these values to match your physical setup.
// ============================================================
// -- LED strip type ------------------------------------------
// Uncomment the line that matches your strip.
// Full list: https://github.com/FastLED/FastLED/wiki/Chipset-reference
#define LED_TYPE WS2812B // NeoPixel — most common, single data wire
// #define LED_TYPE APA102 // Dotstar — requires data + clock wire
// -- Color order ---------------------------------------------
// WS2812B is almost always GRB. If your colors look wrong, try RGB.
#define COLOR_ORDER GRB
// -- Wiring --------------------------------------------------
// Pin on the Arduino connected to the strip's DIN (data-in).
#define LED_DATA_PIN 6
// Clock pin — only needed for APA102/SK9822 strips.
// #define LED_CLOCK_PIN 7
// -- Strip size ----------------------------------------------
// Total number of LEDs in your strip.
#define NUM_LEDS 60
// -- Brightness ----------------------------------------------
// 0 (off) to 255 (full). Keep at or below 180 to limit heat and
// current draw, especially when powered from USB.
#define MAX_BRIGHTNESS 150
// -- Button --------------------------------------------------
// Pin connected to one leg of the navigation button (other leg to GND).
// Uses INPUT_PULLUP — no external resistor needed.
// 1 tap → next show
// 2 taps → previous show
// hold → reset to show 0 (blue pulse)
#define BUTTON_PIN 2
// -- Active pattern ------------------------------------------
// Controls how the current color is mapped to the LEDs.
// Only PATTERN_SOLID is included; see DETAILS.md for adding more.
#define PATTERN_SOLID 0
#define ACTIVE_PATTERN PATTERN_SOLID
+113
View File
@@ -0,0 +1,113 @@
/*
* Amirine Cosplay Lights
*
* Plays lightshows defined in shows.h on a WS2812B LED strip.
* A single button navigates between shows:
* 1 tap — next show
* 2 taps — previous show
* hold — reset to show 0 (slow blue pulse)
*
* To add or update shows:
* 1. Add or edit a .txt file in converter/shows/
* 2. Run: make shows
* 3. Run: make upload
*
* Required library: FastLED (Sketch > Include Library > Manage Libraries)
* SPDX-License-Identifier: BSD-2-Clause
*/
#include "config.h"
#include "led_controller.h"
#include "button.h"
#include "shows.h"
// Milliseconds between LED updates. 16ms ≈ 60 refreshes/second.
#define UPDATE_INTERVAL_MS 16
// ---- Color math --------------------------------------------------------
// Interpolate a single 8-bit channel from a to b by ratio t (0.0 1.0).
static uint8_t lerp_channel(uint8_t a, uint8_t b, float t) {
return (uint8_t)(a + (b - a) * t);
}
// Blend two colors: t=0.0 returns 'from', t=1.0 returns 'to'.
static CRGB blend_colors(CRGB from, CRGB to, float t) {
return CRGB(
lerp_channel(from.r, to.r, t),
lerp_channel(from.g, to.g, t),
lerp_channel(from.b, to.b, t)
);
}
// ---- Playback state ----------------------------------------------------
static uint8_t s_show_index = 0; // which show is active (index into SHOWS[])
static ShowDef s_show; // active show, read from PROGMEM once on load
static uint16_t s_step_index = 0; // current step within the active show
static uint32_t s_step_start = 0; // millis() when this step began
static CRGB s_from_color = CRGB::Black; // color at the start of this transition
// Load show at 'index', resetting playback to the first step.
static void load_show(uint8_t index) {
s_show_index = index;
s_show = read_show_def(&SHOWS[index]);
s_step_index = 0;
s_step_start = millis();
s_from_color = CRGB::Black;
}
// How far through the current step we are (0.0 1.0).
// Returns 1.0 immediately for instant steps (duration_ms == 0).
static float step_progress(const Step& step) {
if (step.duration_ms == 0) return 1.0f;
uint32_t elapsed = millis() - s_step_start;
float t = (float)elapsed / step.duration_ms;
return (t < 1.0f) ? t : 1.0f;
}
// Complete the current step and advance to the next (loops at end of show).
static void advance_step(CRGB reached_color) {
s_from_color = reached_color;
s_step_index = (s_step_index + 1) % s_show.length;
s_step_start = millis();
}
// ---- Arduino entry points ----------------------------------------------
void setup() {
leds_begin();
button_begin(BUTTON_PIN);
load_show(0); // start on show 0 — slow blue pulse
}
void loop() {
// ---- Button navigation ----
ButtonEvent evt = button_update();
if (evt == BTN_TAP) {
// Next show, wrapping around to 0 after the last one.
load_show((s_show_index + 1) % SHOW_COUNT);
}
if (evt == BTN_DOUBLE_TAP) {
// Previous show, wrapping around to the last one from show 0.
load_show((s_show_index + SHOW_COUNT - 1) % SHOW_COUNT);
}
if (evt == BTN_HOLD) {
// Reset to show 0 (blue pulse) from any position.
load_show(0);
}
// ---- Playback ----
Step step = read_step(&s_show.steps[s_step_index]);
CRGB to = CRGB(step.r, step.g, step.b);
float t = step_progress(step);
leds_apply_color(blend_colors(s_from_color, to, t));
leds_show();
if (t >= 1.0f) {
advance_step(to);
}
delay(UPDATE_INTERVAL_MS);
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: BSD-2-Clause
#include "led_controller.h"
// Internal LED buffer — FastLED writes to this array, then pushes it to the strip.
static CRGB leds[NUM_LEDS];
void leds_begin() {
FastLED.addLeds<LED_TYPE, LED_DATA_PIN, COLOR_ORDER>(leds, NUM_LEDS);
FastLED.setBrightness(MAX_BRIGHTNESS);
FastLED.clear(true); // clear + show: strip starts fully off
}
void leds_apply_color(CRGB color) {
#if ACTIVE_PATTERN == PATTERN_SOLID
// All LEDs show the same color.
fill_solid(leds, NUM_LEDS, color);
// To add a new pattern, add a new #define in config.h and a new branch here.
// The pattern receives 'leds', 'NUM_LEDS', and the blended 'color' for this frame.
#endif
}
void leds_show() {
FastLED.show();
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: BSD-2-Clause
#pragma once
#include <FastLED.h>
#include "config.h"
// Initialize the LED strip. Call once in setup().
void leds_begin();
// Apply 'color' to all LEDs using the pattern defined in config.h (ACTIVE_PATTERN).
// Call leds_show() afterwards to push the update to the physical strip.
void leds_apply_color(CRGB color);
// Flush the LED buffer to the physical strip. Call once per frame after leds_apply_color().
void leds_show();
+45
View File
@@ -0,0 +1,45 @@
/*
* lightshow_format.h — Core data structures for show playback.
*
* All show data lives in PROGMEM (flash memory) to preserve the
* Arduino Uno's 2KB of RAM. The read_* helpers copy values out of
* PROGMEM so the rest of the code can work with them normally.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <avr/pgmspace.h>
#include <stdint.h>
// One step in a lightshow: a target color and the time to reach it.
struct Step {
uint8_t r, g, b; // Target RGB color (0255 each)
uint16_t duration_ms; // Milliseconds to fade from the previous color (0 = instant)
};
// Descriptor for one complete show: a pointer to its PROGMEM Step array and its length.
struct ShowDef {
const Step* steps; // Pointer to a PROGMEM Step array
uint16_t length; // Number of steps in the array
};
// Read one Step from PROGMEM into a regular struct.
// Direct pointer access does not work for PROGMEM on AVR — use this helper.
inline Step read_step(const Step* ptr) {
Step s;
s.r = pgm_read_byte(&ptr->r);
s.g = pgm_read_byte(&ptr->g);
s.b = pgm_read_byte(&ptr->b);
s.duration_ms = pgm_read_word(&ptr->duration_ms);
return s;
}
// Read one ShowDef from a PROGMEM ShowDef array.
// On AVR, pointers are 2 bytes wide, so pgm_read_word works for both fields.
inline ShowDef read_show_def(const ShowDef* ptr) {
ShowDef sd;
sd.steps = (const Step*)pgm_read_word(&ptr->steps);
sd.length = pgm_read_word(&ptr->length);
return sd;
}
+13
View File
@@ -0,0 +1,13 @@
// Generated by convert_all.py from: blue_pulse.txt
// Do not edit manually — edit the .txt file and run: make shows
// SPDX-License-Identifier: BSD-2-Clause
#pragma once
#include "lightshow_format.h"
const Step SHOW_BLUE_PULSE[] PROGMEM = {
{ 5, 0, 37, 0}, // #050025, 0ms
{ 40, 40, 255, 3000}, // #2828FF, 3000ms
{ 5, 0, 37, 3000}, // #050025, 3000ms
};
const uint16_t SHOW_BLUE_PULSE_LENGTH = 3;
@@ -0,0 +1,16 @@
// Generated by convert_all.py from: example_fade.txt
// Do not edit manually — edit the .txt file and run: make shows
// SPDX-License-Identifier: BSD-2-Clause
#pragma once
#include "lightshow_format.h"
const Step SHOW_EXAMPLE_FADE[] PROGMEM = {
{255, 0, 0, 0}, // #FF0000, 0ms
{ 0, 255, 0, 3000}, // #00FF00, 3000ms
{ 0, 0, 255, 0}, // #0000FF, 0ms
{255, 0, 255, 2000}, // #FF00FF, 2000ms
{255, 128, 0, 1000}, // #FF8000, 1000ms
{ 0, 0, 0, 5000}, // #000000, 5000ms
};
const uint16_t SHOW_EXAMPLE_FADE_LENGTH = 6;
@@ -0,0 +1,27 @@
// Generated by convert_all.py from: example_party.txt
// Do not edit manually — edit the .txt file and run: make shows
// SPDX-License-Identifier: BSD-2-Clause
#pragma once
#include "lightshow_format.h"
const Step SHOW_EXAMPLE_PARTY[] PROGMEM = {
{255, 0, 0, 0}, // #FF0000, 0ms
{255, 0, 0, 150}, // #FF0000, 150ms
{255, 136, 0, 0}, // #FF8800, 0ms
{255, 136, 0, 150}, // #FF8800, 150ms
{255, 255, 0, 0}, // #FFFF00, 0ms
{255, 255, 0, 150}, // #FFFF00, 150ms
{ 0, 255, 0, 0}, // #00FF00, 0ms
{ 0, 255, 0, 150}, // #00FF00, 150ms
{ 0, 255, 255, 0}, // #00FFFF, 0ms
{ 0, 255, 255, 150}, // #00FFFF, 150ms
{ 0, 0, 255, 0}, // #0000FF, 0ms
{ 0, 0, 255, 150}, // #0000FF, 150ms
{255, 0, 255, 0}, // #FF00FF, 0ms
{255, 0, 255, 150}, // #FF00FF, 150ms
{255, 255, 255, 0}, // #FFFFFF, 0ms
{255, 255, 255, 150}, // #FFFFFF, 150ms
{ 0, 0, 0, 500}, // #000000, 500ms
};
const uint16_t SHOW_EXAMPLE_PARTY_LENGTH = 17;
@@ -0,0 +1,13 @@
// Generated by convert_all.py from: example_pulse.txt
// Do not edit manually — edit the .txt file and run: make shows
// SPDX-License-Identifier: BSD-2-Clause
#pragma once
#include "lightshow_format.h"
const Step SHOW_EXAMPLE_PULSE[] PROGMEM = {
{ 32, 0, 0, 0}, // #200000, 0ms
{255, 0, 0, 1200}, // #FF0000, 1200ms
{ 32, 0, 0, 1200}, // #200000, 1200ms
};
const uint16_t SHOW_EXAMPLE_PULSE_LENGTH = 3;
+29
View File
@@ -0,0 +1,29 @@
// =====================================================================
// shows.h — Master show index.
// Generated by: make shows (converter/convert_all.py)
// Do not edit manually — add .txt files to converter/shows/ instead.
// =====================================================================
//
// Show 0 is always the home/reset show (slow blue pulse).
// Holding the button resets back to show 0.
//
// SPDX-License-Identifier: BSD-2-Clause
#pragma once
#include "lightshow_format.h"
// ---- Individual show data ----------------------------------------------
#include "show_blue_pulse.h"
#include "show_example_fade.h"
#include "show_example_party.h"
#include "show_example_pulse.h"
// ---- Show index (PROGMEM) ----------------------------------------------
const ShowDef SHOWS[] PROGMEM = {
{SHOW_BLUE_PULSE, SHOW_BLUE_PULSE_LENGTH}, // 0 — home show
{SHOW_EXAMPLE_FADE, SHOW_EXAMPLE_FADE_LENGTH}, // 1
{SHOW_EXAMPLE_PARTY, SHOW_EXAMPLE_PARTY_LENGTH}, // 2
{SHOW_EXAMPLE_PULSE, SHOW_EXAMPLE_PULSE_LENGTH}, // 3
};
const uint8_t SHOW_COUNT = 4;
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
convert.py — Convert a single .txt show file to an Arduino header.
Use this to preview or test a single show.
For the full workflow (all shows + regenerate shows.h), use convert_all.py.
Usage:
python converter/convert.py path/to/show.txt
Output:
show_<name>.h written to the current directory.
Move it to arduino/cosplay_lights/ then run: make upload
Show file format:
Each step is one line: #RRGGBB, duration_ms
Lines starting with // are comments and are ignored.
Blank lines are ignored.
duration_ms controls how long the fade from the PREVIOUS color takes:
0 = instant switch (no fade)
500 = half-second fade
5000 = five-second slow fade
To hold a color, add a second step with the same color and the hold time as duration:
#FF0000, 0 // snap to red
#FF0000, 2000 // hold red for 2 seconds
#0000FF, 3000 // fade to blue over 3 seconds
SPDX-License-Identifier: BSD-2-Clause
"""
import sys
import re
from pathlib import Path
STEP_PATTERN = re.compile(r'^\s*#([0-9A-Fa-f]{6})\s*,\s*(\d+)')
def filename_to_symbol(stem: str) -> str:
"""Convert a filename stem to an uppercase C array symbol."""
clean = re.sub(r'[^a-zA-Z0-9]', '_', stem)
return "SHOW_" + clean.upper()
def hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
return int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)
def parse_show(filepath: Path) -> list[tuple[int, int, int, int]]:
"""
Parse a .txt show file. Returns list of (r, g, b, duration_ms) tuples.
Raises ValueError on malformed lines.
"""
steps = []
with open(filepath) as f:
for lineno, raw_line in enumerate(f, 1):
line = raw_line.split("//")[0].strip()
if not line:
continue
match = STEP_PATTERN.match(line)
if not match:
raise ValueError(
f"Line {lineno}: expected '#RRGGBB, duration_ms' "
f"but got: {raw_line.rstrip()!r}"
)
r, g, b = hex_to_rgb(match.group(1))
duration = int(match.group(2))
steps.append((r, g, b, duration))
if not steps:
raise ValueError("Show file contains no steps.")
return steps
def render_header(steps: list, source_name: str, symbol: str) -> str:
"""Render parsed steps as a C++ header file string."""
lines = [
f"// Generated by convert.py from: {source_name}",
"// Do not edit manually — edit the .txt file instead.",
"// SPDX-License-Identifier: BSD-2-Clause",
"",
"#pragma once",
'#include "lightshow_format.h"',
"",
f"const Step {symbol}[] PROGMEM = {{",
]
for r, g, b, dur in steps:
hex_color = f"#{r:02X}{g:02X}{b:02X}"
lines.append(f" {{{r:3d}, {g:3d}, {b:3d}, {dur:5d}}}, // {hex_color}, {dur}ms")
lines += [
"};",
f"const uint16_t {symbol}_LENGTH = {len(steps)};",
"",
]
return "\n".join(lines)
def main() -> None:
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
source_path = Path(sys.argv[1])
if not source_path.exists():
print(f"Error: file not found: {source_path}")
sys.exit(1)
try:
steps = parse_show(source_path)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
symbol = filename_to_symbol(source_path.stem)
header_text = render_header(steps, source_path.name, symbol)
output_path = Path(f"show_{source_path.stem}.h")
output_path.write_text(header_text, encoding="utf-8")
print(f"OK {len(steps)} steps → {output_path}")
print(f" Move to 'arduino/cosplay_lights/' then run 'make upload'.")
print(f" Or run 'make shows' to convert all shows at once.")
if __name__ == "__main__":
main()
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
convert_all.py — Convert all .txt show files and regenerate shows.h.
Reads every .txt file in converter/shows/, converts each one to a
show_<name>.h header, and regenerates arduino/cosplay_lights/shows.h
with the updated SHOWS[] index.
Usage:
python converter/convert_all.py
(or via Makefile: make shows)
Show 0 is always 'blue_pulse' — the home/reset show.
All other shows are sorted alphabetically and follow after it.
SPDX-License-Identifier: BSD-2-Clause
"""
import re
import sys
from pathlib import Path
# Paths relative to this script's location
SCRIPT_DIR = Path(__file__).parent
ROOT_DIR = SCRIPT_DIR.parent
SHOWS_DIR = SCRIPT_DIR / "shows"
SKETCH_DIR = ROOT_DIR / "arduino" / "cosplay_lights"
# The special first show — always placed at index 0.
HOME_SHOW = "blue_pulse"
# Regex to match a valid step line: #RRGGBB, duration_ms
STEP_PATTERN = re.compile(r'^\s*#([0-9A-Fa-f]{6})\s*,\s*(\d+)')
# ---- Helpers -----------------------------------------------------------
def filename_to_symbol(stem: str) -> str:
"""Convert a filename stem to an uppercase C array symbol. e.g. 'example_fade''SHOW_EXAMPLE_FADE'"""
clean = re.sub(r'[^a-zA-Z0-9]', '_', stem)
return "SHOW_" + clean.upper()
def hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
return int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)
def parse_show(filepath: Path) -> list[tuple[int, int, int, int]]:
"""
Parse a .txt show file. Returns list of (r, g, b, duration_ms) tuples.
Raises ValueError on malformed lines.
"""
steps = []
with open(filepath) as f:
for lineno, raw_line in enumerate(f, 1):
line = raw_line.split("//")[0].strip()
if not line:
continue
match = STEP_PATTERN.match(line)
if not match:
raise ValueError(
f"{filepath.name} line {lineno}: "
f"expected '#RRGGBB, duration_ms' but got: {raw_line.rstrip()!r}"
)
r, g, b = hex_to_rgb(match.group(1))
duration = int(match.group(2))
steps.append((r, g, b, duration))
if not steps:
raise ValueError(f"{filepath.name}: file contains no steps.")
return steps
def render_show_header(steps: list, source_name: str, symbol: str) -> str:
"""Render a show as a C++ header file."""
lines = [
f"// Generated by convert_all.py from: {source_name}",
"// Do not edit manually — edit the .txt file and run: make shows",
"// SPDX-License-Identifier: BSD-2-Clause",
"",
"#pragma once",
'#include "lightshow_format.h"',
"",
f"const Step {symbol}[] PROGMEM = {{",
]
for r, g, b, dur in steps:
hex_color = f"#{r:02X}{g:02X}{b:02X}"
lines.append(f" {{{r:3d}, {g:3d}, {b:3d}, {dur:5d}}}, // {hex_color}, {dur}ms")
lines += [
"};",
f"const uint16_t {symbol}_LENGTH = {len(steps)};",
"",
]
return "\n".join(lines)
def render_shows_index(ordered_stems: list[str]) -> str:
"""Render the master shows.h index file."""
includes = "\n".join(f'#include "show_{stem}.h"' for stem in ordered_stems)
entries = "\n".join(
f" {{{filename_to_symbol(s)}, {filename_to_symbol(s)}_LENGTH}},"
+ (" // 0 — home show" if i == 0 else f" // {i}")
for i, s in enumerate(ordered_stems)
)
count = len(ordered_stems)
return f"""\
// =====================================================================
// shows.h — Master show index.
// Generated by: make shows (converter/convert_all.py)
// Do not edit manually — add .txt files to converter/shows/ instead.
// =====================================================================
//
// Show 0 is always the home/reset show (slow blue pulse).
// Holding the button resets back to show 0.
//
// SPDX-License-Identifier: BSD-2-Clause
#pragma once
#include "lightshow_format.h"
// ---- Individual show data ----------------------------------------------
{includes}
// ---- Show index (PROGMEM) ----------------------------------------------
const ShowDef SHOWS[] PROGMEM = {{
{entries}
}};
const uint8_t SHOW_COUNT = {count};
"""
# ---- Main --------------------------------------------------------------
def main() -> None:
txt_files = sorted(SHOWS_DIR.glob("*.txt"))
if not txt_files:
print(f"No .txt files found in {SHOWS_DIR}")
sys.exit(1)
# Separate the home show from the rest; sort the rest alphabetically.
home_file = SHOWS_DIR / f"{HOME_SHOW}.txt"
other_files = [f for f in txt_files if f.stem != HOME_SHOW]
if not home_file.exists():
print(f"Warning: home show '{HOME_SHOW}.txt' not found — show 0 will be {txt_files[0].name}")
ordered_files = txt_files
else:
ordered_files = [home_file] + other_files
SKETCH_DIR.mkdir(parents=True, exist_ok=True)
converted = []
errors = []
for txt_path in ordered_files:
stem = txt_path.stem
symbol = filename_to_symbol(stem)
try:
steps = parse_show(txt_path)
header = render_show_header(steps, txt_path.name, symbol)
out = SKETCH_DIR / f"show_{stem}.h"
out.write_text(header, encoding="utf-8")
print(f" OK {txt_path.name} → show_{stem}.h ({len(steps)} steps)")
converted.append(stem)
except ValueError as e:
print(f" ERR {e}")
errors.append(stem)
if errors:
print(f"\n{len(errors)} file(s) had errors and were skipped.")
if not converted:
print("No shows converted — shows.h not updated.")
sys.exit(1)
# Regenerate shows.h
index_path = SKETCH_DIR / "shows.h"
index_path.write_text(render_shows_index(converted), encoding="utf-8")
print(f"\n OK shows.h updated ({len(converted)} shows, show 0 = {converted[0]})")
print( " Run 'make upload' to compile and send to the Arduino.")
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
// Blue pulse — home / reset show (always index 0)
// A slow, gentle breathing effect in deep blue.
// Loops continuously as the default show.
//
// Format: #RRGGBB, duration_ms
#050025, 0 // snap to dim blue — sets the starting point
#2828FF, 3000 // slow breathe up to bright blue
#050025, 3000 // slow breathe back down to dim
+12
View File
@@ -0,0 +1,12 @@
// Example: slow color cycle
// Smooth fades between colors with different speeds.
//
// Format: #RRGGBB, duration_ms
// duration_ms = time to fade FROM the previous color TO this one.
#FF0000, 0 // snap to red immediately
#00FF00, 3000 // 3 second fade to green
#0000FF, 0 // snap to blue
#FF00FF, 2000 // 2 second fade to purple
#FF8000, 1000 // 1 second fade to orange
#000000, 5000 // 5 second slow fade to off
+24
View File
@@ -0,0 +1,24 @@
// Example: party mode — rapid color flashes
//
// Trick: to HOLD a color for N ms, add a second step with the same color
// and set that step's duration to N. The "fade from red to red" is invisible.
//
// Format: #RRGGBB, duration_ms
#FF0000, 0 // snap to red
#FF0000, 150 // hold red 150ms
#FF8800, 0 // snap to orange
#FF8800, 150 // hold orange 150ms
#FFFF00, 0 // snap to yellow
#FFFF00, 150 // hold yellow 150ms
#00FF00, 0 // snap to green
#00FF00, 150 // hold green 150ms
#00FFFF, 0 // snap to cyan
#00FFFF, 150 // hold cyan 150ms
#0000FF, 0 // snap to blue
#0000FF, 150 // hold blue 150ms
#FF00FF, 0 // snap to magenta
#FF00FF, 150 // hold magenta 150ms
#FFFFFF, 0 // snap to white
#FFFFFF, 150 // hold white 150ms
#000000, 500 // fade to off — brief pause before looping
+10
View File
@@ -0,0 +1,10 @@
// Example: slow red pulse / heartbeat effect
//
// Fades from dim red to bright red and back, repeating.
// Shows how to create an organic-feeling breathing effect.
//
// Format: #RRGGBB, duration_ms
#200000, 0 // start at dim red (no fade — sets the initial state)
#FF0000, 1200 // fade up to bright red
#200000, 1200 // fade back down to dim red