#!/usr/bin/env python3 """ Graphical NeoPixel simulator for led-driver (CPython). Treats ``led-driver/src`` as flash root (``/``) and ``led-driver/lib`` as ``/lib``. **Start** runs the full application: ``led-driver/src/main.py`` as ``__main__`` (``asyncio.run(main(port=…))``) — same startup as the device: ``settings``, ``presets.json``, default preset, ``patterns/``, WebSocket, UDP hello, etc. """ from __future__ import annotations import os import queue import runpy import shutil import sys import threading import tkinter as tk from pathlib import Path from tkinter import messagebox, ttk # --- Paths (before importing driver code) --- SIM_ROOT = Path(__file__).resolve().parent WORKSPACE = SIM_ROOT / "workspace" STUBS_ROOT = SIM_ROOT / "stubs" DRIVER_ROOT = SIM_ROOT.parent / "led-driver" DRIVER_SRC = DRIVER_ROOT / "src" DRIVER_LIB = DRIVER_ROOT / "lib" DRIVER_MAIN = DRIVER_SRC / "main.py" DEFAULT_SETTINGS = SIM_ROOT / "settings.default.json" def _ensure_workspace_dirs(): WORKSPACE.mkdir(parents=True, exist_ok=True) (WORKSPACE / "patterns").mkdir(parents=True, exist_ok=True) settings_path = WORKSPACE / "settings.json" if not settings_path.is_file() and DEFAULT_SETTINGS.is_file(): shutil.copy2(DEFAULT_SETTINGS, settings_path) presets_path = WORKSPACE / "presets.json" if not presets_path.is_file(): presets_path.write_text("{}\n", encoding="utf-8") def _install_driver_path_and_stubs(): """``/lib`` then ``/`` then stubs (MicroPython search order, stubs win).""" for p in (DRIVER_LIB, DRIVER_SRC, STUBS_ROOT): sys.path.insert(0, str(p)) def _patch_settings_path(): _ensure_workspace_dirs() import settings as led_settings led_settings.Settings.SETTINGS_FILE = str(WORKSPACE / "settings.json") _install_driver_path_and_stubs() _patch_settings_path() import neopixel as neopixel_stub # noqa: E402 def _ensure_src_symlinks(): """If flash root (``src``) has no ``patterns`` / ``presets.json``, link workspace copies.""" _ensure_workspace_dirs() pat_ws = WORKSPACE / "patterns" pat_link = DRIVER_SRC / "patterns" if not pat_link.exists(): pat_link.symlink_to(pat_ws.resolve(), target_is_directory=True) elif pat_link.is_symlink(): pass # Real directory: treat as on-device flash (do not replace). pr_ws = WORKSPACE / "presets.json" if not pr_ws.is_file(): pr_ws.write_text("{}\n", encoding="utf-8") pr_link = DRIVER_SRC / "presets.json" if not pr_link.exists(): pr_link.symlink_to(pr_ws.resolve()) class LedSimulatorApp: def __init__(self): _ensure_workspace_dirs() self.root = tk.Tk() self.root.title("led-simulator — led-driver (full app)") self.root.minsize(640, 280) self._pixel_queue: queue.Queue[list[tuple[int, int, int]] | None] = queue.Queue() self._driver_thread: threading.Thread | None = None self._driver_running = False self._ws_port = tk.IntVar(value=8765) self._status = tk.StringVar( value="Idle — Start runs the full main.py (settings, presets, patterns/, /ws)." ) self._canvas_leds: list[int] = [] self._build_ui() self._install_pixel_sink() self.root.after(50, self._pump_pixels) self.root.protocol("WM_DELETE_WINDOW", self._on_quit) def _build_ui(self): top = ttk.Frame(self.root, padding=8) top.pack(fill=tk.X) row = ttk.Frame(top) row.pack(fill=tk.X) ttk.Button(row, text="Start", command=self._on_start).pack(side=tk.LEFT, padx=(0, 6)) ttk.Button(row, text="Stop", command=self._on_stop).pack(side=tk.LEFT, padx=(0, 16)) ttk.Label(row, text="HTTP / WS port:").pack(side=tk.LEFT) ttk.Spinbox(row, from_=1024, to=65535, textvariable=self._ws_port, width=8).pack( side=tk.LEFT, padx=4 ) ttk.Label(row, text="(Microdot; path /ws)").pack(side=tk.LEFT, padx=8) hint = ttk.Label( top, text=( "Flash root is led-driver/src: edit workspace/settings.json, presets.json, " "and patterns/ (or use files already under src/)." ), wraplength=620, justify=tk.LEFT, ) hint.pack(fill=tk.X, pady=(8, 0)) self._canvas = tk.Canvas(self.root, height=120, background="#1a1a1a", highlightthickness=0) self._canvas.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8)) st = ttk.Label(self.root, textvariable=self._status, padding=(8, 0, 8, 8)) st.pack(fill=tk.X) def _install_pixel_sink(self): def sink(buf: list[tuple[int, int, int]]): self._pixel_queue.put(buf) neopixel_stub.PIXEL_SINK = sink def _pump_pixels(self): try: while True: buf = self._pixel_queue.get_nowait() if buf is None: self._draw_leds([(0, 0, 0)] * max(1, len(self._canvas_leds))) else: self._draw_leds(buf) except queue.Empty: pass self.root.after(50, self._pump_pixels) def _draw_leds(self, buf: list[tuple[int, int, int]]): self._canvas.delete("led") n = len(buf) if n == 0: return cw = self._canvas.winfo_width() or 700 margin = 12 usable = max(40, cw - 2 * margin) gap = 4 diam = max(6, min(22, (usable - (n - 1) * gap) // n)) y = 50 total_w = n * diam + (n - 1) * gap x0 = max(margin, (cw - total_w) // 2) self._canvas_leds = [] for i, (r, g, b) in enumerate(buf): x = x0 + i * (diam + gap) fill = f"#{r:02x}{g:02x}{b:02x}" outline = "#444444" if (r + g + b) > 40 else "#666666" oid = self._canvas.create_oval( x, y, x + diam, y + diam, fill=fill, outline=outline, width=1, tags="led" ) self._canvas_leds.append(oid) def _run_main_py_thread(self, port: int): try: try: import led_driver_sim_hook as sim_hook sim_hook.clear_app() except ImportError: pass os.environ["LED_SIM_PORT"] = str(port) os.environ["LED_SIM_ROOT"] = str(SIM_ROOT) runpy.run_path(str(DRIVER_MAIN), run_name="__main__") except Exception as ex: # noqa: BLE001 self.root.after(0, lambda e=ex: messagebox.showerror("Simulator", str(e))) finally: for key in ("LED_SIM_PORT", "LED_SIM_ROOT"): os.environ.pop(key, None) try: import led_driver_sim_hook as sim_hook sim_hook.clear_app() except ImportError: pass self._driver_running = False self._pixel_queue.put(None) self.root.after(0, lambda: self._status.set("Stopped.")) def _on_start(self): if self._driver_running: messagebox.showwarning("Simulator", "Already running — press Stop first.") return for k in list(sys.modules): if k.startswith("patterns."): del sys.modules[k] _ensure_src_symlinks() os.chdir(DRIVER_SRC) port = int(self._ws_port.get()) self._driver_running = True self.root.after( 0, lambda p=port: self._status.set( f"Running full app (main.py) — ws://127.0.0.1:{p}/ws" ), ) self._driver_thread = threading.Thread( target=self._run_main_py_thread, args=(port,), daemon=True, ) self._driver_thread.start() def _on_stop(self): try: import led_driver_sim_hook as sim_hook app = sim_hook.get_app() if app is not None and getattr(app, "server", None): app.shutdown() except ImportError: pass def _on_quit(self): self._on_stop() self.root.destroy() def run(self): self.root.mainloop() def main(): if not DRIVER_SRC.is_dir() or not DRIVER_MAIN.is_file(): print("led-driver/src/main.py not found next to led-simulator.", file=sys.stderr) sys.exit(1) if not DRIVER_LIB.is_dir(): print("led-driver/lib not found next to led-simulator.", file=sys.stderr) sys.exit(1) app = LedSimulatorApp() app.run() if __name__ == "__main__": main()