PM_X-1 0.0.2: portrait flip + Kit-style layout (no more overlapping bits)

The 320x240 landscape layout was too cramped vertically; multiple elements
sat on the same Y rows, especially the BPM big-number area and the bar/time
meters, plus the run dot collided with the MIDI/USB icons.

Switching to portrait at 240x320 (display.rotation = 270; user holds the
device with A/B/C buttons along the top) gives the same vertical real
estate the PM_K-1 Kit's portrait UI uses, just narrower. Layout now mirrors
the Kit:
- y 0..28: header (logo + version + MIDI/USB icons + run dot)
- y 44:    BPM big (right)
- y 50:    elapsed time (left, FONT_M)
- y 78:    bar counter (left, FONT_M)
- y 100:   ramp / gap-trainer indicators
- y 118:   setlist tab + CONT (single row)
- y 134:   track title (FONT_M)
- y 138+:  pad grid (up to 6 lanes, taller rowh ceiling now 30px)

Plus:
- DISPLAY_ROTATION constant near the top of CONFIG so the user can flip it
  to 90 / 180 if their orientation differs.
- Pad grid uses px0=48 (was 60) since the lane label column has less
  horizontal room at 240 width; max 6-char labels.
- Removed the inline modal hints (e.g. "X/Z move, A select, C close") that
  would have collided with the modal titles at 240 width. The Help screen
  documents the modal nav pattern, which is consistent across modals.
- HELP_PAGES page 1 leads with "Hold portrait with A/B/C on top."
- README documents the rotation flag.

Bumps Explorer to 0.0.2. .mpy can be pushed via the editor's Update firmware
flow (device id reply = X;0.0.1 -> editor fetches the right .mpy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-30 21:37:12 -05:00
parent 05ce1d5ce4
commit 51c81b45e0
2 changed files with 61 additions and 53 deletions

View file

@ -4,10 +4,16 @@ The **CircuitPython** firmware for the [Pimoroni Explorer Kit (PIM744)](https://
set up as a self-contained appliance. Sibling to the PM_K-1 build in `../pico-cp/` (the 52Pi EP-0172 set up as a self-contained appliance. Sibling to the PM_K-1 build in `../pico-cp/` (the 52Pi EP-0172
kit) — same engine, same program strings, same `programs.json`, same web editor. kit) — same engine, same program strings, same `programs.json`, same web editor.
This board is a **2.8″ ST7789V 320×240 LCD + 6 user buttons (A/B/C on the left, X/Y/Z on the right) This board is a **2.8″ ST7789V LCD + 6 user buttons + piezo speaker** built around an RP2350B
+ piezo speaker** built around an RP2350B (Pico 2 class chip). **No touchscreen, no joystick, no (Pico 2 class chip). **No touchscreen, no joystick, no RGB LED.** Editing is done in the web
RGB LED.** Editing is done in the web editor with **Live sync** on; the device mirrors changes in editor with **Live sync** on; the device mirrors changes in real time and emits its own
real time and emits its own play/stop/bpm/sel deltas back. play/stop/bpm/sel deltas back.
**Hold the device in portrait** with the A/B/C buttons along the top and X/Y/Z along the
bottom. The firmware drives the LCD as a **240 × 320 portrait** at `display.rotation = 270`
same UI shape as the PM_K-1 Kit, just shorter. If the screen comes up upside-down on your
unit, change `DISPLAY_ROTATION` near the top of `app.py` to `90` (or `180` if rotated 180°)
and re-flash.
## Controls ## Controls

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.1" # firmware version (the A/B updater pushes/compares this) APP_VERSION = "0.0.2" # 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
@ -37,6 +37,8 @@ MIDI_CLOCK_IN = False # follow an external 24 PPQN clock
MIDI_CLOCK_IN_TRANSPORT = True MIDI_CLOCK_IN_TRANSPORT = True
MUTE_SPEAKER = False # always silence the on-board piezo MUTE_SPEAKER = False # always silence the on-board piezo
SPEAKER_AUTO_MUTE = True # auto-mute the piezo when a MIDI host is listening SPEAKER_AUTO_MUTE = True # auto-mute the piezo when a MIDI host is listening
DISPLAY_ROTATION = 270 # 0=native landscape; 90/270 = portrait. Hold the device with A/B/C
# on top: try 270 first; if upside-down try 90; 180 = flipped landscape.
# ----- pins (Pimoroni Explorer board layout) ----- # ----- pins (Pimoroni Explorer board layout) -----
P_AUDIO = board.GP12 # piezo PWM (variable frequency) P_AUDIO = board.GP12 # piezo PWM (variable frequency)
@ -45,13 +47,14 @@ P_BTNA, P_BTNB, P_BTNC = board.GP16, board.GP15, board.GP14 # left-side button
P_BTNX, P_BTNY, P_BTNZ = board.GP17, board.GP18, board.GP19 # right-side buttons (top to bottom) P_BTNX, P_BTNY, P_BTNZ = board.GP17, board.GP18, board.GP19 # right-side buttons (top to bottom)
P_SDA, P_SCL = board.GP20, board.GP21 # QwSTEMMA (unused by the firmware - future expansion) P_SDA, P_SCL = board.GP20, board.GP21 # QwSTEMMA (unused by the firmware - future expansion)
# Display is initialised by the board definition (8-bit parallel bus, 320x240 landscape). # Display is initialised by the board definition (8-bit parallel bus). We grab board.DISPLAY +
# We grab it via board.DISPLAY rather than rolling our own init sequence. # call display.rotation = DISPLAY_ROTATION (above) to turn it portrait so the layout has the same
WIDTH, HEIGHT = 320, 240 # shape as the PM_K-1 Kit's portrait UI but in 240x320 instead of 320x480.
GRID_TOP = 100 # top of the pad grid (header + meters fit above) WIDTH, HEIGHT = 240, 320
GRID_TOP = 138 # top of the pad grid (header + meters + title fit above)
MAXLANES = 6 # lanes shown on the pad grid (parser still accepts more; they just play silent visually) MAXLANES = 6 # lanes shown on the pad grid (parser still accepts more; they just play silent visually)
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 = 7 # log entries shown in the Practice-log menu screen LOG_MENU_ROWS = 8 # log entries shown in the Practice-log menu screen (more vertical room in portrait)
# ----- 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 = [
@ -100,13 +103,14 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare
GM_DEFAULT = 37 GM_DEFAULT = 37
HELP_PAGES = ( HELP_PAGES = (
("Transport & Navigation", ( ("Transport & Navigation", (
"Hold portrait with A/B/C on top.",
"A: play / stop", "A: play / stop",
"B: tap tempo", "B: tap tempo",
"C: menu (this)", "C: menu (this)",
"X: prev track (hold to repeat)", "X: prev track (hold to repeat)",
"Z: next track (hold to repeat)", "Z: next track (hold to repeat)",
"Y: tempo -1 (hold = -5 after 1.5s)", "Y: tempo -1 (-5 after 1.5s held)",
"X+Z chord: tempo +1 (same hold rule)", "X+Z chord: tempo +1",
)), )),
("Menu navigation", ( ("Menu navigation", (
"X / Z: move cursor up / down", "X / Z: move cursor up / down",
@ -321,6 +325,8 @@ def rect(x, y, w, h, color):
class App: class App:
def __init__(self): def __init__(self):
self.display = board.DISPLAY # board.c built the BusDisplay; we just use it self.display = board.DISPLAY # board.c built the BusDisplay; we just use it
try: self.display.rotation = DISPLAY_ROTATION # turn portrait (240x320) - same shape as the Kit's UI
except Exception: pass
try: self.display.auto_refresh = False # we manage refresh in run() (predictive skip + ~20Hz throttle) try: self.display.auto_refresh = False # we manage refresh in run() (predictive skip + ~20Hz throttle)
except Exception: pass except Exception: pass
self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) # QwSTEMMA - unused by the firmware, available to user code self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) # QwSTEMMA - unused by the firmware, available to user code
@ -388,40 +394,38 @@ class App:
d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP
return d return d
# ---------- scene graph (320x240 landscape) ---------- # ---------- scene graph (240x320 portrait; same shape as the Kit's UI, shorter) ----------
def _build_scene(self): def _build_scene(self):
root = displayio.Group(); self.display.root_group = root root = displayio.Group(); self.display.root_group = root
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG)) root.append(rect(0, 0, WIDTH, HEIGHT, C_BG))
# Header (y 0..28): VARASYS logo + version + run dot + MIDI/USB badges # Header (y 0..28): VARASYS logo + version + (right edge) MIDI/USB badges + run dot
if LOGO: if LOGO:
tg, _p, lw, lh = make_glyph(LOGO, C_CYAN, C_BG); tg.x = 8; tg.y = 6; root.append(tg) tg, _p, lw, lh = make_glyph(LOGO, C_CYAN, C_BG); tg.x = 8; tg.y = 8; root.append(tg)
lx = 8 + lw lx = 8 + lw
else: else:
tg, w, h = make_text("VARASYS", FONT_M, C_CYAN, C_BG); tg.x = 8; tg.y = 6; root.append(tg) tg, w, h = make_text("VARASYS", FONT_M, C_CYAN, C_BG); tg.x = 8; tg.y = 8; root.append(tg)
lx = 8 + w lx = 8 + w
vtg, vw, vh = make_text("v" + APP_VERSION, FONT_S, C_DIM, C_BG); vtg.x = lx + 6; vtg.y = 8; root.append(vtg) vtg, vw, vh = make_text("v" + APP_VERSION, FONT_S, C_DIM, C_BG); vtg.x = lx + 4; vtg.y = 10; root.append(vtg)
# MIDI/USB icons + run dot at the right of the header # Run dot + MIDI/USB icons packed at the right edge (replaces the Kit's hamburger; C button opens the menu)
x = WIDTH - 10 self.run_dot_pal = displayio.Palette(1); self.run_dot_pal[0] = C_RUN_IDLE
self.run_dot = vectorio.Circle(pixel_shader=self.run_dot_pal, radius=4, x=WIDTH - 12, y=14)
root.append(self.run_dot)
x = WIDTH - 22 # icons live to the LEFT of the run dot
for asset, attr in ((ICON_USB, "ic_usb_pal"), (ICON_MIDI, "ic_midi_pal")): for asset, attr in ((ICON_USB, "ic_usb_pal"), (ICON_MIDI, "ic_midi_pal")):
if asset: if asset:
tg, pal, w, h = make_glyph(asset, C_DIM, C_BG); x -= w; tg.x = x; tg.y = 6; x -= 6 tg, pal, w, h = make_glyph(asset, C_DIM, C_BG); x -= w; tg.x = x; tg.y = 8; x -= 6
root.append(tg); setattr(self, attr, pal) root.append(tg); setattr(self, attr, pal)
# Run dot at the far right root.append(rect(0, 28, WIDTH, 1, C_PANEL)) # header divider
self.run_dot_pal = displayio.Palette(1); self.run_dot_pal[0] = C_RUN_IDLE # Dynamic groups - layout order matches the Kit (BPM big at top-right + meters left;
x -= 10 # then ramp/trainer indicators; then setlist tab + CONT; then track title; then pad grid).
self.run_dot = vectorio.Circle(pixel_shader=self.run_dot_pal, radius=4, x=x, y=14) self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big BPM (right, y ~44)
root.append(self.run_dot) self.g_time = displayio.Group(); root.append(self.g_time) # elapsed [of total] (left, y ~50)
# Header divider self.g_bar = displayio.Group(); root.append(self.g_bar) # bar [of total] (left, y ~78)
root.append(rect(0, 28, WIDTH, 1, C_PANEL)) self.g_train = displayio.Group(); root.append(self.g_train) # ramp / gap-trainer indicators (y ~100)
# dynamic groups 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 (left of title row) 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 indicator 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 self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads (y >= GRID_TOP=138)
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right)
self.g_time = displayio.Group(); root.append(self.g_time) # elapsed [of total] (left)
self.g_bar = displayio.Group(); root.append(self.g_bar) # bar [of total] (left)
self.g_train = displayio.Group(); root.append(self.g_train) # ramp / gap-trainer indicators
self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads
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):
@ -503,8 +507,6 @@ class App:
PH = 24 + len(rows) * RH + 18 PH = 24 + len(rows) * RH + 18
g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN)) g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN))
t, w, h = make_text("Menu", FONT_M, C_TXT, C_PANEL); t.x = PX + 12; t.y = PY + 6; g.append(t) t, w, h = make_text("Menu", FONT_M, C_TXT, C_PANEL); t.x = PX + 12; t.y = PY + 6; g.append(t)
ht, hw, hh = make_text("X / Z move, A select, C close", FONT_S, C_DIM, C_PANEL)
ht.x = PX + PW - hw - 12; ht.y = PY + 10; g.append(ht)
for i, (label, _v, _act) in enumerate(rows): for i, (label, _v, _act) in enumerate(rows):
yy = PY + 26 + i * RH yy = PY + 26 + i * RH
sel = (i == self._modal_cursor) sel = (i == self._modal_cursor)
@ -538,8 +540,6 @@ class App:
PH = 24 + len(rows) * RH + 14 PH = 24 + len(rows) * RH + 14
g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN)) g.append(rect(PX, PY, PW, PH, C_PANEL)); g.append(rect(PX, PY, PW, 2, C_CYAN))
t, w, h = make_text("Settings", FONT_M, C_TXT, C_PANEL); t.x = PX + 12; t.y = PY + 5; g.append(t) t, w, h = make_text("Settings", FONT_M, C_TXT, C_PANEL); t.x = PX + 12; t.y = PY + 5; g.append(t)
ht, hw, hh = make_text("X/Z row, Y -, A +, B back", FONT_S, C_DIM, C_PANEL)
ht.x = PX + PW - hw - 12; ht.y = PY + 10; g.append(ht)
for i, (label, value, _adj) in enumerate(rows): for i, (label, value, _adj) in enumerate(rows):
yy = PY + 26 + i * RH yy = PY + 26 + i * RH
sel = (i == self._modal_cursor) sel = (i == self._modal_cursor)
@ -1128,17 +1128,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
self._place(self.g_bpm, str(self.bpm), 0, 56, C_TXT, C_BG, FONT_L, right_edge=WIDTH-10) self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-10)
def draw_status(self): def draw_status(self):
sl = self.setlists[self.sl] sl = self.setlists[self.sl]
self._place(self.g_idx, "%s %d/%d" % (sl['title'][:14], self.idx + 1, len(sl['items'])), # setlist tab line at y=118 (matches the Kit's spacing); muted = built-in, cyan = your own
10, 32, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S) self._place(self.g_idx, "%s %d/%d" % (sl['title'][:13], self.idx + 1, len(sl['items'])),
self._place(self.g_cont, "CONT", 0, 32, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-10) 10, 118, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S)
self._place(self.g_name, self.name[:22], 10, 48, C_TXT, C_BG, FONT_M) self._place(self.g_cont, "CONT", 0, 118, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-10)
# track title at y=134 (FONT_M; ~16 px tall, fits above GRID_TOP=138)
self._place(self.g_name, self.name[:20], 10, 134, 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 = 10; y = 84 x = 10; y = 100 # ramp / gap-trainer indicators on a single 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)]
@ -1173,9 +1175,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, 10, 66, C_TXT, C_BG, FONT_S); self._lastTs = ts self._place(self.g_time, ts, 10, 50, C_TXT, C_BG, FONT_M); self._lastTs = ts
if bs != self._lastBs: if bs != self._lastBs:
self._place(self.g_bar, bs, 10, 80, C_MUTE, C_BG, FONT_S); self._lastBs = bs self._place(self.g_bar, bs, 10, 78, C_MUTE, C_BG, FONT_M); 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):
@ -1188,8 +1190,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(22, ((HEIGHT - 6) - top) // max(1, n)) top = GRID_TOP; rowh = min(30, ((HEIGHT - 6) - top) // max(1, n)) # more vertical room in portrait -> taller rows
px0 = 60; usable = WIDTH - 8 - px0 - 8; gridh = n * rowh 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:
@ -1211,10 +1213,10 @@ class App:
y = top + li * rowh; cy = y + rowh // 2 y = top + li * rowh; cy = y + rowh // 2
st = self._grid_lane_st st = self._grid_lane_st
if st is None: if st is None:
tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG) tg, w, h = make_text((L.get('sound', '') or '?')[:6], FONT_S, C_MUTE, C_BG) # 6-char label fits the 48px lane column
tg.x = 6; tg.y = cy - h // 2; self.g_grid.append(tg) tg.x = 4; tg.y = cy - h // 2; self.g_grid.append(tg)
steps = L['steps']; sub = L['sub']; stepw = max(1, usable // steps) steps = L['steps']; sub = L['sub']; stepw = max(1, usable // steps)
side = max(4, min(12, stepw - 1, rowh - 6)) side = max(5, min(14, stepw - 1, rowh - 6)) # squares can be bigger in portrait
rad = max(2, min(side // 2, stepw // 2 - 1)) rad = max(2, min(side // 2, stepw // 2 - 1))
self._grid_lane_st = (cy, steps, sub, stepw, side, rad) self._grid_lane_st = (cy, steps, sub, stepw, side, rad)
self._grid_pi = 0; self._grid_pads = []; self.dirty = True self._grid_pi = 0; self._grid_pads = []; self.dirty = True