e9a12f66a9
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>
177 lines
5.2 KiB
Python
177 lines
5.2 KiB
Python
#!/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()
|