Add terminal simulator for previewing shows without hardware

Adds converter/simulate.py which renders the LED strip color live in the
terminal using ANSI 24-bit color, running the same step playback logic as
the Arduino firmware (blending, step timing, SHOW_LOOP/SHOW_SINGLE mode).
Keyboard controls mirror the physical button. Also adds 'make simulate'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 10:41:01 +02:00
parent 4e5396f231
commit e9a12f66a9
2 changed files with 181 additions and 1 deletions
+5 -1
View File
@@ -19,7 +19,7 @@ PORT ?= $(shell arduino-cli board list 2>/dev/null | awk '/arduino:avr:uno/{prin
# ---- Targets -----------------------------------------------------------
.PHONY: all shows build upload port clean
.PHONY: all shows simulate build upload port clean
all: build
@@ -27,6 +27,10 @@ all: build
shows:
python converter/convert_all.py
## Preview shows in the terminal without hardware.
simulate:
python converter/simulate.py
## Compile the sketch.
build:
arduino-cli compile --fqbn $(FQBN) --build-path $(BUILD_DIR) $(SKETCH)
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
simulate.py — Preview lightshows in the terminal without hardware.
Reads the same .txt show files the Arduino firmware uses and runs the
same playback logic: blending, step timing, loop/single mode, and
SHOW_SINGLE auto-advance.
Controls:
→ or Space next show
← previous show
r reset to show 0 (home)
q / Ctrl-C quit
SPDX-License-Identifier: BSD-2-Clause
"""
import sys
import select
import termios
import time
import tty
from pathlib import Path
# Reuse the show parser from the converter.
sys.path.insert(0, str(Path(__file__).parent))
from convert_all import parse_show_file, SHOWS_DIR, HOME_SHOW
# How many colored blocks to draw for the strip preview.
STRIP_WIDTH = 44
# ---- Show loading ----------------------------------------------------------
def load_shows() -> list[dict]:
txt_files = sorted(SHOWS_DIR.glob("*.txt"), key=lambda p: p.stem.lower())
home = SHOWS_DIR / f"{HOME_SHOW}.txt"
others = [f for f in txt_files if f.stem != HOME_SHOW]
ordered = ([home] + others) if home.exists() else txt_files
shows = []
for path in ordered:
try:
steps, mode = parse_show_file(path)
shows.append({"name": path.stem, "steps": steps, "mode": mode})
except ValueError as e:
print(f" Warning: {e}", file=sys.stderr)
return shows
# ---- Playback --------------------------------------------------------------
def blend_color(c1: tuple, c2: tuple, t: float) -> tuple:
return tuple(int(a + (b - a) * t) for a, b in zip(c1, c2))
# ---- Terminal rendering ----------------------------------------------------
RESET = "\x1b[0m"
def ansi_bg(r: int, g: int, b: int) -> str:
return f"\x1b[48;2;{r};{g};{b}m"
def render(color: tuple, show_name: str, step_idx: int, step_count: int, mode: str) -> None:
r, g, b = color
# Dim the foreground text so it's readable on any background.
bar = ansi_bg(r, g, b) + " " * STRIP_WIDTH + RESET
name = show_name.replace("_", " ")
info = f" {name} [{mode}] step {step_idx + 1}/{step_count} #{r:02X}{g:02X}{b:02X}"
sys.stdout.write(f"\r{bar}{info} ")
sys.stdout.flush()
# ---- Non-blocking keyboard input -------------------------------------------
def get_key() -> str | None:
if not select.select([sys.stdin], [], [], 0)[0]:
return None
ch = sys.stdin.read(1)
if ch == "\x1b":
more = select.select([sys.stdin], [], [], 0.02)[0]
rest = sys.stdin.read(2) if more else ""
if rest == "[C":
return "right"
if rest == "[D":
return "left"
return "esc"
return ch
# ---- Main ------------------------------------------------------------------
def main() -> None:
shows = load_shows()
if not shows:
print(f"No .txt files found in {SHOWS_DIR}")
sys.exit(1)
print(f"Loaded {len(shows)} show(s). Controls: → next ← prev r reset q quit\n")
# Playback state — mirrors the Arduino sketch variables.
show_idx = 0
step_idx = 0
step_start = time.monotonic()
from_color = (0, 0, 0)
def switch_show(idx: int) -> None:
nonlocal show_idx, step_idx, step_start, from_color
show_idx = idx % len(shows)
step_idx = 0
step_start = time.monotonic()
from_color = (0, 0, 0)
if not sys.stdin.isatty():
print("simulate.py must be run in an interactive terminal.", file=sys.stderr)
sys.exit(1)
old_settings = termios.tcgetattr(sys.stdin)
try:
tty.setraw(sys.stdin.fileno())
while True:
# ---- Button input ----
key = get_key()
if key in ("q", "\x03"): # q or Ctrl-C
break
elif key in ("right", " "):
switch_show(show_idx + 1)
elif key == "left":
switch_show(show_idx - 1)
elif key in ("r", "R"):
switch_show(0)
# ---- Playback ----
show = shows[show_idx]
steps = show["steps"]
mode = show["mode"]
r, g, b, duration_ms = steps[step_idx]
to = (r, g, b)
now = time.monotonic()
elapsed_ms = (now - step_start) * 1000.0
if duration_ms == 0:
t = 1.0
else:
t = min(elapsed_ms / duration_ms, 1.0)
render(blend_color(from_color, to, t), show["name"], step_idx, len(steps), mode)
# ---- Advance step when complete ----
if t >= 1.0:
from_color = to
next_step = step_idx + 1
if next_step >= len(steps):
if mode == "SHOW_SINGLE":
switch_show(show_idx + 1)
else:
step_idx = 0
step_start = time.monotonic()
else:
step_idx = next_step
step_start = time.monotonic()
time.sleep(0.016) # ~60 fps
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
sys.stdout.write(f"\r{RESET}\n")
sys.stdout.flush()
if __name__ == "__main__":
main()