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) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-30 22:40:11 -05:00
parent ea7bb9bfee
commit 3805c5ee00
3 changed files with 70 additions and 27 deletions

View file

@ -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 // 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. // 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) { 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]); _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 CH = 64, total = Math.ceil(b64.length / CH); let done = 0;
const t0 = Date.now(); const t0 = Date.now();
for (let o = 0; o < b64.length; o += CH) { for (let o = 0; o < b64.length; o += CH) {
const part = b64.slice(o, o + CH); const msg = [0xF0, 0x7D, 0x22]; 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 for (let i = 0; i < part.length; i++) msg.push(part.charCodeAt(i)); // base64 is ASCII
msg.push(0xF7); _send(msg); msg.push(0xF7);
// Generous per-chunk timeout: an occasional flash flush on the device can take several seconds. const sendT = Date.now(); _send(msg);
const a = await _ack(10000); 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 (a !== true) return "chunk " + (done + 1) + "/" + total + (a === false ? " rejected" : " no-ack");
if (++done % 25 === 0) { if (++done % 25 === 0) {
const el = ((Date.now() - t0) / 1000).toFixed(1); const el = ((Date.now() - t0) / 1000).toFixed(1);

View file

@ -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 // 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. // 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) { 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]); _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 CH = 64, total = Math.ceil(b64.length / CH); let done = 0;
const t0 = Date.now(); const t0 = Date.now();
for (let o = 0; o < b64.length; o += CH) { for (let o = 0; o < b64.length; o += CH) {
const part = b64.slice(o, o + CH); const msg = [0xF0, 0x7D, 0x22]; 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 for (let i = 0; i < part.length; i++) msg.push(part.charCodeAt(i)); // base64 is ASCII
msg.push(0xF7); _send(msg); msg.push(0xF7);
// Generous per-chunk timeout: an occasional flash flush on the device can take several seconds. const sendT = Date.now(); _send(msg);
const a = await _ack(10000); 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 (a !== true) return "chunk " + (done + 1) + "/" + total + (a === false ? " rejected" : " no-ack");
if (++done % 25 === 0) { if (++done % 25 === 0) {
const el = ((Date.now() - t0) / 1000).toFixed(1); const el = ((Date.now() - t0) / 1000).toFixed(1);

View file

@ -13,7 +13,7 @@
import board, busio, digitalio, pwmio, displayio, vectorio, time, json, gc, os, supervisor 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 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) DEVICE_ID = "X" # 'X' = Explorer, 'K' = 52Pi kit (per docs/livesync-protocol.md and the version reply)
try: try:
import rtc # set from the editor's clock SysEx so the log has real timestamps 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 # 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. # shape as the PM_K-1 Kit's portrait UI but in 240x320 instead of 320x480.
WIDTH, HEIGHT = 240, 320 WIDTH, HEIGHT = 240, 320
GRID_TOP = 104 # top of the pad grid (compact header + meters + title fit above) GRID_TOP = 110 # 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) 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 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 ----- # ----- BUILT-IN playlists: same defaults as the Kit so the two firmwares feel identical -----
BUILTIN_SETLISTS = [ 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_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_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_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) 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): 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 self._heavy_redraw_at = 0; self._heavy_log_pending = False; self._grid_li = None
while len(self.g_overlay): self.g_overlay.pop() while len(self.g_overlay): self.g_overlay.pop()
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train() 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): def _prog_str(self):
parts = ['t' + str(self.bpm)] parts = ['t' + str(self.bpm)]
if self.bars: parts.append('b' + str(self.bars)) if self.bars: parts.append('b' + str(self.bars))
@ -762,7 +765,7 @@ class App:
while len(self.g_overlay): self.g_overlay.pop() while len(self.g_overlay): self.g_overlay.pop()
self._reset_clock() self._reset_clock()
self.draw_bpm(); self.draw_status(); self.draw_train(); self.draw_meters() 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() if running and not self.running: self.toggle()
elif (not running) and self.running: self.toggle() elif (not running) and self.running: self.toggle()
except Exception as e: except Exception as e:
@ -1134,20 +1137,19 @@ class App:
def draw_bpm(self): def draw_bpm(self):
if self.bpm == self._displayed_bpm: return if self.bpm == self._displayed_bpm: return
self._displayed_bpm = self.bpm 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, 38, C_TXT, C_BG, FONT_M, right_edge=WIDTH-8)
self._place(self.g_bpm, str(self.bpm), 0, 30, C_TXT, C_BG, FONT_M, right_edge=WIDTH-8)
def draw_status(self): def draw_status(self):
sl = self.setlists[self.sl] 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'])), 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) 6, 72, 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) 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=82 (FONT_M; ~16 px tall, fits above GRID_TOP=104) # track title at y=88 (FONT_M; ~16 px tall, fits above GRID_TOP=110)
self._place(self.g_name, self.name[:22], 6, 82, C_TXT, C_BG, FONT_M) self._place(self.g_name, self.name[:22], 6, 88, C_TXT, C_BG, FONT_M)
def draw_train(self): def draw_train(self):
g = self.g_train g = self.g_train
while len(g): g.pop() 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: if self.ramp:
up = self.ramp['amt'] >= 0 up = self.ramp['amt'] >= 0
pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)] pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)]
@ -1182,9 +1184,9 @@ class App:
else: else:
ts = self._fmt_t(el); bs = "bar %s" % cur ts = self._fmt_t(el); bs = "bar %s" % cur
if ts != self._lastTs: 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: 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) ---------- # ---------- pad grid (chunked rebuild; per-pad chunks so audio interleaves) ----------
def _padbase(self, L, s): def _padbase(self, L, s):
@ -1197,8 +1199,8 @@ class App:
self.lane_pads = []; self.lane_lit = [] self.lane_pads = []; self.lane_lit = []
gc.collect() gc.collect()
n = min(len(self.lanes), MAXLANES) 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 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 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} self._grid = {'top': top, 'rowh': rowh, 'px0': px0, 'usable': usable, 'n': n}
m = self.lanes[0] if self.lanes else None m = self.lanes[0] if self.lanes else None
if m is not 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, 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}) "dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name})
del self.log[200:] 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) ---------- # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs + live-sync) ----------
def _feed_midi(self, buf, n): def _feed_midi(self, buf, n):
@ -1418,9 +1439,11 @@ class App:
self.draw_bpm(); self.draw_status(); self.draw_train(); self.draw_meters() self.draw_bpm(); self.draw_status(); self.draw_train(); self.draw_meters()
if self._heavy_redraw_at and time.monotonic() >= self._heavy_redraw_at: if self._heavy_redraw_at and time.monotonic() >= self._heavy_redraw_at:
self._heavy_redraw_at = 0 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: if self._grid_li is not None:
self._grid_rebuild_step() 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() tnow = time.monotonic()
if tnow >= self._uiNext: if tnow >= self._uiNext:
self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm() self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm()