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:
parent
9651e8bc6a
commit
44193a07c1
2 changed files with 83 additions and 26 deletions
|
|
@ -122,10 +122,11 @@ The Kit can drive a **physical metronome pendulum**: a 4-input unipolar stepper
|
||||||
power‑cycle.
|
power‑cycle.
|
||||||
- **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 on‑screen
|
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
|
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 glitch‑free). *Tuning:* hold to spin, release
|
(hardware‑timed 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`. Power‑cycle (no buttons) to exit.
|
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
|
## programs.json
|
||||||
|
|
||||||
|
|
|
||||||
100
pico-cp/app.py
100
pico-cp/app.py
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue