feat(simulator): add GUI runner, stubs, and workspace assets
Add host simulator scaffolding, examples, and docs so led-driver main can run end-to-end with MicroPython module stubs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
265
gui_main.py
Normal file
265
gui_main.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user