diff --git a/pico-explorer/README.md b/pico-explorer/README.md index 9a2ca2a..b776f46 100644 --- a/pico-explorer/README.md +++ b/pico-explorer/README.md @@ -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 diff --git a/pico-explorer/app.py b/pico-explorer/app.py index 6526959..f3a0cb0 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.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