PM_K-1 firmware Phase 1: VARASYS logo, MIDI/USB status icons, square/outline beats, beat gridlines, stopwatch + bar counter

Device screen redesign (CircuitPython app.py), built proportional to WIDTH/HEIGHT
so it scales to other panels (one adaptive firmware, per-panel config — not a fork):

- gen_assets.py bakes logo.bin (VARASYS wordmark, no tagline), midi.bin (DIN-5),
  usb.bin (trident) as 4-bit-alpha bitmaps (same packing as the fonts).
- Header: VARASYS logo (brand cyan) replaces the "PM_K-1 KIT" text; MIDI icon goes
  green when a host is listening, USB icon lights when supervisor.runtime.usb_connected.
  load_alpha/make_glyph are non-fatal — a missing .bin falls back to text, never a
  black screen (addresses the corrupt-file failure mode we just hit).
- Pad grid: filled squares on main beats, hollow outline squares (outer+inner rect) on
  off-beats; playhead fills the lit pad. Vertical gridlines at the master lane's beats
  (full height) so beats line up across lanes.
- Stopwatch (m:ss) + bar counter (master-lane cycles), refreshed ~4x/s only on change.

The .bin assets ship in the drive bundle (the A/B updater only pushes app.py), so a
one-time re-copy is needed to pick them up. APP_VERSION -> 0.0.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-29 08:56:45 -05:00
parent 591fd8cfe5
commit dec6c61fce
8 changed files with 251 additions and 23 deletions

View file

@ -42,7 +42,7 @@ print("copied pico-cp-app.py")
import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY)
with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
for f in ("code.py", "app.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin",
"README.md", "protect-firmware.sh"):
"logo.bin", "midi.bin", "usb.bin", "README.md", "protect-firmware.sh"):
z.write("pico-cp/" + f, f)
z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive
print("zipped pm_k1_circuitpy.zip")

View file

@ -24,8 +24,11 @@ from the web editor over USBMIDI**, and plays through your **computer's speak
(<https://circuitpython.org/board/raspberry_pi_pico/> — Pico 2 / W builds also fine). A `CIRCUITPY`
drive appears.
2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py` (loader) + `app.py` (the application),
`programs.json`, `font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the
helper scripts. (`code.py` is a tiny stable loader; `app.py` is what firmware updates replace.)
`programs.json`, `font_s.bin` / `font_m.bin` / `font_l.bin`, `logo.bin` / `midi.bin` / `usb.bin` (the
on-screen logo + MIDI/USB status icons), `editor.html` (offline editor), and the helper scripts.
(`code.py` is a tiny stable loader; `app.py` is what firmware updates replace. The `.bin` assets — like
the fonts — ride in the bundle, since the one-click updater only pushes `app.py`; if a `.bin` is missing
the firmware just falls back to text and never fails to boot.)
3. **Powercycle** (so `boot.py` takes effect). It boots into appliance mode and runs.
## Program it from the web (push over USBMIDI)

View file

@ -18,7 +18,7 @@
import board, busio, digitalio, analogio, 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)
try:
import rtc # set from the editor's clock SysEx so the log has real timestamps
except ImportError:
@ -85,10 +85,13 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare
GM_DEFAULT = 37
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
MAXLANES = 5 # lanes shown on the pad grid (extras still play)
GRID_TOP = 150 # top of the pad grid (leaves room for stopwatch/bar)
LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid)
MIN_LOG_SEC = 5 # don't log plays shorter than this
PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost
PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost
C_GRID = 0x1A2330 # faint vertical beat gridlines (beats line up across lanes)
PAD_OUTLINE = 0x33414F # off-beat hollow-square border when idle
# WS2812 RGB LED - self-contained via the core neopixel_write module (no external library)
class RGB:
@ -162,6 +165,31 @@ def make_text(s, font, fg, bg):
pen += adv
return displayio.TileGrid(bmp, pixel_shader=pal), w, h
# ---- single-image alpha assets (logo, status icons) — blit like a one-off glyph; see gen_assets.py ----
def load_alpha(path):
try:
with open(path, "rb") as f: blob = f.read()
return (blob[0], blob[1], blob) # (w, h, bytes); pixels start at offset 2
except Exception:
return None # missing/corrupt -> caller falls back to text (no crash)
def make_glyph(asset, fg, bg):
w, h, blob = asset
gc.collect()
bmp = displayio.Bitmap(w, h, 16); pal = displayio.Palette(16)
for i in range(16): pal[i] = _blend(bg, fg, i)
for k in range(w * h):
byte = blob[2 + (k >> 1)]
nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF)
if nib: bmp[k % w, k // w] = nib
return displayio.TileGrid(bmp, pixel_shader=pal), pal, w, h
def _recolor(pal, fg, bg): # re-tint a stored asset palette in place (tear-free)
for i in range(16): pal[i] = _blend(bg, fg, i)
LOGO = load_alpha("/logo.bin") # VARASYS wordmark (no tagline)
ICON_MIDI = load_alpha("/midi.bin") # DIN-5: green when a MIDI host is listening
ICON_USB = load_alpha("/usb.bin") # trident: lit when USB-connected to a computer
gc.collect()
# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ==============================
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PRIO = {2: 3, 1: 2, 3: 1}
@ -312,9 +340,13 @@ class App:
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0)
self.programs = load_programs()
self.dirty = True
self.pad_pal = displayio.Palette(8)
self.pad_pal = displayio.Palette(10) # 0-3 idle levels, 4-7 lit levels, 8 off-beat border, 9 hollow bg
for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i]
self.pad_pal[8] = PAD_OUTLINE; self.pad_pal[9] = C_BG
self.lane_pads = []; self.lane_lit = []
self.usb_conn = False; self._m_steps = 0 # USB-connected state; master-lane steps (for the bar counter)
self._uiNext = 0.0; self._lastTs = None; self._lastBs = None # throttle the stopwatch/bar redraw
self.ic_midi_pal = None; self.ic_usb_pal = None
# practice history - persisted to /history.json (next to programs.json) when we own the filesystem
self.can_write = self._probe_write()
self.log = self._load_log()
@ -322,7 +354,7 @@ class App:
self._armed = None; self.log_rows = []
self._build_scene()
self.load(0)
self.draw_log()
self.draw_log(); self.draw_icons(); self.draw_meters()
def _btn(self, pin):
d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP
@ -332,13 +364,23 @@ class App:
def _build_scene(self):
root = displayio.Group(); self.display.root_group = root
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG))
tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg)
# header: VARASYS logo (left, no tagline) + MIDI / USB status icons (right)
if LOGO:
tg, _p, lw, lh = make_glyph(LOGO, C_CYAN, C_BG); tg.x = 10; tg.y = 9; root.append(tg)
else:
tg, w, h = make_text("VARASYS", FONT_M, C_CYAN, C_BG); tg.x = 10; tg.y = 8; root.append(tg)
x = WIDTH - 12
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 = 8; x -= 8
root.append(tg); setattr(self, attr, pal)
root.append(rect(0, 38, WIDTH, 2, C_PANEL))
# dynamic groups
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right)
self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left)
self.g_time = displayio.Group(); root.append(self.g_time) # stopwatch (m:ss, left)
self.g_bar = displayio.Group(); root.append(self.g_bar) # bar counter (right)
self.g_name = displayio.Group(); root.append(self.g_name) # item index + name
self.g_midi = displayio.Group(); root.append(self.g_midi) # "MIDI" indicator (top-right) when a host is listening
self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads
root.append(rect(0, LOG_TOP - 6, WIDTH, 2, C_PANEL)) # divider above the history log
self.g_log = displayio.Group(); root.append(self.g_log) # practice history (tap a row to delete)
@ -376,6 +418,7 @@ class App:
now = time.monotonic_ns()
for L in self.lanes:
L['next'] = now; L['step'] = -1
self._m_steps = 0 # restart the bar count
# ---------- audio + light ----------
def click(self, level):
@ -428,6 +471,7 @@ class App:
adv = False
while now >= L['next']:
L['step'] = (L['step'] + 1) % L['steps']
if li == 0: self._m_steps += 1 # count master-lane steps -> bars
lvl = 0 if L['mute'] else L['levels'][L['step']]
if lvl > 0:
fired.append(lvl)
@ -482,7 +526,10 @@ class App:
if host != self.midi_host:
self.midi_host = host
if host: self.buz.duty_cycle = 0 # silence the buzzer when the computer takes over
self.led_off(); self.draw_midi()
self.led_off(); self.draw_icons()
uc = bool(getattr(supervisor.runtime, "usb_connected", True)) # connected to a computer?
if uc != self.usb_conn:
self.usb_conn = uc; self.draw_icons()
# ---------- drawing ----------
def draw_bpm(self):
@ -491,42 +538,78 @@ class App:
self._place(self.g_run, "RUN" if self.running else "STOP", 12, 48,
C_GREEN if self.running else C_MUTE, C_BG, FONT_M)
self._place(self.g_name, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]),
12, 112, C_TXT, C_BG, FONT_M)
def draw_midi(self):
self._place(self.g_midi, "MIDI" if self.midi_host else "", 0, 12, C_GREEN, C_BG, FONT_M, right_edge=WIDTH-12)
12, 120, C_TXT, C_BG, FONT_M)
def draw_icons(self): # recolor the MIDI/USB icons by state (tear-free palette swap)
if self.ic_midi_pal is not None:
_recolor(self.ic_midi_pal, C_GREEN if self.midi_host else C_DIM, C_BG)
if self.ic_usb_pal is not None:
_recolor(self.ic_usb_pal, C_CYAN if self.usb_conn else C_DIM, C_BG)
self.dirty = True
def draw_meters(self): # stopwatch (m:ss) + bar counter; called ~4x/s from run()
if self.running and self.play_start is not None:
el = int(time.monotonic() - self.play_start)
ts = "%d:%02d" % (el // 60, el % 60)
mlen = self.lanes[0]['steps'] if self.lanes else 1
bs = "bar %d" % (self._m_steps // max(1, mlen) + 1)
else:
ts = "0:00"; bs = "bar -"
if ts != self._lastTs:
self._place(self.g_time, ts, 12, 86, C_TXT, C_BG, FONT_M); self._lastTs = ts
if bs != self._lastBs:
self._place(self.g_bar, bs, 0, 92, C_MUTE, C_BG, FONT_M, right_edge=WIDTH-12); self._lastBs = bs
# ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ----------
def _padbase(self, L, s):
return 0 if L['mute'] else L['levels'][s]
def _sq(self, cx, cy, side, ci): # a centred square pad sharing pad_pal
r = vectorio.Rectangle(pixel_shader=self.pad_pal, width=side, height=side, x=cx - side // 2, y=cy - side // 2)
r.color_index = ci; return r
def build_grid(self):
while len(self.g_grid): self.g_grid.pop()
self.lane_pads = []; self.lane_lit = []
n = min(len(self.lanes), MAXLANES)
top = 140; rowh = min(40, (296 - top) // max(1, n))
top = GRID_TOP; rowh = min(40, ((LOG_TOP - 10) - top) // max(1, n))
px0 = 60; usable = WIDTH - 8 - px0 - 12; gridh = n * rowh
# vertical gridlines at the master lane's beats, full height -> beats line up across lanes
m = self.lanes[0]; mbeats = max(1, m['steps'] // max(1, m['sub']))
for bcol in range(mbeats):
self.g_grid.append(rect(px0 + 6 + (bcol * usable) // mbeats, top, 1, gridh, C_GRID))
for li in range(n):
L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2
tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG)
tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg)
steps = L['steps']; sub = L['sub']; px0 = 60
usable = WIDTH - 8 - px0 - 12; stepw = max(1, usable // steps)
r_big = max(2, min(6, stepw // 2, (rowh - 8) // 2)); r_sml = max(2, r_big - 2)
steps = L['steps']; sub = L['sub']; stepw = max(1, usable // steps)
side = max(5, min(15, stepw - 1, rowh - 6)); inner = max(2, side - 4)
pads = []
for s in range(steps):
rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision
base = self._padbase(L, s)
cxp = px0 + 6 + (s * usable) // steps # proportional -> beats line up across lanes
c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy)
c.color_index = self._padbase(L, s); self.g_grid.append(c); pads.append(c)
if s % sub == 0: # main beat -> filled square
sq = self._sq(cxp, cy, side, base); self.g_grid.append(sq)
pads.append(("fill", (sq,), base))
else: # off-beat -> hollow outline square
out = self._sq(cxp, cy, side, base if base else 8)
ins = self._sq(cxp, cy, inner, 9) # bg fill = the hollow centre
self.g_grid.append(out); self.g_grid.append(ins)
pads.append(("out", (out, ins), base))
self.lane_pads.append(pads); self.lane_lit.append(-1)
self.dirty = True
def _pad_idle(self, pad):
kind, shapes, base = pad
if kind == "fill": shapes[0].color_index = base
else: shapes[0].color_index = base if base else 8; shapes[1].color_index = 9 # ring + hollow centre
def _pad_lit(self, pad):
kind, shapes, base = pad
for sh in shapes: sh.color_index = base + 4 # fill the square (lit level) regardless of shape
def _move_playhead(self, li, step):
pads = self.lane_pads[li]; prev = self.lane_lit[li]
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
if step < len(pads): pads[step].color_index = self._padbase(self.lanes[li], step) + 4
if 0 <= prev < len(pads): self._pad_idle(pads[prev])
if step < len(pads): self._pad_lit(pads[step])
self.lane_lit[li] = step; self.dirty = True
def reset_playheads(self):
for li, pads in enumerate(self.lane_pads):
prev = self.lane_lit[li]
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
if 0 <= prev < len(pads): self._pad_idle(pads[prev])
self.lane_lit[li] = -1
self.dirty = True
@ -635,7 +718,10 @@ class App:
except OSError: committed = True
while True:
self.tick(); self.poll()
if not committed and time.monotonic() - boot > 5: # booted & ran fine for 5s -> confirm the update
tnow = time.monotonic()
if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter
self._uiNext = tnow + 0.25; self.draw_meters()
if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update
try: os.remove("/trial")
except Exception: pass
committed = True

139
pico-cp/gen_assets.py Normal file
View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
# Generate the on-screen bitmap assets the CircuitPython firmware (app.py) blits:
# logo.bin - the VARASYS wordmark (no tagline), tinted brand cyan at the top
# midi.bin - a 5-pin DIN icon (lights green when a MIDI host is listening)
# usb.bin - the USB "trident" icon (lights when the device is USB-connected)
#
# Each file is a single 4-bit-alpha image with a 2-byte header (matches the font packing
# in gen_font.py, just without the glyph metrics table):
# byte 0 = width, byte 1 = height, then ((w*h+1)//2) bytes of 4-bit alpha,
# row-major, two pixels per byte (first pixel = high nibble).
# app.py's load_alpha()/make_glyph() decode it and blend bg->fg per pixel (smooth).
#
# Re-run after changing the logo/icons: python3 pico-cp/gen_assets.py
# Writes pico-cp/{logo,midi,usb}.bin and /tmp/assets_verify.png (eyeball it).
import math, pathlib
from PIL import Image, ImageDraw
HERE = pathlib.Path(__file__).parent
LOGO_PNG = pathlib.Path.home() / "src/varasys_logo/For using with dark colored background/VARASYS Limited.png"
SS = 6 # supersample factor for the drawn icons (downscaled -> anti-aliased)
def pack(coverage_img):
"""coverage_img: 'L' image (0..255 alpha). -> bytes(w, h, packed 4-bit alpha)."""
w, h = coverage_img.size
assert w <= 255 and h <= 255, "asset too large for the 1-byte dims (%dx%d)" % (w, h)
px = coverage_img.load()
nib = []
for y in range(h):
for x in range(w):
nib.append(px[x, y] >> 4) # 8-bit -> 4-bit alpha
if len(nib) % 2:
nib.append(0)
out = bytearray([w, h])
for i in range(0, len(nib), 2):
out.append((nib[i] << 4) | nib[i + 1])
return bytes(out)
def make_logo(target_w=156):
img = Image.open(LOGO_PNG).convert("RGBA")
r, g, b, a = img.split()
# Coverage = alpha if the PNG is genuinely transparent, else brightness (cyan on a flat bg).
if a.getextrema()[0] < 250:
cov = a
else:
cov = img.convert("L")
bbox = cov.getbbox()
if bbox:
cov = cov.crop(bbox)
w, h = cov.size
th = max(1, round(target_w * h / w))
cov = cov.resize((target_w, th), Image.LANCZOS)
return pack(cov)
def make_midi(size=22):
cw = size * SS
img = Image.new("L", (cw, cw), 0)
d = ImageDraw.Draw(img)
cx = cy = cw / 2
R = cw * 0.44
lw = max(2, int(cw * 0.07))
d.ellipse([cx - R, cy - R, cx + R, cy + R], outline=255, width=lw) # connector shell
# 5 pins in the DIN fan (one top-centre, a flanking pair above, a wider pair below)
ring = R * 0.60
pr = cw * 0.075
pins = [(0.0, -1.0), (-0.72, -0.55), (0.72, -0.55), (-0.95, 0.18), (0.95, 0.18)]
for fx, fy in pins:
x = cx + fx * ring
y = cy + fy * ring
d.ellipse([x - pr, y - pr, x + pr, y + pr], fill=255)
return pack(img.resize((size, size), Image.LANCZOS))
def make_usb(size=22):
cw = size * SS
img = Image.new("L", (cw, cw), 0)
d = ImageDraw.Draw(img)
cx = cw / 2
lw = max(2, int(cw * 0.075))
top, bot = cw * 0.10, cw * 0.90
d.line([(cx, top + cw * 0.10), (cx, bot)], fill=255, width=lw) # shaft
# arrowhead at the top
ah = cw * 0.13
d.polygon([(cx, top), (cx - ah, top + ah * 1.4), (cx + ah, top + ah * 1.4)], fill=255)
# base plug (filled circle at the bottom)
br = cw * 0.10
d.ellipse([cx - br, bot - br, cx + br, bot + br], fill=255)
# left branch -> small filled circle
ly = cw * 0.46
lx = cx - cw * 0.26
d.line([(cx, ly + cw * 0.10), (lx, ly)], fill=255, width=lw)
cr = cw * 0.085
d.ellipse([lx - cr, ly - cr, lx + cr, ly + cr], fill=255)
# right branch -> small filled square
ry = cw * 0.34
rx = cx + cw * 0.26
d.line([(cx, ry + cw * 0.10), (rx, ry)], fill=255, width=lw)
sq = cw * 0.08
d.rectangle([rx - sq, ry - sq, rx + sq, ry + sq], fill=255)
return pack(img.resize((size, size), Image.LANCZOS))
def unpack_to_img(blob, fg=(10, 179, 247), bg=(6, 9, 14)):
"""Decode like app.py would, for the verify sheet."""
w, h = blob[0], blob[1]
im = Image.new("RGB", (w, h), bg)
for k in range(w * h):
byte = blob[2 + (k >> 1)]
nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF)
t = nib * 17
col = tuple((bg[i] * (255 - t) + fg[i] * t) // 255 for i in range(3))
im.putpixel((k % w, k // w), col)
return im
def main():
assets = {"logo": make_logo(), "midi": make_midi(), "usb": make_usb()}
for name, blob in assets.items():
(HERE / (name + ".bin")).write_bytes(blob)
print("wrote %s.bin %dx%d %d bytes" % (name, blob[0], blob[1], len(blob)))
# verify sheet on a dark panel-like background
pad = 12
imgs = [unpack_to_img(b) for b in assets.values()]
W = max(i.width for i in imgs) + pad * 2
H = sum(i.height for i in imgs) + pad * (len(imgs) + 1)
sheet = Image.new("RGB", (W, H), (6, 9, 14))
y = pad
for i in imgs:
sheet.paste(i, (pad, y))
y += i.height + pad
sheet.save("/tmp/assets_verify.png")
print("verify -> /tmp/assets_verify.png")
if __name__ == "__main__":
main()

BIN
pico-cp/logo.bin Normal file

Binary file not shown.

BIN
pico-cp/midi.bin Normal file

Binary file not shown.

BIN
pico-cp/usb.bin Normal file

Binary file not shown.