Files
python-editor/lib/machine.py
Jimmy ca0ca6fe7e 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>
2026-05-10 06:16:02 +12:00

446 lines
13 KiB
Python

"""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
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