Przeglądaj źródła

Merge stability-fix: high-water-mark mounting, 2s ReversePuck hold + drawn icons, make build/flash tooling

Brings the marshalling-refactor branch to parity with stability-fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jari 3 miesięcy temu
rodzic
commit
2bd315e91d
6 zmienionych plików z 235 dodań i 50 usunięć
  1. 50 1
      Makefile
  2. 3 3
      OpenPuck/OpenPuck.ino
  3. 26 2
      OpenPuck/usb_mount.cpp
  4. 3 1
      README.md
  5. 120 27
      ReversePuck/ui.py
  6. 33 16
      docs/BUILD_AND_DEPLOY.md

+ 50 - 1
Makefile

@@ -16,7 +16,56 @@ FORMAT_FILES := $(shell find OpenPuck ReversePuckFirmware puck_sniffer pairtui \
 	\( -name '*.c' -o -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \
 	-not -name 'git_version.h')
 
-.PHONY: format format-check check
+# --- firmware build ---------------------------------------------------------
+# The OpenPuck firmware needs two TinyUSB config values that differ from the Adafruit nRF52 core defaults:
+#   CFG_TUD_HID=4            -- four HID interfaces (Steam mode exposes four puck slots); core default is 2.
+#   CFG_TUD_TASK_QUEUE_SZ=64 -- deeper usbd event queue; the default 16 deadlocks the loop task under comms
+#                               load -> watchdog reset (see OpenPuck.ino + docs/BUILD_AND_DEPLOY.md).
+# They're baked in here so a normal build is just `make build` -- no need to remember the flags. Override any
+# on the command line, e.g.   make build CFG_TUD_HID=6 CFG_TUD_TASK_QUEUE_SZ=128
+# or add your own defines:     make build EXTRA_FLAGS="-DOPK_LOG=1"
+FQBN ?= adafruit:nrf52:feather52840
+CFG_TUD_HID ?= 4
+CFG_TUD_TASK_QUEUE_SZ ?= 64
+EXTRA_FLAGS ?=
+# {build.flags.usb} is expanded by arduino-cli (VID/PID/strings); pass it through verbatim.
+USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) $(EXTRA_FLAGS)
+
+# `flash`/`deploy` take the serial port as a REQUIRED POSITIONAL arg: `make flash /dev/cu.usbmodem1101`.
+# (No auto-detect -- uploading to a guessed serial port risks writing to the wrong device. List with
+# `arduino-cli board list`.) FLASH_PORT = whatever goal isn't one of our real targets; the catch-all rule at
+# the bottom swallows it so make doesn't try to build the port path as a target.
+FLASH_PORT := $(filter-out format format-check check build build-recovery flash deploy,$(MAKECMDGOALS))
+UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" OpenPuck
+
+.PHONY: format format-check check build build-recovery flash deploy
+
+## Compile the firmware with the required USB flags baked in. Override CFG_TUD_HID / CFG_TUD_TASK_QUEUE_SZ /
+## EXTRA_FLAGS / FQBN as make variables if needed.
+build:
+	arduino-cli compile -b $(FQBN) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" OpenPuck
+
+## One-time factory-reset recovery image (wipes persistent storage once on first boot). See §6 of the build doc.
+build-recovery:
+	$(MAKE) build EXTRA_FLAGS="$(EXTRA_FLAGS) -DOPK_FACTORY_RESET=1"
+
+## Upload the most recent build. Usage: make flash <port>   e.g.  make flash /dev/cu.usbmodem1101
+## The board may need DFU/bootloader mode first (double-tap RST): puck (Steam/Lizard) mode drops the CDC port
+## so arduino-cli can't auto-reset it -- see docs/BUILD_AND_DEPLOY.md.
+flash:
+	@[ -n "$(FLASH_PORT)" ] || { echo "usage: make flash <port>   e.g. make flash /dev/cu.usbmodem1101   (list ports: arduino-cli board list)"; exit 1; }
+	$(UPLOAD)
+
+## Build then upload in one step. Usage: make deploy <port>   (same build overrides as `build`).
+deploy:
+	@[ -n "$(FLASH_PORT)" ] || { echo "usage: make deploy <port>   e.g. make deploy /dev/cu.usbmodem1101"; exit 1; }
+	$(MAKE) build
+	$(UPLOAD)
+
+# Swallow the positional <port> arg so make doesn't error trying to build it as a target. Scoped to things
+# that look like a serial port (so a real typo like `make buld` still errors instead of silently no-op'ing).
+/dev/% COM% tty% cu.%:
+	@:
 
 ## Reformat all C/C++ sources in place using the Linux kernel style.
 format:

+ 3 - 3
OpenPuck/OpenPuck.ino

@@ -2,7 +2,7 @@
 //
 // This firmware impersonates the Valve puck over USB, maintains puck-style bond slots, speaks the
 // reverse-engineered RF protocol to the controller, and re-enumerates into Steam, Xbox, Switch, PS5, or DS4
-// personalities. Build with -DCFG_TUD_HID=4 (the Adafruit nRF port defaults to 2).
+// personalities. Build with `make build` (bakes in the required CFG_TUD_HID=4 + CFG_TUD_TASK_QUEUE_SZ=64).
 //
 // This file is just the entry point: setup() builds the USB presentation for the persisted mode and arms the
 // hardware watchdog; loop() pumps each subsystem. Everything substantive lives in the modules below -- see
@@ -41,7 +41,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include <stdio.h>
 
 #if CFG_TUD_HID < 4
-#error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
+#error "CFG_TUD_HID must be >= 4 (up to 4 HID interfaces per mode). Build with `make build` (bakes it in), or pass -DCFG_TUD_HID=4 in build.extra_flags."
 #endif
 
 // The TinyUSB device-stack event queue (_usbd_q) defaults to 16 entries. tud_task() runs in the dedicated
@@ -55,7 +55,7 @@ using namespace Adafruit_LittleFS_Namespace;
 // TinyUSB-internal #ifndef default, so a sketch header cannot reach usbd.c) -- enforce it here so a build that
 // omits the flag fails loudly instead of shipping the deadlock.
 #if !defined(CFG_TUD_TASK_QUEUE_SZ) || CFG_TUD_TASK_QUEUE_SZ < 32
-#error "build with -DCFG_TUD_TASK_QUEUE_SZ=64 (extra_flags): the default 16-deep usbd event queue deadlocks the loop task under comms load -> watchdog reset"
+#error "CFG_TUD_TASK_QUEUE_SZ must be >= 32: the default 16-deep usbd event queue deadlocks the loop task under comms load -> watchdog reset. Build with `make build` (bakes in 64), or pass -DCFG_TUD_TASK_QUEUE_SZ=64 in build.extra_flags."
 #endif
 
 // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer

+ 26 - 2
OpenPuck/usb_mount.cpp

@@ -15,6 +15,12 @@ static uint8_t g_maxSlots = NSLOT;
 // The connected set must hold steady this long before we re-enumerate -- absorbs RF blips and the staggered
 // way multiple controllers connect at boot (one re-enumeration once the set settles, not one per controller).
 #define MOUNT_DEBOUNCE_MS 2000u
+// Lazy removal: a disconnected slot's interface is only dropped (and the device re-enumerated down) once NO
+// controller has sent input for this long -- i.e. during genuine downtime. Re-enumeration tears down/rebuilds
+// USB from the loop task and is risky to do mid-use, so we never shrink while anything is being played; we
+// just let dead slots linger as idle interfaces and clean them up when the rig goes quiet. Additions are NOT
+// gated by this -- a newly-connected controller grows the set right away.
+#define IDLE_CLEANUP_MS 60000u
 
 static uint8_t connectedMask()
 {
@@ -108,14 +114,32 @@ void usbMountTask()
 	static uint8_t lastMask = 0xFF; // force first compare
 	static unsigned long stableSince = 0;
 	unsigned long now = millis();
-	uint8_t want = connectedMask();
+	// ADD IMMEDIATELY, REMOVE LAZILY. Re-enumeration (usbReenumerate) tears down + rebuilds the whole USB
+	// config from the loop task; doing that while the high-priority usbd task is mid-tud_task races its
+	// internal state -> HardFault/reboot. So we only ever SHRINK the interface set during genuine downtime:
+	//   - active (some controller sent input within IDLE_CLEANUP_MS): target = connected UNION already-mounted
+	//     -> the set only grows. A controller that drops (e.g. an errant ReversePuck disconnect) keeps its
+	//     now-idle interface and reconnecting reuses it with NO re-enumeration. task() gates input per slot,
+	//     so an idle interface just streams nothing.
+	//   - idle (no controller input for a full minute): target = exactly the connected set -> any lingering
+	//     dead slot is dropped and we re-enumerate down, safely, because nobody is playing.
+	// New controllers grow the set right away (after the usual debounce) regardless of idle state.
+	unsigned long lastAny = 0;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] > lastAny)
+			lastAny = g_connReplyMs[s];
+	bool idle = (lastAny == 0) || (now - lastAny) >= IDLE_CLEANUP_MS;
+	uint8_t want = idle ? connectedMask() :
+			      (uint8_t)(connectedMask() | mountedMask());
 	if (want != lastMask) { // set is moving -> restart the stability timer
 		lastMask = want;
 		stableSince = now;
 		return;
 	}
 	// `want` has been steady since stableSince. Re-enumerate once it has held long enough AND the resulting
-	// (order-preserving, capped) mount SET differs from what is currently mounted.
+	// (order-preserving, capped) mount SET differs from what is currently mounted. With the union above,
+	// while active that can only happen when a NEW controller needs an interface (grow); a shrink only ever
+	// comes from the idle branch dropping a long-dead slot.
 	if ((now - stableSince) < MOUNT_DEBOUNCE_MS)
 		return;
 	int8_t tmp[NSLOT];

+ 3 - 1
README.md

@@ -74,11 +74,13 @@ This is a tool to emulate a Steam Controller 2 with almost all of its inputs (ex
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
 - Design the charging portion (and make it short proof)
+- Make ReversePuck into a system that you can plug into most controllers and allow them to talk to OpenPuck
+- A BLE version
 
 # Contributions
 The firmware is split into small, single-responsibility modules under `OpenPuck/` (one file per emulated controller, plus the RF, config, and host-interface layers). Start with [ARCHITECTURE.md](./ARCHITECTURE.md) for the map of how it all fits together and how to add a new USB personality.
 
-I have tested this software fairly extensively but I have limited resources. I only have one controller so I've been unable to do full end-to-end multi-controller hardware testing — the per-slot session address, per-slot USB fanout, and per-slot haptic demux are in place and the design is sound, but the `U` CDC command exists specifically so anyone with a second controller can verify the per-slot state at a glance. Please submit issues with any issues you find. PRs also welcome of course.
+I have tested this software fairly extensively but I have limited resources. Please submit issues with any issues you find. PRs also welcome of course.
 
 # Acknowledgements
 - Valve for putting out the amazing controller

+ 120 - 27
ReversePuck/ui.py

@@ -1,12 +1,22 @@
 """ui.py -- fullscreen touchscreen UI for the Steam Deck forwarder.
 
 Big tappable tiles, one per bonded puck. A tile is tappable when its puck is LIVE (the RF link is up).
-Tapping a live tile detaches the Deck controls and forwards to that puck; tapping anywhere while
-forwarding stops and returns control to the Deck. Sized for the Deck's 1280x800 touchscreen but scales.
+Tapping a live tile detaches the Deck controls and forwards to that puck (a single tap -- connecting is
+cheap and reversible). The two DESTRUCTIVE actions -- stopping/disconnecting a forward, and removing a
+bond (unpair) -- instead require a HOLD (HOLD_SECS): an errant screen tap mid-game used to drop the forward
+(which on the puck side tears down a USB device and could reboot the MCU), so a brief touch must no longer
+trigger it. A progress fill shows the hold; lifting before it completes cancels. Sized for the Deck's
+1280x800 touchscreen but scales.
+
+Status icons (dots, the remove ✕) are drawn with primitives, not font glyphs -- the Deck's available fonts
+don't carry ● ○ ✕ etc., so rendering them as text just showed blank/boxes.
 """
 import time
 import pygame
 
+# Destructive actions (stop forwarding, remove bond) require holding this long; a tap no longer triggers them.
+HOLD_SECS = 2.0
+
 BG = (16, 18, 24)
 FG = (230, 232, 238)
 DIM = (120, 126, 140)
@@ -24,6 +34,23 @@ def _font(sz, bold=False):
     return f
 
 
+def _draw_dot(screen, center, r, color, filled=True):
+    """Status dot drawn with primitives -- font glyphs (● ○) don't render on the Deck's fallback font."""
+    if filled:
+        pygame.draw.circle(screen, color, center, r)
+    else:
+        pygame.draw.circle(screen, color, center, r, max(2, r // 3))
+
+
+def _draw_cross(screen, rect, color, width):
+    """Remove-bond ✕ drawn as two diagonals (the font glyph doesn't render)."""
+    pad = max(6, rect.width // 4)
+    pygame.draw.line(screen, color, (rect.x + pad, rect.y + pad),
+                     (rect.right - pad, rect.bottom - pad), width)
+    pygame.draw.line(screen, color, (rect.right - pad, rect.y + pad),
+                     (rect.x + pad, rect.bottom - pad), width)
+
+
 def run(app):
     pygame.init()
     pygame.display.set_caption("ReversePuck · Steam Deck")
@@ -37,21 +64,37 @@ def run(app):
 
     tile_rects = []  # (rect, slot, tappable)
     del_rects = []   # (rect, slot) -- the per-tile "remove bond" ✕ target
+    # Active hold-to-confirm gesture, or None. dict(kind="stop"|"unpair", slot, start). Read by draw() for
+    # the progress fill; set in handle_down()/cleared in handle_up() and on completion.
+    hold = None
+
+    def hold_progress():
+        """0.0..1.0 of the current hold, or None if no hold is active."""
+        if hold is None:
+            return None
+        return max(0.0, min(1.0, (time.monotonic() - hold["start"]) / HOLD_SECS))
 
     def draw():
         nonlocal tile_rects, del_rects
         screen.fill(BG)
         s = app.status
         connected = app.dongle_connected
-        # compact status line (no big title banner): dongle presence first, then RF link
+        # compact status line (no big title banner): dongle presence first, then RF link. The leading status
+        # dot is drawn (font glyphs don't render); filled = linked, ring = no-dongle / searching.
         if not connected:
-            badge, col = "⨯ no dongle", WARN
+            badge, col = "no dongle", WARN
         elif s["link_up"]:
-            badge, col = "● linked  ch%d" % s["sess_ch"], LIVE
+            badge, col = "linked  ch%d" % s["sess_ch"], LIVE
         else:
-            badge, col = "○ searching…", DIM
+            badge, col = "searching...", DIM
         t = f_med.render(badge, True, col)
-        screen.blit(t, (W - t.get_width() - 40, 24))
+        bx = W - t.get_width() - 40
+        screen.blit(t, (bx, 24))
+        _draw_dot(screen, (bx - 18, 24 + t.get_height() // 2), 8, col,
+                  filled=connected and s["link_up"])
+        # legend (top-left): how the gestures work
+        screen.blit(f_small.render("Tap to connect, hold to disconnect or remove", True, DIM),
+                    (40, 30))
 
         # tiles -- live pucks float to the top, then by slot index
         tile_rects = []
@@ -64,7 +107,7 @@ def run(app):
         if not connected:
             msg = f_big.render("Plug in the ReversePuck dongle", True, WARN)
             screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
-            sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB… it'll appear here automatically.",
+            sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB... it'll appear here automatically.",
                                  True, DIM)
             screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
         elif not bonds:
@@ -82,17 +125,16 @@ def run(app):
             color = TILE_SEL if sel else (TILE_LIVE if b["alive"] else TILE)
             pygame.draw.rect(screen, color, rect, border_radius=18)
             pygame.draw.rect(screen, (ACCENT if (tappable or sel) else DIM), rect, width=2, border_radius=18)
-            # glyph + serial
-            glyph = "●" if b["alive"] else "○"
+            # status dot (drawn -- font ● ○ don't render) + serial
             gcol = LIVE if b["alive"] else DIM
-            screen.blit(f_big.render(glyph, True, gcol), (rect.x + 30, rect.y + th // 2 - 22))
+            _draw_dot(screen, (rect.x + 46, rect.y + th // 2), 13, gcol, filled=b["alive"])
             screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
                         (rect.x + 90, rect.y + 20))
-            state = ("FORWARDING — tap to stop" if sel
-                     else "available — tap to forward" if tappable
+            state = ("FORWARDING - hold anywhere to stop" if sel
+                     else "available - tap to forward" if tappable
                      else "live (forwarding elsewhere)" if b["alive"]
-                     else "paired · offline")
-            screen.blit(f_small.render("slot %d · %s" % (slot, state), True, DIM),
+                     else "paired - offline")
+            screen.blit(f_small.render("slot %d - %s" % (slot, state), True, DIM),
                         (rect.x + 90, rect.y + th - 38))
             tile_rects.append((rect, slot, tappable or sel))
             # remove-bond ✕ on the far right (only when idle -- can't un-bond while forwarding)
@@ -100,10 +142,16 @@ def run(app):
                 dsz = min(th - 24, 64)
                 drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
                 pygame.draw.rect(screen, TILE, drect, border_radius=12)
+                # while this ✕ is being held, fill it from the bottom up as a hold-to-unpair progress meter
+                held = (hold is not None and hold["kind"] == "unpair"
+                        and hold["slot"] == slot)
+                hp = hold_progress()
+                if held and hp:
+                    fh = int(dsz * hp)
+                    fill = pygame.Rect(drect.x, drect.bottom - fh, dsz, fh)
+                    pygame.draw.rect(screen, STOP, fill, border_radius=12)
                 pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
-                x = f_big.render("✕", True, STOP)
-                screen.blit(x, (drect.centerx - x.get_width() // 2,
-                                drect.centery - x.get_height() // 2))
+                _draw_cross(screen, drect, FG if held else STOP, 4)
                 del_rects.append((drect, slot))
 
         # firmware log panel (the in-app serial monitor) — fills the area below the tiles
@@ -128,26 +176,67 @@ def run(app):
         if app.forwarding:
             bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
             pygame.draw.rect(screen, STOP, bar)
-            msg = f_big.render("FORWARDING — Deck controls detached · tap anywhere to stop", True, (255, 255, 255))
+            hp = hold_progress()
+            if hold is not None and hold["kind"] == "stop" and hp:
+                # fill the banner left->right as the stop hold progresses; lighten so the fill reads as a meter
+                pygame.draw.rect(screen, (255, 150, 150),
+                                 pygame.Rect(bar.x, bar.y, int(bar.width * hp), bar.height))
+                left = max(0.0, HOLD_SECS * (1.0 - hp))
+                msg = f_big.render("KEEP HOLDING TO STOP - %.0fs" % (left + 0.99),
+                                   True, (255, 255, 255))
+            else:
+                msg = f_big.render("FORWARDING - Deck controls detached - hold to stop",
+                                   True, (255, 255, 255))
             screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
         pygame.display.flip()
 
-    def handle_tap(pos):
-        # Taps only ENQUEUE intents; the IO thread runs the actual grab()/send() so they never race the
-        # forwarding thread's serial/libusb access.
+    def handle_down(pos):
+        # Press handler. CONNECT (toggle a live tile) fires immediately -- it's cheap and reversible. The
+        # destructive actions (STOP a forward, REMOVE a bond) instead ARM a hold; they only fire from
+        # check_hold() once HOLD_SECS elapses, so an errant tap can't trigger them. Actions still only
+        # ENQUEUE intents so they never race the IO thread's serial/libusb access.
+        nonlocal hold
+        if hold is not None:
+            return  # a hold is already in progress; ignore re-entrant/duplicate down events
         if app.forwarding:
-            app.request_stop()
+            # hold anywhere on the screen to stop forwarding
+            hold = {"kind": "stop", "slot": None, "start": time.monotonic()}
             return
-        # the remove-bond ✕ takes priority over the tile body it sits on
+        # the remove-bond ✕ takes priority over the tile body it sits on -- hold it to unpair
         for drect, slot in del_rects:
             if drect.collidepoint(pos):
-                app.request_remove(slot)
+                hold = {"kind": "unpair", "slot": slot, "start": time.monotonic()}
                 return
+        # live tile: connecting/forwarding stays a single tap
         for rect, slot, tappable in tile_rects:
             if rect.collidepoint(pos) and tappable:
                 app.request_toggle(slot)
                 return
 
+    def handle_up():
+        # Lifting (or a stray up) before HOLD_SECS cancels the pending destructive action.
+        nonlocal hold
+        hold = None
+
+    def check_hold():
+        # Fire the held action once it has been sustained long enough, then disarm. Also cancel a hold whose
+        # target went away (the forward auto-released, or forwarding started so a bond can't be removed).
+        nonlocal hold
+        if hold is None:
+            return
+        if hold["kind"] == "stop" and not app.forwarding:
+            hold = None
+            return
+        if hold["kind"] == "unpair" and app.forwarding:
+            hold = None
+            return
+        if time.monotonic() - hold["start"] >= HOLD_SECS:
+            if hold["kind"] == "stop":
+                app.request_stop()
+            elif hold["kind"] == "unpair":
+                app.request_remove(hold["slot"])
+            hold = None
+
     running = True
     while running:
         for ev in pygame.event.get():
@@ -156,9 +245,13 @@ def run(app):
             elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
                 running = False
             elif ev.type == pygame.MOUSEBUTTONDOWN:
-                handle_tap(ev.pos)
+                handle_down(ev.pos)
             elif ev.type == pygame.FINGERDOWN:
-                handle_tap((int(ev.x * W), int(ev.y * H)))
+                handle_down((int(ev.x * W), int(ev.y * H)))
+            elif ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
+                handle_up()
+
+        check_hold()  # promote a sustained hold into the actual stop/unpair intent
 
         # Render only -- serial I/O + input forwarding run on app._io_loop (a separate thread) so the
         # vsync'd flip() below can't throttle forwarding to 60Hz. A bad draw must never kill the app.

+ 33 - 16
docs/BUILD_AND_DEPLOY.md

@@ -49,32 +49,47 @@ arduino-cli core install adafruit:nrf52 --additional-urls https://adafruit.githu
 From the repository root:
 
 ```bash
-arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64" OpenPuck
+make build
 ```
 
-This sketch requires `CFG_TUD_HID=4` because Steam mode exposes four HID interfaces.
+That's the whole command — the USB flags the firmware needs are baked in, so you don't pass them yourself:
 
-It also requires `CFG_TUD_TASK_QUEUE_SZ=64` (the sketch `#error`s without it). The firmware emits HID reports
-from the `loop()` task while TinyUSB's `tud_task()` runs in a separate high-priority `usbd` task; a cross-task
-`sendReport` whose shared EasyDMA is momentarily busy takes TinyUSB's `usbd_defer_func()` path, which does a
-*blocking* enqueue onto the device event queue. The default 16-deep queue saturates under comms bursts
-(haptics/trackpad floods, the connect-time re-init storm, four slots at once), the `loop()` task then blocks
-posting to it and stops feeding the ~8 s watchdog, and the MCU resets — surfacing as a controller "disconnect".
-A 64-deep queue absorbs realistic bursts so the loop never blocks.
+- `CFG_TUD_HID=4` — Steam mode exposes four HID interfaces (the Adafruit nRF core defaults to 2).
+- `CFG_TUD_TASK_QUEUE_SZ=64` — a deeper TinyUSB device event queue; the default of 16 can deadlock the firmware's loop under heavy USB traffic and trip the watchdog.
 
-**Build provenance.** `gen_version.sh` writes `OpenPuck/git_version.h` with the current commit's short hash and a dirty flag (1 if the working tree has any tracked change or untracked file). The firmware bakes these in and reports them over WebUSB, so the panel's **Build (git)** field shows exactly which commit is flashed and whether it was a clean checkout — handy for confirming what's on a board. The header is git-ignored; if you skip the script the build still succeeds and the panel shows `unknown`. You can also inject the values directly instead of using the script, e.g. append ``-DOPK_GIT_HASH=\"$(git rev-parse --short=8 HEAD)\" -DOPK_GIT_DIRTY=0`` to `build.extra_flags`.
+**Overriding the defaults** (only if you need to) — pass them as `make` variables:
 
-## 5. Upload the firmware
+```bash
+make build CFG_TUD_HID=6 CFG_TUD_TASK_QUEUE_SZ=128   # different interface count / queue depth
+make build EXTRA_FLAGS="-DOPK_LOG=1"                  # add your own defines
+make build FQBN=adafruit:nrf52:somethingelse          # a different nRF52840 board
+```
 
-### macOS / Linux
+**Calling `arduino-cli` directly** instead of `make`? Then you must supply the flags yourself — the build
+`#error`s without them (so a forgotten flag fails loudly instead of shipping a broken/deadlock-prone image):
 
-Find the board port:
+```bash
+arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64" OpenPuck
+```
+
+## 5. Upload the firmware
+
+The quickest path is `make`. The serial port is a **required argument** (find it with `arduino-cli board list`):
 
 ```bash
-arduino-cli board list
+make flash /dev/cu.usbmodem1101    # upload the most recent build to that port
+make deploy /dev/cu.usbmodem1101   # build + flash in one step (same build overrides as `make build`)
 ```
 
-Upload:
+Use the port for your OS: macOS `/dev/cu.usbmodem*`, Linux `/dev/ttyACM0`, Windows `COM5`.
+
+> **DFU note:** in puck (Steam/Lizard) mode the firmware drops the CDC serial port to free a USB endpoint, so `arduino-cli` can't auto-reset the board into its bootloader. If the upload can't connect, put the board in DFU mode first by **double-tapping RST**, then `make flash <bootloader-port>` (re-check `arduino-cli board list` — the port can change in DFU mode). The drag-and-drop UF2 path in §5b also works.
+
+### Manual upload (without `make`)
+
+Find the board port with `arduino-cli board list`, then:
+
+### macOS / Linux
 
 ```bash
 arduino-cli upload \
@@ -141,9 +156,11 @@ Re-flashing firmware does **not** erase the board's internal LittleFS. The paire
 
   ```bash
   ./gen_version.sh   # recommended: gives the build a distinct git hash (see below)
-  arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DOPK_FACTORY_RESET=1" OpenPuck
+  make build-recovery
   ```
 
+  (`make build-recovery` is just `make build` plus `-DOPK_FACTORY_RESET=1`; the usual USB flags are still baked in.)
+
   It is **not** a wipe-every-boot image: after the one-time reset it stamps a tag file with the build's git hash, so subsequent boots skip the wipe and persist normally. Flashing this same image again won't re-wipe (the tag matches); flashing a **different** build (different git hash) re-triggers the one-time reset. For an on-demand wipe at any time, use the WebUSB button or serial `ERASE-ALL` below. Re-pair the controller after a reset.
 - **WebUSB panel (any mode):** open the panel (§8), and in the maintenance card click **⚠ Factory erase**. Confirm the two warning dialogs and type `ERASE` when prompted. The board reformats its filesystem and reboots to factory defaults. This works in every USB mode.
 - **Serial console (CDC):** connect to the board's serial port at 115200 baud and send the line `ERASE-ALL` (exact, all caps). Same effect: reformat + reboot.