diff --git a/pico-cp/README.md b/pico-cp/README.md index 55eb6dc..1aa673f 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -14,9 +14,12 @@ from the web editor over USB‑MIDI**, 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 USB‑MIDI. The drive is then **read‑only 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 wall‑clo - **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 power‑cycles. +## 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 EP‑0172 kit. On the custom PM_K‑1 board GP19/20/21 are already taken by SIG/CLIP LED + ground‑lift, + 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 **de‑energize when stopped**. The on‑screen 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 end‑to‑end, in degrees (default **120**). Single source of truth: + drives the screen graphic exactly and the motor. + - `STEPPER_STEPS_PER_REV` — your motor's half‑steps per full turn (28BYJ‑48 half‑step ≈ 4096); maps + degrees → steps. + - `STEPPER_MAX_RATE` — fastest half‑steps/sec the motor can follow; the swing **auto‑shrinks** (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 on‑screen 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. Power‑cycle (no buttons) to exit. + ## programs.json ```json diff --git a/pico-cp/app.py b/pico-cp/app.py index 750dc96..157b4f6 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -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 ----------