Compare commits
8 Commits
76129469a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 551c3d1efc | |||
| 8c45097ec5 | |||
| d38f819c49 | |||
| 98fa4260d4 | |||
| e3400120d3 | |||
| d355174f5a | |||
| 7ee15f8eac | |||
| b8d62e01d9 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -176,6 +176,9 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Editor / workspace (generated locally)
|
||||
workspace/users/
|
||||
# Editor runtime state — `workspace/` holds user files (auth-mode per-user
|
||||
# folders, no-auth dev's `code/`); the canonical demo source lives under
|
||||
# `src/static/bundled-demos/demo/` seeds into `workspace/demos/` (and per-user `users/.../demos/`)
|
||||
# on startup. Nothing under `workspace/` should ever be committed.
|
||||
/workspace/
|
||||
src/static/.reload-token
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -14,14 +14,8 @@ 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
|
||||
# Mirror canonical demo files into the static bundle so the editor's
|
||||
# "Reset demos" button works from a static-only host too.
|
||||
RUN mkdir -p src/static/bundled-demos && \
|
||||
for f in pattern_rainbow_demo.py pattern_twinkle_demo.py pattern_chase_demo.py \
|
||||
adc_slider_demo.py pin_demo.py serial_demo.py; do \
|
||||
cp -f "workspace/code/$f" "src/static/bundled-demos/$f"; \
|
||||
done
|
||||
# `workspace/` is runtime/user state (gitignored) and is created on demand
|
||||
# at app startup — the image does not need to ship it.
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ This tutorial is for the browser editor's ESP32-style mocks:
|
||||
- `machine.Pin`
|
||||
- `neopixel.NeoPixel`
|
||||
|
||||
Use `workspace/code/led_tutorial.py` while reading this guide.
|
||||
Open `demos/led_tutorial.py` in the editor while reading this guide. (Source of truth: `src/static/bundled-demos/demo/led_tutorial.py` — the top-level `demos/` folder is seeded from there on first run.)
|
||||
|
||||
## 1) Basic setup
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -110,7 +110,7 @@ The browser runtime ships MicroPython-style stubs in repo `lib/` (they appear as
|
||||
- `utime` — `ticks_ms`, `ticks_diff`, `ticks_add`, `sleep_ms`, `sleep_us`, `sleep`
|
||||
- `micropython.const` — no-op helper for ported constant declarations
|
||||
|
||||
Use them from scripts in `workspace/code` like typical ESP32 / MicroPython examples:
|
||||
Use them from the top-level `demos/` folder (sibling of `code/` and `lib/`; first run seeds from `src/static/bundled-demos/demo/`) like typical ESP32 / MicroPython examples:
|
||||
|
||||
```python
|
||||
from machine import Pin
|
||||
@@ -132,18 +132,20 @@ Simulator modes:
|
||||
- rows zig-zag left/right.
|
||||
- The 16x16 popup closes automatically on **Stop** or when script execution finishes.
|
||||
|
||||
Tutorial files:
|
||||
Tutorial files (canonical source — committed under `src/static/bundled-demos/demo/`; copies appear under top-level `demos/` on first run):
|
||||
|
||||
- `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial
|
||||
- `workspace/code/led_tutorial.py` - runnable guided LED example
|
||||
- `workspace/code/led_patterns.py` - shared pattern helpers (used by automated tests); each `pattern_*_demo.py` duplicates what it needs and uses only Python stdlib + `machine` / `neopixel` / `time`
|
||||
- `workspace/code/pattern_rainbow_demo.py` - rainbow animation (self-contained)
|
||||
- `workspace/code/pattern_chase_demo.py` - Knight Rider–style bouncing scanner (self-contained)
|
||||
- `workspace/code/pattern_twinkle_demo.py` - twinkle animation (self-contained)
|
||||
- `workspace/code/panel16_utils.py` - helpers for 16x16 serpentine mapping
|
||||
- `workspace/code/panel16_rainbow_wave.py` - 16x16 rainbow wave
|
||||
- `workspace/code/panel16_bounce.py` - 16x16 bouncing pixel with trail
|
||||
- `workspace/code/panel16_matrix_rain.py` - 16x16 matrix rain effect
|
||||
- `led_tutorial.py` - runnable guided LED example
|
||||
- `led_patterns.py` - shared pattern helpers (used by automated tests); each `pattern_*_demo.py` duplicates what it needs and uses only Python stdlib + `machine` / `neopixel` / `time`
|
||||
- `pattern_rainbow_demo.py` - rainbow animation (self-contained)
|
||||
- `pattern_chase_demo.py` - Knight Rider–style bouncing scanner (self-contained)
|
||||
- `pattern_twinkle_demo.py` - twinkle animation (self-contained)
|
||||
- `panel16_utils.py` - helpers for 16x16 serpentine mapping
|
||||
- `panel16_rainbow_wave.py` - 16x16 rainbow wave
|
||||
- `panel16_bounce.py` - 16x16 bouncing pixel with trail
|
||||
- `panel16_matrix_rain.py` - 16x16 matrix rain effect
|
||||
|
||||
> `workspace/` is gitignored runtime state. To edit the **shipped** demo source, edit `src/static/bundled-demos/demo/<file>.py` and re-run "Reset demos" in the editor (or restart the dev server with an empty `workspace/demos/`).
|
||||
|
||||
## Dev auto-reload hook
|
||||
|
||||
|
||||
38
lib/browser_fetch.py
Normal file
38
lib/browser_fetch.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Browser-friendly async HTTP for the Pyodide worker.
|
||||
|
||||
These helpers use ``pyodide.http.pyfetch``, which maps to the browser
|
||||
``fetch`` API (no Python TLS stack). Prefer them for ``https://`` in
|
||||
Pyodide: ``aiohttp`` in the worker often raises
|
||||
``RuntimeError('SSL is not supported.')`` for HTTPS even though the wheel
|
||||
exists, because user-level SSL is not wired the same as on CPython.
|
||||
|
||||
The browser's normal rules apply: the page is served over HTTPS so
|
||||
``http://`` URLs are blocked as mixed content, and the response host must
|
||||
send permissive CORS headers (e.g. ``Access-Control-Allow-Origin``) or the
|
||||
browser hides the body even if the request succeeded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def fetch_text(url: str) -> str:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.text()
|
||||
|
||||
|
||||
async def fetch_bytes(url: str) -> bytes:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.bytes()
|
||||
|
||||
|
||||
async def fetch_json(url: str, **kwargs: Any) -> Any:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.json(**kwargs)
|
||||
@@ -28,3 +28,11 @@ load_env_file(PROJECT_ROOT / ".env")
|
||||
|
||||
_default_workspace = PROJECT_ROOT / "workspace"
|
||||
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(_default_workspace))).resolve()
|
||||
|
||||
# Canonical demo bundle root (`manifest.json` lives here). Sample `.py`
|
||||
# sources live under `demo/` (same idea as `bundled-lib/` for shared modules).
|
||||
# They ship with the static bundle (`/static/bundled-demos/...`) so a
|
||||
# static-only host also exposes them. `workspace/` is intentionally NOT used
|
||||
# for canonical data — it is treated as runtime/user state and is gitignored.
|
||||
BUNDLED_DEMOS_DIR = STATIC_DIR / "bundled-demos"
|
||||
BUNDLED_DEMOS_CODE_DIR = BUNDLED_DEMOS_DIR / "demo"
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from editor_app.config import STATIC_DIR
|
||||
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
|
||||
from editor_app.db.models import Base
|
||||
from editor_app.db.session import get_engine
|
||||
from editor_app.deps import require_api_access
|
||||
@@ -14,11 +14,19 @@ from editor_app.routers.auth_routes import router as auth_router
|
||||
from editor_app.routers.files import router as files_router
|
||||
from editor_app.routers.frontend import router as frontend_router
|
||||
from editor_app.routers.users_admin import router as users_admin_router
|
||||
from editor_app.services import accounts
|
||||
from editor_app.services import accounts, user_workspace
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
# `workspace/` is gitignored runtime state. On a fresh clone it doesn't
|
||||
# exist, and in no-auth dev mode the file tree would otherwise be empty
|
||||
# — seed every bundled demo under top-level `demos/` (next to `code/`)
|
||||
# so `pipenv run dev` after `git clone` Just Works without needing user
|
||||
# accounts. Existing files are left alone (user edits are preserved).
|
||||
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
user_workspace.seed_all_bundled_demos(WORKSPACE_ROOT)
|
||||
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
|
||||
@@ -7,7 +7,7 @@ from editor_app import config
|
||||
from editor_app.models import FileInfo
|
||||
|
||||
LIB_DIR_NAME = "lib"
|
||||
WRITABLE_ROOTS = {"code"}
|
||||
WRITABLE_ROOTS = {"code", "demos"}
|
||||
|
||||
|
||||
def _workspace_root(workspace_root: Path | None = None) -> Path:
|
||||
@@ -33,6 +33,9 @@ def normalize_relative_path(relative_path: str) -> str:
|
||||
if len(parts) >= 2 and parts[0] == "code":
|
||||
while len(parts) >= 2 and parts[0] == parts[1] == "code":
|
||||
parts.pop(1)
|
||||
if len(parts) >= 2 and parts[0] == "demos":
|
||||
while len(parts) >= 2 and parts[0] == parts[1] == "demos":
|
||||
parts.pop(1)
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
@@ -84,7 +87,7 @@ def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None)
|
||||
if not _is_writable_path(target_path, workspace_root):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only code/ is writable (lib is read-only)",
|
||||
detail="Only code/ and demos/ are writable (lib is read-only)",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ from pathlib import Path
|
||||
|
||||
from editor_app import config
|
||||
|
||||
_BUNDLED_DEMO_PY_DIR = config.BUNDLED_DEMOS_CODE_DIR
|
||||
# Top-level workspace folder for shipped samples (sibling of ``code/``, like ``lib/``).
|
||||
_EDITOR_DEMOS_ROOT = "demos"
|
||||
|
||||
DEFAULT_MAIN_PY = 'print("Hello, World!")\n'
|
||||
|
||||
# Self-contained demos copied from shipped `workspace/code/` (stdlib + machine/neopixel/time only).
|
||||
# New accounts get a copy of each one in their own `code/` folder so the
|
||||
# editor has something to show on first login. They're treated as
|
||||
# starting points — users can edit/delete freely without affecting the
|
||||
# shipped originals.
|
||||
# Self-contained demos copied from static ``bundled-demos/demo/`` (stdlib + machine/neopixel/time only).
|
||||
# New accounts get a copy under ``<user_root>/demos/`` (same level as ``code/``).
|
||||
_CANONICAL_DEMO_FILENAMES = (
|
||||
"pattern_rainbow_demo.py",
|
||||
"pattern_twinkle_demo.py",
|
||||
@@ -20,6 +21,8 @@ _CANONICAL_DEMO_FILENAMES = (
|
||||
"adc_slider_demo.py",
|
||||
"pin_demo.py",
|
||||
"serial_demo.py",
|
||||
"async_fetch_demo.py",
|
||||
"aiohttp_fetch_demo.py",
|
||||
)
|
||||
|
||||
|
||||
@@ -33,11 +36,22 @@ def user_workspace_root(user_id: int, username: str, workspace_root: Path | None
|
||||
return root / "users" / safe_workspace_leaf(username, user_id)
|
||||
|
||||
|
||||
def _seed_canonical_demos_into_code(code_dir: Path) -> None:
|
||||
src_root = config.PROJECT_ROOT.resolve() / "workspace" / "code"
|
||||
def _seed_canonical_demos(user_root: Path) -> None:
|
||||
"""Copy bundled demos into ``demos/`` if missing.
|
||||
|
||||
Reads from `BUNDLED_DEMOS_CODE_DIR` (``src/static/bundled-demos/demo/``).
|
||||
Skips if the file already exists under ``demos/``, legacy ``code/<name>.py``,
|
||||
or old ``code/demos/<name>.py``.
|
||||
"""
|
||||
src_root = _BUNDLED_DEMO_PY_DIR.resolve()
|
||||
demos_dir = user_root / _EDITOR_DEMOS_ROOT
|
||||
code_dir = user_root / "code"
|
||||
demos_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename in _CANONICAL_DEMO_FILENAMES:
|
||||
dst = code_dir / filename
|
||||
if dst.exists():
|
||||
dst = demos_dir / filename
|
||||
legacy_flat = code_dir / filename
|
||||
legacy_nested = code_dir / "demos" / filename
|
||||
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
|
||||
continue
|
||||
src = src_root / filename
|
||||
if src.is_file():
|
||||
@@ -45,13 +59,50 @@ def _seed_canonical_demos_into_code(code_dir: Path) -> None:
|
||||
|
||||
|
||||
def ensure_default_code_main(user_root: Path) -> None:
|
||||
"""Ensure code/ has main.py and self-contained NeoPixel demos (copied from repo workspace/code/)."""
|
||||
"""Ensure ``code/main.py`` and canonical demos under top-level ``demos/``.
|
||||
|
||||
Demos are sourced from `BUNDLED_DEMOS_CODE_DIR` (the single committed home
|
||||
for sample scripts). Only files listed in `_CANONICAL_DEMO_FILENAMES`
|
||||
get auto-seeded — the rest are available via the editor's "Reset
|
||||
demos" button or a manual copy."""
|
||||
code_dir = user_root / "code"
|
||||
code_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_py = code_dir / "main.py"
|
||||
if not main_py.exists():
|
||||
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
|
||||
_seed_canonical_demos_into_code(code_dir)
|
||||
_seed_canonical_demos(user_root)
|
||||
|
||||
|
||||
def seed_all_bundled_demos(user_root: Path) -> None:
|
||||
"""Copy *every* ``.py`` file in `BUNDLED_DEMOS_CODE_DIR` into ``<user_root>/demos/``.
|
||||
|
||||
Used at app startup to populate a fresh workspace with the full sample
|
||||
set so a no-auth dev install (`pipenv run dev` after `git clone`) has
|
||||
something to play with. Existing files are not overwritten — user edits
|
||||
are preserved.
|
||||
"""
|
||||
code_dir = user_root / "code"
|
||||
code_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_py = code_dir / "main.py"
|
||||
if not main_py.exists():
|
||||
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
|
||||
demos_dir = user_root / _EDITOR_DEMOS_ROOT
|
||||
demos_dir.mkdir(parents=True, exist_ok=True)
|
||||
src_root = _BUNDLED_DEMO_PY_DIR.resolve()
|
||||
if not src_root.is_dir():
|
||||
return
|
||||
for src in sorted(src_root.iterdir()):
|
||||
if not src.is_file() or not src.name.endswith(".py"):
|
||||
continue
|
||||
dst = demos_dir / src.name
|
||||
legacy_flat = code_dir / src.name
|
||||
legacy_nested = code_dir / "demos" / src.name
|
||||
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
|
||||
continue
|
||||
try:
|
||||
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
|
||||
|
||||
def rename_user_workspace_leaf(
|
||||
|
||||
32
src/static/bundled-demos/demo/aiohttp_fetch_demo.py
Normal file
32
src/static/bundled-demos/demo/aiohttp_fetch_demo.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Async HTTPS GET of Pyodide’s lock file (browser-safe).
|
||||
|
||||
Older Pyodide + ``aiohttp`` examples used ``ClientSession`` here, but the
|
||||
Pyodide ``aiohttp`` wheel often raises ``RuntimeError('SSL is not supported.')``
|
||||
for ``https://`` — there is no real Python TLS stack in the worker; traffic must
|
||||
go through the browser’s ``fetch``.
|
||||
|
||||
This demo uses the shared ``browser_fetch`` helpers (``pyodide.http.pyfetch``
|
||||
under the hood), same idea as ``async_fetch_demo.py``.
|
||||
|
||||
CORS still applies — jsDelivr allows cross-origin GETs. ``print(..., flush=True)``
|
||||
helps batched worker stdout appear before the run finishes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from browser_fetch import fetch_json
|
||||
|
||||
|
||||
async def main():
|
||||
url = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide-lock.json"
|
||||
print("Fetching", url, "via browser_fetch.fetch_json ...", flush=True)
|
||||
data = await fetch_json(url)
|
||||
info = data.get("info") or {}
|
||||
packages = data.get("packages") or {}
|
||||
print("pyodide-lock version:", info.get("version"), flush=True)
|
||||
print("python runtime :", info.get("python"), flush=True)
|
||||
print("indexed packages :", len(packages), flush=True)
|
||||
print("aiohttp wheel in lock:", "aiohttp" in packages, flush=True)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
27
src/static/bundled-demos/demo/async_fetch_demo.py
Normal file
27
src/static/bundled-demos/demo/async_fetch_demo.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Async HTTP in the browser (Pyodide).
|
||||
|
||||
``aiohttp`` and similar clients use OS sockets, which Wasm does not provide, so
|
||||
a ``session.get(...)`` can hang after ``print("Running")`` with no body.
|
||||
|
||||
Use ``pyodide.http.pyfetch`` or the shared ``browser_fetch`` helpers. From an
|
||||
HTTPS editor page, use ``https://`` URLs (mixed content blocks ``http://``).
|
||||
|
||||
Many sites do not send CORS headers, so the browser blocks the response even
|
||||
when the URL is valid. This demo uses jsDelivr JSON that allows cross-origin
|
||||
GET (same host Pyodide loads from).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from browser_fetch import fetch_json
|
||||
|
||||
|
||||
async def main():
|
||||
print("Running")
|
||||
url = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/repodata.json"
|
||||
data = await fetch_json(url)
|
||||
info = data.get("info") or {}
|
||||
print("Fetched repodata; pyodide lock says:", info.get("version"), info.get("python"))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
56
src/static/bundled-demos/demo/pin_demo.py
Normal file
56
src/static/bundled-demos/demo/pin_demo.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Pin features demo.
|
||||
|
||||
A "Pins" panel appears below the editor while this script runs:
|
||||
|
||||
* Pin 2 (OUT) — blinks automatically every ~200 ms via ``.value(...)``.
|
||||
* Pin 4 (OUT) — stays put until you press the button, then flips
|
||||
(driven from the IRQ handler with ``.toggle()``).
|
||||
* Pin 0 (IN) — click the toggle button in the panel to drive a 0 → 1
|
||||
rising edge; the IRQ fires and flips pin 4.
|
||||
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty.
|
||||
"""
|
||||
|
||||
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):
|
||||
# Pin 4 is IRQ-driven on purpose — its only source of change is the
|
||||
# button press, so when you see it flip you know the IRQ fired.
|
||||
led_b.toggle()
|
||||
|
||||
|
||||
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
|
||||
|
||||
|
||||
tick = 0
|
||||
duty = 0
|
||||
direction = 1024
|
||||
|
||||
while True:
|
||||
# Pin 2: fast on/off via direct .value(...) writes (no IRQ involvement).
|
||||
led_a.value(tick % 2)
|
||||
|
||||
# Pin 13: triangular duty sweep so the PWM bar visibly fills and drains.
|
||||
duty += direction
|
||||
if duty >= 65535:
|
||||
duty = 65535
|
||||
direction = -1024
|
||||
elif duty <= 0:
|
||||
duty = 0
|
||||
direction = 1024
|
||||
fader.duty_u16(duty)
|
||||
|
||||
# Poll the IN pin so its IRQ actually fires when the panel button changes.
|
||||
# (`.value()` reads the current state and dispatches any pending edge.)
|
||||
button.value()
|
||||
|
||||
tick += 1
|
||||
time.sleep(0.1)
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"files": [
|
||||
"pattern_rainbow_demo.py",
|
||||
"pattern_twinkle_demo.py",
|
||||
"pattern_chase_demo.py",
|
||||
"adc_slider_demo.py",
|
||||
"pin_demo.py",
|
||||
"serial_demo.py"
|
||||
"demo/pattern_rainbow_demo.py",
|
||||
"demo/pattern_twinkle_demo.py",
|
||||
"demo/pattern_chase_demo.py",
|
||||
"demo/adc_slider_demo.py",
|
||||
"demo/pin_demo.py",
|
||||
"demo/serial_demo.py",
|
||||
"demo/aiohttp_fetch_demo.py"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""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)
|
||||
38
src/static/bundled-lib/browser_fetch.py
Normal file
38
src/static/bundled-lib/browser_fetch.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Browser-friendly async HTTP for the Pyodide worker.
|
||||
|
||||
These helpers use ``pyodide.http.pyfetch``, which maps to the browser
|
||||
``fetch`` API (no Python TLS stack). Prefer them for ``https://`` in
|
||||
Pyodide: ``aiohttp`` in the worker often raises
|
||||
``RuntimeError('SSL is not supported.')`` for HTTPS even though the wheel
|
||||
exists, because user-level SSL is not wired the same as on CPython.
|
||||
|
||||
The browser's normal rules apply: the page is served over HTTPS so
|
||||
``http://`` URLs are blocked as mixed content, and the response host must
|
||||
send permissive CORS headers (e.g. ``Access-Control-Allow-Origin``) or the
|
||||
browser hides the body even if the request succeeded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def fetch_text(url: str) -> str:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.text()
|
||||
|
||||
|
||||
async def fetch_bytes(url: str) -> bytes:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.bytes()
|
||||
|
||||
|
||||
async def fetch_json(url: str, **kwargs: Any) -> Any:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.json(**kwargs)
|
||||
@@ -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=32">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=41">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -26,6 +26,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="file-tree-context-menu" class="file-tree-context-menu" role="menu" hidden aria-hidden="true"></div>
|
||||
|
||||
<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="true" title="Toggle file browser"><span class="sidebar-toggle-icon" aria-hidden="true">‹</span></button>
|
||||
@@ -48,6 +50,12 @@
|
||||
<input type="checkbox" id="panel-16x16-checkbox" />
|
||||
16×16 panel
|
||||
</label>
|
||||
<button type="button" class="menu-item menu-button" id="packages-menu-btn" role="menuitem">📦 Python packages…</button>
|
||||
<button type="button" class="menu-item menu-button" id="menu-open-local-folder-btn" role="menuitem">📂 Open local folder…</button>
|
||||
<button type="button" class="menu-item menu-button" id="menu-open-local-file-btn" role="menuitem">📄 Open local file…</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<div class="menu-section-label" id="workspace-menu-label">Workspace</div>
|
||||
<div id="workspace-menu-actions" role="group"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -118,6 +126,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/script.js?v=57"></script>
|
||||
<div id="packages-modal" class="modal">
|
||||
<div class="modal-content packages-modal-content">
|
||||
<h3>Python packages (Pyodide)</h3>
|
||||
<p class="packages-modal-hint">Install from PyPI using micropip inside the browser. Pure-Python wheels (or packages built for WebAssembly) usually work; manylinux-only C extensions often do not.</p>
|
||||
<label class="packages-field-label" for="packages-install-input">Package names or PEP 508 specs</label>
|
||||
<input type="text" id="packages-install-input" placeholder="e.g. httpx, phonenumbers" autocomplete="off" />
|
||||
<div id="packages-pypi-info" class="packages-pypi-info" aria-live="polite"></div>
|
||||
<div class="packages-modal-actions-row">
|
||||
<button type="button" id="packages-lookup-btn" class="packages-secondary-btn">Look up on PyPI</button>
|
||||
</div>
|
||||
<div id="packages-modal-status" class="packages-modal-status" aria-live="polite"></div>
|
||||
<pre id="packages-install-log" class="packages-install-log hidden" aria-label="Micropip installer output"></pre>
|
||||
<p class="packages-saved-label">Saved in this browser (restored when the editor loads Pyodide):</p>
|
||||
<ul id="packages-saved-list" class="packages-saved-list"></ul>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="packages-install-btn">Install</button>
|
||||
<button type="button" id="packages-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/script.js?v=83"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -91,7 +91,7 @@ function isLibPath(path) {
|
||||
function isWritablePath(path) {
|
||||
if (!path) return false;
|
||||
const root = path.split('/')[0];
|
||||
return root === 'code';
|
||||
return root === 'code' || root === 'demos';
|
||||
}
|
||||
|
||||
function splitParts(path) {
|
||||
@@ -367,6 +367,7 @@ class FileSystemBackend {
|
||||
}
|
||||
|
||||
async ensureSeed() {
|
||||
await this._resolveDir(['demos'], true);
|
||||
const codeDir = await this._resolveDir(['code'], true);
|
||||
let mainExists = false;
|
||||
for await (const [name] of codeDir.entries()) {
|
||||
@@ -543,20 +544,22 @@ class FileSystemBackend {
|
||||
|
||||
async listAllPyFiles() {
|
||||
const out = {};
|
||||
let codeDir;
|
||||
try {
|
||||
codeDir = await this._resolveDir(['code']);
|
||||
} catch (_e) {
|
||||
return out;
|
||||
}
|
||||
const collected = [];
|
||||
await this._readAllRecursive(['code'], codeDir, collected);
|
||||
for (const { path, handle } of collected) {
|
||||
if (!path.toLowerCase().endsWith('.py')) continue;
|
||||
for (const rootSeg of ['code', 'demos']) {
|
||||
let dir;
|
||||
try {
|
||||
out[path] = await (await handle.getFile()).text();
|
||||
dir = await this._resolveDir([rootSeg]);
|
||||
} catch (_e) {
|
||||
// Skip unreadable files.
|
||||
continue;
|
||||
}
|
||||
const collected = [];
|
||||
await this._readAllRecursive([rootSeg], dir, collected);
|
||||
for (const { path, handle } of collected) {
|
||||
if (!path.toLowerCase().endsWith('.py')) continue;
|
||||
try {
|
||||
out[path] = await (await handle.getFile()).text();
|
||||
} catch (_e) {
|
||||
// Skip unreadable files.
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -564,19 +567,21 @@ class FileSystemBackend {
|
||||
|
||||
async listAllUserFiles() {
|
||||
const out = {};
|
||||
let codeDir;
|
||||
try {
|
||||
codeDir = await this._resolveDir(['code']);
|
||||
} catch (_e) {
|
||||
return out;
|
||||
}
|
||||
const collected = [];
|
||||
await this._readAllRecursive(['code'], codeDir, collected);
|
||||
for (const { path, handle } of collected) {
|
||||
for (const rootSeg of ['code', 'demos']) {
|
||||
let dir;
|
||||
try {
|
||||
out[path] = await (await handle.getFile()).text();
|
||||
dir = await this._resolveDir([rootSeg]);
|
||||
} catch (_e) {
|
||||
// Skip unreadable files (e.g. binaries the editor can't open).
|
||||
continue;
|
||||
}
|
||||
const collected = [];
|
||||
await this._readAllRecursive([rootSeg], dir, collected);
|
||||
for (const { path, handle } of collected) {
|
||||
try {
|
||||
out[path] = await (await handle.getFile()).text();
|
||||
} catch (_e) {
|
||||
// Skip unreadable files (e.g. binaries the editor can't open).
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -700,7 +705,7 @@ export class LocalWorkspaceClient {
|
||||
/* --- Lib bundle ------------------------------------------------- */
|
||||
|
||||
async _tryLoadStaticBundledLib() {
|
||||
const names = ['machine.py', 'neopixel.py'];
|
||||
const names = ['machine.py', 'neopixel.py', 'browser_fetch.py'];
|
||||
const map = {};
|
||||
for (const name of names) {
|
||||
const r = await fetch(`/static/bundled-lib/${encodeURIComponent(name)}`, {
|
||||
@@ -770,6 +775,9 @@ export class LocalWorkspaceClient {
|
||||
if (!filtered.some((row) => row.name === 'code')) {
|
||||
filtered.push({ name: 'code', is_directory: true, size: null });
|
||||
}
|
||||
if (!filtered.some((row) => row.name === 'demos')) {
|
||||
filtered.push({ name: 'demos', is_directory: true, size: null });
|
||||
}
|
||||
if (this.libFiles && Object.keys(this.libFiles).length) {
|
||||
filtered.push({ name: 'lib', is_directory: true, size: null });
|
||||
}
|
||||
@@ -810,7 +818,7 @@ export class LocalWorkspaceClient {
|
||||
const path = normalizePath(rawPath);
|
||||
if (!path) return jsonResponse(400, { detail: 'Empty path' });
|
||||
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
|
||||
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable (lib is read-only)' });
|
||||
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable (lib is read-only)' });
|
||||
const content = body && typeof body.content === 'string' ? body.content : '';
|
||||
await this.backend.writeFile(path, content);
|
||||
return jsonResponse(200, { filename: basename(path) });
|
||||
@@ -821,7 +829,7 @@ export class LocalWorkspaceClient {
|
||||
const path = normalizePath(rawPath);
|
||||
if (!path) return jsonResponse(400, { detail: 'Empty path' });
|
||||
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
|
||||
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
|
||||
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
|
||||
const r = await this.backend.deleteFile(path);
|
||||
if (!r.ok) {
|
||||
if (r.reason === 'missing') return jsonResponse(404, { detail: 'File not found' });
|
||||
@@ -836,7 +844,7 @@ export class LocalWorkspaceClient {
|
||||
const path = normalizePath(rawPath);
|
||||
if (!path) return jsonResponse(400, { detail: 'Empty path' });
|
||||
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
|
||||
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
|
||||
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
|
||||
const r = await this.backend.createFolder(path);
|
||||
if (!r.ok) {
|
||||
if (r.reason === 'exists') return jsonResponse(400, { detail: 'Folder already exists' });
|
||||
@@ -850,7 +858,7 @@ export class LocalWorkspaceClient {
|
||||
const path = normalizePath(rawPath);
|
||||
if (!path) return jsonResponse(400, { detail: 'Empty path' });
|
||||
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
|
||||
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
|
||||
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
|
||||
const r = await this.backend.deleteFolder(path);
|
||||
if (!r.ok) {
|
||||
if (r.reason === 'missing') return jsonResponse(404, { detail: 'Folder not found' });
|
||||
@@ -867,7 +875,7 @@ export class LocalWorkspaceClient {
|
||||
if (!source) return jsonResponse(400, { detail: 'Missing source_path' });
|
||||
if (isLibPath(source) || isLibPath(dest)) return jsonResponse(403, { detail: 'lib is read-only' });
|
||||
if (!isWritablePath(source) || (dest && !isWritablePath(dest))) {
|
||||
return jsonResponse(403, { detail: 'Only code/ is writable' });
|
||||
return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
|
||||
}
|
||||
const r = await this.backend.movePath(source, dest);
|
||||
if (!r.ok) {
|
||||
|
||||
@@ -6,6 +6,26 @@ const PYODIDE_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
|
||||
let pyodide = null;
|
||||
let loadingPromise = null;
|
||||
|
||||
/**
|
||||
* Install PyPI wheels into this Pyodide interpreter via micropip.
|
||||
* Pure-Python wheels (or Pyodide-built packages) work; manylinux
|
||||
* binary wheels that are not built for Emscripten will fail.
|
||||
*/
|
||||
async function micropipInstallSpecs(p, specs) {
|
||||
const list = specs
|
||||
.map((s) => String(s || '').trim())
|
||||
.filter(Boolean);
|
||||
if (!list.length) {
|
||||
return;
|
||||
}
|
||||
p.globals.set('__micropip_specs', p.toPy(list));
|
||||
await p.runPythonAsync(`
|
||||
import micropip
|
||||
specs = list(__micropip_specs)
|
||||
await micropip.install(specs)
|
||||
`);
|
||||
}
|
||||
|
||||
async function ensurePyodide() {
|
||||
if (pyodide) {
|
||||
return pyodide;
|
||||
@@ -20,9 +40,17 @@ async function ensurePyodide() {
|
||||
batched: (txt) => self.postMessage({ type: 'io', stream: 'stderr', text: txt }),
|
||||
});
|
||||
await p.loadPackage('micropip');
|
||||
/* Optional wheels (numpy, scipy, pandas, scikit-learn, …) are not preloaded —
|
||||
first worker init stays small. Install from user code with
|
||||
`await micropip.install("…", index_urls=[…])` (same host as `loadPyodide`).
|
||||
HTTPS: use `pyodide.http.pyfetch` / `browser_fetch`, not `aiohttp`. */
|
||||
await p.runPythonAsync(`
|
||||
import micropip
|
||||
await micropip.install("jedi")
|
||||
try:
|
||||
await micropip.install("nest-asyncio")
|
||||
except Exception:
|
||||
pass
|
||||
`);
|
||||
return p;
|
||||
})();
|
||||
@@ -137,13 +165,47 @@ self.onmessage = async (event) => {
|
||||
self.__serial_in_capacity = 0;
|
||||
}
|
||||
}
|
||||
await ensurePyodide();
|
||||
self.postMessage({ id, type: 'init', ok: true });
|
||||
const p = await ensurePyodide();
|
||||
const rawExtra =
|
||||
payload && Array.isArray(payload.persistedMicropipPackages)
|
||||
? payload.persistedMicropipPackages
|
||||
: [];
|
||||
const extra = rawExtra.map((s) => String(s || '').trim()).filter(Boolean);
|
||||
let micropipRestoreError = null;
|
||||
if (extra.length) {
|
||||
try {
|
||||
await micropipInstallSpecs(p, extra);
|
||||
} catch (err) {
|
||||
micropipRestoreError = err && err.message ? String(err.message) : String(err);
|
||||
}
|
||||
}
|
||||
const initReply = { id, type: 'init', ok: true };
|
||||
if (micropipRestoreError) {
|
||||
initReply.micropipRestoreError = micropipRestoreError;
|
||||
}
|
||||
self.postMessage(initReply);
|
||||
return;
|
||||
}
|
||||
|
||||
const p = await ensurePyodide();
|
||||
|
||||
if (type === 'micropipInstall') {
|
||||
const raw =
|
||||
payload && payload.specs != null
|
||||
? payload.specs
|
||||
: payload && payload.spec != null
|
||||
? [payload.spec]
|
||||
: [];
|
||||
const specs = (Array.isArray(raw) ? raw : [raw]).map((s) => String(s || '').trim()).filter(Boolean);
|
||||
if (!specs.length) {
|
||||
self.postMessage({ id, type: 'micropipInstall', ok: false, error: 'No package names given' });
|
||||
return;
|
||||
}
|
||||
await micropipInstallSpecs(p, specs);
|
||||
self.postMessage({ id, type: 'micropipInstall', ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'complete') {
|
||||
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
|
||||
const vpath = `/workspace/${rel}`;
|
||||
@@ -168,12 +230,12 @@ for rel_path, body in extra.items():
|
||||
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
|
||||
with open(__cm_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__cm_code)
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
sys.path.append(entry)
|
||||
proj = jedi.Project(
|
||||
"/workspace",
|
||||
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
||||
added_sys_path=["/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"],
|
||||
)
|
||||
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
|
||||
items = s.complete(line=__cm_line, column=__cm_col)
|
||||
@@ -206,12 +268,12 @@ for rel_path, body in extra.items():
|
||||
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
|
||||
with open(__diag_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__diag_code)
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
sys.path.append(entry)
|
||||
proj = jedi.Project(
|
||||
"/workspace",
|
||||
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
||||
added_sys_path=["/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"],
|
||||
)
|
||||
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
|
||||
errs = s.get_syntax_errors()
|
||||
@@ -243,15 +305,62 @@ for rel, body in files.items():
|
||||
with open(full, "w", encoding="utf-8") as fh:
|
||||
fh.write(str(body))
|
||||
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
sys.path.append(entry)
|
||||
|
||||
os.chdir("/workspace")
|
||||
main = __run_main
|
||||
sys.argv = [main] + list(__run_args)
|
||||
runpy.run_path(main, run_name="__main__")
|
||||
# runpy.run_path is synchronous; user asyncio.run() needs an *active* loop (nest_asyncio).
|
||||
# Await an async wrapper so get_running_loop() works during the script.
|
||||
import asyncio
|
||||
import asyncio.events
|
||||
import asyncio.base_events
|
||||
def _patch_set_debug(cls):
|
||||
_orig = cls.set_debug
|
||||
def set_debug(self, enabled):
|
||||
try:
|
||||
return _orig(self, enabled)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
cls.set_debug = set_debug
|
||||
_patch_set_debug(asyncio.events.AbstractEventLoop)
|
||||
_patch_set_debug(asyncio.base_events.BaseEventLoop)
|
||||
async def __pyodide_run_user_script():
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply(loop)
|
||||
except Exception:
|
||||
try:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _pyodide_asyncio_run(coro, *, debug=False):
|
||||
task = asyncio.ensure_future(coro, loop=loop)
|
||||
return loop.run_until_complete(task)
|
||||
|
||||
asyncio.run = _pyodide_asyncio_run
|
||||
import runpy
|
||||
runpy.run_path(main, run_name="__main__")
|
||||
|
||||
await __pyodide_run_user_script()
|
||||
`);
|
||||
try {
|
||||
await p.runPythonAsync(`
|
||||
import sys
|
||||
for _s in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_s.flush()
|
||||
except Exception:
|
||||
pass
|
||||
`);
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
self.postMessage({ id, type: 'run', ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
1764
src/static/script.js
1764
src/static/script.js
File diff suppressed because it is too large
Load Diff
@@ -113,7 +113,8 @@ button,
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
z-index: 60;
|
||||
min-width: 220px;
|
||||
min-width: 240px;
|
||||
max-width: min(85vw, 320px);
|
||||
padding: 0.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -137,10 +138,67 @@ button,
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
button.menu-item.menu-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.menu-checkbox input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-separator {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 0.35rem 0.2rem;
|
||||
}
|
||||
|
||||
.menu-section-label {
|
||||
padding: 0.35rem 0.7rem 0.2rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.menu-note {
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Buttons styled as menu items (Workspace actions, etc.). */
|
||||
.menu-action {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.menu-action:hover,
|
||||
.menu-action:focus-visible {
|
||||
background: #f1f5f9;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.menu-action-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.menu-action-danger:hover,
|
||||
.menu-action-danger:focus-visible {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
@@ -190,6 +248,63 @@ button,
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.file-tree-context-menu {
|
||||
position: fixed;
|
||||
z-index: 250;
|
||||
min-width: 210px;
|
||||
max-width: min(92vw, 300px);
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.file-tree-context-menu[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.file-tree-context-menu .file-tree-cm-note {
|
||||
padding: 0.35rem 0.85rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.file-tree-context-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.file-tree-context-menu button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-tree-context-menu button:hover:not(:disabled) {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.file-tree-context-menu button.file-tree-cm-danger {
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.file-tree-context-menu hr.file-tree-cm-sep {
|
||||
height: 1px;
|
||||
margin: 0.3rem 0.5rem;
|
||||
background: #e2e8f0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
@@ -277,40 +392,7 @@ button,
|
||||
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;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
#current-file {
|
||||
@@ -685,6 +767,12 @@ button,
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
/* iOS Safari swallows taps on small buttons inside scrollable parents
|
||||
* unless `touch-action: manipulation` is set (kills the 300ms double-tap
|
||||
* zoom delay and the "is this a scroll start?" hesitation). */
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: rgba(56, 189, 248, 0.25);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pin-toggle.on {
|
||||
@@ -1104,6 +1192,134 @@ button,
|
||||
border-color: #2c5aa0;
|
||||
}
|
||||
|
||||
.packages-modal-content {
|
||||
width: min(440px, 92vw);
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
margin: 6% auto;
|
||||
}
|
||||
|
||||
.packages-modal-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #718096;
|
||||
line-height: 1.45;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.packages-field-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin: 0.25rem 0 0.35rem;
|
||||
}
|
||||
|
||||
.packages-pypi-info {
|
||||
min-height: 1.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: #334155;
|
||||
margin: 0.15rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.packages-modal-actions-row {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.packages-secondary-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.packages-secondary-btn:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.packages-modal-status {
|
||||
min-height: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #2b6cb0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.packages-install-log {
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
margin: 0 0 0.65rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
color: #1e293b;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.packages-saved-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.packages-saved-list {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.packages-saved-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.packages-saved-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.packages-saved-item span {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.packages-saved-empty {
|
||||
padding: 0.55rem 0.65rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.packages-remove-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.packages-remove-btn:hover {
|
||||
background: #fff5f5;
|
||||
border-color: #feb2b2;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
/* Desktop: hide the file browser when collapsed via the hamburger toggle. */
|
||||
@media (min-width: 769px) {
|
||||
.sidebar.is-collapsed {
|
||||
@@ -1172,6 +1388,12 @@ button,
|
||||
.main-content {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
/* Let the right column scroll as a whole on phones. With ADC + Pins +
|
||||
* Serial all open at once, their combined intrinsic heights exceed the
|
||||
* viewport; without this the console (last child) gets clipped. */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
@@ -1244,9 +1466,31 @@ button,
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
/* Pin the editor at a usable height; the rest of the column scrolls. */
|
||||
flex: 0 0 auto;
|
||||
height: 50vh;
|
||||
min-height: 42vh;
|
||||
}
|
||||
|
||||
/* When any of the live simulator panels (Pins / ADC / Serial / LED grid)
|
||||
* is visible, give the editor less screen real estate so the panels and
|
||||
* console don't get pushed below the fold. The whole column is still
|
||||
* scrollable, this just biases space toward the panels. */
|
||||
.main-content:has(.pin-panel:not(.hidden), .adc-panel:not(.hidden), .serial-panel:not(.hidden), .led-sim-panel:not(.hidden)) .editor-container {
|
||||
height: 32vh;
|
||||
min-height: 26vh;
|
||||
}
|
||||
|
||||
/* Stop the editor / panels / console from being squashed by flexbox —
|
||||
* each keeps its natural (or capped) height and the column scrolls. */
|
||||
.led-sim-panel,
|
||||
.pin-panel,
|
||||
.adc-panel,
|
||||
.serial-panel,
|
||||
.console-container {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
font-size: 14px; /* >=16px would prevent iOS zoom but feels too large here; CM is contenteditable so no zoom anyway. */
|
||||
}
|
||||
@@ -1306,6 +1550,21 @@ button,
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Cap each panel's scroll region tighter on mobile so two or three of them
|
||||
* stacked don't push the console far below the fold. The whole column is
|
||||
* already scrollable; this just keeps individual panels compact. */
|
||||
.pin-rows {
|
||||
max-height: 22vh;
|
||||
}
|
||||
|
||||
.adc-sliders {
|
||||
max-height: 22vh;
|
||||
}
|
||||
|
||||
.serial-output {
|
||||
max-height: 22vh;
|
||||
}
|
||||
|
||||
.adc-slider {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.75rem 0;
|
||||
|
||||
@@ -245,6 +245,6 @@
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/static/tutorial.js?v=2"></script>
|
||||
<script type="module" src="/static/tutorial.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -289,7 +289,7 @@ class TutorialApp {
|
||||
|
||||
ensurePyWorker() {
|
||||
if (!this.pyWorker) {
|
||||
this.pyWorker = new Worker("/static/pyodide-worker.js?v=4");
|
||||
this.pyWorker = new Worker("/static/pyodide-worker.js?v=24");
|
||||
this.pyWorker.onmessage = (event) => this.onWorkerMessage(event);
|
||||
}
|
||||
return this.pyWorker;
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
|
||||
|
||||
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
|
||||
code_dir = tmp_path / "code"
|
||||
code_dir.mkdir()
|
||||
code_dir.mkdir(exist_ok=True)
|
||||
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
|
||||
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
|
||||
@@ -115,7 +115,7 @@ def test_read_file_non_utf8_returns_400(client, tmp_path):
|
||||
|
||||
def test_delete_file_success_and_errors(client, tmp_path):
|
||||
target = tmp_path / "code" / "delete-me.txt"
|
||||
target.parent.mkdir()
|
||||
target.parent.mkdir(exist_ok=True)
|
||||
target.write_text("x", encoding="utf-8")
|
||||
|
||||
ok = client.delete("/api/file/code/delete-me.txt")
|
||||
@@ -219,7 +219,7 @@ def test_folder_create_and_delete(client, tmp_path):
|
||||
|
||||
|
||||
def test_create_folder_collapses_duplicate_scoped_prefix(client, tmp_path):
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code").mkdir(exist_ok=True)
|
||||
create = client.post("/api/folder/new/code/code/nested", json={"path": "ignored"})
|
||||
assert create.status_code == 200
|
||||
assert (tmp_path / "code" / "nested").is_dir()
|
||||
@@ -230,14 +230,14 @@ def test_folder_delete_errors(client, tmp_path):
|
||||
missing = client.delete("/api/folder/code/missing")
|
||||
assert missing.status_code == 404
|
||||
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code").mkdir(exist_ok=True)
|
||||
(tmp_path / "code" / "file.txt").write_text("x", encoding="utf-8")
|
||||
not_dir = client.delete("/api/folder/code/file.txt")
|
||||
assert not_dir.status_code == 400
|
||||
|
||||
|
||||
def test_workspace_py_sources_returns_python_files(client, tmp_path):
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code").mkdir(exist_ok=True)
|
||||
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
|
||||
|
||||
response = client.get("/api/workspace/py-sources")
|
||||
|
||||
@@ -92,14 +92,16 @@ def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
|
||||
assert reg.status_code == 200
|
||||
assert reg.json()["username"] == "alice"
|
||||
uid = reg.json()["id"]
|
||||
code_root = tmp_path / "users" / f"alice-{uid}" / "code"
|
||||
user_root = tmp_path / "users" / f"alice-{uid}"
|
||||
code_root = user_root / "code"
|
||||
demos_dir = user_root / "demos"
|
||||
on_disk = code_root / "main.py"
|
||||
assert on_disk.is_file()
|
||||
assert on_disk.read_text(encoding="utf-8") == 'print("Hello, World!")\n'
|
||||
canonical = ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py")
|
||||
for fname in canonical:
|
||||
cp = code_root / fname
|
||||
assert cp.is_file(), f"missing bundled copy {fname} (workspace/code must ship with app)"
|
||||
cp = demos_dir / fname
|
||||
assert cp.is_file(), f"missing bundled copy demos/{fname} (workspace must ship with app)"
|
||||
text = cp.read_text(encoding="utf-8")
|
||||
assert len(text.strip()) > 20
|
||||
assert "from led_patterns" not in text
|
||||
@@ -110,7 +112,7 @@ def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["filename"] == "main.py"
|
||||
assert 'Hello, World!' in fetched.json()["content"]
|
||||
chase = client.get("/api/file/code/pattern_chase_demo.py")
|
||||
chase = client.get("/api/file/demos/pattern_chase_demo.py")
|
||||
assert chase.status_code == 200
|
||||
assert "knight_rider_scanner_frame" in chase.json()["content"]
|
||||
|
||||
@@ -311,14 +313,6 @@ def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
|
||||
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)
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ from pathlib import Path
|
||||
|
||||
def _load_patterns_module():
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
module_path = repo_root / "workspace" / "code" / "led_patterns.py"
|
||||
# Canonical home for shipped demos — `workspace/` is gitignored.
|
||||
module_path = repo_root / "src" / "static" / "bundled-demos" / "demo" / "led_patterns.py"
|
||||
spec = importlib.util.spec_from_file_location("led_patterns", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec is not None and spec.loader is not None
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Knight Rider–style bouncing scanner — self-contained (stdlib + simulated hardware only)."""
|
||||
|
||||
import time
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
# --- helpers
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def _bounce_head_index(led_count: int, frame: int) -> int:
|
||||
if led_count <= 1:
|
||||
return 0
|
||||
span = led_count - 1
|
||||
cycle = span * 2
|
||||
if cycle <= 0:
|
||||
return 0
|
||||
t = frame % cycle
|
||||
return t if t <= span else 2 * span - t
|
||||
|
||||
|
||||
def _bounce_phase_tail_direction(led_count: int, frame: int) -> int:
|
||||
if led_count <= 1:
|
||||
return -1
|
||||
span = led_count - 1
|
||||
cycle = span * 2
|
||||
if cycle <= 0:
|
||||
return -1
|
||||
t = frame % cycle
|
||||
if t <= span:
|
||||
return -1
|
||||
return 1
|
||||
|
||||
|
||||
def knight_rider_scanner_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
head_color=(220, 0, 28),
|
||||
tail_len: int = 8,
|
||||
falloff_gamma: float = 2.6,
|
||||
):
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out = [(0, 0, 0) for _ in range(led_count)]
|
||||
tl = max(1, tail_len)
|
||||
head = _bounce_head_index(led_count, frame)
|
||||
direc = _bounce_phase_tail_direction(led_count, frame)
|
||||
gamma = max(1.05, falloff_gamma)
|
||||
for rk in reversed(range(tl)):
|
||||
idx = head + direc * rk
|
||||
if idx < 0 or idx >= led_count:
|
||||
continue
|
||||
w = max(0.0, float(tl - rk) / float(tl))
|
||||
strength = w**gamma
|
||||
out[idx] = tuple(_clamp(int(head_color[ch] * strength)) for ch in range(3))
|
||||
return out
|
||||
|
||||
|
||||
# --- demo
|
||||
|
||||
NUM_LEDS = 16
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
|
||||
|
||||
for frame in range(200):
|
||||
frame_colors = knight_rider_scanner_frame(
|
||||
len(np),
|
||||
frame,
|
||||
head_color=(220, 0, 36),
|
||||
tail_len=10,
|
||||
falloff_gamma=2.85,
|
||||
)
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.05)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Rainbow NeoPixel sweep — self-contained (stdlib + simulated hardware only)."""
|
||||
|
||||
import time
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
# --- helpers (same logic as bundled led_patterns.py, inlined here)
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def wheel(pos: int):
|
||||
"""Return rainbow RGB at position 0–255."""
|
||||
pos = 255 - (pos & 255)
|
||||
if pos < 85:
|
||||
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
|
||||
if pos < 170:
|
||||
pos -= 85
|
||||
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
|
||||
pos -= 170
|
||||
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
|
||||
|
||||
|
||||
def rainbow_frame(led_count: int, frame: int, step: int = 4):
|
||||
if led_count <= 0:
|
||||
return []
|
||||
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
|
||||
|
||||
|
||||
# --- demo
|
||||
|
||||
NUM_LEDS = 16
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
|
||||
|
||||
for frame in range(120):
|
||||
frame_colors = rainbow_frame(len(np), frame, step=5)
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.05)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Twinkle NeoPixel demo — self-contained (stdlib + simulated hardware only)."""
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
from machine import Pin
|
||||
import neopixel
|
||||
|
||||
# --- helpers
|
||||
|
||||
|
||||
def _clamp(channel: int) -> int:
|
||||
return max(0, min(255, int(channel)))
|
||||
|
||||
|
||||
def twinkle_frame(
|
||||
led_count: int,
|
||||
frame: int,
|
||||
base=(0, 0, 8),
|
||||
sparkle=(255, 255, 180),
|
||||
sparkles: int = 3,
|
||||
seed: int = 1337,
|
||||
):
|
||||
if led_count <= 0:
|
||||
return []
|
||||
out = [tuple(_clamp(v) for v in base) for _ in range(led_count)]
|
||||
rng = random.Random(seed + frame)
|
||||
for _ in range(min(max(0, sparkles), led_count)):
|
||||
idx = rng.randrange(led_count)
|
||||
out[idx] = tuple(_clamp(v) for v in sparkle)
|
||||
return out
|
||||
|
||||
|
||||
# --- demo
|
||||
|
||||
NUM_LEDS = 16
|
||||
|
||||
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
|
||||
|
||||
for frame in range(120):
|
||||
frame_colors = twinkle_frame(
|
||||
len(np),
|
||||
frame,
|
||||
base=(0, 0, 6),
|
||||
sparkle=(255, 210, 130),
|
||||
sparkles=3,
|
||||
)
|
||||
for i, color in enumerate(frame_colors):
|
||||
np[i] = color
|
||||
np.write()
|
||||
time.sleep(0.08)
|
||||
|
||||
np.fill((0, 0, 0))
|
||||
np.write()
|
||||
@@ -1,54 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user