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
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**.
- **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).
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
@ -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.
- 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
```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))
bob = vectorio.Circle(pixel_shader=solid(C_CYAN), radius=13, x=PEND_PX, y=PEND_PY - PEND_LEN)
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()
time.sleep(0.1); center = self.jx.value
last = time.monotonic(); lastdir = None
total = 0; win = 0; peak = 0; tsample = time.monotonic()
while True:
now = time.monotonic()
dx = self.jx.value - center; mag = abs(dx)
@ -1158,9 +1166,9 @@ class App:
cw = dx > 0
frac = (mag - JOY_DEADZONE) / (32768 - JOY_DEADZONE)
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:
self.pend.spin(cw); last = now
self.pend.spin(cw); last = now; total += 1; win += 1
if cw != lastdir: # direction changed -> update LED + needle
lastdir = cw
if cw: self.led.set(0, 150, 0)
@ -1177,6 +1185,11 @@ class App:
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)]
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)
# ---------- audio + light ----------