diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8022c72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +workspace/settings.json +workspace/presets.json +workspace/patterns/* +!workspace/patterns/__init__.py +!workspace/patterns/.gitkeep diff --git a/README.md b/README.md index 5739f11..055a6a7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,48 @@ # led-simulator +Graphical **NeoPixel** simulator for host Python. It uses the same layout as device flash: + +- **`led-driver/src`** → flash root **`/`** (application modules, `patterns/`, `presets.json`, …) +- **`led-driver/lib`** → **`/lib`** (Microdot, utemplate, …) + +It runs the **whole application**: **`led-driver/src/main.py`** as **`__main__`** (`runpy.run_path(..., run_name="__main__")` → `asyncio.run(main(port=…))`). Startup matches the device — **`Settings`**, **`presets.load`**, **default preset** from settings, **`patterns/`**, **Microdot** **`/ws`**, **`presets_loop`**, **UDP hello**. Only **`machine`**, **`neopixel`**, **`network`**, **`utime`**, and **`ubinascii`** are stubbed (`stubs/`). + +The GUI sets **`LED_SIM_ROOT`** so **`main.py`** can register the Microdot app for **Stop**, and **`LED_SIM_PORT`** for the HTTP/WebSocket listen port (default **80** on device when unset). On the ESP32 those variables are unset. + +## Requirements + +- Python 3.10+ +- Tk (e.g. `python3-tk` on Debian/Ubuntu if the window fails to open) + +## Run + +From the repository root: + +```bash +pipenv install # optional; Microdot is vendored under led-driver/lib +python3 led-simulator/gui_main.py +``` + +## UI + +- **Start** / **Stop**: start or stop the full **`main.py`** process (same code path as the chip). +- **HTTP / WS port**: Microdot listen port; WebSocket URL **`ws://127.0.0.1:/ws`** (JSON **v1**). + +Configure the app like on flash: **`workspace/settings.json`** (patched as device settings path), **`presets.json`** and **`patterns/`** under **`led-simulator/workspace/`** when the simulator creates symlinks from **`src/`**, or edit files directly under **`led-driver/src/`** if you use a real **`patterns/`** directory there. + +Example pattern modules: **`led-simulator/examples/`** (`Blink`, `Chase`). Copy into **`patterns/`** as on the device. + +## Notes + +- Changing the port spinbox only takes effect on the next **Start**. +- If **`led-driver/src/patterns`** already exists as a normal directory, the simulator leaves it in place. + +## Shell (no GUI) + +```bash +cd led-driver/src +PYTHONPATH="../lib:../../led-simulator/stubs" LED_SIM_ROOT="../../led-simulator" LED_SIM_PORT=8765 \ + python3 main.py +``` + +(Adjust paths from your checkout root; **`LED_SIM_ROOT`** must point at **`led-simulator/`** so **Stop**-style hooks work if you use them.) diff --git a/__pycache__/led_driver_sim_hook.cpython-313.pyc b/__pycache__/led_driver_sim_hook.cpython-313.pyc new file mode 100644 index 0000000..9b7d425 Binary files /dev/null and b/__pycache__/led_driver_sim_hook.cpython-313.pyc differ diff --git a/examples/blink.py b/examples/blink.py new file mode 100644 index 0000000..2ee3734 --- /dev/null +++ b/examples/blink.py @@ -0,0 +1,19 @@ +"""Example pattern for led-simulator / led-driver (generator + yield per tick).""" + +import utime + + +class Blink: + def __init__(self, presets): + self.presets = presets + + def run(self, preset): + colors = preset.c if preset.c else [(255, 255, 255)] + idx = 0 + delay_ms = max(1, int(preset.d)) + while True: + rgb = colors[idx % len(colors)] + self.presets.fill(self.presets.apply_brightness(rgb, preset.b)) + yield + utime.sleep_ms(delay_ms) + idx += 1 diff --git a/examples/chase.py b/examples/chase.py new file mode 100644 index 0000000..baf222f --- /dev/null +++ b/examples/chase.py @@ -0,0 +1,25 @@ +"""Simple one-pixel chase (uses preset c[0] for the dot, preset n3 as step).""" + +import utime + + +class Chase: + def __init__(self, presets): + self.presets = presets + + def run(self, preset): + n = self.presets.num_leds + rgb = preset.c[0] if preset.c else (255, 64, 0) + raw_step = int(getattr(preset, "n3", 1)) + step = raw_step if raw_step != 0 else 1 + pos = 0 + delay_ms = max(1, int(preset.d)) + while True: + off = (0, 0, 0) + for i in range(n): + self.presets.n[i] = off + self.presets.n[pos % n] = self.presets.apply_brightness(rgb, preset.b) + self.presets.n.write() + yield + utime.sleep_ms(delay_ms) + pos += step diff --git a/gui_main.py b/gui_main.py new file mode 100644 index 0000000..b6ff8ce --- /dev/null +++ b/gui_main.py @@ -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() diff --git a/led_driver_sim_hook.py b/led_driver_sim_hook.py new file mode 100644 index 0000000..174018c --- /dev/null +++ b/led_driver_sim_hook.py @@ -0,0 +1,17 @@ +"""Host-only: led-simulator registers the Microdot app so Stop can call shutdown().""" + +_app = None + + +def register_app(app): + global _app + _app = app + + +def get_app(): + return _app + + +def clear_app(): + global _app + _app = None diff --git a/settings.default.json b/settings.default.json new file mode 100644 index 0000000..c802665 --- /dev/null +++ b/settings.default.json @@ -0,0 +1,13 @@ +{ + "led_pin": 10, + "num_leds": 32, + "color_order": "rgb", + "name": "sim", + "debug": false, + "default": "on", + "brightness": 255, + "transport_type": "wifi", + "wifi_channel": 1, + "ssid": "", + "password": "" +} diff --git a/stubs/__pycache__/machine.cpython-313.pyc b/stubs/__pycache__/machine.cpython-313.pyc new file mode 100644 index 0000000..4d1b859 Binary files /dev/null and b/stubs/__pycache__/machine.cpython-313.pyc differ diff --git a/stubs/__pycache__/neopixel.cpython-313.pyc b/stubs/__pycache__/neopixel.cpython-313.pyc new file mode 100644 index 0000000..5aa6701 Binary files /dev/null and b/stubs/__pycache__/neopixel.cpython-313.pyc differ diff --git a/stubs/__pycache__/network.cpython-313.pyc b/stubs/__pycache__/network.cpython-313.pyc new file mode 100644 index 0000000..6725339 Binary files /dev/null and b/stubs/__pycache__/network.cpython-313.pyc differ diff --git a/stubs/__pycache__/ubinascii.cpython-313.pyc b/stubs/__pycache__/ubinascii.cpython-313.pyc new file mode 100644 index 0000000..f157879 Binary files /dev/null and b/stubs/__pycache__/ubinascii.cpython-313.pyc differ diff --git a/stubs/__pycache__/utime.cpython-313.pyc b/stubs/__pycache__/utime.cpython-313.pyc new file mode 100644 index 0000000..8ba05e9 Binary files /dev/null and b/stubs/__pycache__/utime.cpython-313.pyc differ diff --git a/stubs/machine.py b/stubs/machine.py new file mode 100644 index 0000000..d4d00bc --- /dev/null +++ b/stubs/machine.py @@ -0,0 +1,18 @@ +"""CPython stub for MicroPython `machine` (led-simulator).""" + + +class Pin: + OUT = 1 + IN = 0 + + def __init__(self, pin_id, mode=None, *, pull=None, value=None): + self.id = pin_id + self.mode = mode + + +class WDT: + def __init__(self, timeout=10000): + self._timeout = timeout + + def feed(self): + pass diff --git a/stubs/neopixel.py b/stubs/neopixel.py new file mode 100644 index 0000000..36f2213 --- /dev/null +++ b/stubs/neopixel.py @@ -0,0 +1,37 @@ +"""Host NeoPixel: keeps RGB buffer and notifies the GUI on `write()`.""" + +# Set by led-simulator main: callable(list[tuple[int,int,int]]) -> None +PIXEL_SINK = None + + +class NeoPixel: + def __init__(self, pin, n): + self.pin = pin + self.n = n + self._buf = [(0, 0, 0)] * n + + def __setitem__(self, index, value): + if isinstance(value, (list, tuple)) and len(value) >= 3: + r, g, b = (int(value[0]), int(value[1]), int(value[2])) + self._buf[index] = ( + max(0, min(255, r)), + max(0, min(255, g)), + max(0, min(255, b)), + ) + + def __getitem__(self, index): + return self._buf[index] + + def fill(self, color): + c = (0, 0, 0) + if isinstance(color, (list, tuple)) and len(color) >= 3: + c = ( + max(0, min(255, int(color[0]))), + max(0, min(255, int(color[1]))), + max(0, min(255, int(color[2]))), + ) + self._buf = [c] * self.n + + def write(self): + if PIXEL_SINK is not None: + PIXEL_SINK(list(self._buf)) diff --git a/stubs/network.py b/stubs/network.py new file mode 100644 index 0000000..add5267 --- /dev/null +++ b/stubs/network.py @@ -0,0 +1,38 @@ +"""Minimal `network` stub so imports succeed (led-simulator).""" + +STA_IF = 0 +AP_IF = 1 + +# Fixed “STA” identity for hello.py / discovery on the host. +_SIM_MAC = b"\xaa\xbb\xcc\xdd\xee\xff" + + +class WLAN: + PM_NONE = 0 + + def __init__(self, interface): + self._interface = interface + self._active = False + + def active(self, is_active=None): + if is_active is None: + return self._active + self._active = bool(is_active) + return None + + def config(self, param=None, **kwargs): + if param == "mac": + return _SIM_MAC + return None + + def connect(self, *args, **kwargs): + return None + + def isconnected(self): + return True + + def ifconfig(self, config_tuple=None): + if config_tuple is None: + # ip, subnet, gateway, dns — enough for hello UDP targets + return ("192.168.1.100", "255.255.255.0", "192.168.1.1", "8.8.8.8") + return None diff --git a/stubs/ubinascii.py b/stubs/ubinascii.py new file mode 100644 index 0000000..b78bb5c --- /dev/null +++ b/stubs/ubinascii.py @@ -0,0 +1,6 @@ +"""Map MicroPython `ubinascii` to CPython `binascii`.""" + +import binascii + +hexlify = binascii.hexlify +unhexlify = binascii.unhexlify diff --git a/stubs/utime.py b/stubs/utime.py new file mode 100644 index 0000000..1ed55e5 --- /dev/null +++ b/stubs/utime.py @@ -0,0 +1,22 @@ +"""CPython stub for MicroPython `utime` (led-simulator).""" + +import time + +_MS_MASK = (1 << 30) - 1 + + +def sleep_ms(ms): + time.sleep(max(0, ms) / 1000.0) + + +def sleep(s): + time.sleep(s) + + +def ticks_ms(): + return int(time.monotonic() * 1000) & _MS_MASK + + +def ticks_diff(t1, t0): + """Approximate MicroPython ticks_diff for host monotonic ms.""" + return ((t1 - t0 + (1 << 29)) & _MS_MASK) - (1 << 29) diff --git a/workspace/patterns/.gitkeep b/workspace/patterns/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/workspace/patterns/.gitkeep @@ -0,0 +1 @@ + diff --git a/workspace/patterns/__init__.py b/workspace/patterns/__init__.py new file mode 100644 index 0000000..3885a94 --- /dev/null +++ b/workspace/patterns/__init__.py @@ -0,0 +1 @@ +# Host package mirror of device flash `patterns/` (pattern modules).