From 3805c5ee00290bb4976b37c9d1855675f1cc3d03 Mon Sep 17 00:00:00 2001 From: Me Here Date: Sat, 30 May 2026 22:40:11 -0500 Subject: [PATCH] PM_X-1 0.0.4 + editor push diagnostics Layout fixes (user reported BPM/time were still bumping the header at 0.0.3): - All Y coords below the header divider shifted down 6px: BPM 30->38, time 32->38, bar 44->50, train 52->58, setlist tab 66->72, title 82->88. - GRID_TOP 104 -> 110. Restored the Kit-style footer practice log: - LOG_TOP=218, LOG_ROWH=14, LOG_ROWS=6. - MAXLANES dropped from 6 visible to 4 visible (rowh capped at 26 so the grid doesn't run into the log). Tracks with more lanes still play silently. - _build_scene now appends g_log (with a divider above it). - draw_log() draws the current-track log into the footer; load() + _log_play() + the seam apply path all call it. The Practice-log menu entry is kept for the full scrollable history. Editor diagnostics for the firmware push (the user got chunk-1 ACK then the device's MIDI badge went gray, meaning chunks 2+ never reached it): - editor.html + editor-beta.html _pushFirmware() now logs every MIDI output + input it sees along with which ones _isDevicePort() matched, plus per- chunk send/ACK timing for the first 3 chunks and any failed chunk. - This narrows down whether the failure is (a) wrong-port routing (filter doesn't match the Pimoroni Explorer's name), (b) ACK never arriving back to the host, or (c) chunks sent fine but the device's RX buffer is dropping them. Co-Authored-By: Claude Opus 4.7 (1M context) --- editor-beta.html | 16 +++++++++-- editor.html | 16 +++++++++-- pico-explorer/app.py | 65 ++++++++++++++++++++++++++++++-------------- 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/editor-beta.html b/editor-beta.html index 7a74624..b9b0c3e 100644 --- a/editor-beta.html +++ b/editor-beta.html @@ -1274,16 +1274,26 @@ function _b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String. // waiting for each ACK. The device base64-decodes each chunk to /app.new, verifies the .mpy header, then // A/B-installs + reboots. CH is small (and a multiple of 4) so a chunk fits the Pico's USB-MIDI RX buffer. async function _pushFirmware(b64) { + // Diagnostic: list every MIDI output the editor sees + which pass _isDevicePort + console.log("[fw] outputs:", _midiOutputs().map(o => ({ name: o.name || "?", match: _isDevicePort(o) }))); + console.log("[fw] inputs:", _midiInputs() .map(i => ({ name: i.name || "?", match: _isDevicePort(i) }))); + console.log("[fw] sending BEGIN (0x21)"); _send([0xF0, 0x7D, 0x21, 0xF7]); - if (await _ack(5000) !== true) return "handshake"; + const beginA = await _ack(5000); + console.log("[fw] BEGIN ack:", beginA); + if (beginA !== true) return "handshake"; const CH = 64, total = Math.ceil(b64.length / CH); let done = 0; const t0 = Date.now(); for (let o = 0; o < b64.length; o += CH) { const part = b64.slice(o, o + CH); const msg = [0xF0, 0x7D, 0x22]; for (let i = 0; i < part.length; i++) msg.push(part.charCodeAt(i)); // base64 is ASCII - msg.push(0xF7); _send(msg); - // Generous per-chunk timeout: an occasional flash flush on the device can take several seconds. + msg.push(0xF7); + const sendT = Date.now(); _send(msg); const a = await _ack(10000); + const ackT = Date.now(); + if (done < 3 || a !== true) { // log the first few chunks + any failure + console.log("[fw] chunk " + (done + 1) + "/" + total + " sent (" + msg.length + " bytes) ack=" + a + " after " + (ackT - sendT) + "ms"); + } if (a !== true) return "chunk " + (done + 1) + "/" + total + (a === false ? " rejected" : " no-ack"); if (++done % 25 === 0) { const el = ((Date.now() - t0) / 1000).toFixed(1); diff --git a/editor.html b/editor.html index de2bfef..1915633 100644 --- a/editor.html +++ b/editor.html @@ -1267,16 +1267,26 @@ function _b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String. // waiting for each ACK. The device base64-decodes each chunk to /app.new, verifies the .mpy header, then // A/B-installs + reboots. CH is small (and a multiple of 4) so a chunk fits the Pico's USB-MIDI RX buffer. async function _pushFirmware(b64) { + // Diagnostic: list every MIDI output the editor sees + which pass _isDevicePort + console.log("[fw] outputs:", _midiOutputs().map(o => ({ name: o.name || "?", match: _isDevicePort(o) }))); + console.log("[fw] inputs:", _midiInputs() .map(i => ({ name: i.name || "?", match: _isDevicePort(i) }))); + console.log("[fw] sending BEGIN (0x21)"); _send([0xF0, 0x7D, 0x21, 0xF7]); - if (await _ack(5000) !== true) return "handshake"; + const beginA = await _ack(5000); + console.log("[fw] BEGIN ack:", beginA); + if (beginA !== true) return "handshake"; const CH = 64, total = Math.ceil(b64.length / CH); let done = 0; const t0 = Date.now(); for (let o = 0; o < b64.length; o += CH) { const part = b64.slice(o, o + CH); const msg = [0xF0, 0x7D, 0x22]; for (let i = 0; i < part.length; i++) msg.push(part.charCodeAt(i)); // base64 is ASCII - msg.push(0xF7); _send(msg); - // Generous per-chunk timeout: an occasional flash flush on the device can take several seconds. + msg.push(0xF7); + const sendT = Date.now(); _send(msg); const a = await _ack(10000); + const ackT = Date.now(); + if (done < 3 || a !== true) { // log the first few chunks + any failure + console.log("[fw] chunk " + (done + 1) + "/" + total + " sent (" + msg.length + " bytes) ack=" + a + " after " + (ackT - sendT) + "ms"); + } if (a !== true) return "chunk " + (done + 1) + "/" + total + (a === false ? " rejected" : " no-ack"); if (++done % 25 === 0) { const el = ((Date.now() - t0) / 1000).toFixed(1); diff --git a/pico-explorer/app.py b/pico-explorer/app.py index aaffd0b..e79b982 100644 --- a/pico-explorer/app.py +++ b/pico-explorer/app.py @@ -13,7 +13,7 @@ import board, busio, digitalio, pwmio, displayio, vectorio, time, json, gc, os, supervisor supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart -APP_VERSION = "0.0.3" # firmware version (the A/B updater pushes/compares this) +APP_VERSION = "0.0.4" # firmware version (the A/B updater pushes/compares this) DEVICE_ID = "X" # 'X' = Explorer, 'K' = 52Pi kit (per docs/livesync-protocol.md and the version reply) try: import rtc # set from the editor's clock SysEx so the log has real timestamps @@ -55,10 +55,11 @@ P_SDA, P_SCL = board.GP20, board.GP21 # QwSTEMMA (unuse # call display.rotation = DISPLAY_ROTATION (above) to turn it portrait so the layout has the same # shape as the PM_K-1 Kit's portrait UI but in 240x320 instead of 320x480. WIDTH, HEIGHT = 240, 320 -GRID_TOP = 104 # top of the pad grid (compact header + meters + title fit above) -MAXLANES = 6 # lanes shown on the pad grid (parser still accepts more; they just play silent visually) +GRID_TOP = 110 # top of the pad grid (compact header + meters + title fit above) +MAXLANES = 4 # lanes visible on the pad grid (parser still accepts more; they just play silent) +LOG_TOP, LOG_ROWH, LOG_ROWS = 218, 14, 6 # footer practice log: rows below the grid like the Kit MIN_LOG_SEC = 5 # don't log plays shorter than this -LOG_MENU_ROWS = 8 # log entries shown in the Practice-log menu screen +LOG_MENU_ROWS = 8 # log entries shown in the Practice-log menu screen (longer history view) # ----- BUILT-IN playlists: same defaults as the Kit so the two firmwares feel identical ----- BUILTIN_SETLISTS = [ @@ -429,7 +430,9 @@ class App: self.g_idx = displayio.Group(); root.append(self.g_idx) # set-list tab (y ~118) self.g_cont = displayio.Group(); root.append(self.g_cont) # CONT (auto-advance) toggle (y ~118) self.g_name = displayio.Group(); root.append(self.g_name) # track title (y ~134) - self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads (y >= GRID_TOP=138) + self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads (y >= GRID_TOP) + root.append(rect(0, LOG_TOP - 4, WIDTH, 1, C_PANEL)) # divider above the footer practice log + self.g_log = displayio.Group(); root.append(self.g_log) # practice history (Kit-style footer) self.g_overlay = displayio.Group(); root.append(self.g_overlay) # modals (drawn on top) def _place(self, group, s, x, y, fg, bg, font, right_edge=None): @@ -469,7 +472,7 @@ class App: self._heavy_redraw_at = 0; self._heavy_log_pending = False; self._grid_li = None while len(self.g_overlay): self.g_overlay.pop() self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train() - self.build_grid() + self.build_grid(); self.draw_log() def _prog_str(self): parts = ['t' + str(self.bpm)] if self.bars: parts.append('b' + str(self.bars)) @@ -762,7 +765,7 @@ class App: while len(self.g_overlay): self.g_overlay.pop() self._reset_clock() self.draw_bpm(); self.draw_status(); self.draw_train(); self.draw_meters() - self.build_grid() + self.build_grid(); self.draw_log() if running and not self.running: self.toggle() elif (not running) and self.running: self.toggle() except Exception as e: @@ -1134,20 +1137,19 @@ class App: def draw_bpm(self): if self.bpm == self._displayed_bpm: return self._displayed_bpm = self.bpm - # Smaller than the Kit's BPM: FONT_M instead of FONT_L. Kit's FONT_L was ~30px tall at 480 - too big proportionally at 320. - self._place(self.g_bpm, str(self.bpm), 0, 30, C_TXT, C_BG, FONT_M, right_edge=WIDTH-8) + self._place(self.g_bpm, str(self.bpm), 0, 38, C_TXT, C_BG, FONT_M, right_edge=WIDTH-8) def draw_status(self): sl = self.setlists[self.sl] - # setlist tab line at y=66; muted = built-in, cyan = your own + # setlist tab line at y=72; muted = built-in, cyan = your own self._place(self.g_idx, "%s %d/%d" % (sl['title'][:13], self.idx + 1, len(sl['items'])), - 6, 66, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S) - self._place(self.g_cont, "CONT", 0, 66, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-6) - # track title at y=82 (FONT_M; ~16 px tall, fits above GRID_TOP=104) - self._place(self.g_name, self.name[:22], 6, 82, C_TXT, C_BG, FONT_M) + 6, 72, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S) + self._place(self.g_cont, "CONT", 0, 72, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-6) + # track title at y=88 (FONT_M; ~16 px tall, fits above GRID_TOP=110) + self._place(self.g_name, self.name[:22], 6, 88, C_TXT, C_BG, FONT_M) def draw_train(self): g = self.g_train while len(g): g.pop() - x = 6; y = 52 # ramp / gap-trainer indicators below the meters row, above the setlist tab + x = 6; y = 58 # ramp / gap-trainer indicators below the meters row, above the setlist tab if self.ramp: up = self.ramp['amt'] >= 0 pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)] @@ -1182,9 +1184,9 @@ class App: else: ts = self._fmt_t(el); bs = "bar %s" % cur if ts != self._lastTs: - self._place(self.g_time, ts, 6, 32, C_TXT, C_BG, FONT_S); self._lastTs = ts + self._place(self.g_time, ts, 6, 38, C_TXT, C_BG, FONT_S); self._lastTs = ts if bs != self._lastBs: - self._place(self.g_bar, bs, 6, 44, C_MUTE, C_BG, FONT_S); self._lastBs = bs + self._place(self.g_bar, bs, 6, 50, C_MUTE, C_BG, FONT_S); self._lastBs = bs # ---------- pad grid (chunked rebuild; per-pad chunks so audio interleaves) ---------- def _padbase(self, L, s): @@ -1197,8 +1199,8 @@ class App: self.lane_pads = []; self.lane_lit = [] gc.collect() n = min(len(self.lanes), MAXLANES) - top = GRID_TOP; rowh = min(30, ((HEIGHT - 6) - top) // max(1, n)) # more vertical room in portrait -> taller rows - px0 = 48; usable = WIDTH - 8 - px0 - 8; gridh = n * rowh # narrower screen -> tighter lane-label column + top = GRID_TOP; rowh = min(26, ((LOG_TOP - 6) - top) // max(1, n)) # leave room for the footer log below + px0 = 48; usable = WIDTH - 8 - px0 - 8; gridh = n * rowh # narrower screen -> tighter lane-label column self._grid = {'top': top, 'rowh': rowh, 'px0': px0, 'usable': usable, 'n': n} m = self.lanes[0] if self.lanes else None if m is not None: @@ -1286,7 +1288,26 @@ class App: self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm, "dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name}) del self.log[200:] - self._save_log() + self._save_log(); self.draw_log() + def draw_log(self): # footer practice log (this track only), Kit-style + g = self.g_log + while len(g): g.pop() + gc.collect() + hdr, w, h = make_text("PRACTICE LOG", FONT_S, C_MUTE, C_BG); hdr.x = 6; hdr.y = LOG_TOP; g.append(hdr) + rows = [(i, e) for i, e in enumerate(self.log) if e.get("name") == self.name] + if not rows: + tg, w, h = make_text("no plays over 5s yet", FONT_S, C_DIM, C_BG) + tg.x = 6; tg.y = LOG_TOP + LOG_ROWH + 2; g.append(tg) + self.dirty = True; return + y = LOG_TOP + LOG_ROWH + 2 + for k in range(min(LOG_ROWS, len(rows))): + _oi, e = rows[k] + dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60) + bars = e.get("bars", 0); bstr = (" %dbar" % bars) if bars else "" + line = "%s %3dbpm %s%s" % (e.get("t", "--:--"), e["bpm"], dur, bstr) + tg, w, h = make_text(line, FONT_S, C_TXT, C_BG); tg.x = 6; tg.y = y; g.append(tg) + y += LOG_ROWH + self.dirty = True # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs + live-sync) ---------- def _feed_midi(self, buf, n): @@ -1418,9 +1439,11 @@ class App: self.draw_bpm(); self.draw_status(); self.draw_train(); self.draw_meters() if self._heavy_redraw_at and time.monotonic() >= self._heavy_redraw_at: self._heavy_redraw_at = 0 - self._grid_rebuild_start() + self._grid_rebuild_start(); self._heavy_log_pending = True if self._grid_li is not None: self._grid_rebuild_step() + elif self._heavy_log_pending: # grid done -> redraw footer log + self._heavy_log_pending = False; self.draw_log() tnow = time.monotonic() if tnow >= self._uiNext: self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm()