pm-kit: hidden stepper jog/test mode (hold A+B at boot)

Hold both buttons at power-on to enter a self-contained jog screen: the joystick
spins the stepper CW/CCW (speed by deflection), with an on-screen direction
needle + RGB LED feedback. Runs in its own loop; power-cycle to return to normal.

- app.py: _jog_loop drawn entirely in the overlay group (cover + labels + needle);
  Pendulum.spin() does a free half-step either way; _jog set when A+B held in init;
  run() branches to it before the normal loop.
- boot.py: editor mode is now "A alone" (A pressed, B not). A+B stays in appliance
  mode, so the jog chord doesn't also flip the drive writable.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-05 21:37:57 -05:00
parent 5f3c518089
commit 15755f4d0c
2 changed files with 64 additions and 8 deletions

View file

@ -213,6 +213,8 @@ class Pendulum:
def step_toward(self, target): # one half-step toward target
if target > self.pos: self.phase += 1; self.pos += 1; self._write()
elif target < self.pos: self.phase -= 1; self.pos -= 1; self._write()
def spin(self, cw): # one free half-step either way (jog/test mode)
self.phase += 1 if cw else -1; self._write()
def release(self): # de-energize all coils (cool + quiet when idle)
for d in self.io: d.value = False
@ -538,6 +540,7 @@ class App:
self._pendNext = 0.0 # ~30fps cadence for the on-screen pendulum
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
self._aPrev = True; self._bPrev = True
self._jog = (not self.btnA.value) and (not self.btnB.value) # both held at boot -> hidden jog/test mode
self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY)
self._joyNext = 0
self._touchDown = False; self._touchSeen = 0
@ -1128,6 +1131,54 @@ class App:
self._pend_arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (bx, by)]
self.dirty = True
def _jog_loop(self): # hidden stepper jog/test (A+B held at boot)
# Joystick L/R spins the motor CCW/CW (speed by how far you push). The on-screen needle + RGB
# LED show direction. Runs forever; power-cycle with no buttons held to return to normal.
while len(self.g_overlay): self.g_overlay.pop()
self.g_overlay.append(rect(0, 40, WIDTH, HEIGHT - 40, C_BG)) # cover the normal UI
for s, fnt, col, yy in (("STEPPER JOG TEST", FONT_M, C_CYAN, 64),
("Joystick L = CCW R = CW", FONT_S, C_TXT, 96),
("push further = faster", FONT_S, C_DIM, 114),
("power-cycle (no buttons) to exit", FONT_S, C_DIM, 132)):
tg, w, h = make_text(s, fnt, col, C_BG); tg.x = 12; tg.y = yy; self.g_overlay.append(tg)
self.g_overlay.append(rect(PEND_PX - 26, PEND_PY + 4, 52, 4, C_PANEL)) # stand
arm = vectorio.Polygon(pixel_shader=solid(C_DIM),
points=[(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (PEND_PX, PEND_PY - PEND_LEN)], x=0, y=0)
self.g_overlay.append(arm)
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)
self.display.refresh()
time.sleep(0.1); center = self.jx.value
last = time.monotonic(); lastdir = None
while True:
now = time.monotonic()
dx = self.jx.value - center; mag = abs(dx)
if mag > JOY_DEADZONE:
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
if self.pend is not None and self.pend.ok and now - last >= dt:
self.pend.spin(cw); last = now
if cw != lastdir: # direction changed -> update LED + needle
lastdir = cw
if cw: self.led.set(0, 150, 0)
else: self.led.set(0, 0, 255)
ang = PEND_THETA if cw else -PEND_THETA
bx = PEND_PX + int(PEND_LEN * math.sin(ang)); by = PEND_PY - int(PEND_LEN * math.cos(ang))
bob.x = bx; bob.y = by
arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (bx, by)]
self.display.refresh()
elif lastdir is not None: # back to center -> stop, release, recentre needle
lastdir = None
if self.pend is not None and self.pend.ok: self.pend.release()
self.led.set(0, 0, 0)
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()
time.sleep(0.0005)
# ---------- audio + light ----------
def click(self, level):
self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
@ -1893,6 +1944,8 @@ class App:
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F if ok else 0x7E, 0xF7]))
def run(self):
if self._jog: # A+B held at boot -> hidden stepper jog/test mode
self._jog_loop()
if self.touch.addr is None:
print("GT911 touch not found")
boot = time.monotonic()

View file

@ -4,19 +4,22 @@
# /history.json and write /programs.json that the editor pushes over USB-MIDI. The drive is then
# READ-ONLY to the computer — which also protects the firmware from accidental deletion.
#
# HOLD BUTTON A (GP15) WHILE PLUGGING IN = editor mode: the drive is writable by the computer, so
# you can drag programs.json / code.py on from any OS or browser (the universal fallback). Reset
# HOLD BUTTON A (GP15) ALONE WHILE PLUGGING IN = editor mode: the drive is writable by the computer,
# so you can drag programs.json / code.py on from any OS or browser (the universal fallback). Reset
# afterwards to return to appliance mode.
#
# HOLD BUTTON A + B TOGETHER = the firmware's hidden stepper jog/test mode (app.py). That chord
# stays in appliance mode here (drive NOT flipped writable) so testing doesn't disturb the drive.
#
# Also frees a USB endpoint (disables unused HID) and makes sure USB-MIDI is available.
import board, digitalio, storage, usb_hid, usb_midi
try: usb_hid.disable()
except Exception: pass
usb_midi.enable()
a = digitalio.DigitalInOut(board.GP15)
a.switch_to_input(pull=digitalio.Pull.UP)
appliance = a.value # value True (pull-up, not pressed) -> appliance mode
a.deinit()
if appliance:
try: storage.remount("/", readonly=False) # writable by code, read-only to the computer
a = digitalio.DigitalInOut(board.GP15); a.switch_to_input(pull=digitalio.Pull.UP)
b = digitalio.DigitalInOut(board.GP14); b.switch_to_input(pull=digitalio.Pull.UP)
editor = (not a.value) and b.value # editor mode = A pressed, B NOT pressed (A+B is the jog chord)
a.deinit(); b.deinit()
if not editor:
try: storage.remount("/", readonly=False) # appliance: writable by code, read-only to the computer
except Exception: pass