From e8945ee1d1a9e5f0aa7952373c758ac821847139 Mon Sep 17 00:00:00 2001 From: Me Here Date: Fri, 29 May 2026 06:55:58 -0500 Subject: [PATCH] PM_K-1: one-click A/B firmware updates over USB-MIDI (+ version check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the CircuitPython firmware into a tiny stable loader (code.py) + the application (app.py, carries APP_VERSION). The editor's ⋯ → "⬆ Update firmware" queries the device version (SysEx 0x02 -> 0x03 reply), fetches the latest app from the site (/pico-cp-app.py), shows device-vs-latest, and pushes the new app.py over USB-MIDI (SysEx 0x20). The device installs it to a trial slot (old build kept as app.bak), reboots, and the loader AUTO-ROLLS-BACK to app.bak if the new build fails to start; a build that runs cleanly ~5s is confirmed (clears /trial). No BOOTSEL, no dragging; Chromium/Firefox. app.py forced to pure ASCII so it pushes raw (no base64); SysEx buffer raised to 60KB. build.sh/deploy.sh: bundle code.py+app.py and serve /pico-cp-app.py. Docs updated. Verified in CPython: version reply, update install+reboot+ACK, rollback file dance; editor loads clean with the updater wired. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +- build.sh | 4 +- deploy.sh | 1 + editor.html | 36 +- info-kit.html | 1 + pico-cp/README.md | 15 +- pico-cp/__pycache__/app.cpython-312.pyc | Bin 0 -> 47106 bytes pico-cp/__pycache__/code.cpython-312.pyc | Bin 45320 -> 894 bytes pico-cp/app.py | 648 +++++++++++++++++++++++ pico-cp/code.py | 639 +--------------------- 10 files changed, 720 insertions(+), 628 deletions(-) create mode 100644 pico-cp/__pycache__/app.cpython-312.pyc create mode 100644 pico-cp/app.py diff --git a/README.md b/README.md index 2be39e2..94af3a5 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,9 @@ flashing steps. Firmware lives in **`pico/`**: set lists **pushed from the editor over USB‑MIDI** (with a universal download‑and‑drag fallback), and plays **out your computer's speakers over USB‑MIDI** (the editor's **🎹 Device audio**). By default the firmware owns the drive (read‑only to the computer, so it's protected); hold **button A** at power‑on for - editor mode (drive writable). The MicroPython build stays the simple, no‑computer option. + editor mode (drive writable). **Firmware updates are one click** from the editor (⋯ → Update firmware) — + pushed over USB‑MIDI as an A/B update with automatic rollback. The MicroPython build stays the simple, + no‑computer option. ## Keyboard shortcuts diff --git a/build.sh b/build.sh index 374aa36..2a6b795 100755 --- a/build.sh +++ b/build.sh @@ -37,9 +37,11 @@ pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) print("copied embed.js") pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable print("copied pico-main.py") +pathlib.Path("dist/pico-cp-app.py").write_text(pathlib.Path("pico-cp/app.py").read_text()) # served for the editor's A/B firmware updater +print("copied pico-cp-app.py") import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY) with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z: - for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", + for f in ("code.py", "app.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", "README.md", "protect-firmware.sh"): z.write("pico-cp/" + f, f) z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive diff --git a/deploy.sh b/deploy.sh index 40a7dff..bc05d79 100755 --- a/deploy.sh +++ b/deploy.sh @@ -49,6 +49,7 @@ done cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)" cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle +cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # PM_K-1 firmware for the editor's A/B updater rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/) # info-*.html are first-class pages again: each form factor has a lean widget page diff --git a/editor.html b/editor.html index 1f51cc8..3cf3ee5 100644 --- a/editor.html +++ b/editor.html @@ -349,6 +349,7 @@ + @@ -1128,7 +1129,7 @@ async function loadFromDevice() { /* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through this page's synth, so the device drives sound out the computer's speakers, locked to its clock. */ -let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null; +let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null, _verCb = null; function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; } function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; } function _send(bytes) { for (const o of _midiOutputs()) { try { o.send(bytes); } catch (_) {} } } @@ -1145,8 +1146,10 @@ async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/ function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); } function onDeviceMidi(e) { const d = e.data; if (!d) return; - if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply to a program push - if (_saveCb) { const cb = _saveCb; _saveCb = null; cb(d[2] === 0x7F); } // 0x7F ACK / 0x7E NAK + if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply + const cmd = d[2]; + if (cmd === 0x03 && _verCb) { const cb = _verCb; _verCb = null; cb(String.fromCharCode(...d.slice(3, d.length - 1))); } // version + else if ((cmd === 0x7F || cmd === 0x7E) && _saveCb) { const cb = _saveCb; _saveCb = null; cb(cmd === 0x7F); } // ACK/NAK return; } if (_midiOn && (d[0] & 0xf0) === 0x90 && d.length >= 3 && d[2] > 0) { // Note On -> voice it @@ -1179,6 +1182,32 @@ async function toggleDeviceAudio() { ? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green per note." : "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically."); } +function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03) + return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); }); +} +async function updateFirmware() { // A/B firmware update over USB-MIDI, with a version check + if (!(await _ensureMidi()) || !_midiOutputs().length) + return alert("Connect the PM_K-1 (Chrome/Edge/Firefox), then try again."); + const dev = await _queryDeviceVersion(); + let src; + try { src = await (await fetch("/pico-cp-app.py", { cache: "no-store" })).text(); } + catch (e) { return alert("Couldn't fetch the latest firmware from the site."); } + const m = src.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); const latest = m && m[1]; + if (!latest) return alert("Couldn't read the latest firmware version."); + const upToDate = dev && dev === latest; + if (!confirm("Device firmware: " + (dev || "unknown") + "\nLatest: " + latest + + (upToDate ? "\n\nYou're up to date. Re-install anyway?" + : "\n\nUpdate now? The device reboots, runs the new build, and auto-rolls-back if it fails to start."))) return; + const bytes = [0xF0, 0x7D, 0x20]; + for (let i = 0; i < src.length; i++) bytes.push(src.charCodeAt(i) & 0x7F); // app.py is ASCII + bytes.push(0xF7); + const p = new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, 5000); }); + _send(bytes); + const ok = await p; + alert(ok === true ? "Update sent ✓ — the device is rebooting into the new build (v" + latest + "). It auto-confirms after a few seconds, or rolls back if it won't start." + : ok === false ? "The device is in editor mode (read-only to the updater). Reboot it normally (don't hold A) and try again." + : "No acknowledgement from the device. Make sure it's connected and not in editor mode — or drag app.py onto the drive in editor mode."); +} // Apply a shared link on load. Returns true if it set the metronome state. function applyHashShare() { @@ -1405,6 +1434,7 @@ $("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $( $("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; }); $("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); }); $("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); }); +$("updateFwBtn").addEventListener("click", () => { $("trayMenu").hidden = true; updateFirmware(); }); $("midiBtn").addEventListener("click", toggleDeviceAudio); $("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); }); $("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); }); diff --git a/info-kit.html b/info-kit.html index 2cd9c94..7f211e0 100644 --- a/info-kit.html +++ b/info-kit.html @@ -152,6 +152,7 @@
  • Play through your computer: click 🎹 Device audio, then press play on the device — the full groove sounds through your speakers over USB‑MIDI, in sync; the screen shows a MIDI badge and the buzzer mutes.
  • Practice log: plays over 5 s appear at the bottom of the screen (time · BPM · duration · track); tap a row twice to delete.
  • +
  • Firmware updates: ⋯ menu → ⬆ Update firmware — it checks your version, pushes the latest over USB‑MIDI, and the device A/B‑updates with automatic rollback if a build won't boot.
  • diff --git a/pico-cp/README.md b/pico-cp/README.md index 3768677..3a994a7 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -23,8 +23,9 @@ from the web editor over USB‑MIDI**, and plays through your **computer's speak 1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, drop the CircuitPython `.uf2` onto `RPI‑RP2` ( — Pico 2 / W builds also fine). A `CIRCUITPY` drive appears. -2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py`, `programs.json`, - `font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the helper scripts. +2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py` (loader) + `app.py` (the application), + `programs.json`, `font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the + helper scripts. (`code.py` is a tiny stable loader; `app.py` is what firmware updates replace.) 3. **Power‑cycle** (so `boot.py` takes effect). It boots into appliance mode and runs. ## Program it from the web (push over USB‑MIDI) @@ -36,6 +37,16 @@ acknowledges — the editor shows **Saved ✓**. **📥 Load from device** reads *Universal fallback (any browser / OS, even Safari):* Save to device **downloads** `programs.json` when no device answers — boot the Pico in **editor mode** (hold A) and drag the file onto the `CIRCUITPY` drive. +## Firmware updates (one‑click, A/B with auto‑rollback) + +`code.py` is a small stable **loader**; the application is `app.py` (it carries `APP_VERSION`). To update: +the editor's ⋯ menu → **⬆ Update firmware…** queries the device's version, fetches the latest from the site, +shows *device vs latest*, and on confirm **pushes the new `app.py` over USB‑MIDI**. The device installs it to +a **trial slot** (keeping the old build as `app.bak`) and reboots; if the new build **doesn't boot, the loader +automatically rolls back** to `app.bak`. A build that runs cleanly for ~5 s is confirmed. No BOOTSEL, no +dragging. (Updating CircuitPython *itself* still uses BOOTSEL + a `.uf2`, but that's rare. And the Pico is +unbrickable as the ultimate backstop.) + ## Play through the computer's speakers The Pico is a USB‑MIDI device and sends a note per click (GM drum note per lane, velocity by accent). diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cfd5ae9545e3bf1fa649ab169211fb669099473f GIT binary patch literal 47106 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 ABLDyZ literal 0 HcmV?d00001 diff --git a/pico-cp/__pycache__/code.cpython-312.pyc b/pico-cp/__pycache__/code.cpython-312.pyc index c2baf00aaa27bb6e2a4c45ad7dab6aea13152337..29e08de7019217d1adc02a9a4b81103ea4d5044d 100644 GIT binary patch literal 894 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 cb_Eg+$5sUgWoIJb*z$8Q=uAW$TMeA}H General-MIDI note (USB-MIDI bridge), and level -> MIDI velocity +SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare909":38, + "clap":39,"clap808":39,"clap909":39, "rim":37, "hatClosed":42,"hat808":42,"hat909":42, + "hatOpen":46,"openHat808":46, "ride":51,"ride909":51, "crash":49,"crash909":49, + "tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54, + "cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37} +GM_DEFAULT = 37 +MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost +MAXLANES = 5 # lanes shown on the pad grid (extras still play) +LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid) +MIN_LOG_SEC = 5 # don't log plays shorter than this +PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost +PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost + +# WS2812 RGB LED - self-contained via the core neopixel_write module (no external library) +class RGB: + def __init__(self, pin): + self.ok = neopixel_write is not None + if self.ok: + self.io = digitalio.DigitalInOut(pin); self.io.direction = digitalio.Direction.OUTPUT + self.buf = bytearray(3) + def set(self, r, g, b): + if not self.ok: return + # WS2812 wants GRB order; scale down so it isn't blinding + self.buf[0] = int(g * LED_BRIGHTNESS); self.buf[1] = int(r * LED_BRIGHTNESS); self.buf[2] = int(b * LED_BRIGHTNESS) + try: neopixel_write.neopixel_write(self.io, self.buf) + except Exception: self.ok = False + +# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ============================== +def load_font(path): + with open(path, "rb") as f: + blob = f.read() + count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {} + for _ in range(count): + cp = (blob[p] << 8) | blob[p+1]; w = blob[p+2]; h = blob[p+3] + xoff = blob[p+4]; xoff = xoff - 256 if xoff > 127 else xoff + top = blob[p+5]; adv = blob[p+6]; p += 7 + glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2 + return (glyphs, blob) + +FONT_S = load_font("/font_s.bin") # small - pad-grid lane labels +FONT_M = load_font("/font_m.bin") # labels / buttons +FONT_L = load_font("/font_l.bin") # big BPM +gc.collect() + +def _blend(bg, fg, i): + t = i * 17 + r = (((bg >> 16) & 0xFF)*(255-t) + ((fg >> 16) & 0xFF)*t) // 255 + g = (((bg >> 8) & 0xFF)*(255-t) + ((fg >> 8) & 0xFF)*t) // 255 + b = ((bg & 0xFF)*(255-t) + (fg & 0xFF)*t) // 255 + return (r << 16) | (g << 8) | b + +def make_text(s, font, fg, bg): + """Render a string into a displayio TileGrid (anti-aliased via a 16-step blend palette).""" + glyphs, blob = font + w = 0; top0 = 999; bot = 0 + for c in s: + g = glyphs.get(ord(c)) + if not g: continue + w += g[4] + if g[1]: + if g[3] < top0: top0 = g[3] + if g[3] + g[1] > bot: bot = g[3] + g[1] + if top0 == 999: top0 = 0 + w = max(1, w); h = max(1, bot - top0) + gc.collect() + bmp = displayio.Bitmap(w, h, 16) + pal = displayio.Palette(16) + for i in range(16): pal[i] = _blend(bg, fg, i) + pen = 0 + for c in s: + g = glyphs.get(ord(c)) + if not g: continue + gw, gh, xoff, gtop, adv, off = g + for j in range(gh): + row = (gtop - top0) + j + for i in range(gw): + k = j * gw + i + byte = blob[off + (k >> 1)] + nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF) + if nib: + x = pen + xoff + i + if 0 <= x < w and 0 <= row < h: bmp[x, row] = nib + pen += adv + return displayio.TileGrid(bmp, pixel_shader=pal), w, h + +# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ============================== +PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} +PRIO = {2: 3, 1: 2, 3: 1} + +def parse_program(s): + bpm = 120; lanes = [] + for tok in s.strip().split(';'): + tok = tok.strip() + if not tok: continue + if tok[0] == 't' and tok[1:].isdigit(): + bpm = int(tok[1:]); continue + if ':' not in tok: continue + lane = _parse_lane(tok) + if lane: lanes.append(lane) + if not lanes: lanes = [_parse_lane("beep:4")] + return max(30, min(300, bpm)), lanes + +def _parse_lane(tok): + poly = '~' in tok; mute = '!' in tok + tok = tok.replace('~', '').replace('!', '') + if '@' in tok: tok = tok.split('@')[0] + sound, _, rest = tok.partition(':') + pattern = None + if '=' in rest: rest, _, pattern = rest.partition('=') + sub = 1; swing = False + if '/' in rest: + rest, _, sd = rest.partition('/') + swing = sd.endswith('s'); sd = sd.rstrip('s') # "/2s" = swung eighths + sub = int(sd) if sd.isdigit() else 1 + groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4] + beats = sum(groups); starts = set(); acc = 0 + for gp in groups: starts.add(acc); acc += gp + steps = beats * sub + if pattern: + levels = [PAT.get(ch, 0) for ch in pattern] + if len(levels) < steps: levels += [0] * (steps - len(levels)) + steps = len(levels) + else: + levels = [] + for i in range(steps): + if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) + else: levels.append(0) + return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute} + +def load_programs(): + try: + with open("/programs.json") as f: + d = json.load(f) + progs = [(p["name"], p["prog"]) for p in d["programs"]] + if progs: return progs + except Exception as e: + print("programs.json:", e) + return DEFAULT_PROGRAMS + +# ============================== GT911 TOUCH ============================== +class GT911: + def __init__(self, i2c): + self.i2c = i2c; self.addr = None + while not i2c.try_lock(): pass + try: found = i2c.scan() + finally: i2c.unlock() + for a in (0x5D, 0x14): + if a in found: self.addr = a; break + if self.addr is None and found: self.addr = found[0] + def _rd(self, reg, n): + b = bytearray(n) + while not self.i2c.try_lock(): pass + try: + self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF])) + self.i2c.readfrom_into(self.addr, b) + finally: self.i2c.unlock() + return b + def _wr(self, reg, val): + while not self.i2c.try_lock(): pass + try: self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF, val])) + finally: self.i2c.unlock() + def read(self): + if self.addr is None: return None + try: st = self._rd(0x814E, 1)[0] + except OSError: return None + if not (st & 0x80): return None + n = st & 0x0F; pt = None + if n >= 1: + b = self._rd(0x8150, 4); tx = b[0] | (b[1] << 8); ty = b[2] | (b[3] << 8) + pt = self._map(tx, ty) + try: self._wr(0x814E, 0) + except OSError: pass + return pt + def _map(self, tx, ty): + if TOUCH_DEBUG: print("touch raw", tx, ty) + if TOUCH_SWAP_XY: tx, ty = ty, tx + if TOUCH_INVERT_X: tx = WIDTH - 1 - tx + if TOUCH_INVERT_Y: ty = HEIGHT - 1 - ty + if 0 <= tx < WIDTH and 0 <= ty < HEIGHT: return (tx, ty) + return None + +# ============================== DISPLAY SETUP ============================== +def st7796_init(): + inv = b'\x21\x00' if INVERT_COLORS else b'\x20\x00' + return ( + b'\x01\x80\x78' # SWRESET + 120ms + b'\x11\x80\x78' # SLPOUT + 120ms + b'\xF0\x01\xC3' b'\xF0\x01\x96' # command-set unlock + + bytes([0x36, 0x01, MADCTL]) + + b'\x3A\x01\x55' # 16bpp + b'\xB4\x01\x01' + b'\xB6\x03\x80\x02\x3B' + b'\xE8\x08\x40\x8A\x00\x00\x29\x19\xA5\x33' + b'\xC1\x01\x06' b'\xC2\x01\xA7' + b'\xC5\x81\x18\x78' # VCOM + 120ms + b'\xE0\x0E\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B' + b'\xE1\x0E\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B' + b'\xF0\x01\x3C' b'\xF0\x81\x69\x78' # lock + 120ms + + inv + + b'\x29\x80\x32' # DISPON + 50ms + ) + +def make_display(): + displayio.release_displays() + spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI) + bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD) + return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=False) + +def solid(color): + p = displayio.Palette(1); p[0] = color; return p + +def rect(x, y, w, h, color): + return vectorio.Rectangle(pixel_shader=solid(color), width=w, height=h, x=x, y=y) + +# ============================== APP ============================== +class App: + def __init__(self): + self.display = make_display() + self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) + self.touch = GT911(self.i2c) + self.midi = usb_midi.ports[1] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 1) else None + self.midi_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None + self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0 + self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs) + self.led = RGB(P_RGB) + self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) + self.buz_off = 0 + self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB) + self._aPrev = True; self._bPrev = True + self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY) + self._joyNext = 0 + self._touchDown = False; self._touchSeen = 0 + self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0) + self.programs = load_programs() + self.dirty = True + self.pad_pal = displayio.Palette(8) + for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i] + self.lane_pads = []; self.lane_lit = [] + # practice history - persisted to /history.json (next to programs.json) when we own the filesystem + self.can_write = self._probe_write() + self.log = self._load_log() + self.play_start = None; self.play_bpm = 0; self.play_name = "" + self._armed = None; self.log_rows = [] + self._build_scene() + self.load(0) + self.draw_log() + + def _btn(self, pin): + d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP + return d + + # ---------- scene graph ---------- + def _build_scene(self): + root = displayio.Group(); self.display.root_group = root + root.append(rect(0, 0, WIDTH, HEIGHT, C_BG)) + tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg) + root.append(rect(0, 38, WIDTH, 2, C_PANEL)) + # dynamic groups + self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right) + self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left) + self.g_name = displayio.Group(); root.append(self.g_name) # item index + name + self.g_midi = displayio.Group(); root.append(self.g_midi) # "MIDI" indicator (top-right) when a host is listening + self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads + root.append(rect(0, LOG_TOP - 6, WIDTH, 2, C_PANEL)) # divider above the history log + self.g_log = displayio.Group(); root.append(self.g_log) # practice history (tap a row to delete) + # (no on-screen buttons - transport is the joystick + buttons A/B; touch deletes log rows) + + def _place(self, group, s, x, y, fg, bg, font, right_edge=None): + while len(group): group.pop() + self.dirty = True + if not s: return + tg, w, h = make_text(s, font, fg, bg) + tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg) + def _center(self, group, s, cx, cy, fg, bg, font): + while len(group): group.pop() + tg, w, h = make_text(s, font, fg, bg); tg.x = cx - w//2; tg.y = cy - h//2; group.append(tg) + self.dirty = True + + # ---------- program ---------- + def load(self, i): + n = len(self.programs); self.idx = i % n + self.name, prog = self.programs[self.idx] + self.bpm, self.lanes = parse_program(prog) + self.master = self.lanes[0] + self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid() + def _step_dur(self, L, step): + beat = 60_000_000_000 / self.bpm + if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar + m = self.lanes[0]; master_bar = beat * (m['steps'] // m['sub']) + return int(master_bar / L['steps']) + sub = L['sub'] + if L['swing'] and sub % 2 == 0: # swing even subdivisions: long-short (2:1) pairs + pair = beat / (sub // 2) + return int(pair * 2 / 3) if (step % sub) % 2 == 0 else int(pair / 3) + return int(beat / sub) # straight: a step = one beat / subdivision + def _reset_clock(self): + now = time.monotonic_ns() + for L in self.lanes: + L['next'] = now; L['step'] = -1 + + # ---------- audio + light ---------- + def click(self, level): + self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600) + self.buz.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000) + self.buz_off = time.monotonic_ns() + 22_000_000 + def flash(self, level): + self.rgb = LEVEL_RGB.get(level, (0, 150, 255)) + self.led.set(*self.rgb) + def led_off(self): + self.rgb = (0, 0, 0) + self.led.set(0, 0, 0) + def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer + if self.midi is None: return + try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive - no Note Off needed) + except Exception: pass + + # ---------- transport ---------- + def toggle(self): + self.running = not self.running + if self.running: self._reset_clock(); self._start_play() + else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads(); self._log_play() + self.draw_status() + def set_bpm(self, v): + v = max(30, min(300, v)) + if v != self.bpm: + self.bpm = v + self.draw_bpm() + def goto(self, i): + was = self.running + if was: self.running = False; self._log_play() # close out the track that was playing + self.load(i) + if was: self.running = True; self._reset_clock(); self._start_play() + def tap(self): + now = time.monotonic() + if not hasattr(self, '_taps'): self._taps = [] + self._taps = [t for t in self._taps if now - t < 2.4] + self._taps.append(now) + if len(self._taps) >= 2: + span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1) + if span > 0: self.set_bpm(round(60 / span)) + + # ---------- scheduler ---------- + def tick(self): + now = time.monotonic_ns() + if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0 + if self.running: + fired = [] + for li, L in enumerate(self.lanes): + adv = False + while now >= L['next']: + L['step'] = (L['step'] + 1) % L['steps'] + lvl = 0 if L['mute'] else L['levels'][L['step']] + if lvl > 0: + fired.append(lvl) + self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane + L['next'] += self._step_dur(L, L['step']); adv = True + if adv and li < len(self.lane_pads): self._move_playhead(li, L['step']) + if fired: + best = max(fired, key=lambda l: PRIO.get(l, 0)) + if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead + self.flash(best) + if self.rgb != (0, 0, 0): + r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10 + self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0) + self.led.set(*self.rgb) + + # ---------- inputs ---------- + def poll(self): + a = self.btnA.value + if (not a) and self._aPrev: self.toggle() + self._aPrev = a + b = self.btnB.value + if (not b) and self._bPrev: self.tap() + self._bPrev = b + now = time.monotonic_ns() + if now >= self._joyNext: + x = self.jx.value - 32768; y = self.jy.value - 32768 + if JOY_INVERT_X: x = -x + if JOY_INVERT_Y: y = -y + if abs(y) > JOY_DEADZONE: + self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1)) + self._joyNext = now + 70_000_000 + elif abs(x) > JOY_DEADZONE: + self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = now + 350_000_000; return + else: + self._joyNext = now + 20_000_000 + pt = self.touch.read() + nowms = time.monotonic() + if pt: + self._touchSeen = nowms + if not self._touchDown: + self._touchDown = True; self._tap_log(pt[0], pt[1]) + elif self._touchDown and (nowms - self._touchSeen) > 0.14: + self._touchDown = False + # USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs) + if self.midi_in is not None: + try: n = self.midi_in.readinto(self._mbuf) + except Exception: n = 0 + if n: + self.last_midi_in = nowms + self._feed_midi(self._mbuf, n) + host = bool(self.last_midi_in) and (nowms - self.last_midi_in) < 1.0 + if host != self.midi_host: + self.midi_host = host + if host: self.buz.duty_cycle = 0 # silence the buzzer when the computer takes over + self.led_off(); self.draw_midi() + + # ---------- drawing ---------- + def draw_bpm(self): + self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12) + def draw_status(self): + self._place(self.g_run, "RUN" if self.running else "STOP", 12, 48, + C_GREEN if self.running else C_MUTE, C_BG, FONT_M) + self._place(self.g_name, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]), + 12, 112, C_TXT, C_BG, FONT_M) + def draw_midi(self): + self._place(self.g_midi, "MIDI" if self.midi_host else "", 0, 12, C_GREEN, C_BG, FONT_M, right_edge=WIDTH-12) + + # ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ---------- + def _padbase(self, L, s): + return 0 if L['mute'] else L['levels'][s] + def build_grid(self): + while len(self.g_grid): self.g_grid.pop() + self.lane_pads = []; self.lane_lit = [] + n = min(len(self.lanes), MAXLANES) + top = 140; rowh = min(40, (296 - top) // max(1, n)) + for li in range(n): + L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2 + tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG) + tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg) + steps = L['steps']; sub = L['sub']; px0 = 60 + usable = WIDTH - 8 - px0 - 12; stepw = max(1, usable // steps) + r_big = max(2, min(6, stepw // 2, (rowh - 8) // 2)); r_sml = max(2, r_big - 2) + pads = [] + for s in range(steps): + rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision + cxp = px0 + 6 + (s * usable) // steps # proportional -> beats line up across lanes + c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy) + c.color_index = self._padbase(L, s); self.g_grid.append(c); pads.append(c) + self.lane_pads.append(pads); self.lane_lit.append(-1) + self.dirty = True + def _move_playhead(self, li, step): + pads = self.lane_pads[li]; prev = self.lane_lit[li] + if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev) + if step < len(pads): pads[step].color_index = self._padbase(self.lanes[li], step) + 4 + self.lane_lit[li] = step; self.dirty = True + def reset_playheads(self): + for li, pads in enumerate(self.lane_pads): + prev = self.lane_lit[li] + if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev) + self.lane_lit[li] = -1 + self.dirty = True + + # ---------- practice history (saved to /history.json, next to programs.json) ---------- + def _probe_write(self): + try: + with open("/.wtest", "w") as f: f.write("1") + try: os.remove("/.wtest") + except Exception: pass + return True + except OSError: + return False # editor mode: the computer owns the FS + def _load_log(self): + try: + with open("/history.json") as f: return json.load(f).get("log", []) + except Exception: + return [] + def _save_log(self): + if not self.can_write: return + try: + with open("/history.json", "w") as f: json.dump({"log": self.log[:200]}, f) + except OSError: + self.can_write = False + def _start_play(self): + self.play_start = time.monotonic(); self.play_bpm = self.bpm; self.play_name = self.name + def _log_play(self): + if self.play_start is None: return + dur = int(time.monotonic() - self.play_start); self.play_start = None + if dur < MIN_LOG_SEC: return # skip plays under 5 seconds + t = time.localtime() + self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm, + "dur": dur, "name": self.play_name}) + del self.log[200:]; self._armed = None + self._save_log(); self.draw_log() + def draw_log(self): + g = self.g_log + while len(g): g.pop() + self.log_rows = [] + hdr, w, h = make_text("PRACTICE LOG", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr) + if not self.log: + tg, w, h = make_text("plays over 5s show here", FONT_S, C_DIM, C_BG); tg.x = 10; tg.y = LOG_TOP + LOG_ROWH; g.append(tg) + self.dirty = True; return + y = LOG_TOP + LOG_ROWH + 2 + for idx in range(min(LOG_ROWS, len(self.log))): + e = self.log[idx]; armed = (idx == self._armed) + dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60) + line = "%s%s %3d %5s %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur, e["name"][:16]) + tg, w, h = make_text(line, FONT_S, C_AMBER if armed else C_TXT, C_BG); tg.x = 10; tg.y = y; g.append(tg) + self.log_rows.append((y - 2, y + LOG_ROWH - 2, idx)) + y += LOG_ROWH + self.dirty = True + def _tap_log(self, x, ty): + for y0, y1, idx in self.log_rows: + if y0 <= ty <= y1: + if self._armed == idx: del self.log[idx]; self._armed = None; self._save_log(); self.draw_log() # confirm delete + else: self._armed = idx; self.draw_log() # arm (tap again) + return + if self._armed is not None: self._armed = None; self.draw_log() # tapped elsewhere -> cancel + + # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs) ---------- + def _feed_midi(self, buf, n): + for i in range(n): + b = buf[i] + if b == 0xF0: self._sx = bytearray(); self._sxon = True + elif b == 0xF7: + if self._sxon: self._handle_sysex(self._sx) + self._sxon = False + elif b >= 0xF8: pass # real-time (e.g. Active Sensing 0xFE) - ignore + elif self._sxon: + if len(self._sx) < 60000: self._sx.append(b) # big enough for a pushed firmware (app.py) + else: self._sxon = False # overflow guard + def _handle_sysex(self, sx): + if len(sx) < 2 or sx[0] != 0x7D: return # 0x7D = our (educational) manufacturer id + cmd = sx[1] + if cmd == 0x01 and len(sx) >= 8 and rtc is not None: # set clock: yr-2000, mo, dd, hh, mm, ss + try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1)) + except Exception: pass + elif cmd == 0x02: # version query -> reply 0x03 + APP_VERSION + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x03]) + APP_VERSION.encode() + bytes([0xF7])) + elif cmd == 0x10: # write /programs.json pushed from the editor, then reload + try: + with open("/programs.json", "wb") as f: f.write(bytes(sx[2:])) + self.programs = load_programs(); self.idx = min(self.idx, len(self.programs) - 1) + self.load(self.idx) + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK ok + except OSError: + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: read-only (editor mode) + elif cmd == 0x20: # A/B firmware update: install new app.py to the trial slot + try: + data = bytes(sx[2:]) + try: os.remove("/app.bak") + except OSError: pass + os.rename("/app.py", "/app.bak") # keep the current build as the rollback + with open("/app.py", "wb") as f: f.write(data) + open("/trial", "w").close() # arm the trial; the loader reverts if it won't boot + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK -> rebooting + time.sleep(0.3); supervisor.reload() + except OSError: + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: read-only (editor mode) + + def run(self): + if self.touch.addr is None: + print("GT911 touch not found") + boot = time.monotonic() + try: os.stat("/trial"); committed = False # we're a freshly-pushed build on trial + except OSError: committed = True + while True: + self.tick(); self.poll() + if not committed and time.monotonic() - boot > 5: # booted & ran fine for 5s -> confirm the update + try: os.remove("/trial") + except Exception: pass + committed = True + # push a complete frame only when something changed (no mid-update tearing); + # capped at the display's refresh rate, so dirty regions stay small and quick + if self.dirty and self.display.refresh(): + self.dirty = False + time.sleep(0.0005) + +App().run() diff --git a/pico-cp/code.py b/pico-cp/code.py index 3f9dd87..3ea730c 100644 --- a/pico-cp/code.py +++ b/pico-cp/code.py @@ -1,626 +1,23 @@ -# VARASYS PolyMeter — PM_K-1 "Kit" firmware (CircuitPython edition) -# Raspberry Pi Pico (Pico / Pico W / Pico 2) on the 52Pi EP-0172 "Pico Breadboard Kit Plus": -# 3.5" ST7796 320x480 cap-touch (GT911), PSP joystick, WS2812 RGB, buzzer, 2 buttons. +# code.py - PM_K-1 A/B firmware loader (stable; rarely changes). # -# WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your -# tracks (programs.json) and a copy of the editor — edit on the web, "Save to device" writes -# programs.json here, and CircuitPython auto-reloads with the new grooves. It also sends USB-MIDI -# (a note per click) so the web editor can play it out the computer's speakers ("Device audio"). -# Runs the SAME program strings as metronome.varasys.io. -# -# INSTALL: flash CircuitPython (https://circuitpython.org/board/raspberry_pi_pico/), then copy -# this file as code.py plus programs.json onto the CIRCUITPY drive. It runs on boot. -# -# Fallback: the simpler MicroPython firmware (pico/main.py) is always available — BOOTSEL + -# drag a MicroPython .uf2 to go back. The Pico cannot be bricked. -# -# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md. +# The real application lives in app.py; app.bak holds the previous known-good build. The web editor +# pushes a new app.py to a "trial" slot over USB-MIDI; this loader runs it, and if the new build +# fails to boot it AUTOMATICALLY ROLLS BACK to app.bak. (The Pico is also unbrickable: BOOTSEL -> +# drag a CircuitPython .uf2.) app.py clears the /trial marker once it has run healthily for ~5s. +import supervisor, os +supervisor.runtime.autoreload = False # updates reboot explicitly; never auto-restart on our own writes + +def _trial(): + try: os.stat("/trial"); return True + except OSError: return False -import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor -supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart try: - import rtc # set from the editor's clock SysEx so the log has real timestamps -except ImportError: - rtc = None -try: # CircuitPython 9.x - from fourwire import FourWire - from busdisplay import BusDisplay -except ImportError: # CircuitPython 8.x - from displayio import FourWire - from displayio import Display as BusDisplay -try: - import neopixel_write # core module on RP2040 — drives WS2812 with no external library -except ImportError: - neopixel_write = None -try: - import usb_midi # default-enabled on RP2040 — sends a MIDI note per click to the computer -except ImportError: - usb_midi = None - -# ============================== CONFIG (tweak if needed) ============================== -SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable -LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you — keep it dim (0..1) -MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio") -MUTE_BUZZER = False # silence the on-board buzzer (e.g. when using computer audio) -WIDTH, HEIGHT = 320, 480 -MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed. -INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative -# Touch (GT911) — flip if taps land wrong: -TOUCH_SWAP_XY = False -TOUCH_INVERT_X = False -TOUCH_INVERT_Y = False -TOUCH_DEBUG = False -# Joystick: -JOY_INVERT_X = False -JOY_INVERT_Y = False -JOY_DEADZONE = 9000 - -# ----- pins (fixed by the EP-0172 board) ----- -P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7 -P_SDA, P_SCL = board.GP8, board.GP9 -P_RGB, P_BUZ, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14 -P_JOYX, P_JOYY = board.GP26, board.GP27 - -# ----- baked default grooves (used only if programs.json is missing/bad) ----- -DEFAULT_PROGRAMS = [ - ("Four on the floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"), - ("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"), - ("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"), - ("5 over 4", "t100;kick:4;claves:5~"), - ("Straight click", "t120;beep:4"), -] - -# ============================== COLOURS (0xRRGGBB; displayio handles 565) ============================== -C_BG, C_PANEL, C_TXT, C_MUTE = 0x06090E, 0x1C222C, 0xC7D0DB, 0x788494 -C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240 -C_BTN = 0x1C222C -LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)} -# voice -> General-MIDI note (USB-MIDI bridge), and level -> MIDI velocity -SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare909":38, - "clap":39,"clap808":39,"clap909":39, "rim":37, "hatClosed":42,"hat808":42,"hat909":42, - "hatOpen":46,"openHat808":46, "ride":51,"ride909":51, "crash":49,"crash909":49, - "tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54, - "cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37} -GM_DEFAULT = 37 -MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost -MAXLANES = 5 # lanes shown on the pad grid (extras still play) -LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid) -MIN_LOG_SEC = 5 # don't log plays shorter than this -PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost -PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost - -# WS2812 RGB LED — self-contained via the core neopixel_write module (no external library) -class RGB: - def __init__(self, pin): - self.ok = neopixel_write is not None - if self.ok: - self.io = digitalio.DigitalInOut(pin); self.io.direction = digitalio.Direction.OUTPUT - self.buf = bytearray(3) - def set(self, r, g, b): - if not self.ok: return - # WS2812 wants GRB order; scale down so it isn't blinding - self.buf[0] = int(g * LED_BRIGHTNESS); self.buf[1] = int(r * LED_BRIGHTNESS); self.buf[2] = int(b * LED_BRIGHTNESS) - try: neopixel_write.neopixel_write(self.io, self.buf) - except Exception: self.ok = False - -# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ============================== -def load_font(path): - with open(path, "rb") as f: - blob = f.read() - count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {} - for _ in range(count): - cp = (blob[p] << 8) | blob[p+1]; w = blob[p+2]; h = blob[p+3] - xoff = blob[p+4]; xoff = xoff - 256 if xoff > 127 else xoff - top = blob[p+5]; adv = blob[p+6]; p += 7 - glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2 - return (glyphs, blob) - -FONT_S = load_font("/font_s.bin") # small — pad-grid lane labels -FONT_M = load_font("/font_m.bin") # labels / buttons -FONT_L = load_font("/font_l.bin") # big BPM -gc.collect() - -def _blend(bg, fg, i): - t = i * 17 - r = (((bg >> 16) & 0xFF)*(255-t) + ((fg >> 16) & 0xFF)*t) // 255 - g = (((bg >> 8) & 0xFF)*(255-t) + ((fg >> 8) & 0xFF)*t) // 255 - b = ((bg & 0xFF)*(255-t) + (fg & 0xFF)*t) // 255 - return (r << 16) | (g << 8) | b - -def make_text(s, font, fg, bg): - """Render a string into a displayio TileGrid (anti-aliased via a 16-step blend palette).""" - glyphs, blob = font - w = 0; top0 = 999; bot = 0 - for c in s: - g = glyphs.get(ord(c)) - if not g: continue - w += g[4] - if g[1]: - if g[3] < top0: top0 = g[3] - if g[3] + g[1] > bot: bot = g[3] + g[1] - if top0 == 999: top0 = 0 - w = max(1, w); h = max(1, bot - top0) - gc.collect() - bmp = displayio.Bitmap(w, h, 16) - pal = displayio.Palette(16) - for i in range(16): pal[i] = _blend(bg, fg, i) - pen = 0 - for c in s: - g = glyphs.get(ord(c)) - if not g: continue - gw, gh, xoff, gtop, adv, off = g - for j in range(gh): - row = (gtop - top0) + j - for i in range(gw): - k = j * gw + i - byte = blob[off + (k >> 1)] - nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF) - if nib: - x = pen + xoff + i - if 0 <= x < w and 0 <= row < h: bmp[x, row] = nib - pen += adv - return displayio.TileGrid(bmp, pixel_shader=pal), w, h - -# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ============================== -PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} -PRIO = {2: 3, 1: 2, 3: 1} - -def parse_program(s): - bpm = 120; lanes = [] - for tok in s.strip().split(';'): - tok = tok.strip() - if not tok: continue - if tok[0] == 't' and tok[1:].isdigit(): - bpm = int(tok[1:]); continue - if ':' not in tok: continue - lane = _parse_lane(tok) - if lane: lanes.append(lane) - if not lanes: lanes = [_parse_lane("beep:4")] - return max(30, min(300, bpm)), lanes - -def _parse_lane(tok): - poly = '~' in tok; mute = '!' in tok - tok = tok.replace('~', '').replace('!', '') - if '@' in tok: tok = tok.split('@')[0] - sound, _, rest = tok.partition(':') - pattern = None - if '=' in rest: rest, _, pattern = rest.partition('=') - sub = 1; swing = False - if '/' in rest: - rest, _, sd = rest.partition('/') - swing = sd.endswith('s'); sd = sd.rstrip('s') # "/2s" = swung eighths - sub = int(sd) if sd.isdigit() else 1 - groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4] - beats = sum(groups); starts = set(); acc = 0 - for gp in groups: starts.add(acc); acc += gp - steps = beats * sub - if pattern: - levels = [PAT.get(ch, 0) for ch in pattern] - if len(levels) < steps: levels += [0] * (steps - len(levels)) - steps = len(levels) - else: - levels = [] - for i in range(steps): - if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) - else: levels.append(0) - return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute} - -def load_programs(): - try: - with open("/programs.json") as f: - d = json.load(f) - progs = [(p["name"], p["prog"]) for p in d["programs"]] - if progs: return progs - except Exception as e: - print("programs.json:", e) - return DEFAULT_PROGRAMS - -# ============================== GT911 TOUCH ============================== -class GT911: - def __init__(self, i2c): - self.i2c = i2c; self.addr = None - while not i2c.try_lock(): pass - try: found = i2c.scan() - finally: i2c.unlock() - for a in (0x5D, 0x14): - if a in found: self.addr = a; break - if self.addr is None and found: self.addr = found[0] - def _rd(self, reg, n): - b = bytearray(n) - while not self.i2c.try_lock(): pass + import app # runs the application (app.py ends with App().run()) +except Exception: + if _trial(): # a freshly-pushed build crashed on startup -> roll back try: - self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF])) - self.i2c.readfrom_into(self.addr, b) - finally: self.i2c.unlock() - return b - def _wr(self, reg, val): - while not self.i2c.try_lock(): pass - try: self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF, val])) - finally: self.i2c.unlock() - def read(self): - if self.addr is None: return None - try: st = self._rd(0x814E, 1)[0] - except OSError: return None - if not (st & 0x80): return None - n = st & 0x0F; pt = None - if n >= 1: - b = self._rd(0x8150, 4); tx = b[0] | (b[1] << 8); ty = b[2] | (b[3] << 8) - pt = self._map(tx, ty) - try: self._wr(0x814E, 0) - except OSError: pass - return pt - def _map(self, tx, ty): - if TOUCH_DEBUG: print("touch raw", tx, ty) - if TOUCH_SWAP_XY: tx, ty = ty, tx - if TOUCH_INVERT_X: tx = WIDTH - 1 - tx - if TOUCH_INVERT_Y: ty = HEIGHT - 1 - ty - if 0 <= tx < WIDTH and 0 <= ty < HEIGHT: return (tx, ty) - return None - -# ============================== DISPLAY SETUP ============================== -def st7796_init(): - inv = b'\x21\x00' if INVERT_COLORS else b'\x20\x00' - return ( - b'\x01\x80\x78' # SWRESET + 120ms - b'\x11\x80\x78' # SLPOUT + 120ms - b'\xF0\x01\xC3' b'\xF0\x01\x96' # command-set unlock - + bytes([0x36, 0x01, MADCTL]) + - b'\x3A\x01\x55' # 16bpp - b'\xB4\x01\x01' - b'\xB6\x03\x80\x02\x3B' - b'\xE8\x08\x40\x8A\x00\x00\x29\x19\xA5\x33' - b'\xC1\x01\x06' b'\xC2\x01\xA7' - b'\xC5\x81\x18\x78' # VCOM + 120ms - b'\xE0\x0E\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B' - b'\xE1\x0E\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B' - b'\xF0\x01\x3C' b'\xF0\x81\x69\x78' # lock + 120ms - + inv + - b'\x29\x80\x32' # DISPON + 50ms - ) - -def make_display(): - displayio.release_displays() - spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI) - bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD) - return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=False) - -def solid(color): - p = displayio.Palette(1); p[0] = color; return p - -def rect(x, y, w, h, color): - return vectorio.Rectangle(pixel_shader=solid(color), width=w, height=h, x=x, y=y) - -# ============================== APP ============================== -class App: - def __init__(self): - self.display = make_display() - self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) - self.touch = GT911(self.i2c) - self.midi = usb_midi.ports[1] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 1) else None - self.midi_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None - self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0 - self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs) - self.led = RGB(P_RGB) - self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) - self.buz_off = 0 - self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB) - self._aPrev = True; self._bPrev = True - self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY) - self._joyNext = 0 - self._touchDown = False; self._touchSeen = 0 - self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0) - self.programs = load_programs() - self.dirty = True - self.pad_pal = displayio.Palette(8) - for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i] - self.lane_pads = []; self.lane_lit = [] - # practice history — persisted to /history.json (next to programs.json) when we own the filesystem - self.can_write = self._probe_write() - self.log = self._load_log() - self.play_start = None; self.play_bpm = 0; self.play_name = "" - self._armed = None; self.log_rows = [] - self._build_scene() - self.load(0) - self.draw_log() - - def _btn(self, pin): - d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP - return d - - # ---------- scene graph ---------- - def _build_scene(self): - root = displayio.Group(); self.display.root_group = root - root.append(rect(0, 0, WIDTH, HEIGHT, C_BG)) - tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg) - root.append(rect(0, 38, WIDTH, 2, C_PANEL)) - # dynamic groups - self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right) - self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left) - self.g_name = displayio.Group(); root.append(self.g_name) # item index + name - self.g_midi = displayio.Group(); root.append(self.g_midi) # "MIDI" indicator (top-right) when a host is listening - self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes × step pads - root.append(rect(0, LOG_TOP - 6, WIDTH, 2, C_PANEL)) # divider above the history log - self.g_log = displayio.Group(); root.append(self.g_log) # practice history (tap a row to delete) - # (no on-screen buttons — transport is the joystick + buttons A/B; touch deletes log rows) - - def _place(self, group, s, x, y, fg, bg, font, right_edge=None): - while len(group): group.pop() - self.dirty = True - if not s: return - tg, w, h = make_text(s, font, fg, bg) - tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg) - def _center(self, group, s, cx, cy, fg, bg, font): - while len(group): group.pop() - tg, w, h = make_text(s, font, fg, bg); tg.x = cx - w//2; tg.y = cy - h//2; group.append(tg) - self.dirty = True - - # ---------- program ---------- - def load(self, i): - n = len(self.programs); self.idx = i % n - self.name, prog = self.programs[self.idx] - self.bpm, self.lanes = parse_program(prog) - self.master = self.lanes[0] - self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid() - def _step_dur(self, L, step): - beat = 60_000_000_000 / self.bpm - if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar - m = self.lanes[0]; master_bar = beat * (m['steps'] // m['sub']) - return int(master_bar / L['steps']) - sub = L['sub'] - if L['swing'] and sub % 2 == 0: # swing even subdivisions: long–short (2:1) pairs - pair = beat / (sub // 2) - return int(pair * 2 / 3) if (step % sub) % 2 == 0 else int(pair / 3) - return int(beat / sub) # straight: a step = one beat / subdivision - def _reset_clock(self): - now = time.monotonic_ns() - for L in self.lanes: - L['next'] = now; L['step'] = -1 - - # ---------- audio + light ---------- - def click(self, level): - self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600) - self.buz.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000) - self.buz_off = time.monotonic_ns() + 22_000_000 - def flash(self, level): - self.rgb = LEVEL_RGB.get(level, (0, 150, 255)) - self.led.set(*self.rgb) - def led_off(self): - self.rgb = (0, 0, 0) - self.led.set(0, 0, 0) - def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer - if self.midi is None: return - try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive — no Note Off needed) + os.remove("/app.py"); os.rename("/app.bak", "/app.py"); os.remove("/trial") except Exception: pass - - # ---------- transport ---------- - def toggle(self): - self.running = not self.running - if self.running: self._reset_clock(); self._start_play() - else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads(); self._log_play() - self.draw_status() - def set_bpm(self, v): - v = max(30, min(300, v)) - if v != self.bpm: - self.bpm = v - self.draw_bpm() - def goto(self, i): - was = self.running - if was: self.running = False; self._log_play() # close out the track that was playing - self.load(i) - if was: self.running = True; self._reset_clock(); self._start_play() - def tap(self): - now = time.monotonic() - if not hasattr(self, '_taps'): self._taps = [] - self._taps = [t for t in self._taps if now - t < 2.4] - self._taps.append(now) - if len(self._taps) >= 2: - span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1) - if span > 0: self.set_bpm(round(60 / span)) - - # ---------- scheduler ---------- - def tick(self): - now = time.monotonic_ns() - if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0 - if self.running: - fired = [] - for li, L in enumerate(self.lanes): - adv = False - while now >= L['next']: - L['step'] = (L['step'] + 1) % L['steps'] - lvl = 0 if L['mute'] else L['levels'][L['step']] - if lvl > 0: - fired.append(lvl) - self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane - L['next'] += self._step_dur(L, L['step']); adv = True - if adv and li < len(self.lane_pads): self._move_playhead(li, L['step']) - if fired: - best = max(fired, key=lambda l: PRIO.get(l, 0)) - if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead - self.flash(best) - if self.rgb != (0, 0, 0): - r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10 - self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0) - self.led.set(*self.rgb) - - # ---------- inputs ---------- - def poll(self): - a = self.btnA.value - if (not a) and self._aPrev: self.toggle() - self._aPrev = a - b = self.btnB.value - if (not b) and self._bPrev: self.tap() - self._bPrev = b - now = time.monotonic_ns() - if now >= self._joyNext: - x = self.jx.value - 32768; y = self.jy.value - 32768 - if JOY_INVERT_X: x = -x - if JOY_INVERT_Y: y = -y - if abs(y) > JOY_DEADZONE: - self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1)) - self._joyNext = now + 70_000_000 - elif abs(x) > JOY_DEADZONE: - self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = now + 350_000_000; return - else: - self._joyNext = now + 20_000_000 - pt = self.touch.read() - nowms = time.monotonic() - if pt: - self._touchSeen = nowms - if not self._touchDown: - self._touchDown = True; self._tap_log(pt[0], pt[1]) - elif self._touchDown and (nowms - self._touchSeen) > 0.14: - self._touchDown = False - # USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs) - if self.midi_in is not None: - try: n = self.midi_in.readinto(self._mbuf) - except Exception: n = 0 - if n: - self.last_midi_in = nowms - self._feed_midi(self._mbuf, n) - host = bool(self.last_midi_in) and (nowms - self.last_midi_in) < 1.0 - if host != self.midi_host: - self.midi_host = host - if host: self.buz.duty_cycle = 0 # silence the buzzer when the computer takes over - self.led_off(); self.draw_midi() - - # ---------- drawing ---------- - def draw_bpm(self): - self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12) - def draw_status(self): - self._place(self.g_run, "RUN" if self.running else "STOP", 12, 48, - C_GREEN if self.running else C_MUTE, C_BG, FONT_M) - self._place(self.g_name, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]), - 12, 112, C_TXT, C_BG, FONT_M) - def draw_midi(self): - self._place(self.g_midi, "MIDI" if self.midi_host else "", 0, 12, C_GREEN, C_BG, FONT_M, right_edge=WIDTH-12) - - # ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ---------- - def _padbase(self, L, s): - return 0 if L['mute'] else L['levels'][s] - def build_grid(self): - while len(self.g_grid): self.g_grid.pop() - self.lane_pads = []; self.lane_lit = [] - n = min(len(self.lanes), MAXLANES) - top = 140; rowh = min(40, (296 - top) // max(1, n)) - for li in range(n): - L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2 - tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG) - tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg) - steps = L['steps']; sub = L['sub']; px0 = 60 - usable = WIDTH - 8 - px0 - 12; stepw = max(1, usable // steps) - r_big = max(2, min(6, stepw // 2, (rowh - 8) // 2)); r_sml = max(2, r_big - 2) - pads = [] - for s in range(steps): - rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision - cxp = px0 + 6 + (s * usable) // steps # proportional → beats line up across lanes - c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy) - c.color_index = self._padbase(L, s); self.g_grid.append(c); pads.append(c) - self.lane_pads.append(pads); self.lane_lit.append(-1) - self.dirty = True - def _move_playhead(self, li, step): - pads = self.lane_pads[li]; prev = self.lane_lit[li] - if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev) - if step < len(pads): pads[step].color_index = self._padbase(self.lanes[li], step) + 4 - self.lane_lit[li] = step; self.dirty = True - def reset_playheads(self): - for li, pads in enumerate(self.lane_pads): - prev = self.lane_lit[li] - if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev) - self.lane_lit[li] = -1 - self.dirty = True - - # ---------- practice history (saved to /history.json, next to programs.json) ---------- - def _probe_write(self): - try: - with open("/.wtest", "w") as f: f.write("1") - try: os.remove("/.wtest") - except Exception: pass - return True - except OSError: - return False # editor mode: the computer owns the FS - def _load_log(self): - try: - with open("/history.json") as f: return json.load(f).get("log", []) - except Exception: - return [] - def _save_log(self): - if not self.can_write: return - try: - with open("/history.json", "w") as f: json.dump({"log": self.log[:200]}, f) - except OSError: - self.can_write = False - def _start_play(self): - self.play_start = time.monotonic(); self.play_bpm = self.bpm; self.play_name = self.name - def _log_play(self): - if self.play_start is None: return - dur = int(time.monotonic() - self.play_start); self.play_start = None - if dur < MIN_LOG_SEC: return # skip plays under 5 seconds - t = time.localtime() - self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm, - "dur": dur, "name": self.play_name}) - del self.log[200:]; self._armed = None - self._save_log(); self.draw_log() - def draw_log(self): - g = self.g_log - while len(g): g.pop() - self.log_rows = [] - hdr, w, h = make_text("PRACTICE LOG", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr) - if not self.log: - tg, w, h = make_text("plays over 5s show here", FONT_S, C_DIM, C_BG); tg.x = 10; tg.y = LOG_TOP + LOG_ROWH; g.append(tg) - self.dirty = True; return - y = LOG_TOP + LOG_ROWH + 2 - for idx in range(min(LOG_ROWS, len(self.log))): - e = self.log[idx]; armed = (idx == self._armed) - dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60) - line = "%s%s %3d %5s %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur, e["name"][:16]) - tg, w, h = make_text(line, FONT_S, C_AMBER if armed else C_TXT, C_BG); tg.x = 10; tg.y = y; g.append(tg) - self.log_rows.append((y - 2, y + LOG_ROWH - 2, idx)) - y += LOG_ROWH - self.dirty = True - def _tap_log(self, x, ty): - for y0, y1, idx in self.log_rows: - if y0 <= ty <= y1: - if self._armed == idx: del self.log[idx]; self._armed = None; self._save_log(); self.draw_log() # confirm delete - else: self._armed = idx; self.draw_log() # arm (tap again) - return - if self._armed is not None: self._armed = None; self.draw_log() # tapped elsewhere -> cancel - - # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs) ---------- - def _feed_midi(self, buf, n): - for i in range(n): - b = buf[i] - if b == 0xF0: self._sx = bytearray(); self._sxon = True - elif b == 0xF7: - if self._sxon: self._handle_sysex(self._sx) - self._sxon = False - elif b >= 0xF8: pass # real-time (e.g. Active Sensing 0xFE) — ignore - elif self._sxon: - if len(self._sx) < 6000: self._sx.append(b) - else: self._sxon = False # overflow guard - def _handle_sysex(self, sx): - if len(sx) < 2 or sx[0] != 0x7D: return # 0x7D = our (educational) manufacturer id - cmd = sx[1] - if cmd == 0x01 and len(sx) >= 8 and rtc is not None: # set clock: yr-2000, mo, dd, hh, mm, ss - try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1)) - except Exception: pass - elif cmd == 0x10: # write /programs.json pushed from the editor, then reload - try: - with open("/programs.json", "wb") as f: f.write(bytes(sx[2:])) - self.programs = load_programs(); self.idx = min(self.idx, len(self.programs) - 1) - self.load(self.idx) - if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK ok - except OSError: - if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: read-only (editor mode) - - def run(self): - if self.touch.addr is None: - print("GT911 touch not found") - while True: - self.tick(); self.poll() - # push a complete frame only when something changed (no mid-update tearing); - # capped at the display's refresh rate, so dirty regions stay small and quick - if self.dirty and self.display.refresh(): - self.dirty = False - time.sleep(0.0005) - -App().run() + supervisor.reload() # reboot into the restored known-good build + else: + raise # the active build failed unexpectedly (rare) -> on-screen traceback