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