From 44193a07c1be795be89e4e02a5c7a39c2537b8b8 Mon Sep 17 00:00:00 2001 From: Me Here Date: Fri, 5 Jun 2026 22:24:12 -0500 Subject: [PATCH] pm-kit: PIO/DMA stepper for jog mode (hardware-timed, CPU-free pulses) Move jog-mode stepping off the CPU loop onto a PIO state machine: one hardcoded instruction (out pins,4 [31] = 0x7F04, no adafruit_pioasm needed) shifts a 4-bit coil nibble to GP18..21 every 32 PIO cycles; one 32-bit word packs all 8 half-step phases; background_write(loop=) DMA-feeds it continuously. half-steps/s = clock/32, so speed + accel = setting sm.frequency. Pulses now run on dedicated hardware, so a display refresh or GC pause can't stall them - which was the ~1s "smooth then jump". Jog loop is now a light 100Hz CPU controller (joystick + accel ramp + frequency); the live step/rate readout is restored since the motor runs from PIO/DMA. Bit-bang Pendulum keeps a deinit() so jog can hand GP18..21 to PIO. Beat-pendulum still on bit-bang (PIO port next). Co-Authored-By: Claude Opus 4.8 (1M context) --- pico-cp/README.md | 9 +++-- pico-cp/app.py | 100 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/pico-cp/README.md b/pico-cp/README.md index 36dd9d0..c7e2916 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -122,10 +122,11 @@ The Kit can drive a **physical metronome pendulum**: a 4-input unipolar stepper power‑cycle. - **Jog / test mode** (hold **A + B** at boot): the joystick sets **direction only** — **L = CCW, R = CW** — and the motor **accelerates to `STEPPER_MAX_RATE`** (reversing decelerates through zero first), with an on‑screen - needle + RGB LED. The **step count + peak‑rate readout updates when you release** (drawing mid‑spin would - stall the step loop and make it jumpy, so the spin itself stays glitch‑free). *Tuning:* hold to spin, release - to read the peak; raise `STEPPER_MAX_RATE` until the motor skips, then back off; if it stalls *starting*, - lower `STEPPER_ACCEL` / `STEPPER_JOG_START`. Power‑cycle (no buttons) to exit. + needle + RGB LED and a **live step count + rate readout**. The step pulses are generated by **PIO + DMA** + (hardware‑timed on a state machine), so the motor stays smooth even while the screen redraws — there's no CPU + step loop to stall. *Tuning:* hold to spin; raise `STEPPER_MAX_RATE` until the motor skips, then back off; if + it stalls *starting*, lower `STEPPER_ACCEL` / `STEPPER_JOG_START`. Power‑cycle (no buttons) to exit. + *(The beat‑pendulum during play still uses the simple step loop for now; it moves to the PIO driver next.)* ## programs.json diff --git a/pico-cp/app.py b/pico-cp/app.py index f6df469..6cb8e45 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -16,7 +16,8 @@ # # Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md. -import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor, math +import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor, math, rp2pio +from array import array supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart APP_VERSION = "0.0.24" # firmware version (the A/B updater pushes/compares this) DEVICE_ID = "K" # 'K' = 52Pi kit, 'X' = Pimoroni Explorer (per docs/livesync-protocol.md and the version reply) @@ -219,6 +220,57 @@ class Pendulum: self.phase += 1 if cw else -1; self._write() def release(self): # de-energize all coils (cool + quiet when idle) for d in self.io: d.value = False + def deinit(self): # free the 4 pins so PIO can claim them (jog mode) + for d in self.io: + try: d.deinit() + except Exception: pass + self.io = []; self.ok = False + +# PIO-driven stepper: the step pulses come from a PIO state machine fed by DMA (a looping +# background_write), so they keep flowing on dedicated hardware even while the CPU does a display +# refresh or a GC pause - which is what made the bit-bang version jumpy. One 32-bit word packs the 8 +# half-step phases (4 bits each); the single-instruction program shifts a nibble to GP18..21 every 32 +# PIO cycles, so half-steps/sec = frequency / 32. Speed + accel = just change the state-machine frequency. +def _pack_phases(seq): + w = 0 + for i in range(len(seq)): + a, b, c, d = seq[i] + w |= (a | (b << 1) | (c << 2) | (d << 3)) << (i * 4) + return w + +class PioStepper: + PROG = array("H", [0x7F04]) # "out pins, 4 [31]" -> 1 instr + 31 delay = 32 cycles per half-step + def __init__(self, base): + self.ok = False; self.dir = 0 + try: + self.cw = array("I", [_pack_phases(HALF_SEQ)]) + self.ccw = array("I", [_pack_phases(tuple(reversed(HALF_SEQ)))]) + self.zero = array("I", [0]) + self.sm = rp2pio.StateMachine(self.PROG, frequency=4000, + first_out_pin=base, out_pin_count=4, + auto_pull=True, pull_threshold=32, out_shift_right=True) + self.sm.background_write(loop=self.zero) # idle: clock the all-off pattern + self.ok = True + except Exception as e: + print("pio:", e) + def set_rate(self, hz): # half-steps/sec -> PIO clock (32 cycles/step) + if not self.ok: return + f = int(hz) * 32 + if f < 4000: f = 4000 # PIO clock floor (~125 half-steps/s) + elif f > 30000000: f = 30000000 + try: self.sm.frequency = f + except Exception: pass + def run(self, cw): # spin a direction (DMA loops the phase word) + if not self.ok: return + d = 1 if cw else -1 + if d != self.dir: + self.dir = d + self.sm.background_write(loop=(self.cw if cw else self.ccw)) + def off(self): # de-energize (loop the all-zero pattern) + if not self.ok: return + self.dir = 0 + try: self.sm.background_write(loop=self.zero) + except Exception: pass # ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ============================== def load_font(path): @@ -1176,35 +1228,39 @@ class App: bob.x = bx; bob.y = by arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (bx, by)] self.display.refresh() - # Joystick = DIRECTION only (no fine speed). Spin at STEPPER_MAX_RATE, reached via an - # acceleration ramp (STEPPER_ACCEL) so the motor doesn't stall trying to start at top speed; - # reversing decelerates through zero, then accelerates the other way. - spin = 0; cur = 0.0; total = 0; win = 0; peak = 0; lastrate = 0 - now = time.monotonic(); last = now; tsample = now; tjoy = now - gc.collect() # clean heap before spinning (avoid a GC pause mid-spin) - while True: # no per-iteration sleep: tight step timing in this mode + # Free the bit-bang coil pins, then drive the motor from PIO: the pulses come from the state + # machine + DMA, so a display refresh or GC pause can't stall them (that was the jumpiness). + if self.pend is not None: self.pend.deinit() + st = PioStepper(P_STEP[0]) + if not st.ok: + t, w, h = make_text("PIO unavailable", FONT_S, C_RED, C_BG); t.x = 12; t.y = 212; self.g_overlay.append(t) + self.display.refresh() + # Joystick = DIRECTION only. The CPU just ramps the commanded rate (accel) and sets the PIO + # clock; the motor steps autonomously. Reversing decelerates through zero, then the other way. + spin = 0; cur = 0.0; total = 0.0; peak = 0 + now = time.monotonic(); tctl = now; tsample = now + gc.collect() + while True: now = time.monotonic() - if now - tjoy >= 0.004: # control update (joystick + accel), off the step hot-path - cdt = now - tjoy; tjoy = now + if now - tctl >= 0.01: # 100 Hz control loop (the PIO handles step timing) + cdt = now - tctl; tctl = now dx = self.jx.value - center want = (1 if dx > 0 else -1) if abs(dx) > JOY_DEADZONE else 0 if spin == 0 and want != 0: # start from rest at the safe pull-in rate - spin = want; cur = float(STEPPER_JOG_START); set_needle(spin) + spin = want; cur = float(STEPPER_JOG_START); st.run(want > 0); set_needle(spin) target = float(STEPPER_MAX_RATE) if (spin != 0 and want == spin) else 0.0 if cur < target: cur = min(target, cur + STEPPER_ACCEL * cdt) # accelerate elif cur > target: cur = max(0.0, cur - STEPPER_ACCEL * cdt) # decelerate - if cur <= 0.0 and spin != 0 and want != spin: # finished slowing -> stop, or flip direction - if self.pend is not None and self.pend.ok: self.pend.release() - if want == 0: # stopped -> now safe to draw the readout + tidy the heap - spin = 0; show_stats(total, lastrate, peak); set_needle(0); gc.collect() + if spin != 0: + if cur <= 0.0 and want != spin: # finished slowing -> stop, or flip direction + st.off() + if want == 0: spin = 0; set_needle(0); gc.collect() + else: spin = want; cur = float(STEPPER_JOG_START); st.run(want > 0); set_needle(spin) else: - spin = want; cur = float(STEPPER_JOG_START); set_needle(spin) - if spin != 0 and cur > 0.0 and self.pend is not None and self.pend.ok and now - last >= 1.0 / cur: - self.pend.spin(spin > 0); last = now; total += 1; win += 1 - if now - tsample >= 1.0: # measure rate SILENTLY (drawing here is what hitched it) - lastrate = int(win / (now - tsample)) - if lastrate > peak: peak = lastrate - win = 0; tsample = now + st.set_rate(cur if cur > 1.0 else 1.0) + total += cur * cdt; peak = max(peak, int(cur)) + if now - tsample >= 0.5: # LIVE readout - safe now: the motor runs from PIO/DMA + show_stats(int(total), int(cur), peak); tsample = now; self.display.refresh() # ---------- audio + light ---------- def click(self, level):