ab2c1b34b4
- Rename all show .txt files with NNN_ numeric prefix so order is explicit and controlled by filename (001_heartbeat_red through 006_party) - Drop HOME_SHOW special-casing from convert_all.py; show 0 is simply the lowest-numbered file - Add SHOW_FLAG_SPARKLE support: shows can declare '// flags: sparkle' to overlay random white flashes on top of the base color each frame - Wire sparkle into led_controller and config.h (SPARKLE_CHANCE/FRAMES) - Replace old placeholder/example shows with the six production shows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
203 lines
7.3 KiB
Python
203 lines
7.3 KiB
Python
#!/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 files must be named NNN_<name>.txt (e.g. 001_heartbeat_red.txt).
|
|
They run in numeric order; show 0 (the home/reset show) is the lowest-numbered file.
|
|
|
|
Show file directives (in comment lines):
|
|
// mode: single — play once, then advance to the next show (default: loop)
|
|
// flags: sparkle — overlay random white sparkles on this show
|
|
|
|
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"
|
|
|
|
# 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_file(filepath: Path) -> tuple[list[tuple[int, int, int, int]], str, int]:
|
|
"""
|
|
Parse a .txt show file in one pass. Returns (steps, mode_constant, flags).
|
|
steps: list of (r, g, b, duration_ms) tuples
|
|
mode_constant: 'SHOW_LOOP' or 'SHOW_SINGLE' (default SHOW_LOOP if not set)
|
|
flags: integer bitmask (0x01 = sparkle, matches SHOW_FLAG_SPARKLE in lightshow_format.h)
|
|
Raises ValueError on malformed lines or empty files.
|
|
"""
|
|
steps = []
|
|
mode = "SHOW_LOOP"
|
|
flags = 0
|
|
with open(filepath) as f:
|
|
for lineno, raw_line in enumerate(f, 1):
|
|
if re.match(r'^\s*//\s*mode:\s*(\w+)', raw_line, re.IGNORECASE):
|
|
m = re.match(r'^\s*//\s*mode:\s*(\w+)', raw_line, re.IGNORECASE)
|
|
mode = "SHOW_SINGLE" if m.group(1).lower() == "single" else "SHOW_LOOP"
|
|
continue
|
|
mf = re.match(r'^\s*//\s*flags:\s*(.+)', raw_line, re.IGNORECASE)
|
|
if mf:
|
|
flag_tokens = [t.strip().lower() for t in mf.group(1).split(",")]
|
|
if "sparkle" in flag_tokens:
|
|
flags |= 0x01
|
|
continue
|
|
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, mode, flags
|
|
|
|
|
|
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: list[tuple[str, str, int]]) -> str:
|
|
"""Render the master shows.h index file. ordered = [(stem, mode_constant, flags), ...]"""
|
|
includes = "\n".join(f'#include "show_{stem}.h"' for stem, _, __ in ordered)
|
|
entries = "\n".join(
|
|
f" {{{filename_to_symbol(s)}, {filename_to_symbol(s)}_LENGTH, {mode}, {'SHOW_FLAG_SPARKLE' if flags & 0x01 else '0'}}},"
|
|
+ (" // 0 — home show" if i == 0 else f" // {i}")
|
|
for i, (s, mode, flags) in enumerate(ordered)
|
|
)
|
|
count = len(ordered)
|
|
return f"""\
|
|
// =====================================================================
|
|
// shows.h — Master show index.
|
|
// Generated by: make shows (converter/convert_all.py)
|
|
// Do not edit manually — add NNN_<name>.txt files to converter/shows/ instead.
|
|
// =====================================================================
|
|
//
|
|
// Show 0 is the lowest-numbered .txt file (home/reset show).
|
|
// 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"), key=lambda p: p.name.lower())
|
|
if not txt_files:
|
|
print(f"No .txt files found in {SHOWS_DIR}")
|
|
sys.exit(1)
|
|
|
|
ordered_files = txt_files
|
|
|
|
SKETCH_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
converted = [] # list of (stem, mode_constant)
|
|
errors = []
|
|
|
|
for txt_path in ordered_files:
|
|
stem = txt_path.stem
|
|
symbol = filename_to_symbol(stem)
|
|
try:
|
|
steps, mode, flags = parse_show_file(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")
|
|
flag_str = " sparkle" if flags & 0x01 else ""
|
|
print(f" OK {txt_path.name} → show_{stem}.h ({len(steps)} steps, {mode}{flag_str})")
|
|
converted.append((stem, mode, flags))
|
|
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)
|
|
|
|
# Remove stale show_*.h files no longer in the converted list.
|
|
expected = {SKETCH_DIR / f"show_{stem}.h" for stem, _, __ in converted}
|
|
for stale in SKETCH_DIR.glob("show_*.h"):
|
|
if stale not in expected:
|
|
stale.unlink()
|
|
print(f" RM {stale.name} (removed — no matching .txt file)")
|
|
|
|
# Regenerate shows.h
|
|
index_path = SKETCH_DIR / "shows.h"
|
|
index_path.write_text(render_shows_index(converted), encoding="utf-8")
|
|
stems = [s for s, _, __ in converted]
|
|
print(f"\n OK shows.h updated ({len(converted)} shows, show 0 = {stems[0]})")
|
|
print( " Run 'make upload' to compile and send to the Arduino.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|