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

@@ -13,6 +13,7 @@ RUN pipenv install --system --deploy
COPY src ./src
COPY lib ./lib
RUN mkdir -p src/static/bundled-lib && cp -f lib/*.py src/static/bundled-lib/
COPY workspace ./workspace
EXPOSE 8080

View File

@@ -20,7 +20,7 @@ bcrypt = "*"
python_version = "3.12"
[scripts]
dev = "uvicorn app:app --app-dir src --reload --port 8080"
dev = "uvicorn app:app --app-dir src --reload --host 0.0.0.0 --port 8080"
test = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests'"
test-integration = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m integration'"
test-selenium = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m selenium -v'"

View File

@@ -90,17 +90,22 @@ Admins can open another user's workspace from the home page user management pane
The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`.
**Local mode (no login)** — Click *Use locally* on the home page (or open `/editor?local=1`) to run the editor without any FastAPI auth. The boot-time auth probe is skipped when local mode is active, so this works even on a host that has `AUTH_ENABLED=true`. Files default to the browser's **IndexedDB**; inside the editor the workspace badge has a **Folder…** button that opens `window.showDirectoryPicker()` so you can save straight to any folder on disk (Chromium-only — Firefox/Safari stay on IndexedDB). The picked directory handle is persisted across reloads in IndexedDB; if browser permission lapses a *Reconnect* button reappears in the badge. Nothing is sent to the server for file reads/writes. The MicroPython stubs are loaded from **`/static/bundled-lib/*.py`** (files under `src/static/bundled-lib/` in the repo) so a plain static file server is enough; if those requests fail, the app falls back to `GET /api/public/lib-bundle` when FastAPI is available. For static-only hosting, run `python scripts/serve_static_editor.py` from the repo root — it serves `src/static/` with the same `/static/…` URLs the HTML expects (it strips the `/static` prefix when resolving files), rewrites `/editor` → `index.html`, and sends the same COOP/COEP headers as the full app so **ADC sliders, pin toggles, and serial I/O** keep using `SharedArrayBuffer` on mobile Safari and Chrome where supported. An *Exit* button in the editor's workspace badge clears the local-mode flag (your IndexedDB files stay until you wipe browser storage).
## Layout
- `src/` — FastAPI app and static UI (`src/static/`)
- `lib/` — bundled MicroPython stubs (copied into `WORKSPACE_ROOT/lib` when missing; read-only via API)
- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples (editable); runtime `lib/` is filled from `lib/` above
- `lib/` — bundled MicroPython stubs, served read-only as `lib/` in the editor and merged into Pyodide at run time (single source of truth)
- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples and per-user folders (editable); the editor surfaces the repo `lib/` alongside it without copying anything to disk
## ESP32 / NeoPixel mock
The browser runtime ships MicroPython-style stubs in repo `lib/` (they appear as `lib/` in the editor and are read-only via the APIs):
- `machine.Pin`, `machine.freq()`, `machine.unique_id()`, `machine.reset()` (no-op here)
- `machine.Pin` `value/on/off/toggle/high/low/init/__call__/irq` plus a live "Pins" panel: OUT pins show an indicator, IN pins expose a clickable toggle button (its value is what `Pin.value()` returns), `irq()` fires on rising / falling edges as you click
- `machine.PWM` — `freq()` / `duty()` / `duty_u16()` / `duty_ns()` with a duty-cycle bar in the Pins panel
- `machine.ADC` — backed by a live slider in the editor UI (one slider per pin, `read_u16()` returns 0..65535)
- `machine.UART` — opens a Serial Monitor pane; `write()` text appears there, what you type is delivered via `read()` / `readline()`
- `neopixel.NeoPixel`
- `utime` — `ticks_ms`, `ticks_diff`, `ticks_add`, `sleep_ms`, `sleep_us`, `sleep`
- `micropython.const` — no-op helper for ported constant declarations

View File

@@ -1,19 +1,445 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
import json
_PIN_MODE_OUT = 1
_PIN_MODE_IN = 2
_PIN_MODE_PWM = 4
def _pin_view_get(name):
try:
import js
return getattr(js, name, None)
except Exception:
return None
def _sab_isolated():
"""True when the worker has a real SharedArrayBuffer for pin/ADC views.
Set by `pyodide-worker.js` at init time. When false, output updates
must travel through `print("[pin-out]…")` markers because the main
thread can't read worker-local arrays."""
try:
import js
return bool(getattr(js, "__sab_isolated", False))
except Exception:
return False
def _pin_out_publish(pin_id, ui_mode, value):
"""Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
view = _pin_view_get("__pin_out_view")
if view is not None:
packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
try:
import js
js.Atomics.store(view, int(pin_id), packed)
except Exception:
try:
view[int(pin_id)] = packed
except Exception:
pass
if not _sab_isolated():
# Fallback for non-isolated contexts (e.g. mobile over LAN IP):
# the main thread reads `[pin-out]` lines from stdout and updates
# the Pins panel from there. Slower than SAB but works everywhere.
try:
print(
"[pin-out]"
+ json.dumps(
{"pin": int(pin_id), "mode": int(ui_mode), "value": int(value)}
)
)
except Exception:
pass
def _pin_in_read(pin_id):
view = _pin_view_get("__pin_in_view")
if view is None:
return 0
try:
import js
return int(js.Atomics.load(view, int(pin_id))) & 1
except Exception:
try:
return int(view[int(pin_id)]) & 1
except Exception:
return 0
def _pin_register(pin_id, ui_mode, **extra):
payload = {"pin": int(pin_id), "mode": int(ui_mode)}
payload.update({k: v for k, v in extra.items() if v is not None})
try:
print("[pin-register]" + json.dumps(payload))
except Exception:
pass
class Pin:
"""Browser-simulated `machine.Pin`.
A control row appears in the editor UI when a pin is constructed:
* `Pin.OUT` pins show a live indicator that mirrors `value()`.
* `Pin.IN` pins show a toggle button you can click — the next call
to `value()` returns 0 or 1 accordingly.
"""
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
OPEN_DRAIN = 7
def __init__(self, pin_id: int, mode: int = OUT, value: int = 0):
IRQ_FALLING = 1
IRQ_RISING = 2
def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
self.id = int(pin_id)
self.mode = int(mode)
self.pull = int(pull) if pull != -1 else -1
self._value = 1 if value else 0
self._irq_handler = None
self._irq_trigger = 0
self._last_input = 0
self._publish()
def _ui_mode(self):
if self.mode == self.IN:
return _PIN_MODE_IN
return _PIN_MODE_OUT
def _publish(self):
ui_mode = self._ui_mode()
_pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
if ui_mode == _PIN_MODE_OUT:
_pin_out_publish(self.id, ui_mode, self._value)
def init(self, mode=-1, pull=-1, value=None):
if mode != -1:
self.mode = int(mode)
if pull != -1:
self.pull = int(pull)
if value is not None:
self._value = 1 if int(value) else 0
self._publish()
def value(self, new_value=None):
if new_value is None:
if self.mode == self.IN:
v = _pin_in_read(self.id)
if self._irq_handler is not None:
if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
self._fire_irq()
elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
self._fire_irq()
self._last_input = v
return v
return self._value
self._value = 1 if int(new_value) else 0
return self._value
v = 1 if int(new_value) else 0
self._value = v
if self._ui_mode() == _PIN_MODE_OUT:
_pin_out_publish(self.id, _PIN_MODE_OUT, v)
return v
def on(self):
return self.value(1)
def off(self):
return self.value(0)
def high(self):
return self.value(1)
def low(self):
return self.value(0)
def toggle(self):
return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
self._irq_handler = handler
self._irq_trigger = int(trigger)
return self
def _fire_irq(self):
if self._irq_handler is None:
return
try:
self._irq_handler(self)
except Exception as exc:
try:
print(f"[pin] irq handler raised: {exc!r}")
except Exception:
pass
def __call__(self, *args):
return self.value(*args) if args else self.value()
class PWM:
"""Browser-simulated `machine.PWM`.
Visualises the configured duty cycle as a bar in the Pins panel.
`freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
"""
def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
self._pin_id = _adc_pin_id(pin)
self._freq = int(freq) if freq else 1000
self._duty_u16 = 0
if duty_u16 is not None:
self._duty_u16 = max(0, min(65535, int(duty_u16)))
elif duty is not None:
self._duty_u16 = max(0, min(65535, int(duty) * 64))
elif duty_ns is not None:
self._set_duty_ns(int(duty_ns))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def _set_duty_ns(self, ns):
period_ns = 1_000_000_000 // max(1, self._freq)
if period_ns <= 0:
self._duty_u16 = 0
return
self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
def freq(self, value=None):
if value is None:
return self._freq
self._freq = max(1, int(value))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
def duty(self, value=None):
if value is None:
return self._duty_u16 // 64
self._duty_u16 = max(0, min(65535, int(value) * 64))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_u16(self, value=None):
if value is None:
return self._duty_u16
self._duty_u16 = max(0, min(65535, int(value)))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_ns(self, value=None):
period_ns = 1_000_000_000 // max(1, self._freq)
if value is None:
return (self._duty_u16 * period_ns) // 65535
self._set_duty_ns(int(value))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def deinit(self):
_pin_out_publish(self._pin_id, 0, 0)
def _adc_pin_id(pin):
if isinstance(pin, int):
return pin
return int(getattr(pin, "id", 0))
def _adc_read_raw(pin_id: int) -> int:
"""Read the live slider value (0..65535) shared from the editor UI.
Backed by a SharedArrayBuffer so updates from the browser slider propagate
instantly without the script having to yield. Falls back to 0 when the
runtime is not cross-origin isolated (e.g. older browsers).
"""
try:
import js # only available inside Pyodide
view = getattr(js, "__adc_view", None)
if view is None:
return 0
try:
return int(js.Atomics.load(view, pin_id)) & 0xFFFF
except Exception:
return int(view[pin_id]) & 0xFFFF
except Exception:
return 0
class ADC:
"""Browser-simulated `machine.ADC`.
Exposes a slider in the editor UI for the given pin. `read_u16()` returns
the slider value in the 0..65535 range (matching MicroPython's API);
`read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
"""
WIDTH_9BIT = 9
WIDTH_10BIT = 10
WIDTH_11BIT = 11
WIDTH_12BIT = 12
ATTN_0DB = 0
ATTN_2_5DB = 1
ATTN_6DB = 2
ATTN_11DB = 3
def __init__(self, pin):
self._pin = _adc_pin_id(pin)
self._atten = self.ATTN_11DB
self._width = self.WIDTH_12BIT
try:
print("[adc-register]" + json.dumps({"pin": self._pin}))
except Exception:
pass
def atten(self, attn):
self._atten = int(attn)
def width(self, width):
self._width = int(width)
def read_u16(self):
return _adc_read_raw(self._pin)
def read(self):
bits = self._width if self._width in (9, 10, 11, 12) else 12
max_val = (1 << bits) - 1
return (_adc_read_raw(self._pin) * max_val) // 65535
def read_uv(self):
"""Return microvolts assuming a 0..3.3V range (rough simulation)."""
return (_adc_read_raw(self._pin) * 3_300_000) // 65535
def _serial_drain_pending(buf):
"""Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
try:
import js
import base64 # noqa: F401 (kept for symmetry with write path)
indices = getattr(js, "__serial_in_indices", None)
data = getattr(js, "__serial_in_data", None)
if indices is None or data is None:
return
cap = int(getattr(js, "__serial_in_capacity", 0))
if cap <= 0:
return
r = int(js.Atomics.load(indices, 0))
w = int(js.Atomics.load(indices, 1))
while r != w:
buf.append(int(data[r]) & 0xFF)
r = (r + 1) % cap
js.Atomics.store(indices, 0, r)
except Exception:
pass
class UART:
"""Browser-simulated `machine.UART`.
A serial monitor pane appears in the editor when this is constructed.
`write()` text shows up there; characters typed into the input box arrive
via `read()` / `readline()` / `any()`.
"""
INV_TX = 1
INV_RX = 2
INV_RTS = 4
INV_CTS = 8
def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
tx=None, rx=None, timeout=0, **_kwargs):
self.id = int(id) if id is not None else 0
self.baudrate = int(baudrate)
self.bits = int(bits)
self.parity = parity
self.stop = int(stop)
self.tx = tx
self.rx = rx
self.timeout = int(timeout) if timeout is not None else 0
self._buf = bytearray()
try:
print("[serial-register]" + json.dumps({
"id": self.id,
"baudrate": self.baudrate,
}))
except Exception:
pass
def init(self, *args, **kwargs):
if args:
try:
self.baudrate = int(args[0])
except Exception:
pass
if "baudrate" in kwargs:
self.baudrate = int(kwargs["baudrate"])
def deinit(self):
self._buf = bytearray()
def any(self):
_serial_drain_pending(self._buf)
return len(self._buf)
def read(self, nbytes=None):
_serial_drain_pending(self._buf)
if not self._buf:
return None
if nbytes is None:
out = bytes(self._buf)
self._buf = bytearray()
return out
n = min(int(nbytes), len(self._buf))
if n <= 0:
return None
out = bytes(self._buf[:n])
del self._buf[:n]
return out
def readinto(self, buf, nbytes=None):
n = int(nbytes) if nbytes is not None else len(buf)
data = self.read(n)
if not data:
return None
for i, b in enumerate(data):
buf[i] = b
return len(data)
def readline(self):
_serial_drain_pending(self._buf)
if not self._buf:
return None
nl = self._buf.find(b"\n")
if nl == -1:
return None
out = bytes(self._buf[: nl + 1])
del self._buf[: nl + 1]
return out
def write(self, data):
if isinstance(data, str):
buf = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray, memoryview)):
buf = bytes(data)
else:
buf = bytes([int(data) & 0xFF])
try:
import base64
encoded = base64.b64encode(buf).decode("ascii")
print(f"[serial-out]{encoded}")
except Exception:
pass
return len(buf)
def sendbreak(self):
pass
def flush(self):
pass
def txdone(self):
return True

View File

@@ -29,6 +29,17 @@ class NeoPixel:
self.bpp = int(bpp)
self.timing = int(timing)
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
# Tell the editor UI that this pin is the NeoPixel data line so its
# row in the Pins panel goes away (it would just look noisy — every
# `pixels.write()` flips it).
try:
pin_id = getattr(self.pin, "id", self.pin)
print(
"[pin-claim]"
+ json.dumps({"pin": int(pin_id), "by": "neopixel"})
)
except Exception:
pass
def __len__(self):
return self.n

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Serve only the contents of `src/static/` (HTML, CSS, JS, bundled stubs).
Use this with **local mode** in the editor (`?local=1`): files live in IndexedDB,
so no FastAPI file API is required. Maps `/` and `/editor` to `home.html` /
`index.html` so links from the home page keep working.
Sends ``Cross-Origin-Opener-Policy: same-origin`` and
``Cross-Origin-Embedder-Policy: credentialless`` on every response so the page
can become cross-origin isolated (SharedArrayBuffer for live ADC / pins /
serial), matching the full FastAPI app.
Example:
python scripts/serve_static_editor.py
# open http://127.0.0.1:8765/ then "Use locally" → /editor?local=1
Note: Pyodide and CodeMirror still load from CDNs; you need network access.
"""
from __future__ import annotations
import argparse
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)")
parser.add_argument("--port", type=int, default=8765, help="Port (default 8765)")
args = parser.parse_args()
static_root = (Path(__file__).resolve().parent.parent / "src" / "static").resolve()
if not static_root.is_dir():
raise SystemExit(f"Static directory not found: {static_root}")
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=str(static_root), **kw)
def end_headers(self):
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "credentialless")
super().end_headers()
def translate_path(self, path: str) -> str:
clean = path.split("?", 1)[0].split("#", 1)[0]
if clean.startswith("/static/"):
clean = clean[len("/static") :] # e.g. /styles.css, /bundled-lib/machine.py
if clean in ("/", ""):
clean = "/home.html"
elif clean == "/editor" or clean.startswith("/editor/"):
clean = "/index.html"
elif clean == "/tutorial" or clean.startswith("/tutorial/"):
clean = "/tutorial.html"
elif clean == "/login" or clean.startswith("/login/"):
clean = "/login.html"
elif clean == "/register" or clean.startswith("/register/"):
clean = "/register.html"
return super().translate_path(clean)
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"Serving {static_root} at http://{args.host}:{args.port}/")
print("Open / then use “Use locally” for IndexedDB workspace.")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
if __name__ == "__main__":
main()

View File

@@ -1,12 +1,12 @@
import os
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, Request
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
from editor_app.config import STATIC_DIR
from editor_app.db.models import Base
from editor_app.db.session import get_engine
from editor_app.deps import require_api_access
@@ -14,13 +14,11 @@ 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, user_workspace
from editor_app.services import accounts
@asynccontextmanager
async def lifespan(_app: FastAPI):
(WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True)
user_workspace.ensure_workspace_lib()
engine = get_engine()
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
@@ -47,6 +45,18 @@ async def lifespan(_app: FastAPI):
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def cross_origin_isolation(request: Request, call_next):
"""Turn on `crossOriginIsolated` so the editor worker can use SharedArrayBuffer
(powers the live ADC slider). `credentialless` keeps no-credential cross-origin
imports (esm.sh, jsdelivr) loading without requiring CORP everywhere.
"""
response = await call_next(request)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
return response
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(frontend_router)
app.include_router(auth_router)

View File

@@ -1,11 +1,38 @@
from fastapi import APIRouter
from fastapi.responses import FileResponse
from editor_app.config import STATIC_DIR
from editor_app.config import PROJECT_ROOT, STATIC_DIR
router = APIRouter()
@router.get("/api/public/lib-bundle")
async def serve_lib_bundle():
"""Public, unauthenticated dump of the read-only `lib/` stubs.
Local-mode browsers (no login, files in IndexedDB) need access to the
MicroPython mocks so completion, diagnostics, and `runpy` can resolve
`from machine import …`. Reading these is read-only and contains no
user data, so it is safe to expose without auth."""
bundle_root = (PROJECT_ROOT / "lib").resolve()
files: dict[str, str] = {}
if bundle_root.is_dir():
for path in sorted(bundle_root.rglob("*.py")):
if not path.is_file():
continue
try:
rel = path.relative_to(bundle_root)
except ValueError:
continue
if any(part.startswith(".") for part in rel.parts):
continue
try:
files[str(rel).replace("\\", "/")] = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
return {"files": files}
@router.get("/")
async def serve_home():
return FileResponse(STATIC_DIR / "home.html")
@@ -29,3 +56,10 @@ async def serve_login():
@router.get("/register")
async def serve_register():
return FileResponse(STATIC_DIR / "register.html")
@router.get("/users")
async def serve_users():
"""Admin panel for managing accounts and invites. Auth is enforced by
the underlying `/api/users*` endpoints, not the static page itself."""
return FileResponse(STATIC_DIR / "users.html")

View File

@@ -15,12 +15,13 @@ def _workspace_root(workspace_root: Path | None = None) -> Path:
def _shared_lib_root() -> Path:
"""Shared MicroPython stubs live under WORKSPACE_ROOT/lib; seed from bundle if missing (e.g. volume wiped)."""
lib = (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve()
from editor_app.services.user_workspace import ensure_workspace_lib
"""Shared MicroPython stubs ship in the repo `lib/` directory and are read directly.
ensure_workspace_lib()
return lib
Treating the bundle as the single source of truth means there is no on-disk
`workspace/lib/` cache to keep in sync — updates to e.g. `machine.py` flow
straight through to Pyodide and the file tree.
"""
return (config.PROJECT_ROOT / LIB_DIR_NAME).resolve()
def normalize_relative_path(relative_path: str) -> str:
@@ -55,16 +56,8 @@ def resolve_workspace_path(relative_path: str, workspace_root: Path | None = Non
def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
workspace = _workspace_root(workspace_root)
lib_root = (workspace / LIB_DIR_NAME).resolve()
shared_lib_root = _shared_lib_root()
try:
target_path.resolve().relative_to(lib_root)
return True
except ValueError:
pass
try:
target_path.resolve().relative_to(shared_lib_root)
target_path.resolve().relative_to(_shared_lib_root())
return True
except ValueError:
return False

View File

@@ -16,22 +16,6 @@ _CANONICAL_DEMO_FILENAMES = (
)
def ensure_workspace_lib(workspace_root: Path | None = None) -> None:
"""Copy shipped MicroPython stubs from the repo into WORKSPACE_ROOT/lib when each file is absent."""
dst_root = (workspace_root or config.WORKSPACE_ROOT).resolve() / "lib"
dst_root.mkdir(parents=True, exist_ok=True)
src_root = config.PROJECT_ROOT.resolve() / "lib"
if not src_root.is_dir():
return
for src in sorted(src_root.glob("*.py")):
if not src.is_file():
continue
dst = dst_root / src.name
if dst.exists():
continue
shutil.copy2(src, dst)
def safe_workspace_leaf(username: str, user_id: int) -> str:
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", username.strip()).strip("-").lower() or "user"
return f"{base}-{user_id}"

View File

@@ -0,0 +1,419 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
import json
_PIN_MODE_OUT = 1
_PIN_MODE_IN = 2
_PIN_MODE_PWM = 4
def _pin_view_get(name):
try:
import js
return getattr(js, name, None)
except Exception:
return None
def _pin_out_publish(pin_id, ui_mode, value):
"""Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
view = _pin_view_get("__pin_out_view")
if view is None:
return
packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
try:
import js
js.Atomics.store(view, int(pin_id), packed)
except Exception:
try:
view[int(pin_id)] = packed
except Exception:
pass
def _pin_in_read(pin_id):
view = _pin_view_get("__pin_in_view")
if view is None:
return 0
try:
import js
return int(js.Atomics.load(view, int(pin_id))) & 1
except Exception:
try:
return int(view[int(pin_id)]) & 1
except Exception:
return 0
def _pin_register(pin_id, ui_mode, **extra):
payload = {"pin": int(pin_id), "mode": int(ui_mode)}
payload.update({k: v for k, v in extra.items() if v is not None})
try:
print("[pin-register]" + json.dumps(payload))
except Exception:
pass
class Pin:
"""Browser-simulated `machine.Pin`.
A control row appears in the editor UI when a pin is constructed:
* `Pin.OUT` pins show a live indicator that mirrors `value()`.
* `Pin.IN` pins show a toggle button you can click — the next call
to `value()` returns 0 or 1 accordingly.
"""
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
OPEN_DRAIN = 7
IRQ_FALLING = 1
IRQ_RISING = 2
def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
self.id = int(pin_id)
self.mode = int(mode)
self.pull = int(pull) if pull != -1 else -1
self._value = 1 if value else 0
self._irq_handler = None
self._irq_trigger = 0
self._last_input = 0
self._publish()
def _ui_mode(self):
if self.mode == self.IN:
return _PIN_MODE_IN
return _PIN_MODE_OUT
def _publish(self):
ui_mode = self._ui_mode()
_pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
if ui_mode == _PIN_MODE_OUT:
_pin_out_publish(self.id, ui_mode, self._value)
def init(self, mode=-1, pull=-1, value=None):
if mode != -1:
self.mode = int(mode)
if pull != -1:
self.pull = int(pull)
if value is not None:
self._value = 1 if int(value) else 0
self._publish()
def value(self, new_value=None):
if new_value is None:
if self.mode == self.IN:
v = _pin_in_read(self.id)
if self._irq_handler is not None:
if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
self._fire_irq()
elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
self._fire_irq()
self._last_input = v
return v
return self._value
v = 1 if int(new_value) else 0
self._value = v
if self._ui_mode() == _PIN_MODE_OUT:
_pin_out_publish(self.id, _PIN_MODE_OUT, v)
return v
def on(self):
return self.value(1)
def off(self):
return self.value(0)
def high(self):
return self.value(1)
def low(self):
return self.value(0)
def toggle(self):
return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
self._irq_handler = handler
self._irq_trigger = int(trigger)
return self
def _fire_irq(self):
if self._irq_handler is None:
return
try:
self._irq_handler(self)
except Exception as exc:
try:
print(f"[pin] irq handler raised: {exc!r}")
except Exception:
pass
def __call__(self, *args):
return self.value(*args) if args else self.value()
class PWM:
"""Browser-simulated `machine.PWM`.
Visualises the configured duty cycle as a bar in the Pins panel.
`freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
"""
def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
self._pin_id = _adc_pin_id(pin)
self._freq = int(freq) if freq else 1000
self._duty_u16 = 0
if duty_u16 is not None:
self._duty_u16 = max(0, min(65535, int(duty_u16)))
elif duty is not None:
self._duty_u16 = max(0, min(65535, int(duty) * 64))
elif duty_ns is not None:
self._set_duty_ns(int(duty_ns))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def _set_duty_ns(self, ns):
period_ns = 1_000_000_000 // max(1, self._freq)
if period_ns <= 0:
self._duty_u16 = 0
return
self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
def freq(self, value=None):
if value is None:
return self._freq
self._freq = max(1, int(value))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
def duty(self, value=None):
if value is None:
return self._duty_u16 // 64
self._duty_u16 = max(0, min(65535, int(value) * 64))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_u16(self, value=None):
if value is None:
return self._duty_u16
self._duty_u16 = max(0, min(65535, int(value)))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_ns(self, value=None):
period_ns = 1_000_000_000 // max(1, self._freq)
if value is None:
return (self._duty_u16 * period_ns) // 65535
self._set_duty_ns(int(value))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def deinit(self):
_pin_out_publish(self._pin_id, 0, 0)
def _adc_pin_id(pin):
if isinstance(pin, int):
return pin
return int(getattr(pin, "id", 0))
def _adc_read_raw(pin_id: int) -> int:
"""Read the live slider value (0..65535) shared from the editor UI.
Backed by a SharedArrayBuffer so updates from the browser slider propagate
instantly without the script having to yield. Falls back to 0 when the
runtime is not cross-origin isolated (e.g. older browsers).
"""
try:
import js # only available inside Pyodide
view = getattr(js, "__adc_view", None)
if view is None:
return 0
try:
return int(js.Atomics.load(view, pin_id)) & 0xFFFF
except Exception:
return int(view[pin_id]) & 0xFFFF
except Exception:
return 0
class ADC:
"""Browser-simulated `machine.ADC`.
Exposes a slider in the editor UI for the given pin. `read_u16()` returns
the slider value in the 0..65535 range (matching MicroPython's API);
`read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
"""
WIDTH_9BIT = 9
WIDTH_10BIT = 10
WIDTH_11BIT = 11
WIDTH_12BIT = 12
ATTN_0DB = 0
ATTN_2_5DB = 1
ATTN_6DB = 2
ATTN_11DB = 3
def __init__(self, pin):
self._pin = _adc_pin_id(pin)
self._atten = self.ATTN_11DB
self._width = self.WIDTH_12BIT
try:
print("[adc-register]" + json.dumps({"pin": self._pin}))
except Exception:
pass
def atten(self, attn):
self._atten = int(attn)
def width(self, width):
self._width = int(width)
def read_u16(self):
return _adc_read_raw(self._pin)
def read(self):
bits = self._width if self._width in (9, 10, 11, 12) else 12
max_val = (1 << bits) - 1
return (_adc_read_raw(self._pin) * max_val) // 65535
def read_uv(self):
"""Return microvolts assuming a 0..3.3V range (rough simulation)."""
return (_adc_read_raw(self._pin) * 3_300_000) // 65535
def _serial_drain_pending(buf):
"""Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
try:
import js
import base64 # noqa: F401 (kept for symmetry with write path)
indices = getattr(js, "__serial_in_indices", None)
data = getattr(js, "__serial_in_data", None)
if indices is None or data is None:
return
cap = int(getattr(js, "__serial_in_capacity", 0))
if cap <= 0:
return
r = int(js.Atomics.load(indices, 0))
w = int(js.Atomics.load(indices, 1))
while r != w:
buf.append(int(data[r]) & 0xFF)
r = (r + 1) % cap
js.Atomics.store(indices, 0, r)
except Exception:
pass
class UART:
"""Browser-simulated `machine.UART`.
A serial monitor pane appears in the editor when this is constructed.
`write()` text shows up there; characters typed into the input box arrive
via `read()` / `readline()` / `any()`.
"""
INV_TX = 1
INV_RX = 2
INV_RTS = 4
INV_CTS = 8
def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
tx=None, rx=None, timeout=0, **_kwargs):
self.id = int(id) if id is not None else 0
self.baudrate = int(baudrate)
self.bits = int(bits)
self.parity = parity
self.stop = int(stop)
self.tx = tx
self.rx = rx
self.timeout = int(timeout) if timeout is not None else 0
self._buf = bytearray()
try:
print("[serial-register]" + json.dumps({
"id": self.id,
"baudrate": self.baudrate,
}))
except Exception:
pass
def init(self, *args, **kwargs):
if args:
try:
self.baudrate = int(args[0])
except Exception:
pass
if "baudrate" in kwargs:
self.baudrate = int(kwargs["baudrate"])
def deinit(self):
self._buf = bytearray()
def any(self):
_serial_drain_pending(self._buf)
return len(self._buf)
def read(self, nbytes=None):
_serial_drain_pending(self._buf)
if not self._buf:
return None
if nbytes is None:
out = bytes(self._buf)
self._buf = bytearray()
return out
n = min(int(nbytes), len(self._buf))
if n <= 0:
return None
out = bytes(self._buf[:n])
del self._buf[:n]
return out
def readinto(self, buf, nbytes=None):
n = int(nbytes) if nbytes is not None else len(buf)
data = self.read(n)
if not data:
return None
for i, b in enumerate(data):
buf[i] = b
return len(data)
def readline(self):
_serial_drain_pending(self._buf)
if not self._buf:
return None
nl = self._buf.find(b"\n")
if nl == -1:
return None
out = bytes(self._buf[: nl + 1])
del self._buf[: nl + 1]
return out
def write(self, data):
if isinstance(data, str):
buf = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray, memoryview)):
buf = bytes(data)
else:
buf = bytes([int(data) & 0xFF])
try:
import base64
encoded = base64.b64encode(buf).decode("ascii")
print(f"[serial-out]{encoded}")
except Exception:
pass
return len(buf)
def sendbreak(self):
pass
def flush(self):
pass
def txdone(self):
return True

View File

@@ -15,570 +15,152 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
padding: 1rem;
}
.home-card {
width: min(560px, 92vw);
width: min(420px, 100%);
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
text-align: center;
}
h1 {
margin: 0 0 0.5rem 0;
font-size: 1.9rem;
color: #f8fafc;
letter-spacing: 0.02em;
}
p.tagline {
margin: 0 0 1.75rem 0;
color: #94a3b8;
font-size: 0.95rem;
}
.actions {
display: grid;
gap: 0.65rem;
}
h1 { margin: 0 0 0.75rem 0; font-size: 1.8rem; color: #f8fafc; }
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
.btn {
display: inline-block;
display: block;
width: 100%;
text-decoration: none;
border-radius: 8px;
padding: 0.65rem 1rem;
border-radius: 10px;
padding: 0.85rem 1rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
font-size: 1rem;
text-align: center;
}
.btn-primary { background: #3b82f6; color: #ffffff; }
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
input[type="password"] {
width: 100%;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
margin-bottom: 0.75rem;
}
.note { font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; }
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.nav span { color: #94a3b8; font-size: 0.9rem; }
.hidden { display: none !important; }
.invite-panel {
margin: 1rem 0;
padding: 0.9rem;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
background: rgba(30, 41, 59, 0.45);
}
.invite-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.invite-row input[type="email"] {
flex: 1 1 260px;
width: auto;
margin: 0;
}
.invite-row button {
margin: 0;
}
.invite-result {
margin-top: 0.55rem;
font-size: 0.85rem;
color: #cbd5e1;
word-break: break-all;
}
.users-panel {
margin: 1rem 0;
padding: 0.9rem;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
background: rgba(30, 41, 59, 0.45);
}
.users-list {
margin-top: 0.5rem;
display: grid;
gap: 0.45rem;
}
.user-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.user-row a {
color: #93c5fd;
text-decoration: none;
border: 1px solid rgba(147, 197, 253, 0.35);
border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.user-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
justify-content: flex-end;
}
.btn-danger {
.btn-primary:hover { background: #2563eb; }
.btn-ghost {
background: transparent;
border-color: #f87171;
color: #fecaca;
}
.btn-danger:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.15);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
#admin-users-feedback:not(:empty) {
padding: 0.5rem 0.65rem;
border-radius: 8px;
font-size: 0.85rem;
margin: 0.5rem 0;
}
#admin-users-feedback.ok {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(74, 222, 128, 0.35);
color: #bbf7d0;
}
#admin-users-feedback.err {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.user-edit-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.user-edit-form label {
display: block;
margin-bottom: 0.65rem;
}
.user-edit-form input[type='text'],
.user-edit-form input[type='password'] {
width: 100%;
max-width: 320px;
padding: 0.45rem 0.55rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
border-color: #475569;
color: #e2e8f0;
margin-top: 0.25rem;
box-sizing: border-box;
}
.user-edit-form .edit-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.65rem;
}
.user-edit-form .super-line {
display: flex;
align-items: center;
gap: 0.5rem;
.btn-ghost:hover { background: rgba(71, 85, 105, 0.25); }
.btn-link {
background: transparent;
border: none;
color: #93c5fd;
font-size: 0.85rem;
color: #cbd5e1;
margin-bottom: 0.65rem;
cursor: pointer;
padding: 0.4rem;
text-decoration: underline;
}
.user-edit-form .super-line input {
accent-color: #3b82f6;
.signed-in {
font-size: 0.85rem;
color: #94a3b8;
margin-bottom: 0.6rem;
}
.hidden { display: none !important; }
.footnote {
margin-top: 1.25rem;
font-size: 0.78rem;
color: #64748b;
line-height: 1.4;
}
.footnote a { color: #93c5fd; text-decoration: none; }
.footnote a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main class="home-card">
<h1>LED Editor</h1>
<div id="auth-nav" class="nav">
<span id="auth-greeting" class="hidden"></span>
<a class="btn btn-ghost hidden" id="link-login" href="/login">Sign in</a>
<a class="btn btn-ghost hidden" id="link-register" href="/register">Register</a>
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
</div>
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>. Choose Editor or the interactive Tutorial below.</p>
<div id="optional-api-key">
<p>If you use <code style="color:#fcd34d">EDITOR_API_KEY</code> (without user login), store it here for API calls from this browser tab:</p>
<label for="api-key">API key (optional)</label>
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" />
<p class="note">The key is kept in <code>sessionStorage</code>. You can also use <code>?api_key=…</code> on the editor URL.</p>
</div>
<section id="users-panel" class="users-panel hidden">
<strong>User management</strong>
<p class="note" style="margin-top:0.35rem">
New accounts sign up via an <strong>invite link</strong> below. Remove accounts here or open their workspace.
</p>
<div id="admin-users-feedback" role="status" aria-live="polite"></div>
<p style="margin: 1rem 0 0.35rem 0; font-size: 0.9rem; color: #94a3b8">Accounts</p>
<div id="users-list" class="users-list"></div>
<div id="user-edit-form" class="user-edit-form hidden">
<strong id="user-edit-heading">Edit account</strong>
<p class="note" style="margin: 0.35rem 0 0.5rem">Change login name or admin role; set a password only when you mean to reset it.</p>
<input type="hidden" id="edit-user-id" autocomplete="off" />
<label for="edit-user-username">
Username
<input type="text" id="edit-user-username" name="username" autocomplete="username" minlength="3" maxlength="64" />
</label>
<label for="edit-user-password">
New password
<input type="password" id="edit-user-password" name="password" autocomplete="new-password" minlength="8" maxlength="128" placeholder="Leave blank to keep current" />
</label>
<label class="super-line">
<input type="checkbox" id="edit-user-super" />
Superuser (can manage accounts and invites)
</label>
<div class="edit-actions">
<button type="button" class="btn btn-primary" id="edit-user-save">Save changes</button>
<button type="button" class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
</div>
</div>
</section>
<section id="invite-panel" class="invite-panel hidden">
<strong>Add users via invite link</strong>
<p class="note" style="margin-top:0.4rem">
Each link lets <strong>one</strong> person create their own account at <code>/register?invite=…</code> with a password they choose. After it is used, create a new link for the next person.
</p>
<div class="invite-row">
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
<button type="button" class="btn btn-primary" id="invite-create-btn">Email invite</button>
<button type="button" class="btn btn-ghost" id="invite-link-btn">Invite link only</button>
</div>
<div id="invite-result" class="invite-result"></div>
<button type="button" class="btn btn-ghost hidden" id="invite-copy-btn" aria-label="Copy invite link">Copy invite link</button>
</section>
<div class="nav">
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
<a class="btn btn-ghost" href="/tutorial" id="open-tutorial">Open Tutorial</a>
<p class="tagline">Run MicroPython in your browser. Drive simulated NeoPixels, pins, ADC, and serial.</p>
<div id="signed-in-row" class="signed-in hidden"></div>
<div class="actions">
<a class="btn btn-primary hidden" id="btn-signin" href="/login">Sign in</a>
<a class="btn btn-primary hidden" id="btn-open-editor" href="/editor">Open editor</a>
<a class="btn btn-ghost" id="btn-use-locally" href="/editor?local=1">Use locally (no login)</a>
<a class="btn-link hidden" id="btn-manage-users" href="/users">Manage users</a>
<button type="button" class="btn-link hidden" id="btn-signout">Sign out</button>
</div>
<p class="footnote">
Local mode keeps files in this browser's <code>IndexedDB</code>; from the editor you can also pick a folder on disk
(<a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API" target="_blank" rel="noopener">File System Access API</a>, Chromium-only).
</p>
</main>
<script>
const storageKey = 'python-editor.api_key';
const input = document.getElementById('api-key');
const openLink = document.getElementById('open-editor');
let currentAdminUserId = null;
async function init() {
const signedInRow = document.getElementById('signed-in-row');
const btnSignIn = document.getElementById('btn-signin');
const btnOpenEditor = document.getElementById('btn-open-editor');
const btnSignOut = document.getElementById('btn-signout');
const btnManageUsers = document.getElementById('btn-manage-users');
function formatApiDetail(body) {
if (!body || body.detail === undefined || body.detail === null) return '';
const d = body.detail;
if (typeof d === 'string') return d;
if (Array.isArray(d))
return d
.map((item) =>
typeof item === 'object' && item && item.msg ? String(item.msg) : JSON.stringify(item)
)
.join(' ');
return String(d);
}
let authEnabled = false;
try {
const st = await fetch('/api/auth/status');
if (st.ok) {
const status = await st.json();
authEnabled = Boolean(status.auth_enabled);
}
} catch (_e) {
// No backend (static-only host) → behave like auth disabled.
}
function setAdminUsersFeedback(kind, msg) {
const el = document.getElementById('admin-users-feedback');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('ok', 'err');
if (kind === 'ok') el.classList.add('ok');
if (kind === 'err') el.classList.add('err');
}
async function refreshAuthNav() {
const st = await fetch('/api/auth/status');
const status = await st.json();
const loginEl = document.getElementById('link-login');
const regEl = document.getElementById('link-register');
const outEl = document.getElementById('btn-logout');
const greet = document.getElementById('auth-greeting');
const optionalKey = document.getElementById('optional-api-key');
const invitePanel = document.getElementById('invite-panel');
const usersPanel = document.getElementById('users-panel');
if (!status.auth_enabled) {
currentAdminUserId = null;
loginEl.classList.add('hidden');
regEl.classList.add('hidden');
outEl.classList.add('hidden');
greet.classList.add('hidden');
if (invitePanel) invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
if (!authEnabled) {
// No login wall, so "Sign in" makes no sense — go straight to editor.
btnOpenEditor.classList.remove('hidden');
return;
}
loginEl.classList.remove('hidden');
if (status.register_open) {
regEl.classList.remove('hidden');
}
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (me.ok) {
const j = await me.json();
greet.textContent = `Signed in as ${j.user.username}`;
greet.classList.remove('hidden');
loginEl.classList.add('hidden');
regEl.classList.add('hidden');
outEl.classList.remove('hidden');
if (optionalKey) optionalKey.classList.add('hidden');
if (invitePanel) {
if (j.user && j.user.is_superuser) {
currentAdminUserId = j.user.id;
invitePanel.classList.remove('hidden');
if (usersPanel) usersPanel.classList.remove('hidden');
await refreshUsersList(j.user.id);
} else {
currentAdminUserId = null;
invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
}
}
} else {
currentAdminUserId = null;
outEl.classList.add('hidden');
greet.classList.add('hidden');
if (invitePanel) invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
}
}
async function refreshUsersList(viewerId) {
const usersList = document.getElementById('users-list');
if (!usersList) return;
usersList.textContent = 'Loading users...';
let me = null;
try {
const res = await fetch('/api/users', { credentials: 'include' });
const users = await res.json().catch(() => []);
if (!res.ok) {
usersList.textContent = 'Unable to load users.';
return;
}
usersList.innerHTML = '';
for (const user of users) {
const row = document.createElement('div');
row.className = 'user-row';
const name = document.createElement('span');
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
const actions = document.createElement('div');
actions.className = 'user-actions';
const link = document.createElement('a');
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
link.textContent = 'Open workspace';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-ghost';
editBtn.textContent = 'Edit';
editBtn.title = `Edit ${user.username}`;
editBtn.addEventListener('click', () => openUserEdit(user.id, user.username, user.is_superuser));
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-ghost btn-danger';
delBtn.textContent = 'Remove';
const isSelf = Number(user.id) === Number(viewerId);
delBtn.disabled = isSelf;
delBtn.title = isSelf ? 'You cannot delete your own account here' : 'Permanently remove this user';
delBtn.addEventListener('click', async () => {
const ok = confirm(
`Remove user “${user.username}”? Their workspace folder stays on disk until you delete it manually.`
);
if (!ok) return;
delBtn.disabled = true;
setAdminUsersFeedback('', '');
try {
const dres = await fetch(`/api/users/${encodeURIComponent(String(user.id))}`, {
method: 'DELETE',
credentials: 'include'
});
const body = await dres.json().catch(() => ({}));
if (!dres.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || dres.statusText || 'Could not remove user');
delBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Removed ${user.username}.`);
await refreshUsersList(viewerId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
delBtn.disabled = false;
}
});
actions.appendChild(editBtn);
actions.appendChild(link);
actions.appendChild(delBtn);
row.appendChild(name);
row.appendChild(actions);
usersList.appendChild(row);
}
} catch (_err) {
usersList.textContent = 'Unable to load users.';
const meRes = await fetch('/api/auth/me', { credentials: 'include' });
if (meRes.ok) me = (await meRes.json()).user;
} catch (_e) {
// ignore
}
}
function closeUserEdit() {
const sheet = document.getElementById('user-edit-form');
if (sheet) sheet.classList.add('hidden');
}
function openUserEdit(userId, username, isSuperuser) {
document.getElementById('edit-user-id').value = String(userId);
document.getElementById('edit-user-username').value = username;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-super').checked = Boolean(isSuperuser);
document.getElementById('user-edit-heading').textContent = `Edit @${username}`;
document.getElementById('user-edit-form').classList.remove('hidden');
}
document.getElementById('edit-user-cancel').addEventListener('click', () => closeUserEdit());
document.getElementById('edit-user-save').addEventListener('click', async () => {
const id = document.getElementById('edit-user-id').value;
const u = document.getElementById('edit-user-username').value.trim();
const pw = document.getElementById('edit-user-password').value;
const superU = document.getElementById('edit-user-super').checked;
const saveBtn = document.getElementById('edit-user-save');
setAdminUsersFeedback('', '');
if (!u || u.length < 3) {
setAdminUsersFeedback('err', 'Username must be at least 3 characters.');
if (!me) {
btnSignIn.classList.remove('hidden');
return;
}
const payload = { username: u, is_superuser: superU };
const tpw = pw.trim();
if (tpw.length > 0) {
if (tpw.length < 8) {
setAdminUsersFeedback('err', 'New password must be at least 8 characters (or leave blank).');
return;
}
payload.password = tpw;
}
saveBtn.disabled = true;
try {
const res = await fetch(`/api/users/${encodeURIComponent(String(id))}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || res.statusText || 'Update failed');
saveBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Updated @${body.username}.`);
closeUserEdit();
await refreshUsersList(currentAdminUserId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
}
saveBtn.disabled = false;
});
document.getElementById('btn-logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
signedInRow.textContent = `Signed in as ${me.username}${me.is_superuser ? ' (admin)' : ''}`;
signedInRow.classList.remove('hidden');
btnOpenEditor.classList.remove('hidden');
btnSignOut.classList.remove('hidden');
if (me.is_superuser) btnManageUsers.classList.remove('hidden');
}
document.getElementById('btn-signout').addEventListener('click', async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch (_e) {
// ignore
}
window.location.reload();
});
try {
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get('api_key');
if (fromQuery) {
sessionStorage.setItem(storageKey, fromQuery);
}
const existing = sessionStorage.getItem(storageKey);
if (existing) {
input.value = existing;
}
} catch (_e) {}
openLink.addEventListener('click', () => {
try {
const v = input.value.trim();
if (v) {
sessionStorage.setItem(storageKey, v);
} else {
sessionStorage.removeItem(storageKey);
}
} catch (_e) {}
});
function showInviteOutcome(inviteUrl, headline) {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
const line = document.createElement('p');
line.style.margin = '0 0 0.35rem 0';
line.textContent = headline;
const a = document.createElement('a');
a.href = inviteUrl;
a.textContent = inviteUrl;
a.style.color = '#93c5fd';
a.style.wordBreak = 'break-all';
result.appendChild(line);
result.appendChild(a);
copyBtn.classList.remove('hidden');
copyBtn.dataset.inviteUrl = inviteUrl;
}
function clearInviteOutcome() {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
copyBtn.classList.add('hidden');
copyBtn.textContent = 'Copy invite link';
delete copyBtn.dataset.inviteUrl;
}
document.getElementById('invite-copy-btn').addEventListener('click', async () => {
const copyBtn = document.getElementById('invite-copy-btn');
const url = copyBtn.dataset.inviteUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = 'Copied!';
setTimeout(() => {
copyBtn.textContent = 'Copy invite link';
}, 2000);
} catch (_e) {
copyBtn.textContent = 'Copy failed — select the link above';
setTimeout(() => {
copyBtn.textContent = 'Copy invite link';
}, 2500);
}
});
document.getElementById('invite-create-btn').addEventListener('click', async () => {
const emailInput = document.getElementById('invite-email');
const result = document.getElementById('invite-result');
const email = (emailInput.value || '').trim();
clearInviteOutcome();
if (!email) {
result.textContent = 'Enter an email address first.';
return;
}
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, expires_days: 7 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite';
return;
}
const headline = body.delivered
? 'Email sent. They can also use this link to register:'
: 'Email not sent (SMTP not configured). Share this registration link:';
showInviteOutcome(body.invite_url, headline);
} catch (err) {
result.textContent = String(err.message || err);
}
});
document.getElementById('invite-link-btn').addEventListener('click', async () => {
const result = document.getElementById('invite-result');
clearInviteOutcome();
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: null, expires_days: 7 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite link';
return;
}
showInviteOutcome(body.invite_url, 'Share this link so they can create an account:');
} catch (err) {
result.textContent = String(err.message || err);
}
});
refreshAuthNav();
init();
</script>
</body>
</html>

View File

@@ -6,7 +6,7 @@
<meta name="theme-color" content="#2d3748">
<title>LED Editor</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=13">
<link rel="stylesheet" href="/static/styles.css?v=32">
</head>
<body>
<div class="container">
@@ -28,27 +28,28 @@
<div class="main-content">
<div class="editor-header">
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Toggle file tree" aria-controls="sidebar" aria-expanded="false"></button>
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Toggle file tree" aria-controls="sidebar" aria-expanded="true" title="Toggle file browser"><span class="sidebar-toggle-icon" aria-hidden="true"></span></button>
<div class="file-info">
<span id="save-status" class="save-status"></span>
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
<span id="lsp-status" class="runtime-hint" title="Jedi in-browser diagnostics">LSP: n/a</span>
<span id="workspace-badge" class="runtime-hint hidden"></span>
</div>
<div class="mode-toggle">
<a id="home-btn" class="mode-btn active" href="/">Home</a>
</div>
<div class="editor-actions">
<button id="run-btn" disabled>Run Python</button>
<button id="stop-btn" disabled>Stop</button>
<label for="run-main-checkbox" class="run-main-toggle">
<input type="checkbox" id="run-main-checkbox" />
Run `main.py`
</label>
<label for="panel-16x16-checkbox" class="run-main-toggle">
<input type="checkbox" id="panel-16x16-checkbox" />
16x16 panel
</label>
<button id="run-btn" class="icon-btn" disabled aria-label="Run" title="Run"></button>
<button id="stop-btn" class="icon-btn" disabled aria-label="Stop" title="Stop"></button>
<details class="header-menu" id="header-menu">
<summary class="menu-toggle" aria-label="Editor options" title="Options"></summary>
<div class="menu-content" role="menu">
<a href="/" class="menu-item" id="home-btn" role="menuitem">🏠 Home</a>
<label class="menu-item menu-checkbox">
<input type="checkbox" id="run-main-checkbox" />
Run main.py
</label>
<label class="menu-item menu-checkbox">
<input type="checkbox" id="panel-16x16-checkbox" />
16×16 panel
</label>
</div>
</details>
</div>
</div>
@@ -60,20 +61,47 @@
</div>
<section id="led-sim-panel" class="led-sim-panel hidden" aria-label="NeoPixel Simulator">
<div class="led-sim-header">
<h3>NeoPixel Simulator</h3>
<div class="led-sim-actions">
<button id="led-run-btn" type="button">Run</button>
<button id="led-stop-btn" type="button">Stop</button>
<button id="led-close-btn" type="button" aria-label="Close simulator">Close</button>
</div>
</div>
<div id="led-meta" class="led-meta">Waiting for neopixel.write()...</div>
<div id="led-grid" class="led-grid"></div>
</section>
<div class="console-container">
<div class="console-header">Console Output</div>
<section id="pin-panel" class="pin-panel hidden" aria-label="Pins">
<div class="pin-panel-header">
<span class="pin-panel-title">Pins</span>
<span class="pin-panel-hint">OUT shows live state · IN is clickable · PWM shows duty</span>
</div>
<div id="pin-rows" class="pin-rows"></div>
</section>
<section id="adc-panel" class="adc-panel hidden" aria-label="ADC inputs">
<div class="adc-panel-header">
<span class="adc-panel-title">ADC inputs</span>
<span class="adc-panel-hint">drag to set value (065535)</span>
</div>
<div id="adc-sliders" class="adc-sliders"></div>
</section>
<section id="serial-panel" class="serial-panel hidden" aria-label="Serial monitor">
<div class="serial-header">
<span class="serial-title">Serial monitor</span>
<span id="serial-meta" class="serial-meta"></span>
<button type="button" id="serial-clear" class="serial-clear" title="Clear">Clear</button>
</div>
<div id="serial-output" class="serial-output" aria-live="polite"></div>
<form id="serial-form" class="serial-form" autocomplete="off">
<input id="serial-input" type="text" class="serial-input" placeholder="Type and press Enter to send" />
<label class="serial-newline" title="Append \n on send">
<input type="checkbox" id="serial-newline-checkbox" checked />
<span>\n</span>
</label>
<button type="submit" class="serial-send">Send</button>
</form>
</section>
<div class="console-container" id="console-container">
<button type="button" class="console-header" id="console-toggle" aria-expanded="true" aria-controls="console-output">
<span class="chevron" aria-hidden="true"></span>
<span>Console Output</span>
</button>
<pre id="console-output" class="console-output"></pre>
</div>
</div>
@@ -90,6 +118,6 @@
</div>
</div>
<script type="module" src="/static/script.js?v=27"></script>
<script type="module" src="/static/script.js?v=56"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,109 @@ await micropip.install("jedi")
self.onmessage = async (event) => {
const { id, type, payload } = event.data || {};
try {
/* Fast-path: fire-and-forget messages used by the SAB-less fallback
for pin input + ADC slider values. We update the worker-local
Int32Array so Python's next read sees the new value, with no
Pyodide round-trip and no reply expected. */
if (type === 'pinIn') {
const view = self.__pin_in_view;
const pin = payload && Number(payload.pin);
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
try {
view[pin] = (payload.value | 0) ? 1 : 0;
} catch (_e) {
// ignore
}
}
return;
}
if (type === 'adcSet') {
const view = self.__adc_view;
const pin = payload && Number(payload.pin);
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
const value = Math.max(0, Math.min(65535, payload.value | 0));
try {
view[pin] = value;
} catch (_e) {
// ignore
}
}
return;
}
if (type === 'init') {
/* The main thread shares an Int32Array-backed SAB so Python ADC.read*()
can pick up live slider values without yielding. */
const adcSab = payload && payload.adcSab;
if (adcSab && typeof adcSab !== 'string') {
try {
self.__adc_view = new Int32Array(adcSab);
} catch (_e) {
self.__adc_view = null;
}
}
/* No SAB? Fall back to a worker-local Int32Array; the main thread
then delivers slider drags via `postMessage({type:'adcSet'})`. */
if (!self.__adc_view) {
try {
self.__adc_view = new Int32Array(64);
} catch (_e) {
self.__adc_view = null;
}
}
/* Pin output state: Int32Array[64] — Python writes packed
[ui_mode << 24 | value (low 24 bits)] entries, the editor UI reads
them every frame to drive the Pins panel indicators. */
const pinOutSab = payload && payload.pinOutSab;
if (pinOutSab && typeof pinOutSab !== 'string') {
try {
self.__pin_out_view = new Int32Array(pinOutSab);
} catch (_e) {
self.__pin_out_view = null;
}
}
if (!self.__pin_out_view) {
try {
self.__pin_out_view = new Int32Array(64);
} catch (_e) {
self.__pin_out_view = null;
}
}
/* Pin input state: Int32Array[64] — UI buttons write 0/1 per pin,
Python's `Pin.value()` for IN mode reads the matching slot. */
const pinInSab = payload && payload.pinInSab;
if (pinInSab && typeof pinInSab !== 'string') {
try {
self.__pin_in_view = new Int32Array(pinInSab);
} catch (_e) {
self.__pin_in_view = null;
}
}
if (!self.__pin_in_view) {
try {
self.__pin_in_view = new Int32Array(64);
} catch (_e) {
self.__pin_in_view = null;
}
}
/* Tell Python whether SAB was actually wired up so machine.py can
emit `[pin-out]` print markers as a fallback for the live UI. */
self.__sab_isolated = Boolean(pinOutSab && typeof pinOutSab !== 'string');
/* Serial-in ring buffer: first 8 bytes are [readIdx, writeIdx] (Int32),
followed by `capacity` bytes of payload data. */
const serialSab = payload && payload.serialSab;
if (serialSab && typeof serialSab !== 'string') {
try {
self.__serial_in_indices = new Int32Array(serialSab, 0, 2);
const capacity = serialSab.byteLength - 8;
self.__serial_in_capacity = capacity;
self.__serial_in_data = new Uint8Array(serialSab, 8, capacity);
} catch (_e) {
self.__serial_in_indices = null;
self.__serial_in_data = null;
self.__serial_in_capacity = 0;
}
}
await ensurePyodide();
self.postMessage({ id, type: 'init', ok: true });
return;

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,8 @@ body {
button,
.tab,
.file-item,
.mode-btn {
.menu-item,
.menu-toggle {
touch-action: manipulation;
}
@@ -28,7 +29,7 @@ button,
}
.sidebar-toggle {
display: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
@@ -37,7 +38,8 @@ button,
background: white;
color: #2d3748;
border-radius: 8px;
font-size: 1.2rem;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
@@ -46,10 +48,99 @@ button,
background: #f7fafc;
}
.sidebar-toggle .sidebar-toggle-icon {
display: inline-block;
transition: transform 0.15s ease;
}
/* Flip the chevron when the file browser is hidden. */
.sidebar-toggle[aria-expanded="false"] .sidebar-toggle-icon {
transform: rotate(180deg);
}
.sidebar-backdrop {
display: none;
}
/* Editor-header dropdown menu (Home + run options). */
.header-menu {
position: relative;
}
.menu-toggle {
list-style: none;
cursor: pointer;
padding: 0.45rem 0.7rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
color: #4a5568;
font-size: 1.1rem;
line-height: 1;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 36px;
}
.menu-toggle::-webkit-details-marker {
display: none;
}
.menu-toggle::marker {
display: none;
content: '';
}
.menu-toggle:hover {
background: #f7fafc;
border-color: #cbd5e0;
}
.header-menu[open] > .menu-toggle {
background: #edf2f7;
border-color: #cbd5e0;
}
.menu-content {
position: absolute;
right: 0;
top: calc(100% + 6px);
background: white;
border: 1px solid #cbd5e0;
border-radius: 8px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
z-index: 60;
min-width: 220px;
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 0.7rem;
border-radius: 6px;
text-decoration: none;
color: #2d3748;
font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
}
.menu-item:hover {
background: #f7fafc;
}
.menu-checkbox input[type="checkbox"] {
margin: 0;
}
/* Sidebar */
.sidebar {
width: 300px;
@@ -108,6 +199,8 @@ button,
align-items: center;
gap: 0.5rem;
transition: background-color 0.2s;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.file-item:hover {
@@ -116,6 +209,7 @@ button,
.file-item.selected {
background-color: #3182ce;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.22);
}
.file-item.drag-target {
@@ -131,11 +225,6 @@ button,
font-weight: 600;
}
.file-item.root-drop {
border: 1px dashed #4a5568;
margin-bottom: 0.5rem;
}
.file-icon {
font-size: 1rem;
}
@@ -179,6 +268,49 @@ button,
border-radius: 6px;
padding: 0.2rem 0.45rem;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.workspace-badge-label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 220px;
}
.workspace-badge-exit,
.workspace-badge-action {
appearance: none;
border: 1px solid #cbd5e1;
background: transparent;
color: #475569;
border-radius: 4px;
padding: 0.05rem 0.4rem;
font-size: 0.7rem;
cursor: pointer;
line-height: 1.4;
}
.workspace-badge-exit:hover,
.workspace-badge-action:hover {
background: #e2e8f0;
}
.workspace-badge-action {
border-color: #93c5fd;
color: #1d4ed8;
}
.workspace-badge-action:hover {
background: #dbeafe;
}
.workspace-badge-note {
font-size: 0.7rem;
color: #94a3b8;
font-style: italic;
}
#current-file {
@@ -199,31 +331,6 @@ button,
color: #e53e3e;
}
.mode-toggle {
display: inline-flex;
border: 1px solid #cbd5e0;
border-radius: 8px;
overflow: hidden;
}
.mode-btn {
border: none;
background: #edf2f7;
color: #4a5568;
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
}
.mode-btn + .mode-btn {
border-left: 1px solid #cbd5e0;
}
.mode-btn.active {
background: #3182ce;
color: white;
}
.editor-actions {
display: flex;
gap: 0.5rem;
@@ -240,6 +347,21 @@ button,
transition: all 0.2s;
}
.icon-btn {
font-size: 1rem;
line-height: 1;
padding: 0.5rem 0.75rem;
min-width: 40px;
}
#run-btn.icon-btn {
color: #2f855a;
}
#stop-btn.icon-btn {
color: #c53030;
}
.editor-actions button:hover:not(:disabled) {
background-color: #f7fafc;
border-color: #cbd5e0;
@@ -254,23 +376,6 @@ button,
background-color: #edf2f7;
}
.run-main-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
color: #374151;
background: white;
white-space: nowrap;
}
.run-main-toggle input[type="checkbox"] {
margin: 0;
}
.editor-container {
flex: 1;
position: relative;
@@ -375,7 +480,7 @@ button,
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
padding: 1rem;
padding: 0;
resize: none;
}
@@ -386,6 +491,18 @@ button,
line-height: 1.5;
}
/* Pin line-number gutter hard to the left edge of the editor pane. */
.cm-editor .cm-gutters {
border-right: 1px solid #e2e8f0;
background: #f7fafc;
padding-left: 0;
}
.cm-editor .cm-content {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.cm-focused {
outline: none;
}
@@ -396,6 +513,15 @@ button,
display: flex;
flex-direction: column;
background: #0f172a;
flex-shrink: 0;
}
.console-container.is-collapsed {
height: auto;
}
.console-container.is-collapsed .console-output {
display: none;
}
.led-sim-panel {
@@ -481,11 +607,415 @@ button,
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45);
}
.pin-panel {
border-top: 1px solid #e2e8f0;
background: #0b1220;
color: #e5e7eb;
padding: 0.55rem 0.75rem 0.7rem;
}
.pin-panel.hidden {
display: none;
}
.pin-panel-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.45rem;
}
.pin-panel-title {
font-size: 0.85rem;
font-weight: 600;
color: #f3f4f6;
}
.pin-panel-hint {
font-size: 0.7rem;
color: #94a3b8;
}
.pin-rows {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 26vh;
overflow-y: auto;
}
.pin-row {
display: grid;
grid-template-columns: 70px 24px 56px 1fr 90px;
align-items: center;
gap: 0.55rem;
font-size: 0.8rem;
}
.pin-row-label {
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
.pin-led {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1e293b;
border: 1px solid #334155;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.6);
justify-self: center;
}
.pin-led.on {
background: #fde047;
border-color: #facc15;
box-shadow: 0 0 10px rgba(253, 224, 71, 0.7), inset 0 0 4px rgba(0, 0, 0, 0.25);
}
.pin-toggle {
appearance: none;
border: 1px solid #334155;
background: #1e293b;
color: #e5e7eb;
border-radius: 6px;
padding: 0.2rem 0;
font-family: 'SFMono-Regular', Menlo, monospace;
font-size: 0.85rem;
cursor: pointer;
text-align: center;
}
.pin-toggle.on {
background: #16a34a;
border-color: #15803d;
color: #f0fdf4;
}
.pin-pwm-bar {
position: relative;
height: 10px;
background: #1e293b;
border-radius: 999px;
border: 1px solid #334155;
overflow: hidden;
}
.pin-pwm-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #38bdf8, #818cf8);
transition: width 80ms linear;
}
.pin-row-detail {
text-align: right;
font-size: 0.72rem;
color: #94a3b8;
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.pin-row {
grid-template-columns: 60px 22px 50px 1fr 70px;
gap: 0.4rem;
font-size: 0.75rem;
}
.pin-row-detail {
font-size: 0.68rem;
}
}
.adc-panel {
border-top: 1px solid #e2e8f0;
background: #0f172a;
color: #e5e7eb;
padding: 0.6rem 0.75rem 0.75rem;
}
.adc-panel.hidden {
display: none;
}
.adc-panel-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.45rem;
}
.adc-panel-title {
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.02em;
color: #f3f4f6;
}
.adc-panel-hint {
font-size: 0.72rem;
color: #9ca3af;
}
.adc-sliders {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 32vh;
overflow-y: auto;
}
.adc-row {
display: grid;
grid-template-columns: 90px 1fr 130px;
align-items: center;
gap: 0.6rem;
}
.adc-row-label {
font-size: 0.8rem;
color: #cbd5e1;
}
.adc-slider {
width: 100%;
appearance: none;
-webkit-appearance: none;
height: 6px;
background: #1e293b;
border-radius: 999px;
outline: none;
cursor: pointer;
}
.adc-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #38bdf8;
border: 2px solid #0f172a;
cursor: grab;
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.3);
}
.adc-slider::-webkit-slider-thumb:active {
cursor: grabbing;
}
.adc-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #38bdf8;
border: 2px solid #0f172a;
cursor: grab;
}
.adc-readout {
font-family: 'SFMono-Regular', Menlo, monospace;
font-size: 0.78rem;
color: #94a3b8;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.adc-row {
grid-template-columns: 1fr;
gap: 0.25rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
}
.adc-row-label {
font-size: 0.78rem;
}
.adc-readout {
text-align: left;
}
}
.serial-panel {
border-top: 1px solid #e2e8f0;
background: #0b1220;
color: #e5e7eb;
padding: 0.55rem 0.75rem 0.7rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.serial-panel.hidden {
display: none;
}
.serial-header {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.serial-title {
font-size: 0.85rem;
font-weight: 600;
color: #f3f4f6;
}
.serial-meta {
font-size: 0.72rem;
color: #94a3b8;
flex: 1 1 auto;
}
.serial-clear {
appearance: none;
border: 1px solid #334155;
background: #1e293b;
color: #e5e7eb;
padding: 0.15rem 0.55rem;
border-radius: 6px;
font-size: 0.72rem;
cursor: pointer;
}
.serial-clear:hover {
background: #243244;
}
.serial-output {
background: #020617;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 0.55rem 0.7rem;
font-family: 'SFMono-Regular', Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.4;
height: 120px;
max-height: 28vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.serial-rx {
color: #e2e8f0;
}
.serial-tx {
color: #34d399;
}
.serial-form {
display: flex;
gap: 0.5rem;
align-items: center;
}
.serial-input {
flex: 1 1 auto;
min-width: 0;
background: #0f172a;
border: 1px solid #334155;
color: #f8fafc;
padding: 0.4rem 0.6rem;
border-radius: 6px;
font-family: 'SFMono-Regular', Menlo, Consolas, monospace;
font-size: 0.85rem;
}
.serial-input:focus {
outline: none;
border-color: #38bdf8;
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.25);
}
.serial-newline {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #cbd5e1;
user-select: none;
}
.serial-send {
appearance: none;
border: 1px solid #1d4ed8;
background: #2563eb;
color: #fff;
font-weight: 600;
padding: 0.4rem 0.85rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
}
.serial-send:hover {
background: #1d4ed8;
}
@media (max-width: 600px) {
.serial-output {
height: 110px;
font-size: 0.78rem;
}
.serial-form {
flex-wrap: wrap;
}
.serial-input {
flex: 1 1 100%;
}
.serial-newline {
flex: 0 0 auto;
}
.serial-send {
flex: 0 0 auto;
margin-left: auto;
}
}
.console-header {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
color: #cbd5e1;
border-bottom: 1px solid #1e293b;
background: transparent;
border-left: 0;
border-right: 0;
border-top: 0;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
text-align: left;
cursor: pointer;
font-family: inherit;
}
.console-header:hover {
background: rgba(255, 255, 255, 0.04);
}
.console-header .chevron {
display: inline-block;
transition: transform 0.15s ease;
color: #94a3b8;
}
.console-container.is-collapsed .console-header .chevron {
transform: rotate(-90deg);
}
.console-container.is-collapsed .console-header {
border-bottom: 0;
}
.console-output {
@@ -574,6 +1104,13 @@ button,
border-color: #2c5aa0;
}
/* Desktop: hide the file browser when collapsed via the hamburger toggle. */
@media (min-width: 769px) {
.sidebar.is-collapsed {
display: none;
}
}
/* Responsive design */
@media (max-width: 768px) {
.container {
@@ -638,51 +1175,53 @@ button,
}
.editor-header {
padding: 0.55rem 0.65rem;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0.5rem 0.6rem;
flex-wrap: nowrap;
gap: 0.4rem;
align-items: center;
padding-top: max(0.55rem, env(safe-area-inset-top));
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.file-info {
flex: 1 1 auto;
min-width: 0;
gap: 0.5rem;
flex-wrap: wrap;
gap: 0.4rem;
flex-wrap: nowrap;
overflow: hidden;
}
.file-info .runtime-hint {
.save-status {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#workspace-badge {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
}
.mode-toggle {
order: 3;
}
.mode-btn {
min-height: 36px;
padding: 0.5rem 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 8rem;
}
.editor-actions {
width: 100%;
order: 4;
flex-wrap: wrap;
gap: 0.4rem;
flex: 0 0 auto;
gap: 0.35rem;
}
.editor-actions button {
flex: 1 1 110px;
.editor-actions .icon-btn {
flex: 0 0 auto;
font-size: 1.05rem;
padding: 0.45rem 0.7rem;
min-width: 40px;
min-height: 40px;
font-size: 0.95rem;
}
.run-main-toggle {
flex: 1 1 calc(50% - 0.4rem);
.menu-toggle {
min-height: 40px;
font-size: 0.85rem;
flex: 0 0 auto;
padding: 0.45rem 0.55rem;
}
.tabs {
@@ -756,6 +1295,57 @@ button,
}
}
/* ADC / Pins / Serial: touch-friendly on phones + safe-area padding */
@media (max-width: 768px) {
.adc-panel,
.pin-panel,
.serial-panel {
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
padding-bottom: max(0.65rem, env(safe-area-inset-bottom));
-webkit-tap-highlight-color: transparent;
}
.adc-slider {
min-height: 2.75rem;
padding: 0.75rem 0;
box-sizing: border-box;
}
.adc-slider::-webkit-slider-thumb {
width: 28px;
height: 28px;
}
.adc-slider::-moz-range-thumb {
width: 28px;
height: 28px;
}
.pin-toggle {
min-height: 44px;
min-width: 44px;
padding: 0.35rem 0.45rem;
}
.pin-led {
width: 18px;
height: 18px;
}
.serial-send,
.serial-clear {
min-height: 44px;
padding: 0.4rem 0.85rem;
font-size: 0.9rem;
}
.serial-input {
min-height: 44px;
font-size: 16px;
}
}
/* Hide drawer affordances entirely on desktop, even with [hidden] flipped off. */
@media (min-width: 769px) {
.sidebar-backdrop {

484
src/static/users.html Normal file
View File

@@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage users — LED Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
}
.home-card {
width: min(640px, 92vw);
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
}
h1 { margin: 0 0 0.5rem 0; font-size: 1.6rem; color: #f8fafc; }
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
.btn {
display: inline-block;
text-decoration: none;
border-radius: 8px;
padding: 0.55rem 0.9rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.95rem;
}
.btn-primary { background: #3b82f6; color: #ffffff; }
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
.btn-danger { background: transparent; border-color: #f87171; color: #fecaca; }
.btn-danger:hover:not(:disabled) { background: rgba(248, 113, 113, 0.15); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
.note { font-size: 0.85rem; color: #94a3b8; }
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.hidden { display: none !important; }
.invite-panel,
.users-panel {
margin: 1rem 0;
padding: 0.9rem;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
background: rgba(30, 41, 59, 0.45);
}
.invite-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.invite-row input[type="email"] {
flex: 1 1 260px;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
}
.invite-result {
margin-top: 0.55rem;
font-size: 0.85rem;
color: #cbd5e1;
word-break: break-all;
}
.users-list { margin-top: 0.5rem; display: grid; gap: 0.45rem; }
.user-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.user-row a {
color: #93c5fd;
text-decoration: none;
border: 1px solid rgba(147, 197, 253, 0.35);
border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.user-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
justify-content: flex-end;
}
#admin-users-feedback:not(:empty) {
padding: 0.5rem 0.65rem;
border-radius: 8px;
font-size: 0.85rem;
margin: 0.5rem 0;
}
#admin-users-feedback.ok {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(74, 222, 128, 0.35);
color: #bbf7d0;
}
#admin-users-feedback.err {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.user-edit-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.user-edit-form label { display: block; margin-bottom: 0.65rem; }
.user-edit-form input[type='text'],
.user-edit-form input[type='password'] {
width: 100%;
max-width: 320px;
padding: 0.45rem 0.55rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
margin-top: 0.25rem;
}
.user-edit-form .edit-actions {
display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.65rem;
}
.user-edit-form .super-line {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.85rem; color: #cbd5e1; margin-bottom: 0.65rem;
}
.user-edit-form .super-line input { accent-color: #3b82f6; }
</style>
</head>
<body>
<main class="home-card">
<h1>Manage users</h1>
<p class="note">Superuser-only. Add accounts via invite link, edit roles, or remove users.</p>
<div class="nav">
<a class="btn btn-ghost" href="/">← Home</a>
<a class="btn btn-ghost" href="/editor">Open editor</a>
<span id="auth-greeting"></span>
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
</div>
<div id="not-allowed" class="hidden">
<p style="color:#fca5a5">You need to be signed in as a superuser to manage users.
<a href="/login?next=/users" style="color:#93c5fd">Sign in</a></p>
</div>
<section id="users-panel" class="users-panel hidden">
<strong>Accounts</strong>
<div id="admin-users-feedback" role="status" aria-live="polite"></div>
<div id="users-list" class="users-list"></div>
<div id="user-edit-form" class="user-edit-form hidden">
<strong id="user-edit-heading">Edit account</strong>
<p class="note" style="margin: 0.35rem 0 0.5rem">Change login name or admin role; set a password only when you mean to reset it.</p>
<input type="hidden" id="edit-user-id" autocomplete="off" />
<label for="edit-user-username">Username
<input type="text" id="edit-user-username" name="username" autocomplete="username" minlength="3" maxlength="64" />
</label>
<label for="edit-user-password">New password
<input type="password" id="edit-user-password" name="password" autocomplete="new-password" minlength="8" maxlength="128" placeholder="Leave blank to keep current" />
</label>
<label class="super-line">
<input type="checkbox" id="edit-user-super" />
Superuser (can manage accounts and invites)
</label>
<div class="edit-actions">
<button type="button" class="btn btn-primary" id="edit-user-save">Save changes</button>
<button type="button" class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
</div>
</div>
</section>
<section id="invite-panel" class="invite-panel hidden">
<strong>Add users via invite link</strong>
<p class="note" style="margin-top:0.4rem">
Each link lets <strong>one</strong> person create their own account at <code>/register?invite=…</code> with a password they choose. After it is used, create a new link for the next person.
</p>
<div class="invite-row">
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
<button type="button" class="btn btn-primary" id="invite-create-btn">Email invite</button>
<button type="button" class="btn btn-ghost" id="invite-link-btn">Invite link only</button>
</div>
<div id="invite-result" class="invite-result"></div>
<button type="button" class="btn btn-ghost hidden" id="invite-copy-btn" aria-label="Copy invite link">Copy invite link</button>
</section>
</main>
<script>
let currentAdminUserId = null;
function formatApiDetail(body) {
if (!body || body.detail === undefined || body.detail === null) return '';
const d = body.detail;
if (typeof d === 'string') return d;
if (Array.isArray(d))
return d.map((item) => (typeof item === 'object' && item && item.msg ? String(item.msg) : JSON.stringify(item))).join(' ');
return String(d);
}
function setAdminUsersFeedback(kind, msg) {
const el = document.getElementById('admin-users-feedback');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('ok', 'err');
if (kind === 'ok') el.classList.add('ok');
if (kind === 'err') el.classList.add('err');
}
async function refreshUsersList(viewerId) {
const usersList = document.getElementById('users-list');
if (!usersList) return;
usersList.textContent = 'Loading users...';
try {
const res = await fetch('/api/users', { credentials: 'include' });
const users = await res.json().catch(() => []);
if (!res.ok) {
usersList.textContent = 'Unable to load users.';
return;
}
usersList.innerHTML = '';
for (const user of users) {
const row = document.createElement('div');
row.className = 'user-row';
const name = document.createElement('span');
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
const actions = document.createElement('div');
actions.className = 'user-actions';
const link = document.createElement('a');
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
link.textContent = 'Open workspace';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-ghost';
editBtn.textContent = 'Edit';
editBtn.title = `Edit ${user.username}`;
editBtn.addEventListener('click', () => openUserEdit(user.id, user.username, user.is_superuser));
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-ghost btn-danger';
delBtn.textContent = 'Remove';
const isSelf = Number(user.id) === Number(viewerId);
delBtn.disabled = isSelf;
delBtn.title = isSelf ? 'You cannot delete your own account here' : 'Permanently remove this user';
delBtn.addEventListener('click', async () => {
const ok = confirm(`Remove user “${user.username}”? Their workspace folder stays on disk until you delete it manually.`);
if (!ok) return;
delBtn.disabled = true;
setAdminUsersFeedback('', '');
try {
const dres = await fetch(`/api/users/${encodeURIComponent(String(user.id))}`, {
method: 'DELETE', credentials: 'include',
});
const body = await dres.json().catch(() => ({}));
if (!dres.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || dres.statusText || 'Could not remove user');
delBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Removed ${user.username}.`);
await refreshUsersList(viewerId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
delBtn.disabled = false;
}
});
actions.appendChild(editBtn);
actions.appendChild(link);
actions.appendChild(delBtn);
row.appendChild(name);
row.appendChild(actions);
usersList.appendChild(row);
}
} catch (_err) {
usersList.textContent = 'Unable to load users.';
}
}
function closeUserEdit() {
const sheet = document.getElementById('user-edit-form');
if (sheet) sheet.classList.add('hidden');
}
function openUserEdit(userId, username, isSuperuser) {
document.getElementById('edit-user-id').value = String(userId);
document.getElementById('edit-user-username').value = username;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-super').checked = Boolean(isSuperuser);
document.getElementById('user-edit-heading').textContent = `Edit @${username}`;
document.getElementById('user-edit-form').classList.remove('hidden');
}
document.getElementById('edit-user-cancel').addEventListener('click', () => closeUserEdit());
document.getElementById('edit-user-save').addEventListener('click', async () => {
const id = document.getElementById('edit-user-id').value;
const u = document.getElementById('edit-user-username').value.trim();
const pw = document.getElementById('edit-user-password').value;
const superU = document.getElementById('edit-user-super').checked;
const saveBtn = document.getElementById('edit-user-save');
setAdminUsersFeedback('', '');
if (!u || u.length < 3) {
setAdminUsersFeedback('err', 'Username must be at least 3 characters.');
return;
}
const payload = { username: u, is_superuser: superU };
const tpw = pw.trim();
if (tpw.length > 0) {
if (tpw.length < 8) {
setAdminUsersFeedback('err', 'New password must be at least 8 characters (or leave blank).');
return;
}
payload.password = tpw;
}
saveBtn.disabled = true;
try {
const res = await fetch(`/api/users/${encodeURIComponent(String(id))}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || res.statusText || 'Update failed');
saveBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Updated @${body.username}.`);
closeUserEdit();
await refreshUsersList(currentAdminUserId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
}
saveBtn.disabled = false;
});
document.getElementById('btn-logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/';
});
function showInviteOutcome(inviteUrl, headline) {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
const line = document.createElement('p');
line.style.margin = '0 0 0.35rem 0';
line.textContent = headline;
const a = document.createElement('a');
a.href = inviteUrl;
a.textContent = inviteUrl;
a.style.color = '#93c5fd';
a.style.wordBreak = 'break-all';
result.appendChild(line);
result.appendChild(a);
copyBtn.classList.remove('hidden');
copyBtn.dataset.inviteUrl = inviteUrl;
}
function clearInviteOutcome() {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
copyBtn.classList.add('hidden');
copyBtn.textContent = 'Copy invite link';
delete copyBtn.dataset.inviteUrl;
}
document.getElementById('invite-copy-btn').addEventListener('click', async () => {
const copyBtn = document.getElementById('invite-copy-btn');
const url = copyBtn.dataset.inviteUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2000);
} catch (_e) {
copyBtn.textContent = 'Copy failed — select the link above';
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2500);
}
});
document.getElementById('invite-create-btn').addEventListener('click', async () => {
const emailInput = document.getElementById('invite-email');
const result = document.getElementById('invite-result');
const email = (emailInput.value || '').trim();
clearInviteOutcome();
if (!email) {
result.textContent = 'Enter an email address first.';
return;
}
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, expires_days: 7 }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite';
return;
}
const headline = body.delivered
? 'Email sent. They can also use this link to register:'
: 'Email not sent (SMTP not configured). Share this registration link:';
showInviteOutcome(body.invite_url, headline);
} catch (err) {
result.textContent = String(err.message || err);
}
});
document.getElementById('invite-link-btn').addEventListener('click', async () => {
const result = document.getElementById('invite-result');
clearInviteOutcome();
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: null, expires_days: 7 }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite link';
return;
}
showInviteOutcome(body.invite_url, 'Share this link so they can create an account:');
} catch (err) {
result.textContent = String(err.message || err);
}
});
async function init() {
const greet = document.getElementById('auth-greeting');
const logout = document.getElementById('btn-logout');
const usersPanel = document.getElementById('users-panel');
const invitePanel = document.getElementById('invite-panel');
const blocked = document.getElementById('not-allowed');
const st = await fetch('/api/auth/status').catch(() => null);
if (!st || !st.ok) {
blocked.classList.remove('hidden');
return;
}
const status = await st.json();
if (!status.auth_enabled) {
// Auth disabled — anyone can poke /api/users; show panels regardless.
usersPanel.classList.remove('hidden');
invitePanel.classList.remove('hidden');
await refreshUsersList(null);
return;
}
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (!me.ok) {
blocked.classList.remove('hidden');
return;
}
const j = await me.json();
if (!j.user || !j.user.is_superuser) {
blocked.classList.remove('hidden');
greet.textContent = `Signed in as ${j.user.username}`;
logout.classList.remove('hidden');
return;
}
currentAdminUserId = j.user.id;
greet.textContent = `Signed in as ${j.user.username} (admin)`;
logout.classList.remove('hidden');
usersPanel.classList.remove('hidden');
invitePanel.classList.remove('hidden');
await refreshUsersList(j.user.id);
}
init();
</script>
</body>
</html>

204
src/static/zip-utils.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Tiny zero-dependency ZIP encoder. STORE-only (no compression) — fine for
* Python source which is small, gzip would only save a few KB. Kept inline
* here so local-mode export works on every browser, including the ones
* (Firefox/Safari/Brave-without-flag) that have no folder picker.
*
* Format reference: APPNOTE.TXT 6.3.x
*/
let _crcTable = null;
function crcTable() {
if (_crcTable) return _crcTable;
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[i] = c;
}
_crcTable = t;
return t;
}
function crc32(bytes) {
const tbl = crcTable();
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = tbl[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function dosTime(d) {
return ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff;
}
function dosDate(d) {
return (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff;
}
/**
* Build an uncompressed ZIP from a list of `{ name, content }` entries.
* `content` may be a string or a Uint8Array.
*
* Returns a Blob (`application/zip`).
*/
export function createZip(entries) {
const enc = new TextEncoder();
const date = new Date();
const time = dosTime(date);
const ddate = dosDate(date);
const parts = [];
const central = [];
let offset = 0;
for (const entry of entries) {
const data = typeof entry.content === 'string' ? enc.encode(entry.content) : entry.content;
const nameBytes = enc.encode(entry.name);
const crc = crc32(data);
const local = new ArrayBuffer(30 + nameBytes.length);
const lv = new DataView(local);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(4, 20, true);
lv.setUint16(6, 0x0800, true); // bit 11 = UTF-8 filename
lv.setUint16(8, 0, true);
lv.setUint16(10, time, true);
lv.setUint16(12, ddate, true);
lv.setUint32(14, crc, true);
lv.setUint32(18, data.length, true);
lv.setUint32(22, data.length, true);
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true);
new Uint8Array(local, 30).set(nameBytes);
parts.push(local);
parts.push(data);
const cd = new ArrayBuffer(46 + nameBytes.length);
const cv = new DataView(cd);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(4, 0x031e, true); // made by: unix + zip 3.0
cv.setUint16(6, 20, true);
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true);
cv.setUint16(12, time, true);
cv.setUint16(14, ddate, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, data.length, true);
cv.setUint32(24, data.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint16(30, 0, true);
cv.setUint16(32, 0, true);
cv.setUint16(34, 0, true);
cv.setUint16(36, 0, true);
cv.setUint32(38, 0, true);
cv.setUint32(42, offset, true);
new Uint8Array(cd, 46).set(nameBytes);
central.push(cd);
offset += local.byteLength + data.length;
}
const cdOffset = offset;
let cdSize = 0;
for (const cd of central) {
parts.push(cd);
cdSize += cd.byteLength;
}
const eocd = new ArrayBuffer(22);
const ev = new DataView(eocd);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(4, 0, true);
ev.setUint16(6, 0, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, cdSize, true);
ev.setUint32(16, cdOffset, true);
ev.setUint16(20, 0, true);
parts.push(eocd);
return new Blob(parts, { type: 'application/zip' });
}
/**
* Parse an uncompressed-or-DEFLATEd ZIP buffer into an array of
* `{ name, content }` entries (text). Skips directory entries (those that
* end with '/'). Throws if the buffer is not a valid ZIP archive.
*
* Compression methods supported:
* - 0 (STORE)
* - 8 (DEFLATE) — decompressed via the browser's `DecompressionStream`
* ('deflate-raw'), available in Chrome/Edge/Brave 113+, Firefox 113+,
* Safari 16.4+. If the browser is too old we surface a clear error.
*/
export async function readZip(arrayBuffer) {
const buf = arrayBuffer instanceof ArrayBuffer ? arrayBuffer : arrayBuffer.buffer;
const view = new DataView(buf);
if (buf.byteLength < 22) throw new Error('Not a ZIP file (too small)');
// EOCD signature scan from the end (max 64 KiB comment).
let eocd = -1;
const minScan = Math.max(0, buf.byteLength - 22 - 65535);
for (let i = buf.byteLength - 22; i >= minScan; i--) {
if (view.getUint32(i, true) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd === -1) throw new Error('Not a ZIP file (no end-of-central-directory)');
const totalEntries = view.getUint16(eocd + 10, true);
const cdOffset = view.getUint32(eocd + 16, true);
const dec = new TextDecoder();
const out = [];
let p = cdOffset;
for (let i = 0; i < totalEntries; i++) {
if (view.getUint32(p, true) !== 0x02014b50) {
throw new Error('Bad central-directory entry');
}
const method = view.getUint16(p + 10, true);
const compSize = view.getUint32(p + 20, true);
const uncompSize = view.getUint32(p + 24, true);
const nameLen = view.getUint16(p + 28, true);
const extraLen = view.getUint16(p + 30, true);
const commentLen = view.getUint16(p + 32, true);
const localOff = view.getUint32(p + 42, true);
const name = dec.decode(new Uint8Array(buf, p + 46, nameLen));
p += 46 + nameLen + extraLen + commentLen;
// Directory entry — skip; createFolder happens implicitly when we
// write a child file under that path.
if (name.endsWith('/')) continue;
const lview = new DataView(buf, localOff, 30);
if (lview.getUint32(0, true) !== 0x04034b50) {
throw new Error(`Bad local header for "${name}"`);
}
const lNameLen = lview.getUint16(26, true);
const lExtraLen = lview.getUint16(28, true);
const dataOff = localOff + 30 + lNameLen + lExtraLen;
let bytes;
if (method === 0) {
bytes = new Uint8Array(buf, dataOff, uncompSize);
} else if (method === 8) {
if (typeof DecompressionStream === 'undefined') {
throw new Error(
`"${name}" is DEFLATE-compressed but this browser has no DecompressionStream`
);
}
const compressed = new Uint8Array(buf, dataOff, compSize);
const stream = new ReadableStream({
start(controller) {
controller.enqueue(compressed);
controller.close();
},
}).pipeThrough(new DecompressionStream('deflate-raw'));
bytes = new Uint8Array(await new Response(stream).arrayBuffer());
} else {
throw new Error(`Unsupported compression method ${method} on "${name}"`);
}
out.push({ name, content: dec.decode(bytes) });
}
return out;
}

View File

@@ -1,5 +1,4 @@
import importlib
import shutil
from pathlib import Path
import pytest
@@ -10,33 +9,24 @@ def test_root_serves_html(client):
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Python Editor" in response.text
assert "LED Editor" in response.text
def test_editor_route_serves_editor_html(client):
response = client.get("/editor")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Python Editor" in response.text
assert "LED Editor" in response.text
def test_workspace_lib_seeded_from_bundle_on_startup(client, tmp_path):
"""Populate WORKSPACE_ROOT/lib with shipped stubs when absent (empty volume / fresh tmp)."""
lib_dir = tmp_path / "lib"
assert (lib_dir / "machine.py").is_file()
assert (lib_dir / "neopixel.py").is_file()
assert len((lib_dir / "machine.py").read_text(encoding="utf-8")) > 0
def test_workspace_lib_repops_when_removed_after_startup(client, tmp_path):
"""Touching lib via the API restores bundled stubs after the workspace lib dir is wiped."""
lib_dir = tmp_path / "lib"
assert (lib_dir / "machine.py").is_file()
shutil.rmtree(lib_dir)
def test_lib_served_directly_from_repo_bundle(client, tmp_path):
"""`lib/` reads come straight from repo `lib/`; no workspace cache is created."""
resp = client.get("/api/file/lib/machine.py")
assert resp.status_code == 200
assert (lib_dir / "machine.py").is_file()
body = resp.json().get("content", "")
assert "class Pin" in body and len(body) > 0
assert not (tmp_path / "lib").exists()
def test_list_files_hides_dotfiles_and_reports_sizes(client, tmp_path):
(tmp_path / ".hidden.txt").write_text("secret", encoding="utf-8")
@@ -76,9 +66,6 @@ def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
lib_dir = tmp_path / "lib"
lib_dir.mkdir(exist_ok=True)
(lib_dir / "helper.py").write_text("x = 1\n", encoding="utf-8")
code_dir = tmp_path / "code"
code_dir.mkdir()
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
@@ -86,7 +73,7 @@ def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
assert save_blocked.status_code == 403
delete_blocked = client.delete("/api/file/lib/helper.py")
delete_blocked = client.delete("/api/file/lib/machine.py")
assert delete_blocked.status_code == 403
move_blocked = client.post(
@@ -252,14 +239,14 @@ def test_folder_delete_errors(client, tmp_path):
def test_workspace_py_sources_returns_python_files(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
(tmp_path / "lib").mkdir(exist_ok=True)
(tmp_path / "lib" / "util.py").write_text("def f():\n pass\n", encoding="utf-8")
response = client.get("/api/workspace/py-sources")
assert response.status_code == 200
files = response.json()["files"]
assert files["code/app.py"] == "x = 1\n"
assert "lib/util.py" in files
# Bundled stubs from repo `lib/` are merged in automatically.
assert "lib/machine.py" in files
assert "lib/neopixel.py" in files
def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
@@ -284,7 +271,9 @@ def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
assert ok.status_code == 200
def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
def test_create_app_startup_does_not_seed_workspace_lib(tmp_path, monkeypatch):
"""Bundle stubs are read directly from repo `lib/`, so startup must not create
a `WORKSPACE_ROOT/lib` cache."""
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
@@ -302,4 +291,4 @@ def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
assert not (tmp_path / "lib").exists()
with TestClient(main.app) as _client:
_client.get("/api/auth/status")
assert (tmp_path / "lib").is_dir()
assert not (tmp_path / "lib").exists()

View File

@@ -18,7 +18,7 @@ def _reload_app(tmp_path, monkeypatch, **env):
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
for k, v in env.items():
monkeypatch.setenv(k, v)
config.WORKSPACE_ROOT = tmp_path
monkeypatch.setattr(config, "WORKSPACE_ROOT", tmp_path)
db_sess.reset_engine()
importlib.reload(main)
return main.app
@@ -304,9 +304,23 @@ def test_users_have_isolated_workspaces(tmp_path, monkeypatch):
def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
"""`lib/` lives at the repo root (single source of truth), not under WORKSPACE_ROOT,
so we patch `config.PROJECT_ROOT` to give the test its own isolated lib bundle."""
import editor_app.config as config
shared_lib = tmp_path / "lib"
shared_lib.mkdir(parents=True, exist_ok=True)
(shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8")
# Mirror canonical demo files so `_seed_canonical_demos_into_code` still works.
real_demos = config.PROJECT_ROOT / "workspace" / "code"
fake_demos = tmp_path / "workspace" / "code"
fake_demos.mkdir(parents=True, exist_ok=True)
for fname in ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py"):
src = real_demos / fname
if src.is_file():
(fake_demos / fname).write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
monkeypatch.setattr(config, "PROJECT_ROOT", tmp_path)
with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")

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