diff --git a/.gitignore b/.gitignore
index e12b5d9..9a5a693 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,7 +15,6 @@ dist/
downloads/
eggs/
.eggs/
-/lib/
/lib64/
parts/
sdist/
diff --git a/Dockerfile b/Dockerfile
index 4034e25..acafa03 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,6 +12,7 @@ COPY Pipfile Pipfile.lock ./
RUN pipenv install --system --deploy
COPY src ./src
+COPY lib ./lib
COPY workspace ./workspace
EXPOSE 8080
diff --git a/README.md b/README.md
index d693349..abfd6d7 100644
--- a/README.md
+++ b/README.md
@@ -93,16 +93,19 @@ The home page can store the API key in `sessionStorage` when you are not using c
## Layout
- `src/` — FastAPI app and static UI (`src/static/`)
-- `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API)
+- `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
## ESP32 / NeoPixel mock
-The browser runtime now includes MicroPython-style mocks in `workspace/lib`:
+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.Pin`, `machine.freq()`, `machine.unique_id()`, `machine.reset()` (no-op here)
- `neopixel.NeoPixel`
+- `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` exactly like ESP32 examples:
+Use them from scripts in `workspace/code` like typical ESP32 / MicroPython examples:
```python
from machine import Pin
diff --git a/lib/machine.py b/lib/machine.py
new file mode 100644
index 0000000..eb0a242
--- /dev/null
+++ b/lib/machine.py
@@ -0,0 +1,19 @@
+"""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
diff --git a/lib/neopixel.py b/lib/neopixel.py
new file mode 100644
index 0000000..9446b38
--- /dev/null
+++ b/lib/neopixel.py
@@ -0,0 +1,56 @@
+"""NeoPixel mock for Pyodide/browser execution.
+
+Supports a useful subset of MicroPython's neopixel.NeoPixel API:
+- NeoPixel(pin, n, bpp=3, timing=1)
+- __setitem__, __getitem__, __len__
+- fill(color)
+- write() # prints current pixel buffer snapshot
+"""
+
+import json
+
+
+def _normalize_color(value, bpp: int):
+ if not hasattr(value, "__iter__"):
+ raise TypeError("Color must be an iterable, e.g. (r, g, b)")
+ parts = [int(v) for v in value]
+ if len(parts) != bpp:
+ raise ValueError(f"Expected {bpp} color channels, got {len(parts)}")
+ out = []
+ for channel in parts:
+ out.append(max(0, min(255, channel)))
+ return tuple(out)
+
+
+class NeoPixel:
+ def __init__(self, pin, n: int, bpp: int = 3, timing: int = 1):
+ self.pin = pin
+ self.n = int(n)
+ self.bpp = int(bpp)
+ self.timing = int(timing)
+ self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
+
+ def __len__(self):
+ return self.n
+
+ def __getitem__(self, index):
+ return self._buf[int(index)]
+
+ def __setitem__(self, index, color):
+ idx = int(index)
+ self._buf[idx] = _normalize_color(color, self.bpp)
+
+ def fill(self, color):
+ c = _normalize_color(color, self.bpp)
+ for i in range(self.n):
+ self._buf[i] = c
+
+ def write(self):
+ pin_id = getattr(self.pin, "id", self.pin)
+ payload = {
+ "type": "neopixel",
+ "pin": pin_id,
+ "pixels": [list(pixel) for pixel in self._buf],
+ "bpp": self.bpp,
+ }
+ print("[neopixel-json]" + json.dumps(payload))
diff --git a/src/editor_app/main.py b/src/editor_app/main.py
index 16bae78..8d00246 100644
--- a/src/editor_app/main.py
+++ b/src/editor_app/main.py
@@ -14,12 +14,13 @@ 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_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:
diff --git a/src/editor_app/services/filesystem.py b/src/editor_app/services/filesystem.py
index 1727382..183f7cb 100644
--- a/src/editor_app/services/filesystem.py
+++ b/src/editor_app/services/filesystem.py
@@ -15,7 +15,12 @@ def _workspace_root(workspace_root: Path | None = None) -> Path:
def _shared_lib_root() -> Path:
- return (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve()
+ """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
+
+ ensure_workspace_lib()
+ return lib
def normalize_relative_path(relative_path: str) -> str:
@@ -213,25 +218,24 @@ def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None:
def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]:
- """Return all UTF-8 .py files under the workspace for browser-side Pyodide sync."""
+ """Return UTF-8 `.py` under the scoped workspace plus shared stubs from `WORKSPACE_ROOT/lib/` (bundled Micropython mocks for Jedi/completion and Pyodide)."""
result: dict[str, str] = {}
workspace = _workspace_root(workspace_root)
- if not workspace.exists():
- return result
- for path in workspace.rglob("*.py"):
- try:
- rel = path.relative_to(workspace)
- except ValueError:
- continue
- if any(part.startswith(".") for part in rel.parts):
- continue
- try:
- key = str(rel).replace("\\", "/")
- result[key] = path.read_text(encoding="utf-8")
- except (UnicodeDecodeError, OSError):
- continue
+ if workspace.exists():
+ for path in workspace.rglob("*.py"):
+ try:
+ rel = path.relative_to(workspace)
+ except ValueError:
+ continue
+ if any(part.startswith(".") for part in rel.parts):
+ continue
+ try:
+ key = str(rel).replace("\\", "/")
+ result[key] = path.read_text(encoding="utf-8")
+ except (UnicodeDecodeError, OSError):
+ continue
shared_lib = _shared_lib_root()
- if shared_lib.exists() and shared_lib.is_dir() and shared_lib != (workspace / LIB_DIR_NAME).resolve():
+ if shared_lib.is_dir():
for path in shared_lib.rglob("*.py"):
try:
rel = path.relative_to(shared_lib)
diff --git a/src/editor_app/services/user_workspace.py b/src/editor_app/services/user_workspace.py
index 3f3b6f7..fe83c8f 100644
--- a/src/editor_app/services/user_workspace.py
+++ b/src/editor_app/services/user_workspace.py
@@ -16,6 +16,22 @@ _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}"
diff --git a/src/static/index.html b/src/static/index.html
index 4a5dceb..000b63a 100644
--- a/src/static/index.html
+++ b/src/static/index.html
@@ -87,6 +87,6 @@
-
+