From 5e71df6b17d5093b275d9caf27a69bbfba81f9b6 Mon Sep 17 00:00:00 2001 From: Me Here Date: Fri, 29 May 2026 11:33:43 -0500 Subject: [PATCH] PM_K-1: chunked firmware transfer (reliable), LED run/stop indicator, revert bg tint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APP_VERSION -> 0.0.6. Device firmware + editor change in lockstep — one-time manual copy of 0.0.6 needed (the broken single-shot updater can't deliver it). Update transport (fixes the failed/bricking updates): - Editor now pushes app.py in 512-byte flow-controlled chunks: begin(0x21,len) -> data(0x22)* -> commit(0x23), waiting for each ACK before the next. A single ~38KB SysEx overran the device's USB-MIDI input buffer and arrived corrupt. - Device writes chunks straight to /app.new, and on commit verifies length + no NUL + App().run()/APP_VERSION present before the A/B install; rejects (NAK) otherwise and keeps the working build. All errors caught -> never bricks. Run/stop indicator moved off the screen onto the RGB LED (per feedback that recoloring the whole background is wrong — it forces a full-screen SPI repaint and fringes the anti-aliased text): - Dim GREEN when stopped ("on"), dim RED while playing; the beat pulse flashes brighter and now decays back to the running base instead of to black. Background is static black. Co-Authored-By: Claude Opus 4.7 (1M context) --- editor.html | 34 ++++++--- pico-cp/__pycache__/app.cpython-312.pyc | Bin 54623 -> 56614 bytes pico-cp/app.py | 95 +++++++++++++++--------- 3 files changed, 85 insertions(+), 44 deletions(-) diff --git a/editor.html b/editor.html index c8ea865..724271f 100644 --- a/editor.html +++ b/editor.html @@ -1197,15 +1197,31 @@ async function updateFirmware() { // A/B firmware update over USB-MIDI, with a if (!confirm("Device firmware: " + (dev || "unknown") + "\nNew build: " + latest + (upToDate ? "\n\nSame version. 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."); + const err = await _pushFirmware(src); + if (err) return alert("Update didn't complete (" + err + ").\n\nThe device kept its working firmware — nothing was installed. " + + "Make sure it's plugged in and NOT in editor mode (don't hold A), on firmware 0.0.6+, then retry. " + + "(If the device is older than 0.0.6, drag app.py onto the drive once in editor mode to get the chunked updater.)"); + alert("Update sent ✓ — the device verified v" + latest + " and is rebooting into it. It auto-confirms after a few seconds, or rolls back if it won't start."); +} +// One ACK/NAK await (the device replies 0x7F=ok / 0x7E=rejected after each SysEx); resolves true/false/null(timeout). +function _ack(timeout) { + return new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, timeout); }); +} +// Push app.py in small, flow-controlled chunks: a giant single SysEx overruns the device's MIDI input +// buffer and arrives corrupt. begin(0x21,len) -> data(0x22)* -> commit(0x23); wait for each ACK. +async function _pushFirmware(src) { + const data = []; for (let i = 0; i < src.length; i++) data.push(src.charCodeAt(i) & 0x7F); // 7-bit ASCII + const n = data.length; + _send([0xF0, 0x7D, 0x21, n & 0x7F, (n >> 7) & 0x7F, (n >> 14) & 0x7F, (n >> 21) & 0x7F, 0xF7]); + if (await _ack(3000) !== true) return "handshake"; + const CH = 512; + for (let o = 0; o < n; o += CH) { + _send([0xF0, 0x7D, 0x22].concat(data.slice(o, o + CH)).concat([0xF7])); + const a = await _ack(4000); + if (a !== true) return "transfer at " + o + "/" + n + (a === false ? " (rejected)" : " (timeout)"); + } + _send([0xF0, 0x7D, 0x23, 0xF7]); // commit: device verifies the whole file, then reboots + return (await _ack(6000)) === true ? null : "verify"; } // Where the new app.py comes from: the site when online (the https editor, same-origin), else let the user // pick the file — so the OFFLINE editor that ships ON the device can update too (file:// pages can't fetch). diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index 20ce088f4cc55974f9d44b59499d402b0279a86c..47a56bdc98b319858e8441f45fd8a42164cecf53 100644 GIT binary patch delta 14220 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 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, @@ -91,7 +93,6 @@ MIN_LOG_SEC = 5 # don't log plays shorter t PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost C_GRID = 0x1A2330 # faint vertical beat gridlines (beats line up across lanes) -C_RUNBG = 0x161D28 # background tint while running (vs near-black when stopped) # WS2812 RGB LED - self-contained via the core neopixel_write module (no external library) class RGB: @@ -331,6 +332,7 @@ class App: 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._fw = None; self._fw_len = 0 # chunked firmware transfer: staging file + expected size self.led = RGB(P_RGB) self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) self.buz_off = 0 @@ -347,7 +349,7 @@ class App: self.lane_pads = []; self.lane_lit = [] self.usb_conn = False; self._m_steps = 0 # USB-connected state; master-lane steps (for the bar counter) self._uiNext = 0.0; self._lastTs = None; self._lastBs = None # throttle the stopwatch/bar redraw - self.ic_midi_pal = None; self.ic_usb_pal = None; self.bg_pal = None + self.ic_midi_pal = None; self.ic_usb_pal = None # 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() @@ -355,7 +357,7 @@ class App: self._armed = None; self.log_rows = [] self._build_scene() self.load(0) # load() also draws the (track-filtered) practice log - self.draw_icons(); self.draw_meters() + self.draw_icons(); self.draw_meters(); self.led_rest() # LED green = on def _btn(self, pin): d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP @@ -364,8 +366,7 @@ class App: # ---------- scene graph ---------- def _build_scene(self): root = displayio.Group(); self.display.root_group = root - self.bg_pal = solid(C_BG) # recolored on play/stop (black <-> running gray) - root.append(vectorio.Rectangle(pixel_shader=self.bg_pal, width=WIDTH, height=HEIGHT, x=0, y=0)) + root.append(rect(0, 0, WIDTH, HEIGHT, C_BG)) # static background (run state shows on the LED) # header: VARASYS logo (left, no tagline) + version (small, top, right of the logo) + MIDI/USB icons (right) if LOGO: tg, _p, lw, lh = make_glyph(LOGO, C_CYAN, C_BG); tg.x = 10; tg.y = 9; root.append(tg) @@ -430,12 +431,14 @@ class App: 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 _led_base(self): + return LED_RUN if self.running else LED_IDLE # dim red while playing / dim green when stopped def flash(self, level): - self.rgb = LEVEL_RGB.get(level, (0, 150, 255)) + self.rgb = LEVEL_RGB.get(level, (0, 150, 255)) # bright beat pulse, fades back to the base in tick() + self.led.set(*self.rgb) + def led_rest(self): # settle to the resting colour (green idle / red running) + self.rgb = self._led_base() 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) @@ -445,8 +448,8 @@ class App: 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_runbg(); self.draw_meters() + else: self.buz.duty_cycle = 0; self.reset_playheads(); self._log_play() + self.led_rest(); self.draw_meters() # LED shows run state: red running / green stopped def set_bpm(self, v): v = max(30, min(300, v)) if v != self.bpm: @@ -457,7 +460,7 @@ class App: 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() - self.draw_runbg(); self.draw_meters() + self.led_rest(); self.draw_meters() def tap(self): now = time.monotonic() if not hasattr(self, '_taps'): self._taps = [] @@ -488,10 +491,13 @@ class App: 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) + base = self._led_base() # decay the beat pulse back down to the red running base + if self.rgb != base: + r = base[0] + (self.rgb[0]-base[0])*7//10 + g = base[1] + (self.rgb[1]-base[1])*7//10 + b = base[2] + (self.rgb[2]-base[2])*7//10 + if abs(r-base[0])+abs(g-base[1])+abs(b-base[2]) < 6: r, g, b = base + self.rgb = (r, g, b); self.led.set(r, g, b) # ---------- inputs ---------- def poll(self): @@ -532,7 +538,7 @@ class App: 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_icons() + self.led_rest(); self.draw_icons() uc = bool(getattr(supervisor.runtime, "usb_connected", True)) # connected to a computer? if uc != self.usb_conn: self.usb_conn = uc; self.draw_icons() @@ -544,9 +550,6 @@ class App: self._place(self.g_name, self.name[:22], 12, 116, C_TXT, C_BG, FONT_M) self._place(self.g_idx, "%d/%d" % (self.idx + 1, len(self.programs)), 0, 120, C_DIM, C_BG, FONT_S, right_edge=WIDTH - 12) - def draw_runbg(self): # run/stop indicator: tint the whole background - if self.bg_pal is not None: self.bg_pal[0] = C_RUNBG if self.running else C_BG - self.dirty = True def draw_icons(self): # recolor the MIDI/USB icons by state (tear-free palette swap) if self.ic_midi_pal is not None: _recolor(self.ic_midi_pal, C_GREEN if self.midi_host else C_DIM, C_BG) @@ -701,23 +704,45 @@ class App: 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 + # A/B firmware update, sent as small flow-controlled chunks (a single huge SysEx overruns the + # USB-MIDI input buffer and arrives corrupt). begin(0x21,len) -> data(0x22)* -> commit(0x23). + elif cmd == 0x21: # BEGIN: open the staging file; sx[2:6] = expected length (7-bit) + self._fw_len = (sx[2] | (sx[3] << 7) | (sx[4] << 14) | (sx[5] << 21)) if len(sx) >= 6 else 0 try: - data = bytes(sx[2:]) - # sanity-check the transfer before touching the working build: a corrupt/truncated push - # (e.g. a dropped MIDI byte, or a 7-bit-mangled non-ASCII char -> NUL) must NOT be installed. - if (0 in data) or (b"App().run()" not in data) or (b"APP_VERSION" not in data): - if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: rejected, kept current build - return + try: self._fw.close() + except Exception: pass + self._fw = open("/app.new", "wb"); self._ack(True) + except Exception: # read-only (editor mode) / no space + self._fw = None; self._ack(False) + elif cmd == 0x22: # DATA: append a chunk to the staging file + try: + if self._fw is None: raise OSError() + self._fw.write(bytes(sx[2:])); self._fw.flush(); self._ack(True) + except Exception: + try: self._fw.close() + except Exception: pass + self._fw = None; self._ack(False) + elif cmd == 0x23: # COMMIT: verify the whole file, then A/B install + reboot + try: + try: self._fw.close() + except Exception: pass + self._fw = None; gc.collect() + with open("/app.new", "rb") as f: data = f.read() + if (self._fw_len and len(data) != self._fw_len) or (0 in data) \ + or (b"App().run()" not in data) or (b"APP_VERSION" not in data): + try: os.remove("/app.new") # corrupt/truncated -> reject, keep the working build + except OSError: pass + self._ack(False); return 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) + os.rename("/app.py", "/app.bak") # current build becomes the rollback + os.rename("/app.new", "/app.py") 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.4); supervisor.reload() - except Exception: # catch ALL (OSError read-only, MemoryError, ...) -> never brick - if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK + self._ack(True); time.sleep(0.4); supervisor.reload() + except Exception: # catch ALL (read-only, MemoryError, ...) -> never brick + self._ack(False) + def _ack(self, ok): + if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F if ok else 0x7E, 0xF7])) def run(self): if self.touch.addr is None: