PM_K-1 CircuitPython: self-contained RGB LED + fix screen tearing

From on-board feedback (memory + colours now good):
  - LED: drive the WS2812 via the core neopixel_write module (no neopixel library to
    install) — a tiny RGB class. Self-contained: it works straight from the bundle.
  - Tearing: switch displayio to auto_refresh=False and push a complete frame only when
    the scene changed (dirty flag, capped at the panel's refresh rate) so updates are
    never shown mid-paint. Beat dots now recolour in place (vectorio color_index) instead
    of being rebuilt every beat, shrinking the dirty region.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 21:43:48 -05:00
parent 365d242339
commit ffededd05b
3 changed files with 42 additions and 15 deletions

View file

@ -47,8 +47,8 @@ Each `prog` is a program string from the web editor. Add/replace entries and sav
- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`. - **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`.
- **Screen blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341 - **Screen blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341
instead of the 320×480 ST7796, the init/size need changing (this targets the 320×480 you have). instead of the 320×480 ST7796, the init/size need changing (this targets the 320×480 you have).
- **No RGB LED:** the WS2812 needs the `neopixel` library on `CIRCUITPY/lib` (`circup install neopixel`) - **RGB LED** is driven by the core `neopixel_write` module — no library to install. If it stays dark,
— everything else works without it. your CircuitPython build is unusually missing that module (everything else still works).
If `code.py` ever errors, CircuitPython prints the traceback **on the screen and over USB serial** If `code.py` ever errors, CircuitPython prints the traceback **on the screen and over USB serial**
copy that to me and I'll fix it. copy that to me and I'll fix it.

View file

@ -23,9 +23,9 @@ except ImportError: # CircuitPython 8.x
from displayio import FourWire from displayio import FourWire
from displayio import Display as BusDisplay from displayio import Display as BusDisplay
try: try:
import neopixel import neopixel_write # core module on RP2040 — drives WS2812 with no external library
except ImportError: except ImportError:
neopixel = None neopixel_write = None
# ============================== CONFIG (tweak if needed) ============================== # ============================== CONFIG (tweak if needed) ==============================
SPI_BAUD = 40_000_000 SPI_BAUD = 40_000_000
@ -63,6 +63,19 @@ C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240
C_BTN = 0x1C222C C_BTN = 0x1C222C
LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)} LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)}
# WS2812 RGB LED — self-contained via the core neopixel_write module (no external library)
class RGB:
def __init__(self, pin):
self.ok = neopixel_write is not None
if self.ok:
self.io = digitalio.DigitalInOut(pin); self.io.direction = digitalio.Direction.OUTPUT
self.buf = bytearray(3)
def set(self, r, g, b):
if not self.ok: return
self.buf[0] = g; self.buf[1] = r; self.buf[2] = b # WS2812 wants GRB order
try: neopixel_write.neopixel_write(self.io, self.buf)
except Exception: self.ok = False
# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ============================== # ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ==============================
def load_font(path): def load_font(path):
with open(path, "rb") as f: with open(path, "rb") as f:
@ -240,7 +253,7 @@ def make_display():
displayio.release_displays() displayio.release_displays()
spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI) spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI)
bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD) bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD)
return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=True) return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=False)
def solid(color): def solid(color):
p = displayio.Palette(1); p[0] = color; return p p = displayio.Palette(1); p[0] = color; return p
@ -254,7 +267,7 @@ class App:
self.display = make_display() self.display = make_display()
self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000)
self.touch = GT911(self.i2c) self.touch = GT911(self.i2c)
self.np = neopixel.NeoPixel(P_RGB, 1, auto_write=True) if neopixel else None self.led = RGB(P_RGB)
self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0)
self.buz_off = 0 self.buz_off = 0
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB) self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
@ -265,6 +278,9 @@ class App:
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0) self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0)
self.programs = load_programs() self.programs = load_programs()
self.buttons = [] self.buttons = []
self.dirty = True; self.dots = []
self.dot_pal = displayio.Palette(3)
self.dot_pal[0] = C_DIM; self.dot_pal[1] = C_CYAN; self.dot_pal[2] = C_AMBER
self._build_scene() self._build_scene()
self.load(0) self.load(0)
@ -300,12 +316,14 @@ class App:
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):
while len(group): group.pop() while len(group): group.pop()
self.dirty = True
if not s: return if not s: return
tg, w, h = make_text(s, font, fg, bg) tg, w, h = make_text(s, font, fg, bg)
tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg) tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg)
def _center(self, group, s, cx, cy, fg, bg, font): def _center(self, group, s, cx, cy, fg, bg, font):
while len(group): group.pop() while len(group): group.pop()
tg, w, h = make_text(s, font, fg, bg); tg.x = cx - w//2; tg.y = cy - h//2; group.append(tg) tg, w, h = make_text(s, font, fg, bg); tg.x = cx - w//2; tg.y = cy - h//2; group.append(tg)
self.dirty = True
def _label(self, key): def _label(self, key):
sym = {"prev": "◀◀", "next": "▶▶", "minus": "-", "plus": "+", "tap": "TAP", sym = {"prev": "◀◀", "next": "▶▶", "minus": "-", "plus": "+", "tap": "TAP",
"play": "" if self.running else ""}[key] "play": "" if self.running else ""}[key]
@ -331,10 +349,10 @@ class App:
self.buz_off = time.monotonic_ns() + 22_000_000 self.buz_off = time.monotonic_ns() + 22_000_000
def flash(self, level): def flash(self, level):
self.rgb = LEVEL_RGB.get(level, (0, 150, 255)) self.rgb = LEVEL_RGB.get(level, (0, 150, 255))
if self.np: self.np[0] = self.rgb self.led.set(*self.rgb)
def led_off(self): def led_off(self):
self.rgb = (0, 0, 0) self.rgb = (0, 0, 0)
if self.np: self.np[0] = (0, 0, 0) self.led.set(0, 0, 0)
# ---------- transport ---------- # ---------- transport ----------
def toggle(self): def toggle(self):
@ -380,7 +398,7 @@ class App:
if self.rgb != (0, 0, 0): if self.rgb != (0, 0, 0):
r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10 r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10
self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0) self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0)
if self.np: self.np[0] = self.rgb self.led.set(*self.rgb)
# ---------- inputs ---------- # ---------- inputs ----------
def poll(self): def poll(self):
@ -432,17 +450,26 @@ class App:
self._place(self.g_name, self.name[:26], 12, 272, C_TXT, C_BG, FONT_M) self._place(self.g_name, self.name[:26], 12, 272, C_TXT, C_BG, FONT_M)
def draw_dots(self): def draw_dots(self):
m = self.master; bpb = max(1, m['steps'] // m['sub']) m = self.master; bpb = max(1, m['steps'] // m['sub'])
if len(self.dots) != bpb: # rebuild only when the beat count changes
while len(self.g_dots): self.g_dots.pop() while len(self.g_dots): self.g_dots.pop()
sz, sp = 18, 26; x0 = max(12, WIDTH - 12 - bpb*sp) self.dots = []; sz, sp = 18, 26; x0 = max(12, WIDTH - 12 - bpb*sp)
for i in range(bpb): for i in range(bpb):
r = vectorio.Rectangle(pixel_shader=self.dot_pal, width=sz, height=sz, x=x0 + i*sp, y=200)
self.g_dots.append(r); self.dots.append(r)
for i in range(bpb): # otherwise just recolour (cheap, no tearing)
lvl = m['levels'][(i*m['sub']) % m['steps']]; on = self.running and i == self.beat lvl = m['levels'][(i*m['sub']) % m['steps']]; on = self.running and i == self.beat
col = (C_AMBER if lvl == 2 else C_CYAN) if on else C_DIM self.dots[i].color_index = (2 if lvl == 2 else 1) if on else 0
self.g_dots.append(rect(x0 + i*sp, 200, sz, sz, col)) self.dirty = True
def run(self): def run(self):
if self.touch.addr is None: if self.touch.addr is None:
self._place(self.g_name, "touch: not found", 12, 272, C_AMBER, C_BG, FONT_M) self._place(self.g_name, "touch: not found", 12, 272, C_AMBER, C_BG, FONT_M)
while True: while True:
self.tick(); self.poll(); time.sleep(0.0005) self.tick(); self.poll()
# push a complete frame only when something changed (no mid-update tearing);
# capped at the display's refresh rate, so dirty regions stay small and quick
if self.dirty and self.display.refresh():
self.dirty = False
time.sleep(0.0005)
App().run() App().run()