From 76129469a177658dc327fa0af84e84db77418a9d Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 10 May 2026 06:35:03 +1200 Subject: [PATCH] Add 'Reset demos' button to refresh canonical demo files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing accounts (including admin) seeded before new demos shipped had no easy way to pull in the latest copies — the registration-time seeder is intentionally non-destructive. The new badge action fetches src/static/bundled-demos/manifest.json, confirms the overwrite, and re-copies each canonical demo into code/. Open tabs of those files are refreshed in place so the user sees the new content immediately. src/static/bundled-demos/ ships the six canonical files plus the manifest so this works in local mode and on a static-only host. The Dockerfile now mirrors workspace/code/.py into bundled-demos/ during the image build, keeping the two locations in sync. Co-authored-by: Cursor --- Dockerfile | 7 ++ src/static/bundled-demos/adc_slider_demo.py | 55 +++++++++ src/static/bundled-demos/manifest.json | 10 ++ .../bundled-demos/pattern_chase_demo.py | 83 ++++++++++++++ .../bundled-demos/pattern_rainbow_demo.py | 47 ++++++++ .../bundled-demos/pattern_twinkle_demo.py | 54 +++++++++ src/static/bundled-demos/pin_demo.py | 54 +++++++++ src/static/bundled-demos/serial_demo.py | 90 +++++++++++++++ src/static/index.html | 2 +- src/static/script.js | 106 ++++++++++++++++++ 10 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 src/static/bundled-demos/adc_slider_demo.py create mode 100644 src/static/bundled-demos/manifest.json create mode 100644 src/static/bundled-demos/pattern_chase_demo.py create mode 100644 src/static/bundled-demos/pattern_rainbow_demo.py create mode 100644 src/static/bundled-demos/pattern_twinkle_demo.py create mode 100644 src/static/bundled-demos/pin_demo.py create mode 100644 src/static/bundled-demos/serial_demo.py diff --git a/Dockerfile b/Dockerfile index 8deaa78..4421f68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,13 @@ 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 EXPOSE 8080 diff --git a/src/static/bundled-demos/adc_slider_demo.py b/src/static/bundled-demos/adc_slider_demo.py new file mode 100644 index 0000000..79e232f --- /dev/null +++ b/src/static/bundled-demos/adc_slider_demo.py @@ -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) diff --git a/src/static/bundled-demos/manifest.json b/src/static/bundled-demos/manifest.json new file mode 100644 index 0000000..df08cb1 --- /dev/null +++ b/src/static/bundled-demos/manifest.json @@ -0,0 +1,10 @@ +{ + "files": [ + "pattern_rainbow_demo.py", + "pattern_twinkle_demo.py", + "pattern_chase_demo.py", + "adc_slider_demo.py", + "pin_demo.py", + "serial_demo.py" + ] +} diff --git a/src/static/bundled-demos/pattern_chase_demo.py b/src/static/bundled-demos/pattern_chase_demo.py new file mode 100644 index 0000000..ecec0f3 --- /dev/null +++ b/src/static/bundled-demos/pattern_chase_demo.py @@ -0,0 +1,83 @@ +"""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() diff --git a/src/static/bundled-demos/pattern_rainbow_demo.py b/src/static/bundled-demos/pattern_rainbow_demo.py new file mode 100644 index 0000000..1622ebc --- /dev/null +++ b/src/static/bundled-demos/pattern_rainbow_demo.py @@ -0,0 +1,47 @@ +"""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() diff --git a/src/static/bundled-demos/pattern_twinkle_demo.py b/src/static/bundled-demos/pattern_twinkle_demo.py new file mode 100644 index 0000000..349a651 --- /dev/null +++ b/src/static/bundled-demos/pattern_twinkle_demo.py @@ -0,0 +1,54 @@ +"""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() diff --git a/src/static/bundled-demos/pin_demo.py b/src/static/bundled-demos/pin_demo.py new file mode 100644 index 0000000..d63aa68 --- /dev/null +++ b/src/static/bundled-demos/pin_demo.py @@ -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) diff --git a/src/static/bundled-demos/serial_demo.py b/src/static/bundled-demos/serial_demo.py new file mode 100644 index 0000000..ce186b9 --- /dev/null +++ b/src/static/bundled-demos/serial_demo.py @@ -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 | 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 | 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)) diff --git a/src/static/index.html b/src/static/index.html index afa9c9f..45e470e 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -118,6 +118,6 @@ - + diff --git a/src/static/script.js b/src/static/script.js index 72784d9..e887810 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -192,6 +192,14 @@ class TextEditor { importBtn.addEventListener('click', () => this.importWorkspaceZip()); badge.appendChild(importBtn); + const resetBtn = document.createElement('button'); + resetBtn.type = 'button'; + resetBtn.className = 'workspace-badge-action'; + resetBtn.textContent = 'Reset demos'; + resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)'; + resetBtn.addEventListener('click', () => this.resetDemoFiles()); + badge.appendChild(resetBtn); + const exit = document.createElement('button'); exit.type = 'button'; exit.className = 'workspace-badge-exit'; @@ -259,9 +267,107 @@ class TextEditor { importBtn.addEventListener('click', () => this.importWorkspaceZip()); badge.appendChild(importBtn); + const resetBtn = document.createElement('button'); + resetBtn.type = 'button'; + resetBtn.className = 'workspace-badge-action'; + resetBtn.textContent = 'Reset demos'; + resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)'; + resetBtn.addEventListener('click', () => this.resetDemoFiles()); + badge.appendChild(resetBtn); + badge.classList.remove('hidden'); } + async resetDemoFiles() { + let manifest; + try { + const r = await fetch('/static/bundled-demos/manifest.json', { cache: 'no-store' }); + if (!r.ok) throw new Error(`manifest fetch ${r.status}`); + manifest = await r.json(); + } catch (err) { + this.showError( + `Could not load demo manifest: ${err && err.message ? err.message : err}` + ); + return; + } + const names = Array.isArray(manifest && manifest.files) ? manifest.files.slice() : []; + if (!names.length) { + this.showError('No demos in bundle'); + return; + } + if (!confirm( + `Reset ${names.length} demo file${names.length === 1 ? '' : 's'}?\n\n` + + names.map((n) => ` • code/${n}`).join('\n') + + '\n\nAny edits you made to these files will be overwritten. Other ' + + 'files (main.py, your own scripts) are not touched.' + )) return; + + let written = 0; + let failed = 0; + for (const name of names) { + try { + const r = await fetch(`/static/bundled-demos/${encodeURIComponent(name)}`, { + cache: 'no-store', + }); + if (!r.ok) { + failed += 1; + continue; + } + const content = await r.text(); + const w = await this.apiFetch(`/api/file/code/${encodeURIComponent(name)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + if (w && w.ok) { + written += 1; + } else { + failed += 1; + } + } catch (_err) { + failed += 1; + } + } + this.workspaceSourcesCache = null; + this.directoryCache.clear(); + /* If a stale demo is currently open in a tab, refresh the editor + contents from disk so the user sees the new version. */ + for (const name of names) { + const path = `code/${name}`; + if (this.findTab(path)) { + try { + const fr = await this.apiFetch(`/api/file/${encodeURIComponent(path)}`); + if (fr && fr.ok) { + const fd = await fr.json(); + const tab = this.findTab(path); + if (tab) { + tab.content = typeof fd.content === 'string' ? fd.content : ''; + tab.savedContent = tab.content; + if (this.activeTabPath === path) { + this.ignoreNextChange = true; + this.editor.dispatch({ + changes: { + from: 0, + to: this.editor.state.doc.length, + insert: tab.content, + }, + }); + } + } + } + } catch (_err) { + // Skip refresh failure; user can re-open manually. + } + } + } + await this.loadInitialDirectoryState(); + if (failed) { + this.showError(`Reset ${written} demo${written === 1 ? '' : 's'} (${failed} failed)`); + } else { + this.showSuccess(`Reset ${written} demo${written === 1 ? '' : 's'}`); + } + } + async pickLocalFolder() { if (!this.localWorkspace) return; try {