Stop tracking workspace/; bundled-demos/ is the canonical demo source

`workspace/` is runtime state (per-user folders, no-auth dev's `code/`)
and shouldn't be in git. The same files were previously committed under
both `workspace/code/` and `src/static/bundled-demos/`, which forced a
Docker `diff -q` sync check and leaked user-scoped paths into version
control.

- /workspace/ added to .gitignore; all previously tracked files removed
  via `git rm --cached`.
- src/static/bundled-demos/ becomes the single source of truth: panel16
  demos, led_tutorial, led_patterns, neopixel demos, and main.py move
  here alongside the existing canonical demos.
- New BUNDLED_DEMOS_DIR config; user_workspace seeders read from it.
- main.py lifespan seeds WORKSPACE_ROOT/code/ on startup so a fresh
  clone running `pipenv run dev` still gets the full sample set
  (existing files never overwritten — user edits survive restarts).
- Dockerfile drops `COPY workspace` and the diff sanity check.
- README/LED_TUTORIAL repointed at the new canonical paths.
- test_led_patterns loads led_patterns.py from bundled-demos.
- test_api uses mkdir(exist_ok=True) for `code/` (startup pre-creates).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 06:55:59 +12:00
parent b8d62e01d9
commit 7ee15f8eac
24 changed files with 86 additions and 418 deletions

View File

@@ -0,0 +1,143 @@
"""LED pattern helpers inspired by embedded NeoPixel drivers."""
from __future__ import annotations
import random
Color = tuple[int, int, int]
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def wheel(pos: int) -> Color:
"""Return a rainbow color for position 0-255."""
pos = 255 - (pos & 255)
if pos < 85:
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
if pos < 170:
pos -= 85
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
pos -= 170
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
def rainbow_frame(led_count: int, frame: int, step: int = 4) -> list[Color]:
"""Generate one rainbow frame across all LEDs."""
if led_count <= 0:
return []
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
def chase_frame(
led_count: int,
frame: int,
color: Color = (255, 120, 0),
tail: Color = (16, 0, 0),
) -> list[Color]:
"""Generate a two-pixel chase pattern."""
if led_count <= 0:
return []
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
head = frame % led_count
trail = (head - 1) % led_count
out[trail] = tuple(_clamp(v) for v in tail) # type: ignore[assignment]
out[head] = tuple(_clamp(v) for v in color) # type: ignore[assignment]
return out
def _bounce_head_index(led_count: int, frame: int) -> int:
"""Map frame to a triangular index sweep 0..N-1..0 (Ping-Pong position)."""
if led_count <= 1:
return 0
span = led_count - 1
cycle = span * 2
if cycle <= 0:
return 0
t = frame % cycle
return t if t <= span else 2 * span - t
def _bounce_phase_tail_direction(led_count: int, frame: int) -> int:
"""Extend tail opposite motion: -1 fades toward lower indices, +1 toward higher."""
if led_count <= 1:
return -1
span = led_count - 1
cycle = span * 2
if cycle <= 0:
return -1
t = frame % cycle
if t <= span:
return -1
return 1
def knight_rider_scanner_frame(
led_count: int,
frame: int,
head_color: Color = (220, 0, 28),
tail_len: int = 8,
falloff_gamma: float = 2.6,
) -> list[Color]:
"""KITT-style bouncing scanner: saturated head with exponential tail fading to off."""
if led_count <= 0:
return []
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
tl = max(1, tail_len)
head = _bounce_head_index(led_count, frame)
direc = _bounce_phase_tail_direction(led_count, frame)
gamma = max(1.05, falloff_gamma)
for rk in reversed(range(tl)):
idx = head + direc * rk
if idx < 0 or idx >= led_count:
continue
w = max(0.0, float(tl - rk) / float(tl))
strength = w**gamma
out[idx] = tuple(_clamp(int(head_color[ch] * strength)) for ch in range(3))
return out
def scanner_bounce_frame(
led_count: int,
frame: int,
head_color: Color = (0, 220, 255),
tail_color: Color = (0, 40, 90),
tail_len: int = 5,
) -> list[Color]:
"""Ping-pong scanner: head reverses at both ends with a directional fading tail."""
if led_count <= 0:
return []
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
tl = max(1, tail_len)
for rk in reversed(range(tl)):
past = frame - rk
if past < 0:
continue
idx = _bounce_head_index(led_count, past)
strength = max(0.0, float(tl - rk) / float(tl))
if rk == 0:
out[idx] = tuple(_clamp(int(c)) for c in head_color)
else:
out[idx] = tuple(_clamp(int(tail_color[i] * strength)) for i in range(3))
return out
def twinkle_frame(
led_count: int,
frame: int,
base: Color = (0, 0, 8),
sparkle: Color = (255, 255, 180),
sparkles: int = 3,
seed: int = 1337,
) -> list[Color]:
"""Generate deterministic twinkle frames for testing/replay."""
if led_count <= 0:
return []
out: list[Color] = [tuple(_clamp(v) for v in base) for _ in range(led_count)] # type: ignore[list-item]
rng = random.Random(seed + frame)
for _ in range(min(max(0, sparkles), led_count)):
idx = rng.randrange(led_count)
out[idx] = tuple(_clamp(v) for v in sparkle) # type: ignore[assignment]
return out

View File

@@ -0,0 +1,50 @@
"""LED tutorial script for NeoPixel in the browser editor.
Run this file and watch the in-app NeoPixel simulator panel.
"""
from machine import Pin
import neopixel
import time
LED_COUNT = 12
np = neopixel.NeoPixel(Pin(4), LED_COUNT)
def show_step(title: str):
print(f"\n--- {title} ---")
show_step("Step 1: single colors")
np.fill((0, 0, 0))
np.write()
time.sleep(0.2)
np[0] = (255, 0, 0) # red
np[1] = (0, 255, 0) # green
np[2] = (0, 0, 255) # blue
np.write()
time.sleep(0.8)
show_step("Step 2: fill strip")
np.fill((40, 0, 120))
np.write()
time.sleep(0.6)
show_step("Step 3: moving pixel")
for i in range(len(np)):
np.fill((0, 0, 0))
np[i] = (255, 120, 0)
np.write()
time.sleep(0.06)
show_step("Step 4: simple pulse")
for level in list(range(0, 200, 20)) + list(range(200, -1, -20)):
np.fill((level, 0, level // 3))
np.write()
time.sleep(0.05)
show_step("Done")
np.fill((0, 0, 0))
np.write()
print("Tutorial complete.")

View File

@@ -0,0 +1,3 @@
"""Sample script — runs in the browser under Pyodide."""
print("Hello from Pyodide")

View File

@@ -0,0 +1,12 @@
"""Example ESP32 NeoPixel script using mock modules in browser."""
from machine import Pin
import neopixel
np = neopixel.NeoPixel(Pin(4), 8)
np.fill((0, 0, 0))
np[0] = (255, 0, 0)
np[1] = (0, 255, 0)
np[2] = (0, 0, 255)
np.write()

View File

@@ -0,0 +1,32 @@
"""NeoPixel time-based animation test for the browser simulator."""
from machine import Pin
import neopixel
import time
np = neopixel.NeoPixel(Pin(4), 50)
def wheel(pos: int) -> tuple[int, int, int]:
"""Generate rainbow colors across 0-255 positions."""
pos = 255 - (pos & 255)
if pos < 85:
return (255 - pos * 3, 0, pos * 3)
if pos < 170:
pos -= 85
return (0, pos * 3, 255 - pos * 3)
pos -= 170
return (pos * 3, 255 - pos * 3, 0)
print("Starting NeoPixel time test...")
for frame in range(60):
for i in range(len(np)):
np[i] = wheel((i * 256 // len(np) + frame * 4) & 255)
np.write()
time.sleep(0.08)
np.fill((0, 0, 0))
np.write()
print("NeoPixel time test complete.")

View File

@@ -0,0 +1,41 @@
"""16x16 bouncing pixel with fading trail."""
from machine import Pin
import neopixel
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
trail = [[0 for _ in range(PANEL_W)] for _ in range(PANEL_H)]
x = 0
y = 0
vx = 1
vy = 1
for _frame in range(420):
for yy in range(PANEL_H):
for xx in range(PANEL_W):
trail[yy][xx] = max(0, trail[yy][xx] - 14)
trail[y][x] = 255
for yy in range(PANEL_H):
for xx in range(PANEL_W):
v = trail[yy][xx]
np[xy_to_index(xx, yy)] = (clamp8(v), clamp8(v // 2), clamp8(30))
np.write()
time.sleep(0.02)
x += vx
y += vy
if x <= 0 or x >= PANEL_W - 1:
vx *= -1
if y <= 0 or y >= PANEL_H - 1:
vy *= -1
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,37 @@
"""16x16 matrix-style rain animation."""
from machine import Pin
import neopixel
import random
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
rng = random.Random(42)
heads = [rng.randrange(-PANEL_H, 0) for _ in range(PANEL_W)]
for _frame in range(320):
for y in range(PANEL_H):
for x in range(PANEL_W):
np[xy_to_index(x, y)] = (0, 0, 0)
for x in range(PANEL_W):
heads[x] += 1
if heads[x] > PANEL_H + 6:
heads[x] = rng.randrange(-PANEL_H, 0)
head_y = heads[x]
for tail in range(8):
y = head_y - tail
if 0 <= y < PANEL_H:
brightness = clamp8(255 - tail * 36)
np[xy_to_index(x, y)] = (0, brightness, 0)
np.write()
time.sleep(0.045)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,33 @@
"""16x16 rainbow wave animation."""
from machine import Pin
import neopixel
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
def wheel(pos: int) -> tuple[int, int, int]:
pos = 255 - (pos & 255)
if pos < 85:
return (clamp8(255 - pos * 3), 0, clamp8(pos * 3))
if pos < 170:
pos -= 85
return (0, clamp8(pos * 3), clamp8(255 - pos * 3))
pos -= 170
return (clamp8(pos * 3), clamp8(255 - pos * 3), 0)
for frame in range(240):
for y in range(PANEL_H):
for x in range(PANEL_W):
color_pos = ((x * 10) + (y * 6) + frame * 5) & 255
np[xy_to_index(x, y)] = wheel(color_pos)
np.write()
time.sleep(0.04)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,26 @@
"""Helpers for 16x16 serpentine NeoPixel panel animations.
Mapping matches the simulator's 16x16 mode:
- first LED at top-right
- row 0 goes right -> left
- row 1 goes left -> right
"""
from __future__ import annotations
PANEL_W = 16
PANEL_H = 16
def xy_to_index(x: int, y: int, width: int = PANEL_W) -> int:
"""Map panel coordinate (x, y) to LED index."""
x = int(x)
y = int(y)
if y % 2 == 0:
return y * width + (width - 1 - x)
return y * width + x
def clamp8(v: int) -> int:
return max(0, min(255, int(v)))