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) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-05 22:24:12 -05:00
parent 9651e8bc6a
commit 44193a07c1
2 changed files with 83 additions and 26 deletions

View file

@ -122,10 +122,11 @@ The Kit can drive a **physical metronome pendulum**: a 4-input unipolar stepper
powercycle. powercycle.
- **Jog / test mode** (hold **A + B** at boot): the joystick sets **direction only****L = CCW, R = CW** — and - **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 onscreen the motor **accelerates to `STEPPER_MAX_RATE`** (reversing decelerates through zero first), with an onscreen
needle + RGB LED. The **step count + peakrate readout updates when you release** (drawing midspin would needle + RGB LED and a **live step count + rate readout**. The step pulses are generated by **PIO + DMA**
stall the step loop and make it jumpy, so the spin itself stays glitchfree). *Tuning:* hold to spin, release (hardwaretimed on a state machine), so the motor stays smooth even while the screen redraws — there's no CPU
to read the peak; raise `STEPPER_MAX_RATE` until the motor skips, then back off; if it stalls *starting*, step loop to stall. *Tuning:* hold to spin; raise `STEPPER_MAX_RATE` until the motor skips, then back off; if
lower `STEPPER_ACCEL` / `STEPPER_JOG_START`. Powercycle (no buttons) to exit. it stalls *starting*, lower `STEPPER_ACCEL` / `STEPPER_JOG_START`. Powercycle (no buttons) to exit.
*(The beatpendulum during play still uses the simple step loop for now; it moves to the PIO driver next.)*
## programs.json ## programs.json

View file

@ -16,7 +16,8 @@
# #
# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md. # 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 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) 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) 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() self.phase += 1 if cw else -1; self._write()
def release(self): # de-energize all coils (cool + quiet when idle) def release(self): # de-energize all coils (cool + quiet when idle)
for d in self.io: d.value = False 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) ============================== # ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ==============================
def load_font(path): def load_font(path):
@ -1176,35 +1228,39 @@ class App:
bob.x = bx; bob.y = by bob.x = bx; bob.y = by
arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (bx, by)] arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (bx, by)]
self.display.refresh() self.display.refresh()
# Joystick = DIRECTION only (no fine speed). Spin at STEPPER_MAX_RATE, reached via an # Free the bit-bang coil pins, then drive the motor from PIO: the pulses come from the state
# acceleration ramp (STEPPER_ACCEL) so the motor doesn't stall trying to start at top speed; # machine + DMA, so a display refresh or GC pause can't stall them (that was the jumpiness).
# reversing decelerates through zero, then accelerates the other way. if self.pend is not None: self.pend.deinit()
spin = 0; cur = 0.0; total = 0; win = 0; peak = 0; lastrate = 0 st = PioStepper(P_STEP[0])
now = time.monotonic(); last = now; tsample = now; tjoy = now if not st.ok:
gc.collect() # clean heap before spinning (avoid a GC pause mid-spin) t, w, h = make_text("PIO unavailable", FONT_S, C_RED, C_BG); t.x = 12; t.y = 212; self.g_overlay.append(t)
while True: # no per-iteration sleep: tight step timing in this mode 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() now = time.monotonic()
if now - tjoy >= 0.004: # control update (joystick + accel), off the step hot-path if now - tctl >= 0.01: # 100 Hz control loop (the PIO handles step timing)
cdt = now - tjoy; tjoy = now cdt = now - tctl; tctl = now
dx = self.jx.value - center dx = self.jx.value - center
want = (1 if dx > 0 else -1) if abs(dx) > JOY_DEADZONE else 0 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 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 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 if cur < target: cur = min(target, cur + STEPPER_ACCEL * cdt) # accelerate
elif cur > target: cur = max(0.0, cur - STEPPER_ACCEL * cdt) # decelerate 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 spin != 0:
if self.pend is not None and self.pend.ok: self.pend.release() if cur <= 0.0 and want != spin: # finished slowing -> stop, or flip direction
if want == 0: # stopped -> now safe to draw the readout + tidy the heap st.off()
spin = 0; show_stats(total, lastrate, peak); set_needle(0); gc.collect() 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: else:
spin = want; cur = float(STEPPER_JOG_START); set_needle(spin) st.set_rate(cur if cur > 1.0 else 1.0)
if spin != 0 and cur > 0.0 and self.pend is not None and self.pend.ok and now - last >= 1.0 / cur: total += cur * cdt; peak = max(peak, int(cur))
self.pend.spin(spin > 0); last = now; total += 1; win += 1 if now - tsample >= 0.5: # LIVE readout - safe now: the motor runs from PIO/DMA
if now - tsample >= 1.0: # measure rate SILENTLY (drawing here is what hitched it) show_stats(int(total), int(cur), peak); tsample = now; self.display.refresh()
lastrate = int(win / (now - tsample))
if lastrate > peak: peak = lastrate
win = 0; tsample = now
# ---------- audio + light ---------- # ---------- audio + light ----------
def click(self, level): def click(self, level):