Add local-mode workspace, ZIP import/export, and richer pin/ADC/serial sims

Boot:
- Editor now picks local vs server mode based on URL flag, sign-in
  state, and a stale local-mode flag. Signed-in users are no longer
  bounced to IndexedDB if they had previously clicked "Use locally".

Local mode:
- New LocalWorkspaceClient (src/static/local-workspace.js) with
  pluggable IndexedDB and File System Access backends. Picked folder
  handles persist across reloads with a Reconnect button when the
  permission lapses.
- Static-only host: scripts/serve_static_editor.py serves src/static/
  with COOP/COEP so SharedArrayBuffer-backed sims keep working.
- Bundled MicroPython stubs ship under src/static/bundled-lib/ for
  static hosting; FastAPI also exposes them at /api/public/lib-bundle.

Workspace import / export:
- Zero-dep ZIP encoder + reader (STORE + DEFLATE via
  DecompressionStream). Export/Import buttons in the workspace badge
  work in both local and server modes; imports are confined to code/.

Pin / ADC / Serial simulation:
- machine.py grows ADC, UART, expanded Pin, and PWM mocks, all driven
  by SharedArrayBuffer when cross-origin isolated and falling back to
  postMessage + [pin-out] stdout markers otherwise — pins, ADC slider,
  and serial input now keep working over plain HTTP / LAN-IP origins.
- NeoPixel pins are claimed via a [pin-claim] marker and dropped from
  the Pins panel so the data line doesn't flicker per write().
- New demos: adc_slider_demo.py, pin_demo.py, serial_demo.py.

Lib layout:
- Single source of truth at repo lib/; workspace/lib/ caching layer
  removed and the directory deleted. Filesystem service reads stubs
  directly from PROJECT_ROOT/lib.

UI:
- Home page slimmed to "Sign in" + "Use locally" with optional editor
  / manage-users links. Admin user/invite UI moved to /users.
- Workspace badge gains storage indicator, Folder…/Reconnect, Export,
  Import, and Exit controls.
- Mobile-friendly tweaks: safer-area padding, larger touch targets,
  iOS-zoom-proof serial input, file-tree highlight fix.

Tests:
- test_auth.py patches PROJECT_ROOT for the lib-shared test so the
  repo-root lib refactor stays green. test_api.py asserts the new
  "LED Editor" branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 06:16:02 +12:00
parent 9f28eabd2d
commit ca0ca6fe7e
26 changed files with 5080 additions and 793 deletions

View File

@@ -0,0 +1,55 @@
"""ADC slider demo — drag the sliders that appear under the editor.
Two simulated ADCs:
* pin 34 — sets the base hue of a rainbow
* pin 35 — sets overall brightness
The strip lights up while the script runs; the values update live (no need to
restart the script when you move the slider).
"""
import time
from machine import ADC, Pin
from neopixel import NeoPixel
NUM_LEDS = 16
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
hue_pot = ADC(Pin(34))
bri_pot = ADC(Pin(35))
def hsv_to_rgb(h, s, v):
h = h - int(h)
i = int(h * 6)
f = h * 6 - i
p = v * (1 - s)
q = v * (1 - f * s)
t = v * (1 - (1 - f) * s)
if i == 0:
r, g, b = v, t, p
elif i == 1:
r, g, b = q, v, p
elif i == 2:
r, g, b = p, v, t
elif i == 3:
r, g, b = p, q, v
elif i == 4:
r, g, b = t, p, v
else:
r, g, b = v, p, q
return int(r * 255), int(g * 255), int(b * 255)
print("Move the ADC sliders below the editor while this runs.")
while True:
base_hue = hue_pot.read_u16() / 65535
brightness = bri_pot.read_u16() / 65535
for i in range(NUM_LEDS):
h = (base_hue + i / NUM_LEDS) % 1.0
strip[i] = hsv_to_rgb(h, 1.0, brightness)
strip.write()
time.sleep(0.04)

View File

@@ -0,0 +1,54 @@
"""Pin features demo.
A "Pins" panel appears below the editor while this script runs:
* Pin 2 (OUT) — blinks every 200 ms; the indicator follows along.
* Pin 4 (OUT) — chases through .on() / .off() / .toggle().
* Pin 0 (IN) — click the toggle button in the panel to flip its value.
When it goes 0 -> 1 we register an IRQ that toggles pin 2.
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty cycle.
"""
import time
from machine import Pin, PWM
led_a = Pin(2, Pin.OUT)
led_b = Pin(4, Pin.OUT)
button = Pin(0, Pin.IN, Pin.PULL_UP)
fader = PWM(Pin(13), freq=1000, duty_u16=0)
def on_button(pin):
print("[irq] button rising edge -> toggling pin 2")
led_a.toggle()
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
tick = 0
duty = 0
direction = 1024
while True:
led_a.value(tick % 2)
if tick % 4 == 0:
led_b.on()
elif tick % 4 == 2:
led_b.off()
duty += direction
if duty >= 65535:
duty = 65535
direction = -1024
elif duty <= 0:
duty = 0
direction = 1024
fader.duty_u16(duty)
button.value()
tick += 1
time.sleep(0.1)

View File

@@ -0,0 +1,90 @@
"""Serial in/out demo.
When this script runs, a "Serial monitor" pane appears below the editor.
Try this:
* type hello and press Enter -> Python echoes "echo: hello"
* type color red -> the strip turns red
* try color 0,128,255 -> any (r,g,b) tuple works
* type off -> strip blanks
* type bye -> script exits cleanly
Anything Python `write()`s to the UART shows up in green; what you type back
is shown in white.
"""
import time
from machine import Pin, UART
from neopixel import NeoPixel
NUM_LEDS = 16
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
uart = UART(0, baudrate=115200)
PALETTE = {
"red": (255, 0, 0),
"green": (0, 255, 0),
"blue": (0, 0, 255),
"white": (200, 200, 200),
"purple": (160, 0, 200),
"orange": (255, 110, 0),
}
def fill(color):
strip.fill(color)
strip.write()
def parse_color(arg):
arg = arg.strip().lower()
if arg in PALETTE:
return PALETTE[arg]
parts = [p for p in arg.replace(",", " ").split() if p]
if len(parts) == 3:
try:
return tuple(max(0, min(255, int(p))) for p in parts)
except ValueError:
return None
return None
uart.write("ready. commands: color <name|r,g,b> | off | bye\n")
fill((0, 0, 0))
running = True
while running:
line = uart.readline()
if line is None:
time.sleep(0.05)
continue
text = line.decode("utf-8", errors="replace").strip()
if not text:
continue
if text == "bye":
uart.write("goodbye!\n")
running = False
break
if text == "off":
fill((0, 0, 0))
uart.write("strip off\n")
continue
if text.startswith("color"):
rest = text[len("color"):].strip()
color = parse_color(rest) if rest else None
if color is None:
uart.write("usage: color <name> | color r,g,b\n")
else:
fill(color)
uart.write(f"strip = {color}\n")
continue
uart.write(f"echo: {text}\n")
fill((0, 0, 0))

View File

@@ -1,19 +0,0 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
class Pin:
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
def __init__(self, pin_id: int, mode: int = OUT, value: int = 0):
self.id = int(pin_id)
self.mode = int(mode)
self._value = 1 if value else 0
def value(self, new_value=None):
if new_value is None:
return self._value
self._value = 1 if int(new_value) else 0
return self._value

View File

@@ -1,56 +0,0 @@
"""NeoPixel mock for Pyodide/browser execution.
Supports a useful subset of MicroPython's neopixel.NeoPixel API:
- NeoPixel(pin, n, bpp=3, timing=1)
- __setitem__, __getitem__, __len__
- fill(color)
- write() # prints current pixel buffer snapshot
"""
import json
def _normalize_color(value, bpp: int):
if not hasattr(value, "__iter__"):
raise TypeError("Color must be an iterable, e.g. (r, g, b)")
parts = [int(v) for v in value]
if len(parts) != bpp:
raise ValueError(f"Expected {bpp} color channels, got {len(parts)}")
out = []
for channel in parts:
out.append(max(0, min(255, channel)))
return tuple(out)
class NeoPixel:
def __init__(self, pin, n: int, bpp: int = 3, timing: int = 1):
self.pin = pin
self.n = int(n)
self.bpp = int(bpp)
self.timing = int(timing)
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
def __len__(self):
return self.n
def __getitem__(self, index):
return self._buf[int(index)]
def __setitem__(self, index, color):
idx = int(index)
self._buf[idx] = _normalize_color(color, self.bpp)
def fill(self, color):
c = _normalize_color(color, self.bpp)
for i in range(self.n):
self._buf[i] = c
def write(self):
pin_id = getattr(self.pin, "id", self.pin)
payload = {
"type": "neopixel",
"pin": pin_id,
"pixels": [list(pixel) for pixel in self._buf],
"bpp": self.bpp,
}
print("[neopixel-json]" + json.dumps(payload))