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:
@@ -28,3 +28,10 @@ load_env_file(PROJECT_ROOT / ".env")
|
||||
|
||||
_default_workspace = PROJECT_ROOT / "workspace"
|
||||
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(_default_workspace))).resolve()
|
||||
|
||||
# Canonical demo source. Files here are the single source of truth for the
|
||||
# editor's "Reset demos" button and per-user account seeding. They ship with
|
||||
# the static bundle (`/static/bundled-demos/...`) so a static-only host
|
||||
# also exposes them. `workspace/` is intentionally NOT used for canonical
|
||||
# data — it is treated as runtime/user state and is gitignored.
|
||||
BUNDLED_DEMOS_DIR = STATIC_DIR / "bundled-demos"
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from editor_app.config import STATIC_DIR
|
||||
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
|
||||
from editor_app.db.models import Base
|
||||
from editor_app.db.session import get_engine
|
||||
from editor_app.deps import require_api_access
|
||||
@@ -14,11 +14,19 @@ from editor_app.routers.auth_routes import router as auth_router
|
||||
from editor_app.routers.files import router as files_router
|
||||
from editor_app.routers.frontend import router as frontend_router
|
||||
from editor_app.routers.users_admin import router as users_admin_router
|
||||
from editor_app.services import accounts
|
||||
from editor_app.services import accounts, user_workspace
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
# `workspace/` is gitignored runtime state. On a fresh clone it doesn't
|
||||
# exist, and in no-auth dev mode the file tree's `code/` would otherwise
|
||||
# be empty — seed every bundled demo so `pipenv run dev` after `git
|
||||
# clone` Just Works without needing user accounts. Files already in
|
||||
# `code/` are left alone (user edits are preserved across restarts).
|
||||
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
user_workspace.seed_all_bundled_demos(WORKSPACE_ROOT)
|
||||
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
|
||||
@@ -34,7 +34,13 @@ def user_workspace_root(user_id: int, username: str, workspace_root: Path | None
|
||||
|
||||
|
||||
def _seed_canonical_demos_into_code(code_dir: Path) -> None:
|
||||
src_root = config.PROJECT_ROOT.resolve() / "workspace" / "code"
|
||||
"""Copy bundled demos into a user's `code/` if missing.
|
||||
|
||||
Reads from `BUNDLED_DEMOS_DIR` (single source of truth, ships under
|
||||
`src/static/bundled-demos/`), never from `workspace/`, so this works
|
||||
even when `workspace/` is empty (gitignored runtime directory).
|
||||
"""
|
||||
src_root = config.BUNDLED_DEMOS_DIR.resolve()
|
||||
for filename in _CANONICAL_DEMO_FILENAMES:
|
||||
dst = code_dir / filename
|
||||
if dst.exists():
|
||||
@@ -45,7 +51,12 @@ def _seed_canonical_demos_into_code(code_dir: Path) -> None:
|
||||
|
||||
|
||||
def ensure_default_code_main(user_root: Path) -> None:
|
||||
"""Ensure code/ has main.py and self-contained NeoPixel demos (copied from repo workspace/code/)."""
|
||||
"""Ensure code/ has main.py and the canonical NeoPixel demos.
|
||||
|
||||
Demos are sourced from `BUNDLED_DEMOS_DIR` (the single committed home
|
||||
for sample scripts). Only files listed in `_CANONICAL_DEMO_FILENAMES`
|
||||
get auto-seeded — the rest are available via the editor's "Reset
|
||||
demos" button or a manual copy."""
|
||||
code_dir = user_root / "code"
|
||||
code_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_py = code_dir / "main.py"
|
||||
@@ -54,6 +65,34 @@ def ensure_default_code_main(user_root: Path) -> None:
|
||||
_seed_canonical_demos_into_code(code_dir)
|
||||
|
||||
|
||||
def seed_all_bundled_demos(user_root: Path) -> None:
|
||||
"""Copy *every* file in `BUNDLED_DEMOS_DIR` into `<user_root>/code/`.
|
||||
|
||||
Used at app startup to populate a fresh `workspace/code/` with the
|
||||
full sample set so a no-auth dev install (`pipenv run dev` after
|
||||
`git clone`) has something to play with. Existing files are not
|
||||
overwritten — user edits are preserved.
|
||||
"""
|
||||
code_dir = user_root / "code"
|
||||
code_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_py = code_dir / "main.py"
|
||||
if not main_py.exists():
|
||||
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
|
||||
src_root = config.BUNDLED_DEMOS_DIR.resolve()
|
||||
if not src_root.is_dir():
|
||||
return
|
||||
for src in sorted(src_root.iterdir()):
|
||||
if not src.is_file() or not src.name.endswith(".py"):
|
||||
continue
|
||||
dst = code_dir / src.name
|
||||
if dst.exists():
|
||||
continue
|
||||
try:
|
||||
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
|
||||
|
||||
def rename_user_workspace_leaf(
|
||||
user_id: int, old_username: str, new_username: str, workspace_root: Path | None = None
|
||||
) -> None:
|
||||
|
||||
143
src/static/bundled-demos/led_patterns.py
Normal file
143
src/static/bundled-demos/led_patterns.py
Normal 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
|
||||
50
src/static/bundled-demos/led_tutorial.py
Normal file
50
src/static/bundled-demos/led_tutorial.py
Normal 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.")
|
||||
3
src/static/bundled-demos/main.py
Normal file
3
src/static/bundled-demos/main.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Sample script — runs in the browser under Pyodide."""
|
||||
|
||||
print("Hello from Pyodide")
|
||||
12
src/static/bundled-demos/neopixel_demo.py
Normal file
12
src/static/bundled-demos/neopixel_demo.py
Normal 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()
|
||||
32
src/static/bundled-demos/neopixel_time_test.py
Normal file
32
src/static/bundled-demos/neopixel_time_test.py
Normal 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.")
|
||||
41
src/static/bundled-demos/panel16_bounce.py
Normal file
41
src/static/bundled-demos/panel16_bounce.py
Normal 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()
|
||||
37
src/static/bundled-demos/panel16_matrix_rain.py
Normal file
37
src/static/bundled-demos/panel16_matrix_rain.py
Normal 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()
|
||||
33
src/static/bundled-demos/panel16_rainbow_wave.py
Normal file
33
src/static/bundled-demos/panel16_rainbow_wave.py
Normal 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()
|
||||
26
src/static/bundled-demos/panel16_utils.py
Normal file
26
src/static/bundled-demos/panel16_utils.py
Normal 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)))
|
||||
Reference in New Issue
Block a user