pm-kit: jog-mode step counter + rate readout, and document the pendulum/jog feature

- Jog/test screen now shows a live step count and current/peak step rate; jog
  ceiling raised to ~1000 steps/s so you can probe past a motor's max and read
  the peak rate where it stalls -> set STEPPER_MAX_RATE just below that.
- README: new "Pendulum (stepper motion)" section (wiring GP18-21, the config
  knobs, motion behaviour, jog/test mode) + the A-alone / A+B power-on chords;
  noted the GP19/20/21 conflict with the custom PM_K-1 board's ribbon.

Pure ASCII; conformance 47/47; app.mpy precompiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-05 21:43:22 -05:00
parent 15755f4d0c
commit 36c7406d71
2 changed files with 45 additions and 3 deletions

View file

@ -14,9 +14,12 @@ from the web editor over USBMIDI**, and plays through your **computer's speak
- **Appliance mode — default (just plug in / power up).** The *firmware* owns the filesystem, so it - **Appliance mode — default (just plug in / power up).** The *firmware* owns the filesystem, so it
saves your practice log and writes set lists the editor pushes over USBMIDI. The drive is then saves your practice log and writes set lists the editor pushes over USBMIDI. The drive is then
**readonly to the computer** — which also **protects the firmware from accidental deletion**. **readonly to the computer** — which also **protects the firmware from accidental deletion**.
- **Editor mode — hold BUTTON A while plugging in.** The drive is **writable by the computer**, so you - **Editor mode — hold BUTTON A *alone* while plugging in.** The drive is **writable by the computer**, so you
can drag `programs.json` / `code.py` / fonts on from any OS or browser (the universal fallback). can drag `programs.json` / `code.py` / fonts on from any OS or browser (the universal fallback).
Reset afterwards to return to appliance mode. Reset afterwards to return to appliance mode.
- **Stepper jog/test mode — hold BUTTON A + B *together* while plugging in.** A hidden screen where the
joystick spins the stepper CW/CCW for bring-up (see *Pendulum* below). This chord stays in appliance mode
(the drive is **not** flipped writable). Power-cycle with no buttons to return to normal.
## Install ## Install
@ -91,6 +94,32 @@ The editor also syncs the device clock, so the practice log gets real wallclo
- **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **speaker** clicks to match. - **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **speaker** clicks to match.
- The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives powercycles. - The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives powercycles.
## Pendulum (stepper motion) — optional
The Kit can drive a **physical metronome pendulum**: a 4-input unipolar stepper (e.g. a ULN2003 board +
28BYJ-48) swung in time with the beat, plus a **matching pendulum drawn on the screen**.
- **Wiring:** controller IN1..IN4 → **GP18, GP19, GP20, GP21**; controller GND → a Pico GND (shared
ground). Power the motor from the controller's own supply, **not** the Pico. *(These four pins are free
on the EP0172 kit. On the custom PM_K1 board GP19/20/21 are already taken by SIG/CLIP LED + groundlift,
so the production pendulum will need a pin reassignment.)*
- **Motion:** the arm reaches an extreme **exactly on each beat**, then reverses; it reads the live beat
clock, so it follows tempo ramps. Coils **deenergize when stopped**. The onscreen pendulum (shown over
the practice log while playing) mirrors the arm exactly — and animates even with **no motor wired**.
- **Config (top of `code.py`):**
- `STEPPER_ENABLED` — off leaves the four pins free.
- `PEND_SWING_DEG` — total swing arc endtoend, in degrees (default **120**). Single source of truth:
drives the screen graphic exactly and the motor.
- `STEPPER_STEPS_PER_REV` — your motor's halfsteps per full turn (28BYJ48 halfstep ≈ 4096); maps
degrees → steps.
- `STEPPER_MAX_RATE` — fastest halfsteps/sec the motor can follow; the swing **autoshrinks** (rather than
desync) when a beat is too short to sweep the full arc. At wide swings/fast tempi the screen still shows
the full angle while the arm does what it physically can.
- **Jog / test mode** (hold **A + B** at boot): the joystick spins the motor **L = CCW, R = CW** (speed by
how far you push), with an onscreen direction needle + RGB LED and a **live step counter + rate readout**.
Push to full deflection and note the **peak rate** where the motor begins to stall, then set
`STEPPER_MAX_RATE` just below it. Powercycle (no buttons) to exit.
## programs.json ## programs.json
```json ```json

View file

@ -1148,9 +1148,17 @@ class App:
self.g_overlay.append(vectorio.Circle(pixel_shader=solid(C_PANEL), radius=7, x=PEND_PX, y=PEND_PY)) self.g_overlay.append(vectorio.Circle(pixel_shader=solid(C_PANEL), radius=7, x=PEND_PX, y=PEND_PY))
bob = vectorio.Circle(pixel_shader=solid(C_CYAN), radius=13, x=PEND_PX, y=PEND_PY - PEND_LEN) bob = vectorio.Circle(pixel_shader=solid(C_CYAN), radius=13, x=PEND_PX, y=PEND_PY - PEND_LEN)
self.g_overlay.append(bob) self.g_overlay.append(bob)
rg = displayio.Group(); self.g_overlay.append(rg) # live step-count + rate readout
def show_stats(total, rate, peak):
while len(rg): rg.pop()
t, w, h = make_text("steps: %d" % total, FONT_M, C_TXT, C_BG); t.x = 12; t.y = 158; rg.append(t)
t, w, h = make_text("rate: %d/s peak: %d/s" % (rate, peak), FONT_S, C_AMBER, C_BG)
t.x = 12; t.y = 186; rg.append(t)
show_stats(0, 0, 0)
self.display.refresh() self.display.refresh()
time.sleep(0.1); center = self.jx.value time.sleep(0.1); center = self.jx.value
last = time.monotonic(); lastdir = None last = time.monotonic(); lastdir = None
total = 0; win = 0; peak = 0; tsample = time.monotonic()
while True: while True:
now = time.monotonic() now = time.monotonic()
dx = self.jx.value - center; mag = abs(dx) dx = self.jx.value - center; mag = abs(dx)
@ -1158,9 +1166,9 @@ class App:
cw = dx > 0 cw = dx > 0
frac = (mag - JOY_DEADZONE) / (32768 - JOY_DEADZONE) frac = (mag - JOY_DEADZONE) / (32768 - JOY_DEADZONE)
if frac > 1.0: frac = 1.0 if frac > 1.0: frac = 1.0
dt = 0.02 + (0.0015 - 0.02) * frac # full push = fast, just past deadzone = slow dt = 0.02 + (0.001 - 0.02) * frac # full push ~1000 steps/s; just past deadzone = slow
if self.pend is not None and self.pend.ok and now - last >= dt: if self.pend is not None and self.pend.ok and now - last >= dt:
self.pend.spin(cw); last = now self.pend.spin(cw); last = now; total += 1; win += 1
if cw != lastdir: # direction changed -> update LED + needle if cw != lastdir: # direction changed -> update LED + needle
lastdir = cw lastdir = cw
if cw: self.led.set(0, 150, 0) if cw: self.led.set(0, 150, 0)
@ -1177,6 +1185,11 @@ class App:
bob.x = PEND_PX; bob.y = PEND_PY - PEND_LEN bob.x = PEND_PX; bob.y = PEND_PY - PEND_LEN
arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (PEND_PX, PEND_PY - PEND_LEN)] arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (PEND_PX, PEND_PY - PEND_LEN)]
self.display.refresh() self.display.refresh()
if now - tsample >= 0.3: # ~3x/s: commanded-rate window -> readout (peak = motor ceiling)
rate = int(win / (now - tsample))
if rate > peak: peak = rate
show_stats(total, rate, peak); win = 0; tsample = now
self.display.refresh()
time.sleep(0.0005) time.sleep(0.0005)
# ---------- audio + light ---------- # ---------- audio + light ----------