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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user