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:
parent
05ce1d5ce4
commit
51c81b45e0
2 changed files with 61 additions and 53 deletions
|
|
@ -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
|
||||
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)
|
||||
+ piezo speaker** built around an RP2350B (Pico 2 class chip). **No touchscreen, no joystick, no
|
||||
RGB LED.** Editing is done in the web editor with **Live sync** on; the device mirrors changes in
|
||||
real time and emits its own play/stop/bpm/sel deltas back.
|
||||
This board is a **2.8″ ST7789V LCD + 6 user buttons + piezo speaker** built around an RP2350B
|
||||
(Pico 2 class chip). **No touchscreen, no joystick, no RGB LED.** Editing is done in the web
|
||||
editor with **Live sync** on; the device mirrors changes in real time and emits its own
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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)
|
||||
try:
|
||||
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
|
||||
MUTE_SPEAKER = False # always silence the on-board piezo
|
||||
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) -----
|
||||
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_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).
|
||||
# We grab it via board.DISPLAY rather than rolling our own init sequence.
|
||||
WIDTH, HEIGHT = 320, 240
|
||||
GRID_TOP = 100 # top of the pad grid (header + meters fit above)
|
||||
# Display is initialised by the board definition (8-bit parallel bus). We grab board.DISPLAY +
|
||||
# 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 = 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)
|
||||
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 -----
|
||||
BUILTIN_SETLISTS = [
|
||||
|
|
@ -100,13 +103,14 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare
|
|||
GM_DEFAULT = 37
|
||||
HELP_PAGES = (
|
||||
("Transport & Navigation", (
|
||||
"Hold portrait with A/B/C on top.",
|
||||
"A: play / stop",
|
||||
"B: tap tempo",
|
||||
"C: menu (this)",
|
||||
"X: prev track (hold to repeat)",
|
||||
"Z: next track (hold to repeat)",
|
||||
"Y: tempo -1 (hold = -5 after 1.5s)",
|
||||
"X+Z chord: tempo +1 (same hold rule)",
|
||||
"Y: tempo -1 (-5 after 1.5s held)",
|
||||
"X+Z chord: tempo +1",
|
||||
)),
|
||||
("Menu navigation", (
|
||||
"X / Z: move cursor up / down",
|
||||
|
|
@ -321,6 +325,8 @@ def rect(x, y, w, h, color):
|
|||
class App:
|
||||
def __init__(self):
|
||||
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)
|
||||
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
|
||||
|
|
@ -388,40 +394,38 @@ class App:
|
|||
d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP
|
||||
return d
|
||||
|
||||
# ---------- scene graph (320x240 landscape) ----------
|
||||
# ---------- scene graph (240x320 portrait; same shape as the Kit's UI, shorter) ----------
|
||||
def _build_scene(self):
|
||||
root = displayio.Group(); self.display.root_group = root
|
||||
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:
|
||||
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
|
||||
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
|
||||
vtg, vw, vh = make_text("v" + APP_VERSION, FONT_S, C_DIM, C_BG); vtg.x = lx + 6; vtg.y = 8; root.append(vtg)
|
||||
# MIDI/USB icons + run dot at the right of the header
|
||||
x = WIDTH - 10
|
||||
vtg, vw, vh = make_text("v" + APP_VERSION, FONT_S, C_DIM, C_BG); vtg.x = lx + 4; vtg.y = 10; root.append(vtg)
|
||||
# Run dot + MIDI/USB icons packed at the right edge (replaces the Kit's hamburger; C button opens the menu)
|
||||
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")):
|
||||
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)
|
||||
# Run dot at the far right
|
||||
self.run_dot_pal = displayio.Palette(1); self.run_dot_pal[0] = C_RUN_IDLE
|
||||
x -= 10
|
||||
self.run_dot = vectorio.Circle(pixel_shader=self.run_dot_pal, radius=4, x=x, y=14)
|
||||
root.append(self.run_dot)
|
||||
# Header divider
|
||||
root.append(rect(0, 28, WIDTH, 1, C_PANEL))
|
||||
# dynamic groups
|
||||
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 indicator
|
||||
self.g_name = displayio.Group(); root.append(self.g_name) # track title
|
||||
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
|
||||
root.append(rect(0, 28, WIDTH, 1, C_PANEL)) # header divider
|
||||
# Dynamic groups - layout order matches the Kit (BPM big at top-right + meters left;
|
||||
# then ramp/trainer indicators; then setlist tab + CONT; then track title; then pad grid).
|
||||
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big BPM (right, y ~44)
|
||||
self.g_time = displayio.Group(); root.append(self.g_time) # elapsed [of total] (left, y ~50)
|
||||
self.g_bar = displayio.Group(); root.append(self.g_bar) # bar [of total] (left, y ~78)
|
||||
self.g_train = displayio.Group(); root.append(self.g_train) # ramp / gap-trainer indicators (y ~100)
|
||||
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_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):
|
||||
|
|
@ -503,8 +507,6 @@ class App:
|
|||
PH = 24 + len(rows) * RH + 18
|
||||
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)
|
||||
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):
|
||||
yy = PY + 26 + i * RH
|
||||
sel = (i == self._modal_cursor)
|
||||
|
|
@ -538,8 +540,6 @@ class App:
|
|||
PH = 24 + len(rows) * RH + 14
|
||||
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)
|
||||
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):
|
||||
yy = PY + 26 + i * RH
|
||||
sel = (i == self._modal_cursor)
|
||||
|
|
@ -1128,17 +1128,19 @@ class App:
|
|||
def draw_bpm(self):
|
||||
if self.bpm == self._displayed_bpm: return
|
||||
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):
|
||||
sl = self.setlists[self.sl]
|
||||
self._place(self.g_idx, "%s %d/%d" % (sl['title'][:14], self.idx + 1, len(sl['items'])),
|
||||
10, 32, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S)
|
||||
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)
|
||||
self._place(self.g_name, self.name[:22], 10, 48, C_TXT, C_BG, FONT_M)
|
||||
# setlist tab line at y=118 (matches the Kit's spacing); muted = built-in, cyan = your own
|
||||
self._place(self.g_idx, "%s %d/%d" % (sl['title'][:13], self.idx + 1, len(sl['items'])),
|
||||
10, 118, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S)
|
||||
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):
|
||||
g = self.g_train
|
||||
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:
|
||||
up = self.ramp['amt'] >= 0
|
||||
pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)]
|
||||
|
|
@ -1173,9 +1175,9 @@ class App:
|
|||
else:
|
||||
ts = self._fmt_t(el); bs = "bar %s" % cur
|
||||
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:
|
||||
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) ----------
|
||||
def _padbase(self, L, s):
|
||||
|
|
@ -1188,8 +1190,8 @@ class App:
|
|||
self.lane_pads = []; self.lane_lit = []
|
||||
gc.collect()
|
||||
n = min(len(self.lanes), MAXLANES)
|
||||
top = GRID_TOP; rowh = min(22, ((HEIGHT - 6) - top) // max(1, n))
|
||||
px0 = 60; usable = WIDTH - 8 - px0 - 8; gridh = n * rowh
|
||||
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
|
||||
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:
|
||||
|
|
@ -1211,10 +1213,10 @@ class App:
|
|||
y = top + li * rowh; cy = y + rowh // 2
|
||||
st = self._grid_lane_st
|
||||
if st is None:
|
||||
tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG)
|
||||
tg.x = 6; tg.y = cy - h // 2; self.g_grid.append(tg)
|
||||
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 = 4; tg.y = cy - h // 2; self.g_grid.append(tg)
|
||||
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))
|
||||
self._grid_lane_st = (cy, steps, sub, stepw, side, rad)
|
||||
self._grid_pi = 0; self._grid_pads = []; self.dirty = True
|
||||
|
|
|
|||
Loading…
Reference in a new issue