From e9a12f66a9d6870385f1ad76b1d117409209dbe3 Mon Sep 17 00:00:00 2001 From: Bas Grolleman Date: Sat, 23 May 2026 10:41:01 +0200 Subject: [PATCH] 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 --- Makefile | 6 +- converter/simulate.py | 176 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 converter/simulate.py diff --git a/Makefile b/Makefile index 7bd123e..9134f22 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/converter/simulate.py b/converter/simulate.py new file mode 100644 index 0000000..accbe14 --- /dev/null +++ b/converter/simulate.py @@ -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()