Sfoglia il codice sorgente

Zephyr port: foundation + all modules compiling behind compat shims

Migrate OpenPuck from Arduino/Adafruit-nRF52/TinyUSB to Zephyr RTOS (mainline
v4.4.99, adafruit_feather_nrf52840/uf2). The entire ~8000-LOC firmware now
compiles and links under Zephyr.

Approach: shim-based. The reverse-engineered radio/protocol/mode logic ports
near-verbatim behind a compatibility layer:
- Arduino.h shim: micros() via DWT cycle counter, millis()/delay() via kernel,
  GPIO via nrf_gpio, Serial via console, NRF_* registers via nrfx MDK.
- Adafruit_LittleFS/InternalFileSystem shim over Zephyr littlefs on the storage
  partition (config.cpp/bonds.cpp port unchanged).
- Adafruit_TinyUSB shim (Adafruit_USBD_HID/WebUSB/XInput/Device) — currently
  backed by an inert stub (usb_stub.cpp); the real Zephyr USBD backing is staged
  in usb_glue.cpp.

All 20 modules build: radio, rf_link, rf_diag, triton, gamepad_util, haptics,
controllers, puck_hid, modes (lizard/xinput/switch_hori/switch_pro/ps5/hidgyro),
webusb_config, serial_console, usb_mount, wake_hid, config, bonds.

Remaining (not yet functional): real USBD backing (DT HID nodes, usbd_context,
CDC/MSC/WebUSB/XInput class), main-loop/threading wiring (main.c is still a
stub), HFCLK start + status_led pin mapping. Cannot be hardware-verified here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jari 4 mesi fa
parent
commit
498fe81897
60 ha cambiato i file con 9264 aggiunte e 0 eliminazioni
  1. 2 0
      OpenPuckZephyr/.gitignore
  2. 50 0
      OpenPuckZephyr/CMakeLists.txt
  3. 41 0
      OpenPuckZephyr/prj.conf
  4. 56 0
      OpenPuckZephyr/src/bonds.cpp
  5. 33 0
      OpenPuckZephyr/src/bonds.h
  6. 22 0
      OpenPuckZephyr/src/build_info.h
  7. 55 0
      OpenPuckZephyr/src/compat/Adafruit_LittleFS.h
  8. 268 0
      OpenPuckZephyr/src/compat/Adafruit_TinyUSB.h
  9. 110 0
      OpenPuckZephyr/src/compat/Arduino.h
  10. 10 0
      OpenPuckZephyr/src/compat/InternalFileSystem.h
  11. 173 0
      OpenPuckZephyr/src/compat/arduino_compat.cpp
  12. 65 0
      OpenPuckZephyr/src/compat/device/usbd_pvt.h
  13. 133 0
      OpenPuckZephyr/src/compat/littlefs_shim.cpp
  14. 324 0
      OpenPuckZephyr/src/compat/usb_glue.cpp
  15. 18 0
      OpenPuckZephyr/src/compat/usb_identity.h
  16. 213 0
      OpenPuckZephyr/src/compat/usb_stub.cpp
  17. 212 0
      OpenPuckZephyr/src/config.cpp
  18. 182 0
      OpenPuckZephyr/src/config.h
  19. 42 0
      OpenPuckZephyr/src/controllers.cpp
  20. 94 0
      OpenPuckZephyr/src/controllers.h
  21. 245 0
      OpenPuckZephyr/src/gamepad_util.cpp
  22. 97 0
      OpenPuckZephyr/src/gamepad_util.h
  23. 537 0
      OpenPuckZephyr/src/haptics.cpp
  24. 120 0
      OpenPuckZephyr/src/haptics.h
  25. 23 0
      OpenPuckZephyr/src/identity.cpp
  26. 16 0
      OpenPuckZephyr/src/identity.h
  27. 19 0
      OpenPuckZephyr/src/main.c
  28. 241 0
      OpenPuckZephyr/src/mode_hidgyro.cpp
  29. 22 0
      OpenPuckZephyr/src/mode_hidgyro.h
  30. 174 0
      OpenPuckZephyr/src/mode_lizard.cpp
  31. 13 0
      OpenPuckZephyr/src/mode_lizard.h
  32. 252 0
      OpenPuckZephyr/src/mode_ps5.cpp
  33. 22 0
      OpenPuckZephyr/src/mode_ps5.h
  34. 229 0
      OpenPuckZephyr/src/mode_switch_hori.cpp
  35. 23 0
      OpenPuckZephyr/src/mode_switch_hori.h
  36. 786 0
      OpenPuckZephyr/src/mode_switch_pro.cpp
  37. 24 0
      OpenPuckZephyr/src/mode_switch_pro.h
  38. 502 0
      OpenPuckZephyr/src/mode_xinput.cpp
  39. 28 0
      OpenPuckZephyr/src/mode_xinput.h
  40. 648 0
      OpenPuckZephyr/src/puck_hid.cpp
  41. 36 0
      OpenPuckZephyr/src/puck_hid.h
  42. 148 0
      OpenPuckZephyr/src/radio.cpp
  43. 67 0
      OpenPuckZephyr/src/radio.h
  44. 540 0
      OpenPuckZephyr/src/rf_diag.cpp
  45. 37 0
      OpenPuckZephyr/src/rf_diag.h
  46. 857 0
      OpenPuckZephyr/src/rf_link.cpp
  47. 109 0
      OpenPuckZephyr/src/rf_link.h
  48. 475 0
      OpenPuckZephyr/src/serial_console.cpp
  49. 9 0
      OpenPuckZephyr/src/serial_console.h
  50. 38 0
      OpenPuckZephyr/src/status_led.cpp
  51. 32 0
      OpenPuckZephyr/src/status_led.h
  52. 14 0
      OpenPuckZephyr/src/triton.cpp
  53. 93 0
      OpenPuckZephyr/src/triton.h
  54. 127 0
      OpenPuckZephyr/src/usb_mount.cpp
  55. 38 0
      OpenPuckZephyr/src/usb_mount.h
  56. 44 0
      OpenPuckZephyr/src/wake_hid.cpp
  57. 35 0
      OpenPuckZephyr/src/wake_hid.h
  58. 407 0
      OpenPuckZephyr/src/webusb_config.cpp
  59. 15 0
      OpenPuckZephyr/src/webusb_config.h
  60. 19 0
      OpenPuckZephyr/zbuild.sh

+ 2 - 0
OpenPuckZephyr/.gitignore

@@ -0,0 +1,2 @@
+build/
+src/git_version.h

+ 50 - 0
OpenPuckZephyr/CMakeLists.txt

@@ -0,0 +1,50 @@
+# OpenPuck — Zephyr RTOS port.
+#
+# Migrated from the Arduino/Adafruit-nRF52/TinyUSB sketch in ../OpenPuck.
+# Build (from a west workspace with this repo reachable):
+#   west build -b adafruit_feather_nrf52840 OpenPuckZephyr
+#
+# The bare-metal RADIO peripheral is driven directly (no BLE/802.15.4), so the
+# Bluetooth/IEEE802154/MPSL stacks are disabled in prj.conf and the RADIO IRQ is
+# owned by this app.
+cmake_minimum_required(VERSION 3.20.0)
+
+find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
+project(openpuck C CXX)
+
+target_include_directories(app PRIVATE
+  ${CMAKE_CURRENT_SOURCE_DIR}/src
+  ${CMAKE_CURRENT_SOURCE_DIR}/src/compat)
+
+target_sources(app PRIVATE
+  src/main.c
+
+  # compatibility layer
+  src/compat/arduino_compat.cpp
+  src/compat/littlefs_shim.cpp
+  src/compat/usb_stub.cpp        # inert USB backing (swap for usb_glue.cpp)
+
+  # ported firmware modules
+  src/config.cpp
+  src/bonds.cpp
+  src/identity.cpp
+  src/triton.cpp
+  src/gamepad_util.cpp
+  src/radio.cpp
+  src/rf_link.cpp
+  src/rf_diag.cpp
+  src/haptics.cpp
+  src/status_led.cpp
+  src/controllers.cpp
+  src/puck_hid.cpp
+  src/mode_lizard.cpp
+  src/mode_switch_hori.cpp
+  src/mode_switch_pro.cpp
+  src/mode_ps5.cpp
+  src/mode_hidgyro.cpp
+  src/mode_xinput.cpp
+  src/webusb_config.cpp
+  src/serial_console.cpp
+  src/usb_mount.cpp
+  src/wake_hid.cpp
+)

+ 41 - 0
OpenPuckZephyr/prj.conf

@@ -0,0 +1,41 @@
+# OpenPuck Zephyr configuration.
+#
+# The RADIO peripheral is driven bare-metal (reverse-engineered SC2 protocol),
+# so NO Zephyr radio stack may claim it.
+CONFIG_BT=n
+CONFIG_IEEE802154=n
+CONFIG_NRF_802154_RADIO_DRIVER=n
+
+# C++ (the ported modes/protocol are C++).
+CONFIG_CPP=y
+CONFIG_STD_CPP17=y
+CONFIG_REQUIRES_FULL_LIBCPP=n
+CONFIG_CPP_EXCEPTIONS=n
+CONFIG_CPP_RTTI=n
+
+# Console/logging over RTT or UART during bring-up (USB CDC console added later).
+CONFIG_LOG=y
+CONFIG_PRINTK=y
+
+# Core subsystems used by the port.
+CONFIG_GPIO=y
+CONFIG_FLASH=y
+CONFIG_FLASH_MAP=y
+CONFIG_REBOOT=y
+
+# Persistence: the Arduino tree stored cfg.bin/bonds.bin in the nRF52 internal
+# LittleFS. We keep LittleFS (Zephyr's fs subsystem on the storage partition) so
+# the persistence code ports behind a thin Adafruit_LittleFS shim.
+CONFIG_FILE_SYSTEM=y
+CONFIG_FILE_SYSTEM_LITTLEFS=y
+
+# Hardware watchdog (replaces the NRF_WDT poke in the Arduino loop()).
+CONFIG_WATCHDOG=y
+
+# Microsecond timing for the RF poll busy-waits comes from a dedicated 1 MHz
+# nrfx TIMER in the compat layer (the kernel RTC tick is too coarse), so the
+# kernel clock is left at its board default here.
+
+# Reasonable main stack for the cooperative loop + protocol state.
+CONFIG_MAIN_STACK_SIZE=4096
+CONFIG_HEAP_MEM_POOL_SIZE=2048

+ 56 - 0
OpenPuckZephyr/src/bonds.cpp

@@ -0,0 +1,56 @@
+#include "bonds.h"
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+Slot g_slot[NSLOT];
+unsigned long g_connReplyMs[NSLOT] = { 0 };
+volatile bool g_dirty = false;
+bool g_pairing = false;
+
+#define BOND_FILE "/bonds.bin"
+
+bool recEmpty(const uint8_t *r)
+{
+	for (int i = 0; i < 24; i++)
+		if (r[i])
+			return false;
+	return true;
+}
+
+void saveBonds()
+{
+	InternalFS.remove(BOND_FILE);
+	File f(InternalFS);
+	if (f.open(BOND_FILE, FILE_O_WRITE)) {
+		for (int i = 0; i < NSLOT; i++) {
+			uint8_t u = g_slot[i].used ? 1 : 0;
+			f.write(&u, 1);
+			f.write(g_slot[i].rec, 24);
+		}
+		f.close();
+	}
+}
+
+void loadBonds()
+{
+	File f(InternalFS);
+	if (f.open(BOND_FILE, FILE_O_READ))
+		for (int i = 0; i < NSLOT; i++) {
+			uint8_t u = 0;
+			if (f.read(&u, 1) == 1) {
+				g_slot[i].used = u;
+				f.read(g_slot[i].rec, 24);
+			}
+		}
+	f.close();
+}
+
+int bondedSlotCount()
+{
+	int n = 0;
+	for (int i = 0; i < NSLOT; i++)
+		if (g_slot[i].used)
+			n++;
+	return n;
+}

+ 33 - 0
OpenPuckZephyr/src/bonds.h

@@ -0,0 +1,33 @@
+// bonds.h -- the puck's bond slots + live link state.
+//
+// The real puck holds four controller bonds and exposes one HID control interface per slot (interface N owns
+// slot N). We mirror that: g_slot[N] holds the bond record [8 uuid][16 serial] plus a staging buffer for that
+// interface's pending feature-report reply (filled by puck_hid.cpp's command channel). Bonds persist to
+// flash (bonds.bin). g_connReplyMs (per-slot, rf_link sets the active one) is the live RF link state, shared
+// here so the USB feature handler can report per-slot connection status without depending on the RF layer.
+#pragma once
+#include <stdint.h>
+
+#define NSLOT 4
+
+// per-slot state (record = [8 uuid][16 serial]) + per-interface response staging
+struct Slot {
+	uint8_t rec[24];
+	bool used;
+	uint8_t resp[63];
+	uint16_t resp_len;
+};
+extern Slot g_slot[NSLOT];
+
+// Per-slot link state. g_connReplyMs[i] is the millis() of the last F-type reply on slot i (the
+// "controller is alive" timestamp). g_linkRssi (rf_link.h) is also per-slot. Steam reads each slot's
+// B4/0x79/0x7B against its own entry here, so the four interfaces present four independent controllers
+// (each marked connected on its own).
+extern unsigned long g_connReplyMs[NSLOT];
+extern volatile bool g_dirty; // bonds changed -> flush to flash from loop()
+extern bool g_pairing;
+
+bool recEmpty(const uint8_t *r);
+void loadBonds();
+void saveBonds();
+int bondedSlotCount();

+ 22 - 0
OpenPuckZephyr/src/build_info.h

@@ -0,0 +1,22 @@
+// build_info.h -- firmware build provenance, surfaced in the WebUSB panel so you can confirm EXACTLY which
+// build is flashed on a board (the git commit it was built from, and whether that working tree was dirty).
+//
+// The values come from one of two sources, in priority order:
+//   1) -D defines on the compile line, e.g. build.extra_flags += -DOPK_GIT_HASH=\"abc12345\" -DOPK_GIT_DIRTY=1
+//   2) a generated git_version.h (written by gen_version.sh before compiling), picked up via __has_include.
+// If neither is present (a bare checkout compiled by hand) the values fall back to "unknown" / not-dirty, so
+// the build always succeeds -- the panel just shows "unknown" until you generate the header.
+#pragma once
+
+#if !defined(OPK_GIT_HASH) && defined(__has_include)
+#if __has_include("git_version.h")
+#include "git_version.h"
+#endif
+#endif
+
+#ifndef OPK_GIT_HASH
+#define OPK_GIT_HASH "unknown"
+#endif
+#ifndef OPK_GIT_DIRTY
+#define OPK_GIT_DIRTY 0
+#endif

+ 55 - 0
OpenPuckZephyr/src/compat/Adafruit_LittleFS.h

@@ -0,0 +1,55 @@
+// Adafruit_LittleFS.h -- compat shim over Zephyr's LittleFS (fs subsystem).
+//
+// The persistence code (config.cpp, bonds.cpp, mode_switch_pro.cpp) was written
+// against Adafruit_LittleFS's File API on the nRF52 internal flash. This shim
+// reproduces just the surface they use, backed by a Zephyr littlefs mount on the
+// board's storage_partition. Semantics match: open/read/write/close, plus
+// remove()/format() on the filesystem object.
+#pragma once
+#include <stdint.h>
+#include <stddef.h>
+
+namespace Adafruit_LittleFS_Namespace
+{
+
+enum FileMode {
+	FILE_O_READ = 0,
+	FILE_O_WRITE = 1,
+};
+
+class LittleFS; // fwd
+
+// One open file. Mirrors Adafruit's File: constructed from the FS object, then
+// open()ed. read() returns bytes read (int, -1 style errors clamped to 0),
+// write() returns bytes written.
+class File
+{
+    public:
+	explicit File(LittleFS &fs);
+	~File();
+
+	bool open(const char *path, uint8_t mode);
+	int read(uint8_t *buf, size_t n);
+	size_t write(const uint8_t *buf, size_t n);
+	void close();
+	explicit operator bool() const
+	{
+		return _open;
+	}
+
+    private:
+	void *_fp; // struct fs_file_t* (opaque to keep Zephyr headers out of here)
+	bool _open;
+};
+
+// The filesystem object. begin() mounts (idempotent), remove() deletes a file,
+// format() reformats the partition (factory wipe).
+class LittleFS
+{
+    public:
+	bool begin();
+	bool remove(const char *path);
+	bool format();
+};
+
+} // namespace Adafruit_LittleFS_Namespace

+ 268 - 0
OpenPuckZephyr/src/compat/Adafruit_TinyUSB.h

@@ -0,0 +1,268 @@
+// Adafruit_TinyUSB.h -- compatibility shim mapping the Adafruit TinyUSB API the
+// firmware was written against onto Zephyr's USB device stack (USBD "next").
+//
+// The Arduino tree builds its USB presentation imperatively: construct
+// Adafruit_USBD_HID/WebUSB/XInput objects, set descriptors + callbacks, then
+// USBDevice.attach(). Zephyr's USBD registers class instances (HID devices are
+// devicetree nodes) and brings the whole stack up at once. This shim bridges the
+// two: each HID object binds to a pre-declared Zephyr HID device node on begin()
+// in registration order, sendReport()->hid_device_submit_report(), and the
+// TinyUSB get/set report callbacks are dispatched from Zephyr's hid_device_ops.
+//
+// One behavioral concession (documented in ARCHITECTURE.zephyr.md): Zephyr's
+// descriptor set is fixed once the stack is enabled, so the Arduino dynamic
+// "re-enumerate without reboot" path collapses to reboot-to-reenumerate, which
+// was already the firmware's mode-switch path.
+#pragma once
+#include <stdint.h>
+#include <stddef.h>
+
+// ---- HID report types (TinyUSB values; identical to Zephyr's). Guarded so this
+//      never collides with <zephyr/usb/class/usbd_hid.h> when both are pulled
+//      into the shim implementation TU. ----
+typedef uint8_t hid_report_type_t;
+#ifndef ZEPHYR_INCLUDE_USBD_HID_CLASS_DEVICE_H_
+enum {
+	HID_REPORT_TYPE_INVALID = 0,
+	HID_REPORT_TYPE_INPUT,
+	HID_REPORT_TYPE_OUTPUT,
+	HID_REPORT_TYPE_FEATURE,
+};
+#endif
+
+// ---- USB descriptor constants used when hand-building the XInput interface ----
+enum {
+	TUSB_DESC_INTERFACE = 0x04,
+	TUSB_DESC_ENDPOINT = 0x05,
+};
+enum {
+	TUSB_XFER_CONTROL = 0,
+	TUSB_XFER_ISOCHRONOUS,
+	TUSB_XFER_BULK,
+	TUSB_XFER_INTERRUPT,
+};
+enum {
+	TUSB_DIR_OUT = 0,
+	TUSB_DIR_IN = 1,
+};
+#define HID_ITF_PROTOCOL_NONE 0
+#define HID_ITF_PROTOCOL_KEYBOARD 1
+#define HID_ITF_PROTOCOL_MOUSE 2
+
+// Split a uint16 into two little-endian bytes (TinyUSB descriptor helper).
+#define U16_TO_U8S_LE(x) (uint8_t)((x) & 0xFF), (uint8_t)(((x) >> 8) & 0xFF)
+
+// Max HID interface instances (Arduino build set -DCFG_TUD_HID=4).
+#ifndef CFG_TUD_HID
+#define CFG_TUD_HID 4
+#endif
+
+// HID keyboard modifier bitmasks (TinyUSB values).
+enum {
+	KEYBOARD_MODIFIER_LEFTCTRL = 0x01,
+	KEYBOARD_MODIFIER_LEFTSHIFT = 0x02,
+	KEYBOARD_MODIFIER_LEFTALT = 0x04,
+	KEYBOARD_MODIFIER_LEFTGUI = 0x08,
+	KEYBOARD_MODIFIER_RIGHTCTRL = 0x10,
+	KEYBOARD_MODIFIER_RIGHTSHIFT = 0x20,
+	KEYBOARD_MODIFIER_RIGHTALT = 0x40,
+	KEYBOARD_MODIFIER_RIGHTGUI = 0x80,
+};
+
+// Standard boot mouse report (TinyUSB layout): used by lizard + the wake jiggle.
+typedef struct __attribute__((packed)) {
+	uint8_t buttons;
+	int8_t x;
+	int8_t y;
+	int8_t wheel;
+	int8_t pan;
+} hid_mouse_report_t;
+
+// ---- HID keyboard usage IDs (lizard keyboard map) ----
+enum {
+	HID_KEY_NONE = 0x00,
+	HID_KEY_A = 0x04,
+	HID_KEY_O = 0x12,
+	HID_KEY_ENTER = 0x28,
+	HID_KEY_ESCAPE = 0x29,
+	HID_KEY_DELETE = 0x4C,
+	HID_KEY_TAB = 0x2B,
+	HID_KEY_PAGE_UP = 0x4B,
+	HID_KEY_PAGE_DOWN = 0x4E,
+	HID_KEY_ARROW_RIGHT = 0x4F,
+	HID_KEY_ARROW_LEFT = 0x50,
+	HID_KEY_ARROW_DOWN = 0x51,
+	HID_KEY_ARROW_UP = 0x52,
+};
+
+// ---- packed descriptor structs used by the XInput class ----
+typedef struct __attribute__((packed)) {
+	uint8_t bLength;
+	uint8_t bDescriptorType;
+	uint8_t bInterfaceNumber;
+	uint8_t bAlternateSetting;
+	uint8_t bNumEndpoints;
+	uint8_t bInterfaceClass;
+	uint8_t bInterfaceSubClass;
+	uint8_t bInterfaceProtocol;
+	uint8_t iInterface;
+} tusb_desc_interface_t;
+
+typedef struct __attribute__((packed)) {
+	uint8_t bLength;
+	uint8_t bDescriptorType;
+	uint8_t bEndpointAddress;
+	uint8_t bmAttributes;
+	uint16_t wMaxPacketSize;
+	uint8_t bInterval;
+} tusb_desc_endpoint_t;
+
+typedef struct __attribute__((packed)) {
+	uint8_t bmRequestType;
+	uint8_t bRequest;
+	uint16_t wValue;
+	uint16_t wIndex;
+	uint16_t wLength;
+} tusb_control_request_t;
+
+// ---- HID report callback types (TinyUSB signatures) ----
+typedef uint16_t (*hid_get_report_cb_t)(uint8_t report_id,
+					hid_report_type_t type, uint8_t *buffer,
+					uint16_t reqlen);
+typedef void (*hid_set_report_cb_t)(uint8_t report_id, hid_report_type_t type,
+				    uint8_t const *buffer, uint16_t bufsize);
+
+// ---- base interface (XInput subclasses it to emit its own descriptor) ----
+class Adafruit_USBD_Interface
+{
+    public:
+	virtual ~Adafruit_USBD_Interface()
+	{
+	}
+	virtual uint16_t getInterfaceDescriptor(uint8_t itfnum, uint8_t *buf,
+						uint16_t bufsize) = 0;
+	void setStringDescriptor(const char *s)
+	{
+		_desc_str = s;
+	}
+
+    protected:
+	const char *_desc_str = nullptr;
+	uint8_t _strid = 0; // string-descriptor index assigned by the stack
+};
+
+// ---- HID device ----
+// Binds to one Zephyr "zephyr,hid-device" node (claimed in begin(), in order).
+// Inherits Adafruit_USBD_Interface so it can be passed to USBDevice.addInterface
+// (the Zephyr backing builds the real interface from the registered HID node).
+class Adafruit_USBD_HID : public Adafruit_USBD_Interface
+{
+    public:
+	Adafruit_USBD_HID();
+
+	uint16_t getInterfaceDescriptor(uint8_t itfnum, uint8_t *buf,
+					uint16_t bufsize) override
+	{
+		(void)itfnum;
+		(void)buf;
+		(void)bufsize;
+		return 0; // Zephyr emits the HID interface descriptor itself
+	}
+
+	void setReportDescriptor(const uint8_t *desc, uint16_t len);
+	void setReportCallback(hid_get_report_cb_t get_cb,
+			       hid_set_report_cb_t set_cb);
+	void setStringDescriptor(const char *s);
+	void setPollInterval(uint8_t interval_ms);
+	void setBootProtocol(uint8_t protocol);
+	void enableOutEndpoint(bool en);
+	bool begin();
+	bool ready();
+	bool sendReport(uint8_t report_id, const void *report, uint16_t len);
+
+	// Convenience boot-mouse report (wake jiggle). buttons=0 moves without clicking.
+	bool mouseReport(uint8_t report_id, uint8_t buttons, int8_t x, int8_t y,
+			 int8_t wheel, int8_t pan)
+	{
+		hid_mouse_report_t m = { buttons, x, y, wheel, pan };
+		return sendReport(report_id, &m, sizeof m);
+	}
+
+	// internal: invoked by the shim's Zephyr hid_device_ops dispatcher
+	hid_get_report_cb_t _get_cb = nullptr;
+	hid_set_report_cb_t _set_cb = nullptr;
+
+    private:
+	const uint8_t *_desc = nullptr;
+	uint16_t _desc_len = 0;
+	const char *_str = nullptr;
+	uint8_t _poll_ms = 1;
+	uint8_t _boot_proto = 0;
+	bool _has_out = false;
+	int _node = -1; // index into the Zephyr HID device pool, -1 = unbound
+};
+
+// ---- WebUSB vendor interface ----
+class Adafruit_USBD_WebUSB
+{
+    public:
+	Adafruit_USBD_WebUSB();
+	bool begin();
+	bool connected();
+	int available();
+	int read();
+	uint32_t write(const void *buffer, uint32_t bufsize);
+	void flush();
+	void setLandingPage(const char *url)
+	{
+		_url = url;
+	}
+
+    private:
+	const char *_url = nullptr;
+};
+
+// Standard TinyUSB boot-mouse report descriptor (no report ID): 5 buttons +
+// 3-bit pad, then X/Y/wheel/AC-pan as 8-bit relative — matching hid_mouse_report_t.
+#define TUD_HID_REPORT_DESC_MOUSE(...)                                        \
+	0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x05,    \
+		0x09, 0x19, 0x01, 0x29, 0x05, 0x15, 0x00, 0x25, 0x01, 0x95, \
+		0x05, 0x75, 0x01, 0x81, 0x02, 0x95, 0x01, 0x75, 0x03, 0x81, \
+		0x01, 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x38, 0x15, \
+		0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x03, 0x81, 0x06, 0x05, \
+		0x0C, 0x0A, 0x38, 0x02, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, \
+		0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0
+
+// DFU entry (Adafruit nRF52 bootloader): stash the magic in GPREGRET and reset
+// into UF2 mass-storage or serial DFU. Implemented in the USB backing.
+void enterUf2Dfu(void);
+void enterSerialDfu(void);
+
+// ---- the device singleton ----
+class Adafruit_USBD_Device
+{
+    public:
+	void setID(uint16_t vid, uint16_t pid);
+	void setVersion(uint16_t bcd);
+	void setDeviceVersion(uint16_t bcd);
+	void setManufacturerDescriptor(const char *s);
+	void setProductDescriptor(const char *s);
+	void setSerialDescriptor(const char *s);
+
+	void setConfigurationBuffer(uint8_t *buf, uint32_t buflen);
+	void setConfigurationAttribute(uint8_t attr);
+	bool addInterface(Adafruit_USBD_Interface &itf);
+	uint8_t allocInterface(uint8_t count = 1);
+	uint8_t allocEndpoint(uint8_t dir);
+
+	void clearConfiguration();
+	bool detach();
+	bool attach();
+	bool mounted();
+	bool suspended();
+	bool remoteWakeup();
+};
+// Adafruit exposes the device object under both names; some call sites use
+// TinyUSBDevice (e.g. wake_hid), others USBDevice. Both refer to one instance.
+extern Adafruit_USBD_Device USBDevice;
+extern Adafruit_USBD_Device &TinyUSBDevice;

+ 110 - 0
OpenPuckZephyr/src/compat/Arduino.h

@@ -0,0 +1,110 @@
+// Arduino.h -- compatibility shim for the Zephyr port.
+//
+// The OpenPuck modules were written against the Arduino/Adafruit-nRF52 core.
+// Rather than rewrite the reverse-engineered radio/protocol/mode logic, this
+// header provides the small Arduino API surface they actually use (timing,
+// GPIO, Serial, math helpers) on top of Zephyr, plus the Nordic MDK register
+// definitions (NRF_RADIO/NRF_WDT/NRF_FICR/NRF_CLOCK) and CMSIS intrinsics that
+// the bare-metal radio code pokes directly. Those register macros come from the
+// same Nordic MDK that Zephyr's hal_nordic ships, so the radio code ports
+// essentially verbatim.
+#pragma once
+
+#include <stdint.h>
+#include <stddef.h>
+#include <string.h>
+#include <stdlib.h>
+#include <stdio.h>
+
+// Nordic MDK: NRF_RADIO, NRF_WDT, NRF_FICR, NRF_CLOCK, the RADIO_*/WDT_* bitfield
+// macros, and the CMSIS-Core intrinsics (__disable_irq/__enable_irq,
+// NVIC_SystemReset, DWT). nrfx.h pulls in the MDK device + bitfield headers and
+// the CMSIS core for the nRF52840, and is on hal_nordic's include path.
+#include <nrfx.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// ---- timing ----
+// millis(): kernel uptime in ms. micros(): a free-running 1 MHz-equivalent
+// counter derived from the Cortex-M DWT cycle counter (see arduino_compat.cpp).
+// Both are monotonic 32-bit and meant for unsigned-delta use (wrap is fine).
+uint32_t millis(void);
+uint32_t micros(void);
+void delay(uint32_t ms);
+void delayMicroseconds(uint32_t us);
+
+// ---- GPIO (status LED only; resolved against devicetree in arduino_compat) ----
+// LED_BUILTIN: the Feather user LED P1.15, as an absolute nRF pin (port 1 * 32 +
+// 15 = 47), matching the absolute-pin convention of this shim's digitalWrite.
+#define LED_BUILTIN 47
+#define OUTPUT 1
+#define INPUT 0
+#define INPUT_PULLUP 2
+#define HIGH 1
+#define LOW 0
+void pinMode(uint32_t pin, uint32_t mode);
+void digitalWrite(uint32_t pin, uint32_t val);
+int digitalRead(uint32_t pin);
+
+// ---- misc ----
+long random(long howbig);
+long random_range(long howsmall, long howbig);
+
+#ifdef __cplusplus
+}
+#endif
+
+#ifdef __cplusplus
+// Arduino map(): integer rescale, same semantics as the AVR core.
+static inline long map(long x, long in_min, long in_max, long out_min,
+		       long out_max)
+{
+	return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
+}
+
+template <typename T> static inline T mn(T a, T b)
+{
+	return a < b ? a : b;
+}
+template <typename T> static inline T mx(T a, T b)
+{
+	return a > b ? a : b;
+}
+#ifndef min
+#define min(a, b) mn(a, b)
+#endif
+#ifndef max
+#define max(a, b) mx(a, b)
+#endif
+#ifndef constrain
+#define constrain(amt, low, high) \
+	((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt)))
+#endif
+
+// Arduino's Serial: the modules call printf/print/println/availableForWrite/
+// available/read. Backed by the USB CDC console once up, by the Zephyr console
+// (RTT/UART) during bring-up. See arduino_compat.cpp.
+class SerialShim {
+    public:
+	void begin(unsigned long baud)
+	{
+		(void)baud;
+	}
+	int printf(const char *fmt, ...);
+	void print(const char *s);
+	void print(char c);
+	void println(const char *s);
+	void println(void);
+	int available(void);
+	int availableForWrite(void);
+	int read(void);
+	size_t write(uint8_t b);
+	explicit operator bool() const
+	{
+		return true;
+	}
+};
+extern SerialShim Serial;
+#endif

+ 10 - 0
OpenPuckZephyr/src/compat/InternalFileSystem.h

@@ -0,0 +1,10 @@
+// InternalFileSystem.h -- compat shim: the global InternalFS object.
+//
+// In the Arduino tree this is the Adafruit InternalFileSystem singleton over the
+// nRF52 internal flash. Here it is a LittleFS instance mounted on the board's
+// storage partition. Code that does `File f(InternalFS); f.open(...)` ports
+// unchanged.
+#pragma once
+#include "Adafruit_LittleFS.h"
+
+extern Adafruit_LittleFS_Namespace::LittleFS InternalFS;

+ 173 - 0
OpenPuckZephyr/src/compat/arduino_compat.cpp

@@ -0,0 +1,173 @@
+// arduino_compat.cpp -- implementation of the Arduino API shim (see Arduino.h).
+#include "Arduino.h"
+#include <zephyr/kernel.h>
+#include <zephyr/sys/reboot.h>
+#include <zephyr/random/random.h>
+#include <zephyr/drivers/gpio.h>
+
+// ---- microsecond clock via the Cortex-M DWT cycle counter ----
+// The RF poll busy-waits ((micros() - t0) < window_us) need ~us resolution; the
+// kernel RTC tick (32.768 kHz) is far too coarse. DWT->CYCCNT counts CPU cycles
+// at SystemCoreClock (64 MHz on the nRF52840), so micros = CYCCNT / 64. The
+// 32-bit counter wraps every ~67 s, which is harmless under the unsigned-delta
+// comparisons the callers use.
+static inline void dwt_init(void)
+{
+	static bool done;
+	if (done)
+		return;
+	done = true;
+	CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
+	DWT->CYCCNT = 0;
+	DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
+}
+
+extern "C" uint32_t micros(void)
+{
+	dwt_init();
+	return DWT->CYCCNT / (SystemCoreClock / 1000000UL);
+}
+
+extern "C" uint32_t millis(void)
+{
+	return k_uptime_get_32();
+}
+
+extern "C" void delay(uint32_t ms)
+{
+	k_msleep((int32_t)ms);
+}
+
+extern "C" void delayMicroseconds(uint32_t us)
+{
+	k_busy_wait(us);
+}
+
+// ---- GPIO ----
+// The Arduino "pin number" here is the absolute nRF pin index (port * 32 + pin),
+// which is what the ported status_led code passes. We drive it through nrfx via
+// the SoC GPIO registers so no devicetree node is required for an arbitrary pin.
+#include <hal/nrf_gpio.h>
+
+extern "C" void pinMode(uint32_t pin, uint32_t mode)
+{
+	if (mode == OUTPUT)
+		nrf_gpio_cfg_output(pin);
+	else
+		nrf_gpio_cfg_input(pin, mode == INPUT_PULLUP ?
+						NRF_GPIO_PIN_PULLUP :
+						NRF_GPIO_PIN_NOPULL);
+}
+
+extern "C" void digitalWrite(uint32_t pin, uint32_t val)
+{
+	if (val)
+		nrf_gpio_pin_set(pin);
+	else
+		nrf_gpio_pin_clear(pin);
+}
+
+extern "C" int digitalRead(uint32_t pin)
+{
+	return (int)nrf_gpio_pin_read(pin);
+}
+
+// ---- misc ----
+extern "C" long random(long howbig)
+{
+	if (howbig <= 0)
+		return 0;
+	return (long)(sys_rand32_get() % (uint32_t)howbig);
+}
+
+extern "C" long random_range(long howsmall, long howbig)
+{
+	if (howbig <= howsmall)
+		return howsmall;
+	return howsmall + random(howbig - howsmall);
+}
+
+// ---- Serial ----
+// During bring-up Serial output goes to the Zephyr console (printk -> RTT/UART).
+// Once the USB CDC console is up, serial_console's I/O is wired through it; the
+// input path (available/read) is overridden there. printk is used for output so
+// early boot logging works before USB enumerates.
+SerialShim Serial;
+
+extern "C" __attribute__((weak)) size_t opk_serial_write(uint8_t b);
+
+static void serial_puts(const char *s, size_t n)
+{
+	for (size_t i = 0; i < n; i++)
+		opk_serial_write((uint8_t)s[i]);
+}
+
+int SerialShim::printf(const char *fmt, ...)
+{
+	char buf[256];
+	va_list ap;
+	va_start(ap, fmt);
+	int n = vsnprintf(buf, sizeof buf, fmt, ap);
+	va_end(ap);
+	serial_puts(buf, (n > 0 && n < (int)sizeof buf) ? (size_t)n : strlen(buf));
+	return n;
+}
+
+void SerialShim::print(const char *s)
+{
+	serial_puts(s, strlen(s));
+}
+
+void SerialShim::print(char c)
+{
+	opk_serial_write((uint8_t)c);
+}
+
+void SerialShim::println(const char *s)
+{
+	serial_puts(s, strlen(s));
+	opk_serial_write('\n');
+}
+
+void SerialShim::println(void)
+{
+	opk_serial_write('\n');
+}
+
+// Input/flow-control backend: weak free functions so the USB CDC layer can
+// strongly override them once it is up. During bring-up they stub out (no input,
+// always writable, output to the console).
+extern "C" __attribute__((weak)) int opk_serial_available(void)
+{
+	return 0;
+}
+extern "C" __attribute__((weak)) int opk_serial_avail_write(void)
+{
+	return 64;
+}
+extern "C" __attribute__((weak)) int opk_serial_read(void)
+{
+	return -1;
+}
+extern "C" __attribute__((weak)) size_t opk_serial_write(uint8_t b)
+{
+	printk("%c", b);
+	return 1;
+}
+
+int SerialShim::available(void)
+{
+	return opk_serial_available();
+}
+int SerialShim::availableForWrite(void)
+{
+	return opk_serial_avail_write();
+}
+int SerialShim::read(void)
+{
+	return opk_serial_read();
+}
+size_t SerialShim::write(uint8_t b)
+{
+	return opk_serial_write(b);
+}

+ 65 - 0
OpenPuckZephyr/src/compat/device/usbd_pvt.h

@@ -0,0 +1,65 @@
+// device/usbd_pvt.h -- compat shim for TinyUSB's private class-driver API.
+//
+// The XInput personality (mode_xinput.cpp) implements a custom TinyUSB class
+// driver. Zephyr's USBD uses a different class-driver model, so XInput needs a
+// real Zephyr custom-class port to enumerate; until then this header provides
+// the types/prototypes so mode_xinput.cpp compiles and its report-building logic
+// stays intact. The endpoint primitives are stubbed in usb_stub.cpp.
+#pragma once
+#include <stdint.h>
+#include "../Adafruit_TinyUSB.h"
+
+#ifndef CFG_TUSB_DEBUG
+#define CFG_TUSB_DEBUG 0
+#endif
+
+typedef enum {
+	XFER_RESULT_SUCCESS = 0,
+	XFER_RESULT_FAILED,
+	XFER_RESULT_STALLED,
+	XFER_RESULT_TIMEOUT,
+	XFER_RESULT_INVALID,
+} xfer_result_t;
+
+typedef enum {
+	CONTROL_STAGE_IDLE = 0,
+	CONTROL_STAGE_SETUP,
+	CONTROL_STAGE_DATA,
+	CONTROL_STAGE_ACK,
+} control_stage_t;
+
+// TinyUSB endpoint-address direction helper.
+static inline uint8_t tu_edpt_dir(uint8_t addr)
+{
+	return (addr & 0x80) ? TUSB_DIR_IN : TUSB_DIR_OUT;
+}
+
+// TinyUSB class-driver vtable (subset the XInput driver initializes).
+typedef struct {
+#if CFG_TUSB_DEBUG >= 2
+	const char *name;
+#endif
+	void (*init)(void);
+	bool (*deinit)(void);
+	void (*reset)(uint8_t rhport);
+	uint16_t (*open)(uint8_t rhport, const tusb_desc_interface_t *itf_desc,
+			 uint16_t max_len);
+	bool (*control_xfer_cb)(uint8_t rhport, uint8_t stage,
+				const tusb_control_request_t *request);
+	bool (*xfer_cb)(uint8_t rhport, uint8_t ep_addr, xfer_result_t result,
+			uint32_t xferred_bytes);
+	void (*sof)(uint8_t rhport);
+} usbd_class_driver_t;
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+bool tud_mounted(void);
+bool usbd_edpt_open(uint8_t rhport, const tusb_desc_endpoint_t *ep);
+bool usbd_edpt_xfer(uint8_t rhport, uint8_t ep, uint8_t *buf, uint16_t n);
+bool usbd_edpt_busy(uint8_t rhport, uint8_t ep);
+bool usbd_edpt_claim(uint8_t rhport, uint8_t ep);
+void usbd_edpt_release(uint8_t rhport, uint8_t ep);
+#ifdef __cplusplus
+}
+#endif

+ 133 - 0
OpenPuckZephyr/src/compat/littlefs_shim.cpp

@@ -0,0 +1,133 @@
+// littlefs_shim.cpp -- backs the Adafruit_LittleFS / InternalFileSystem shim
+// with Zephyr's LittleFS mounted on the board storage_partition.
+#include "Adafruit_LittleFS.h"
+#include "InternalFileSystem.h"
+
+#include <zephyr/fs/fs.h>
+#include <zephyr/fs/littlefs.h>
+#include <zephyr/storage/flash_map.h>
+#include <zephyr/kernel.h>
+#include <string.h>
+
+using namespace Adafruit_LittleFS_Namespace;
+
+#define LFS_MNT "/lfs"
+#define PART_ID FIXED_PARTITION_ID(storage_partition)
+
+FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(opk_lfs_cfg);
+
+static struct fs_mount_t opk_mp;
+static bool opk_mounted;
+static bool opk_mp_init;
+
+static void mp_setup(void)
+{
+	if (opk_mp_init)
+		return;
+	opk_mp_init = true;
+	opk_mp.type = FS_LITTLEFS;
+	opk_mp.fs_data = &opk_lfs_cfg;
+	opk_mp.storage_dev = (void *)PART_ID;
+	opk_mp.mnt_point = LFS_MNT;
+}
+
+// Build the mount-relative full path. All callers pass leading-slash names.
+static void fullpath(char *out, size_t n, const char *path)
+{
+	snprintf(out, n, "%s%s%s", LFS_MNT, path[0] == '/' ? "" : "/", path);
+}
+
+// ---- LittleFS ----
+bool LittleFS::begin()
+{
+	mp_setup();
+	if (opk_mounted)
+		return true;
+	int rc = fs_mount(&opk_mp);
+	// littlefs auto-formats a blank/corrupt partition on mount, so a clean
+	// device comes up empty (the Arduino InternalFS.begin() behaved the same).
+	opk_mounted = (rc == 0);
+	return opk_mounted;
+}
+
+bool LittleFS::remove(const char *path)
+{
+	char fp[64];
+	fullpath(fp, sizeof fp, path);
+	int rc = fs_unlink(fp);
+	return rc == 0 || rc == -ENOENT;
+}
+
+bool LittleFS::format()
+{
+	if (opk_mounted) {
+		fs_unmount(&opk_mp);
+		opk_mounted = false;
+	}
+	int rc = fs_mkfs(FS_LITTLEFS, (uintptr_t)PART_ID, &opk_lfs_cfg, 0);
+	if (rc < 0)
+		return false;
+	return begin();
+}
+
+// ---- File ----
+File::File(LittleFS &fs) : _fp(nullptr), _open(false)
+{
+	(void)fs;
+	_fp = k_malloc(sizeof(struct fs_file_t));
+	if (_fp)
+		fs_file_t_init((struct fs_file_t *)_fp);
+}
+
+File::~File()
+{
+	close();
+	if (_fp)
+		k_free(_fp);
+}
+
+bool File::open(const char *path, uint8_t mode)
+{
+	if (!_fp)
+		return false;
+	close();
+	char fp[64];
+	fullpath(fp, sizeof fp, path);
+	fs_mode_t flags = (mode == FILE_O_WRITE) ?
+				  (FS_O_CREATE | FS_O_WRITE) :
+				  FS_O_READ;
+	int rc = fs_open((struct fs_file_t *)_fp, fp, flags);
+	_open = (rc == 0);
+	// Zephyr has no FS_O_TRUNC; emulate Adafruit's write-truncates behavior so
+	// a rewritten record never keeps a stale tail (callers also remove() first
+	// for cfg/bonds, but swProSaveCfg overwrites in place).
+	if (_open && mode == FILE_O_WRITE)
+		fs_truncate((struct fs_file_t *)_fp, 0);
+	return _open;
+}
+
+int File::read(uint8_t *buf, size_t n)
+{
+	if (!_open)
+		return 0;
+	ssize_t rc = fs_read((struct fs_file_t *)_fp, buf, n);
+	return rc < 0 ? 0 : (int)rc;
+}
+
+size_t File::write(const uint8_t *buf, size_t n)
+{
+	if (!_open)
+		return 0;
+	ssize_t rc = fs_write((struct fs_file_t *)_fp, buf, n);
+	return rc < 0 ? 0 : (size_t)rc;
+}
+
+void File::close()
+{
+	if (_open) {
+		fs_close((struct fs_file_t *)_fp);
+		_open = false;
+	}
+}
+
+Adafruit_LittleFS_Namespace::LittleFS InternalFS;

+ 324 - 0
OpenPuckZephyr/src/compat/usb_glue.cpp

@@ -0,0 +1,324 @@
+// usb_glue.cpp -- Zephyr USBD backing for the Adafruit_TinyUSB shim.
+//
+// Brings up a single usbd_context, exposes device state (mounted/suspended) to
+// the shim, and binds Adafruit_USBD_HID objects to a pool of devicetree
+// "zephyr,hid-device" nodes. Report callbacks from Zephyr's hid_device_ops are
+// dispatched to the per-object TinyUSB-style get/set callbacks.
+//
+// NOTE: this is the bring-up backing. The pieces that map cleanly to Zephyr
+// (device state, HID submit/callbacks) are functional; the WebUSB vendor pipe
+// and the XInput custom class are scaffolded to compile and are completed in
+// their own glue units (see usb_webusb_glue / mode_xinput port).
+#include "Adafruit_TinyUSB.h"
+
+#include <zephyr/usb/usbd.h>
+#include <zephyr/usb/usb_device.h>
+#include <zephyr/usb/class/usbd_hid.h>
+#include <zephyr/device.h>
+#include <zephyr/devicetree.h>
+#include <zephyr/kernel.h>
+#include <zephyr/sys/atomic.h>
+
+// ---- device state, updated from the USBD message callback ----
+static atomic_t s_mounted = ATOMIC_INIT(0);
+static atomic_t s_suspended = ATOMIC_INIT(0);
+
+extern "C" bool opk_usb_mounted(void)
+{
+	return atomic_get(&s_mounted) != 0;
+}
+extern "C" bool opk_usb_suspended(void)
+{
+	return atomic_get(&s_suspended) != 0;
+}
+
+// Set by the USBD init code (usb_device_setup.cpp) so this TU can drive resume.
+// Weak fallbacks let this glue link before that unit exists (bring-up); the real
+// usbd setup unit strongly overrides them.
+extern "C" __attribute__((weak)) struct usbd_context *opk_usbd_ctx(void)
+{
+	return nullptr;
+}
+extern "C" __attribute__((weak)) int opk_usbd_enable(void)
+{
+	return 0;
+}
+extern "C" __attribute__((weak)) int opk_usbd_disable(void)
+{
+	return 0;
+}
+
+// ---- HID device pool, from devicetree ----
+// Each "zephyr,hid-device" node is one HID interface. Objects claim them in
+// begin() order, mirroring TinyUSB's instance-index assignment.
+#define HID_NODE(inst) DEVICE_DT_GET(DT_NODELABEL(hid_dev_##inst)),
+static const struct device *const s_hid_pool[] = {
+#if DT_NODE_EXISTS(DT_NODELABEL(hid_dev_0))
+	DEVICE_DT_GET(DT_NODELABEL(hid_dev_0)),
+#endif
+#if DT_NODE_EXISTS(DT_NODELABEL(hid_dev_1))
+	DEVICE_DT_GET(DT_NODELABEL(hid_dev_1)),
+#endif
+#if DT_NODE_EXISTS(DT_NODELABEL(hid_dev_2))
+	DEVICE_DT_GET(DT_NODELABEL(hid_dev_2)),
+#endif
+#if DT_NODE_EXISTS(DT_NODELABEL(hid_dev_3))
+	DEVICE_DT_GET(DT_NODELABEL(hid_dev_3)),
+#endif
+#if DT_NODE_EXISTS(DT_NODELABEL(hid_dev_4))
+	DEVICE_DT_GET(DT_NODELABEL(hid_dev_4)),
+#endif
+};
+#define HID_POOL_N ((int)(sizeof(s_hid_pool) / sizeof(s_hid_pool[0])))
+
+// Reverse map: Zephyr device -> owning HID object, for the ops dispatcher.
+static Adafruit_USBD_HID *s_hid_owner[HID_POOL_N];
+static int s_hid_claimed;
+static atomic_t s_hid_ready[HID_POOL_N];
+
+static int pool_index(const struct device *dev)
+{
+	for (int i = 0; i < HID_POOL_N; i++)
+		if (s_hid_pool[i] == dev)
+			return i;
+	return -1;
+}
+
+// ---- Zephyr hid_device_ops -> TinyUSB-style callback dispatch ----
+static void op_iface_ready(const struct device *dev, const bool ready)
+{
+	int i = pool_index(dev);
+	if (i >= 0)
+		atomic_set(&s_hid_ready[i], ready ? 1 : 0);
+}
+
+static int op_get_report(const struct device *dev, const uint8_t type,
+			 const uint8_t id, const uint16_t len, uint8_t *const buf)
+{
+	int i = pool_index(dev);
+	if (i < 0 || !s_hid_owner[i] || !s_hid_owner[i]->_get_cb)
+		return -ENOTSUP;
+	uint16_t n = s_hid_owner[i]->_get_cb(id, type, buf, len);
+	return n ? (int)n : -ENOTSUP;
+}
+
+static int op_set_report(const struct device *dev, const uint8_t type,
+			 const uint8_t id, const uint16_t len,
+			 const uint8_t *const buf)
+{
+	int i = pool_index(dev);
+	if (i < 0 || !s_hid_owner[i] || !s_hid_owner[i]->_set_cb)
+		return -ENOTSUP;
+	s_hid_owner[i]->_set_cb(id, type, buf, len);
+	return 0;
+}
+
+// Output reports delivered via the interrupt OUT pipe: the firmware's set
+// callbacks treat OUTPUT and the interrupt-OUT path identically, so forward to
+// the same callback with the OUTPUT type. The first byte is the report ID when
+// the descriptor uses IDs.
+static void op_output_report(const struct device *dev, const uint16_t len,
+			     const uint8_t *const buf)
+{
+	int i = pool_index(dev);
+	if (i < 0 || !s_hid_owner[i] || !s_hid_owner[i]->_set_cb || len == 0)
+		return;
+	s_hid_owner[i]->_set_cb(buf[0], HID_REPORT_TYPE_OUTPUT, buf + 1,
+				len - 1);
+}
+
+static const struct hid_device_ops s_hid_ops = {
+	.iface_ready = op_iface_ready,
+	.get_report = op_get_report,
+	.set_report = op_set_report,
+	.output_report = op_output_report,
+};
+
+// ===================== Adafruit_USBD_HID =====================
+Adafruit_USBD_HID::Adafruit_USBD_HID()
+{
+}
+
+void Adafruit_USBD_HID::setReportDescriptor(const uint8_t *desc, uint16_t len)
+{
+	_desc = desc;
+	_desc_len = len;
+}
+void Adafruit_USBD_HID::setReportCallback(hid_get_report_cb_t get_cb,
+					  hid_set_report_cb_t set_cb)
+{
+	_get_cb = get_cb;
+	_set_cb = set_cb;
+}
+void Adafruit_USBD_HID::setStringDescriptor(const char *s)
+{
+	_str = s;
+}
+void Adafruit_USBD_HID::setPollInterval(uint8_t interval_ms)
+{
+	_poll_ms = interval_ms;
+}
+void Adafruit_USBD_HID::setBootProtocol(uint8_t protocol)
+{
+	_boot_proto = protocol;
+}
+void Adafruit_USBD_HID::enableOutEndpoint(bool en)
+{
+	_has_out = en;
+}
+
+bool Adafruit_USBD_HID::begin()
+{
+	if (s_hid_claimed >= HID_POOL_N)
+		return false;
+	_node = s_hid_claimed++;
+	s_hid_owner[_node] = this;
+	const struct device *dev = s_hid_pool[_node];
+	if (!device_is_ready(dev))
+		return false;
+	return hid_device_register(dev, _desc, _desc_len, &s_hid_ops) == 0;
+}
+
+bool Adafruit_USBD_HID::ready()
+{
+	if (_node < 0)
+		return false;
+	return atomic_get(&s_hid_ready[_node]) != 0;
+}
+
+bool Adafruit_USBD_HID::sendReport(uint8_t report_id, const void *report,
+				   uint16_t len)
+{
+	if (_node < 0 || !ready())
+		return false;
+	const struct device *dev = s_hid_pool[_node];
+	// Zephyr expects the report buffer; when the descriptor uses report IDs
+	// the ID must lead the buffer. The firmware passes the body separately,
+	// so prepend the ID here when nonzero.
+	if (report_id) {
+		uint8_t buf[80];
+		if ((size_t)len + 1 > sizeof buf)
+			return false;
+		buf[0] = report_id;
+		memcpy(buf + 1, report, len);
+		return hid_device_submit_report(dev, len + 1, buf) == 0;
+	}
+	return hid_device_submit_report(dev, len,
+					(const uint8_t *)report) == 0;
+}
+
+// ===================== TinyUSBDevice =====================
+// Device identity / descriptor mutators feed the usbd descriptor built in
+// usb_device_setup.cpp; here they are recorded into shared state it reads.
+#include "usb_identity.h"
+opk_usb_identity g_opk_usb_id;
+
+void TinyUSBDevice::setID(uint16_t vid, uint16_t pid)
+{
+	g_opk_usb_id.vid = vid;
+	g_opk_usb_id.pid = pid;
+}
+void TinyUSBDevice::setVersion(uint16_t bcd)
+{
+	g_opk_usb_id.bcd_usb = bcd;
+}
+void TinyUSBDevice::setDeviceVersion(uint16_t bcd)
+{
+	g_opk_usb_id.bcd_device = bcd;
+}
+void TinyUSBDevice::setManufacturerDescriptor(const char *s)
+{
+	g_opk_usb_id.manufacturer = s;
+}
+void TinyUSBDevice::setProductDescriptor(const char *s)
+{
+	g_opk_usb_id.product = s;
+}
+void TinyUSBDevice::setSerialDescriptor(const char *s)
+{
+	g_opk_usb_id.serial = s;
+}
+void TinyUSBDevice::setConfigurationBuffer(uint8_t *buf, uint32_t buflen)
+{
+	(void)buf;
+	(void)buflen; // Zephyr builds its own descriptor set
+}
+void TinyUSBDevice::setConfigurationAttribute(uint8_t attr)
+{
+	g_opk_usb_id.cfg_attr = attr;
+}
+
+// Interface/endpoint allocation + dynamic add: the Zephyr descriptor set is
+// fixed at init, so these are recorded for the XInput class glue and are no-ops
+// against the live stack (see the reboot-to-reenumerate concession).
+uint8_t TinyUSBDevice::allocInterface(uint8_t count)
+{
+	uint8_t base = g_opk_usb_id.next_itf;
+	g_opk_usb_id.next_itf += count;
+	return base;
+}
+uint8_t TinyUSBDevice::allocEndpoint(uint8_t dir)
+{
+	uint8_t n = ++g_opk_usb_id.next_ep;
+	return (dir ? 0x80 : 0x00) | n;
+}
+bool TinyUSBDevice::addInterface(Adafruit_USBD_Interface &itf)
+{
+	(void)itf;
+	return true;
+}
+
+void TinyUSBDevice::clearConfiguration()
+{
+	s_hid_claimed = 0;
+	g_opk_usb_id.next_itf = 0;
+	g_opk_usb_id.next_ep = 0;
+}
+
+// detach/attach map to disabling/enabling the usbd stack (opk_usbd_*, above).
+bool TinyUSBDevice::detach()
+{
+	return opk_usbd_disable() == 0;
+}
+bool TinyUSBDevice::attach()
+{
+	return opk_usbd_enable() == 0;
+}
+bool TinyUSBDevice::mounted()
+{
+	return opk_usb_mounted();
+}
+bool TinyUSBDevice::suspended()
+{
+	return opk_usb_suspended();
+}
+bool TinyUSBDevice::remoteWakeup()
+{
+	struct usbd_context *ctx = opk_usbd_ctx();
+	if (!ctx)
+		return false;
+	return usbd_wakeup_request(ctx) == 0;
+}
+
+TinyUSBDevice USBDevice;
+
+// USBD message callback: track mount/suspend. Registered by usb_device_setup.
+extern "C" void opk_usb_msg(const enum usbd_msg_type type)
+{
+	switch (type) {
+	case USBD_MSG_CONFIGURATION:
+		atomic_set(&s_mounted, 1);
+		break;
+	case USBD_MSG_RESET:
+		atomic_set(&s_mounted, 0);
+		break;
+	case USBD_MSG_SUSPEND:
+		atomic_set(&s_suspended, 1);
+		break;
+	case USBD_MSG_RESUME:
+		atomic_set(&s_suspended, 0);
+		break;
+	default:
+		break;
+	}
+}

+ 18 - 0
OpenPuckZephyr/src/compat/usb_identity.h

@@ -0,0 +1,18 @@
+// usb_identity.h -- USB identity/state recorded by the TinyUSB shim mutators and
+// consumed when the Zephyr usbd_context descriptor set is built.
+#pragma once
+#include <stdint.h>
+
+struct opk_usb_identity {
+	uint16_t vid = 0x28DE; // Valve, default (puck identity)
+	uint16_t pid = 0x1304;
+	uint16_t bcd_usb = 0x0200;
+	uint16_t bcd_device = 0x0100;
+	const char *manufacturer = "OpenPuck";
+	const char *product = "OpenPuck";
+	const char *serial = "0";
+	uint8_t cfg_attr = 0xA0; // bus-powered + remote-wakeup
+	uint8_t next_itf = 0;
+	uint8_t next_ep = 0;
+};
+extern opk_usb_identity g_opk_usb_id;

+ 213 - 0
OpenPuckZephyr/src/compat/usb_stub.cpp

@@ -0,0 +1,213 @@
+// usb_stub.cpp -- inert TinyUSB-shim backing used during core bring-up.
+//
+// Lets the non-USB firmware (radio/protocol/modes-logic/data) compile and link
+// before the real Zephyr USBD backing (usb_glue.cpp + DT HID nodes + usbd
+// context) is wired in. Every method is a safe no-op: not mounted/suspended,
+// HIDs never ready so sendReport()s are skipped. Swapped out for usb_glue.cpp
+// once the USBD stack is up.
+#include "Adafruit_TinyUSB.h"
+#include "usb_identity.h"
+#include <string.h>
+
+opk_usb_identity g_opk_usb_id;
+
+Adafruit_USBD_HID::Adafruit_USBD_HID()
+{
+}
+void Adafruit_USBD_HID::setReportDescriptor(const uint8_t *desc, uint16_t len)
+{
+	_desc = desc;
+	_desc_len = len;
+}
+void Adafruit_USBD_HID::setReportCallback(hid_get_report_cb_t g,
+					  hid_set_report_cb_t s)
+{
+	_get_cb = g;
+	_set_cb = s;
+}
+void Adafruit_USBD_HID::setStringDescriptor(const char *s)
+{
+	_str = s;
+}
+void Adafruit_USBD_HID::setPollInterval(uint8_t i)
+{
+	_poll_ms = i;
+}
+void Adafruit_USBD_HID::setBootProtocol(uint8_t p)
+{
+	_boot_proto = p;
+}
+void Adafruit_USBD_HID::enableOutEndpoint(bool e)
+{
+	_has_out = e;
+}
+bool Adafruit_USBD_HID::begin()
+{
+	return true;
+}
+bool Adafruit_USBD_HID::ready()
+{
+	return false;
+}
+bool Adafruit_USBD_HID::sendReport(uint8_t, const void *, uint16_t)
+{
+	return false;
+}
+
+Adafruit_USBD_WebUSB::Adafruit_USBD_WebUSB()
+{
+}
+bool Adafruit_USBD_WebUSB::begin()
+{
+	return true;
+}
+bool Adafruit_USBD_WebUSB::connected()
+{
+	return false;
+}
+int Adafruit_USBD_WebUSB::available()
+{
+	return 0;
+}
+int Adafruit_USBD_WebUSB::read()
+{
+	return -1;
+}
+uint32_t Adafruit_USBD_WebUSB::write(const void *, uint32_t n)
+{
+	return n;
+}
+void Adafruit_USBD_WebUSB::flush()
+{
+}
+
+void Adafruit_USBD_Device::setID(uint16_t v, uint16_t p)
+{
+	g_opk_usb_id.vid = v;
+	g_opk_usb_id.pid = p;
+}
+void Adafruit_USBD_Device::setVersion(uint16_t b)
+{
+	g_opk_usb_id.bcd_usb = b;
+}
+void Adafruit_USBD_Device::setDeviceVersion(uint16_t b)
+{
+	g_opk_usb_id.bcd_device = b;
+}
+void Adafruit_USBD_Device::setManufacturerDescriptor(const char *s)
+{
+	g_opk_usb_id.manufacturer = s;
+}
+void Adafruit_USBD_Device::setProductDescriptor(const char *s)
+{
+	g_opk_usb_id.product = s;
+}
+void Adafruit_USBD_Device::setSerialDescriptor(const char *s)
+{
+	g_opk_usb_id.serial = s;
+}
+void Adafruit_USBD_Device::setConfigurationBuffer(uint8_t *, uint32_t)
+{
+}
+void Adafruit_USBD_Device::setConfigurationAttribute(uint8_t a)
+{
+	g_opk_usb_id.cfg_attr = a;
+}
+uint8_t Adafruit_USBD_Device::allocInterface(uint8_t count)
+{
+	uint8_t b = g_opk_usb_id.next_itf;
+	g_opk_usb_id.next_itf += count;
+	return b;
+}
+uint8_t Adafruit_USBD_Device::allocEndpoint(uint8_t dir)
+{
+	uint8_t n = ++g_opk_usb_id.next_ep;
+	return (dir ? 0x80 : 0x00) | n;
+}
+bool Adafruit_USBD_Device::addInterface(Adafruit_USBD_Interface &)
+{
+	return true;
+}
+void Adafruit_USBD_Device::clearConfiguration()
+{
+	g_opk_usb_id.next_itf = 0;
+	g_opk_usb_id.next_ep = 0;
+}
+bool Adafruit_USBD_Device::detach()
+{
+	return true;
+}
+bool Adafruit_USBD_Device::attach()
+{
+	return true;
+}
+bool Adafruit_USBD_Device::mounted()
+{
+	return true;
+}
+bool Adafruit_USBD_Device::suspended()
+{
+	return false;
+}
+bool Adafruit_USBD_Device::remoteWakeup()
+{
+	return true;
+}
+Adafruit_USBD_Device USBDevice;
+Adafruit_USBD_Device &TinyUSBDevice = USBDevice;
+
+// DFU entry: stash the Adafruit-bootloader magic in GPREGRET and reset. 0x57 ==
+// UF2 mass-storage; 0x4E == serial (OTA) DFU. (Inert here only in that the host
+// reconnects to the bootloader; functional once flashed.)
+#include "Arduino.h"
+void enterUf2Dfu(void)
+{
+	NRF_POWER->GPREGRET = 0x57;
+	NVIC_SystemReset();
+}
+void enterSerialDfu(void)
+{
+	NRF_POWER->GPREGRET = 0x4E;
+	NVIC_SystemReset();
+}
+
+// ---- TinyUSB custom-class-driver stubs (XInput) ----
+// The XInput interface is a custom TinyUSB class driver; these are the endpoint
+// primitives it calls. Stubbed during bring-up (XInput will not enumerate until
+// the Zephyr custom class is implemented — see mode_xinput port).
+#include "device/usbd_pvt.h"
+extern "C" bool tud_mounted(void)
+{
+	return USBDevice.mounted();
+}
+bool usbd_edpt_open(uint8_t rhport, const tusb_desc_endpoint_t *ep)
+{
+	(void)rhport;
+	(void)ep;
+	return false;
+}
+bool usbd_edpt_xfer(uint8_t rhport, uint8_t ep, uint8_t *buf, uint16_t n)
+{
+	(void)rhport;
+	(void)ep;
+	(void)buf;
+	(void)n;
+	return false;
+}
+bool usbd_edpt_busy(uint8_t rhport, uint8_t ep)
+{
+	(void)rhport;
+	(void)ep;
+	return false;
+}
+bool usbd_edpt_claim(uint8_t rhport, uint8_t ep)
+{
+	(void)rhport;
+	(void)ep;
+	return false;
+}
+void usbd_edpt_release(uint8_t rhport, uint8_t ep)
+{
+	(void)rhport;
+	(void)ep;
+}

+ 212 - 0
OpenPuckZephyr/src/config.cpp

@@ -0,0 +1,212 @@
+#include "config.h"
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+#include <string.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+uint8_t g_usbMode = 0;
+bool g_xbox = false;
+uint8_t g_chordBtn[3] = {
+	MODE_LIZARD, MODE_XBOX, MODE_SW_PRO
+}; // back4+B/X/Y -> these modes (A always STEAM); Y defaults to Switch Pro
+bool g_persistMode = false;
+uint8_t g_bootMode = 0xFF;
+
+bool g_debugCdcThisBoot = false;
+
+// persisted one-shot arm, stored in Cfg.rsvd0 (1 = keep CDC for the next boot)
+static uint8_t g_debugCdc = 0;
+
+int g_mDiv = 64, g_mFric = 94;
+
+// Per-type button config. back default {5,6,7,8} = L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad,
+// 16/17 PS touch/mute, 18 Switch Capture). Switch differs: QAM defaults to Capture(18), A/B swap on, and
+// trackpad haptics off. qamMap 0 = unmapped (hardcoded per-mode behavior). ledBright 0 = no override.
+TypeCfg g_type[ET_COUNT] = {
+	/* ET_XBOX   */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
+	/* ET_SWITCH */ { { 5, 6, 7, 8 }, 18, 1, 0, 0 },
+	/* ET_DS4    */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
+	/* ET_DS5    */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
+};
+uint8_t g_etype = ET_NONE;
+
+// Live mirrors of the active type (puck modes use the harmless defaults below).
+uint8_t g_abSwap = 0;
+uint8_t g_back[4] = { 5, 6, 7, 8 };
+uint8_t g_qamMap = 0;
+uint8_t g_padHaptics = 1;
+uint8_t g_ledBright = 0;
+
+void applyActiveType()
+{
+	g_etype = etypeForMode(g_usbMode);
+	if (g_etype >=
+	    ET_COUNT) { // puck mode (Steam/Lizard): no remap, haptics on
+		g_back[0] = 5;
+		g_back[1] = 6;
+		g_back[2] = 7;
+		g_back[3] = 8;
+		g_qamMap = 0;
+		g_abSwap = 0;
+		g_padHaptics = 1;
+		g_ledBright = 0;
+		return;
+	}
+	const TypeCfg &t = g_type[g_etype];
+	for (int i = 0; i < 4; i++)
+		g_back[i] = t.back[i];
+	g_qamMap = t.qamMap;
+	g_abSwap = t.abSwap;
+	g_padHaptics = t.padHaptics;
+	g_ledBright = t.ledBright;
+}
+// rumble strength % (200 = double); adjustable from the WebUSB panel
+uint8_t g_rumbleScale = 200;
+
+// poll rate is fixed. Faster than the controller can refresh wastes airtime; slower adds latency. Any rate
+// persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
+const uint32_t g_pollUs = POLL_US_DEFAULT;
+
+#define CFG_FILE "/cfg.bin"
+// TypeCfg size changed; old flash format is incompatible -> clean defaults on first boot after upgrade
+#define CFG_MAGIC 0xCB
+struct Cfg {
+	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
+		bootMode, chordBtn[3], rumbleScale;
+	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
+}; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
+
+void saveCfg()
+{
+	Cfg c = { CFG_MAGIC,
+		  g_usbMode,
+		  (uint8_t)g_mDiv,
+		  (uint8_t)g_mFric,
+		  g_debugCdc,
+		  (uint8_t)(g_pollUs / 100),
+		  (uint8_t)(g_persistMode ? 1 : 0),
+		  g_bootMode,
+		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
+		  g_rumbleScale,
+		  {} };
+	for (int i = 0; i < ET_COUNT; i++)
+		c.type[i] = g_type[i];
+	InternalFS.remove(CFG_FILE);
+	File f(InternalFS);
+	if (f.open(CFG_FILE, FILE_O_WRITE)) {
+		f.write((uint8_t *)&c, sizeof c);
+		f.close();
+	}
+}
+
+void loadCfg()
+{
+	Cfg c;
+	File f(InternalFS);
+	bool consume = false;
+	if (f.open(CFG_FILE, FILE_O_READ)) {
+		if (f.read((uint8_t *)&c, sizeof c) == (int)sizeof c &&
+		    c.magic == CFG_MAGIC) {
+			g_mDiv = c.mDiv ? c.mDiv : 64;
+			g_mFric = c.mFric;
+			for (int i = 0; i < ET_COUNT; i++)
+				g_type[i] = c.type[i];
+			g_persistMode = c.persistMode ? true : false;
+			// one-shot debug-CDC (Cfg.rsvd0): honor for THIS boot, then consume so the next boot reverts to normal.
+			g_debugCdcThisBoot = c.rsvd0 ? true : false;
+			if (c.rsvd0) {
+				g_debugCdc = 0;
+				consume = true;
+			}
+			// poll rate is fixed; rewrite cfg so the persisted byte matches the new default.
+			if (c.pollU100 != (uint8_t)(POLL_US_DEFAULT / 100))
+				consume = true;
+			// boot-mode policy: a one-shot bootMode (explicit switch when !persist) wins once then clears;
+			// otherwise persist->last mode, else->Steam.
+			if (c.bootMode != 0xFF) {
+				g_usbMode = modeValid(c.bootMode) ? c.bootMode :
+								    0;
+				consume = true;
+			} else
+				g_usbMode = g_persistMode ? (modeValid(c.mode) ?
+								     c.mode :
+								     0) :
+							    0;
+			static const uint8_t CHORD_DEF[3] = { MODE_LIZARD,
+							      MODE_XBOX,
+							      MODE_SW_PRO };
+			for (int i = 0; i < 3; i++)
+				g_chordBtn[i] = modeValid(c.chordBtn[i]) ?
+							c.chordBtn[i] :
+							CHORD_DEF[i];
+
+			// 0 is a valid setting (rumble off)
+			g_rumbleScale = c.rumbleScale;
+		}
+		f.close();
+	}
+	// resolve the active emulated type's settings into the live mirrors the mode builders read
+	applyActiveType();
+	// clear the one-shot so the NEXT cold boot reverts to the default/persist policy
+	if (consume) {
+		g_bootMode = 0xFF;
+		saveCfg();
+	}
+}
+
+void saveMode(uint8_t m)
+{
+	if (g_persistMode) {
+		g_usbMode = m;
+		g_bootMode = 0xFF;
+	} else {
+		g_bootMode = m;
+	}
+	saveCfg();
+}
+
+void armDebugCdcNextBoot()
+{
+	g_debugCdc = 1;
+	saveCfg();
+} // next boot keeps CDC; loadCfg() consumes it after
+
+// FULL factory wipe: reformat the internal LittleFS, erasing cfg.bin (modes/tunables/chords) AND bonds.bin
+// (paired-controller record). Caller reboots: next boot finds no files and falls back to clean defaults, and
+// the controller must be re-paired. Irreversible -- gated behind explicit confirmation at every call site.
+void factoryErase()
+{
+	// ensure mounted before we reformat (no-op if already up)
+	InternalFS.begin();
+	InternalFS.format();
+}
+
+// One-time factory reset for the -DOPK_FACTORY_RESET recovery build: clear a bad config/bond ONCE (first boot
+// after flashing) then persist normally. "Already reset" is tracked by a tag file holding the build's git hash,
+// written AFTER the wipe (so it survives in the freshly-formatted FS):
+//   - tag missing or != this build's hash  -> wipe, then stamp the tag. Next boot persists.
+//   - tag == this build's hash             -> already reset for this build: skip, boot normally.
+// Keying the tag to the git hash means flashing a DIFFERENT build re-triggers the wipe. buildTag is OPK_GIT_HASH.
+#define RESET_TAG_FILE "/rsttag"
+void factoryResetOnce(const char *buildTag)
+{
+	char tag[24] = { 0 };
+	{
+		File f(InternalFS);
+		if (f.open(RESET_TAG_FILE, FILE_O_READ)) {
+			int n = f.read((uint8_t *)tag, sizeof tag - 1);
+			if (n > 0)
+				tag[n] = 0;
+			f.close();
+		}
+	}
+	if (strncmp(tag, buildTag, sizeof tag - 1) == 0)
+		return; // this build already did its one-time reset -> persist
+	factoryErase(); // wipe cfg.bin + bonds.bin + the old tag
+	InternalFS.begin(); // remount the fresh FS
+	File g(InternalFS); // stamp the tag so subsequent boots skip the wipe
+	if (g.open(RESET_TAG_FILE, FILE_O_WRITE)) {
+		g.write((const uint8_t *)buildTag, strlen(buildTag));
+		g.close();
+	}
+}

+ 182 - 0
OpenPuckZephyr/src/config.h

@@ -0,0 +1,182 @@
+// config.h -- USB-presentation modes + persisted, runtime-tunable settings.
+//
+// One puck speaks the SAME RF protocol no matter which USB face it wears; only the USB enumeration and the
+// input-report mapping change. The active face is g_usbMode, persisted to flash (cfg.bin). Tunables here are
+// set over the CDC console (serial_console.cpp) and the WebUSB panel (webusb_config.cpp) and likewise persist.
+#pragma once
+#include <stdint.h>
+
+// Compile-time logging / diagnostics. 0 (default) = PRODUCTION build: no host->controller capture ring
+// (reclaims ~80KB RAM), no per-event logging, no main-loop section timing, no WebUSB capture channel.
+// Functional bits (haptic reconnect block, buzz-clear re-init, rate stats) stay. -DOPK_LOG=1 for diagnostics.
+#ifndef OPK_LOG
+#define OPK_LOG 0
+#endif
+
+// Build-time FACTORY RESET (recovery build). 0 (default) = normal. -DOPK_FACTORY_RESET=1 wipes ALL persistent
+// storage (cfg.bin + bonds.bin) ONCE -- on the first boot after flashing -- then persists normally. A git-hash
+// tag file (written after the wipe) records that this build already reset, so subsequent boots skip it. Flashing
+// a DIFFERENT build re-triggers the one-time wipe. Re-pair the controller after the reset. See factoryResetOnce().
+#ifndef OPK_FACTORY_RESET
+#define OPK_FACTORY_RESET 0
+#endif
+
+// ---- USB presentation modes (g_usbMode). RF poll/relay is identical across all; only USB enumeration +
+//      report mapping differ. ----
+#define MODE_STEAM 0 // Valve puck; auto-lizard when Steam closed
+#define MODE_XBOX 1 // XInput + right-pad mouse
+#define MODE_SW_HORI 2 // HORIPAD (Switch console whitelist)
+#define MODE_LIZARD \
+	3 // Puck HID; always keyboard+mouse (ignores Steam heartbeat)
+#define MODE_SW_PRO 4 // Nintendo Switch Pro Controller (057E:2009) + gyro
+// Sony DualSense (054C:0CE6) + gyro + split trackpad (+ wake mouse + WebUSB panel)
+#define MODE_PS5 5
+// DS4-layout generic HID gamepad + gyro (+ wake mouse + WebUSB panel)
+#define MODE_HIDGYRO 6
+// DualSense, CLEAN single-HID (no wake/WebUSB) so PC games classify it as PlayStation (Fortnite)
+#define MODE_PS5_GAME 7
+// DS4, CLEAN single-HID (no wake/WebUSB) for game PlayStation classification
+#define MODE_DS4_GAME 8
+#define MODE_MAX 8
+
+// The two "game" personalities drop the wake-mouse + WebUSB interfaces so the device is a genuine single-HID PS
+// controller (some PC games -- e.g. Fortnite/UE GameInput -- refuse PS classification when extra interfaces are
+// present). Cost: no config panel / host-wake while in these modes; chord back to Steam (back4 + A) for the panel.
+static inline bool modeIsCleanPS(uint8_t m)
+{
+	return m == MODE_PS5_GAME || m == MODE_DS4_GAME;
+}
+
+static inline bool modeIsPuck(uint8_t m)
+{
+	return m == MODE_STEAM || m == MODE_LIZARD;
+}
+static inline bool modeValid(uint8_t m)
+{
+	return m <= MODE_MAX;
+}
+
+// ---- Emulated controller TYPES. Button-remap config (back paddles / QAM / A-B swap / trackpad haptics) is kept
+//      per-type, not per-mode: the right mapping depends on what controller we present, and the variant modes of
+//      one controller share it. Steam + Lizard are native puck presentations -- they forward raw input to Steam,
+//      which does its own remapping -- so they are NOT emulated types (ET_NONE) and ignore this config. ----
+#define ET_XBOX 0
+#define ET_SWITCH 1
+#define ET_DS4 2
+#define ET_DS5 3
+#define ET_COUNT 4
+#define ET_NONE 0xFF
+static inline uint8_t etypeForMode(uint8_t m)
+{
+	switch (m) {
+	case MODE_XBOX:
+		return ET_XBOX;
+	case MODE_SW_HORI:
+	case MODE_SW_PRO:
+		return ET_SWITCH;
+	case MODE_HIDGYRO:
+	case MODE_DS4_GAME:
+		return ET_DS4;
+	case MODE_PS5:
+	case MODE_PS5_GAME:
+		return ET_DS5;
+	default:
+		return ET_NONE; // Steam / Lizard
+	}
+}
+
+extern uint8_t g_usbMode; // loaded from flash at boot
+
+// true for all non-puck presentations (cached !modeIsPuck(g_usbMode))
+extern bool g_xbox;
+extern uint8_t g_chordBtn[3]; // back4+B/X/Y -> these modes (A always STEAM)
+
+// Mode persistence policy: by DEFAULT every fresh power-on/reconnect lands in STEAM mode (0). An explicit
+// mode switch still works for the session via a ONE-SHOT bootMode (honored once, then cleared). g_persistMode
+// instead remembers the last selected mode across reboots.
+// false (default) = always boot Steam; true = boot into last mode
+extern bool g_persistMode;
+// one-shot: boot into this mode once then clear (!persistMode + explicit switch)
+extern uint8_t g_bootMode;
+
+// One-shot debug CDC. Puck mode normally DROPS the CDC serial console to free the USB endpoint its wake-mouse
+// interface needs (to wake a sleeping Windows host). Arming this keeps CDC for the NEXT boot only -- dropping
+// the wake mouse that boot -- to attach the serial debugger; the boot after reverts automatically.
+// decision for THIS boot: true => keep CDC, skip the wake interface
+extern bool g_debugCdcThisBoot;
+void armDebugCdcNextBoot(); // persist the one-shot (caller reboots)
+
+// persisted, runtime-tunable config:
+extern int g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
+
+// Per-emulated-type button config. One entry per ET_* type. back[] = paddle L4,R4,L5,R5 -> button codes
+// (0..15 standard, 16=PS Touch Click, 17=PS5 Mute, 18=Switch Capture/Screenshot). qamMap = QAM (3 dots)
+// physical button -> same code space (0 = default/unmapped). abSwap = swap A/B and X/Y (Nintendo layout).
+// padHaptics = 1 keeps the controller's autonomous trackpad haptics, 0 disables them for this type.
+// ledBright = LED brightness sent to the controller on connect: 0 = no override (controller default),
+// 1-100 = brightness %. Steam sets brightness each session; emulated modes never do, so the controller
+// comes up at full brightness. Setting a value here preserves the preferred brightness across mode switches.
+struct TypeCfg {
+	uint8_t back[4];
+	uint8_t qamMap;
+	uint8_t abSwap;
+	uint8_t padHaptics;
+	uint8_t ledBright;
+};
+extern TypeCfg g_type[ET_COUNT];
+extern uint8_t
+	g_etype; // etypeForMode(g_usbMode), resolved at boot (ET_NONE for puck modes)
+
+// Live, resolved-active-type mirrors of g_type[g_etype], read by the hot-path mode builders. Refreshed by
+// applyActiveType() at boot and after any edit to the active type.
+extern uint8_t g_abSwap; // 1 = swap A/B and X/Y (Nintendo face-button layout)
+extern uint8_t g_back[4];
+extern uint8_t g_qamMap;
+extern uint8_t
+	g_padHaptics; // 1 = trackpad haptics on (default), 0 = disabled for the active type
+// LED brightness for the active emulated type (0 = no override, 1-100 = brightness %)
+extern uint8_t g_ledBright;
+
+// Copy g_type[g_etype] into the live mirrors above (safe defaults when g_etype == ET_NONE).
+void applyActiveType();
+// rumble strength, percent of decoded amplitude (100 = 1x, 200 = 2x default), all modes
+extern uint8_t g_rumbleScale;
+// Switch Pro motion settings. Persisted in their OWN flash file (mode_switch_pro.cpp), NOT in Cfg -- so changing
+// them never resets the rest of the config. Set from the WebUSB panel.
+// Switch Pro report cadence: 0 = 66Hz (15ms, compat), 1 = 120Hz (8ms, DEFAULT), 2 = full (~250Hz)
+extern uint8_t g_swProRate;
+// Switch Pro gyro sensitivity x10 (10 = 1.0x default; 5/15/20/25/30 = 0.5..3.0x)
+extern uint8_t g_swGyroScale10;
+
+// persist g_swProRate + g_swGyroScale10 to their flash file
+void swProSaveCfg();
+
+// 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
+#define POLL_US_DEFAULT 4000u
+// host-side HID stream cadence for translated modes (~250 Hz)
+#define USB_STREAM_MS 4u
+// How long a USB suspend must PERSIST before we power the controllers off. A brief selective-suspend
+// (host idle power-management) resumes in <1s and must not trigger a self-inflicted power-off -> the
+// resulting disconnect/reconnect churn looked like random controller drops. Real host sleep persists.
+#define SUSPEND_OFF_MS 4000u
+// RF poll cadence (us). FIXED -- not configurable (see loadCfg).
+extern const uint32_t g_pollUs;
+
+// loop-timing diagnostics (defined in OpenPuck.ino) -- surfaced in the WebUSB status blob to find what caps
+// the poll rate: avg loop period, slowest section index, and that section's avg us/iteration.
+extern uint16_t g_loopPeriodUs;
+extern uint8_t g_loopWorst;
+extern uint16_t g_loopWorstUs;
+
+void loadCfg();
+void saveCfg();
+// FULL factory wipe: reformat the internal LittleFS, erasing cfg.bin (modes/tunables/chords) AND bonds.bin
+// (paired controller). Irreversible; the caller reboots so the next boot comes up on clean defaults and the
+// controller must be re-paired. Gated behind explicit confirmation at every call site.
+void factoryErase();
+// One-time factory reset for the -DOPK_FACTORY_RESET recovery build: wipe ONCE on the first boot after flashing
+// (tracked by a git-hash tag file so it doesn't wipe on every boot), then persist normally. buildTag = OPK_GIT_HASH.
+void factoryResetOnce(const char *buildTag);
+// Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
+// the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
+void saveMode(uint8_t m);

+ 42 - 0
OpenPuckZephyr/src/controllers.cpp

@@ -0,0 +1,42 @@
+#include "controllers.h"
+#include "config.h"
+#include "puck_hid.h"
+#include "mode_xinput.h"
+#include "mode_switch_hori.h"
+#include "mode_switch_pro.h"
+#include "mode_ps5.h"
+#include "mode_hidgyro.h"
+
+IController *g_active = nullptr;
+
+// Map a USB-presentation mode to its singleton controller. Steam and Lizard share the puck controller (the
+// lizard-vs-forward decision is made per-report inside it).
+IController *controllerFor(uint8_t mode)
+{
+	switch (mode) {
+	case MODE_STEAM:
+		return &g_steamPuck;
+	case MODE_LIZARD:
+		return &g_steamPuck;
+	case MODE_XBOX:
+		return &g_xboxCtl;
+	case MODE_SW_HORI:
+		return &g_switchHori;
+	case MODE_SW_PRO:
+		return &g_switchPro;
+	case MODE_PS5:
+		return &g_ps5Ctl;
+	case MODE_HIDGYRO:
+		return &g_hidGyroCtl;
+	case MODE_PS5_GAME:
+
+		// same DualSense controller; setup() drops wake/WebUSB for clean enum
+		return &g_ps5Ctl;
+	case MODE_DS4_GAME:
+
+		// same DS4 controller; setup() drops wake/WebUSB for clean enum
+		return &g_hidGyroCtl;
+	default:
+		return &g_steamPuck;
+	}
+}

+ 94 - 0
OpenPuckZephyr/src/controllers.h

@@ -0,0 +1,94 @@
+// controllers.h -- the emulated-USB-controller abstraction.
+//
+// Each USB personality (Steam puck, Xbox/XInput, Switch HORIPAD, Switch Pro, PS5 DualSense, DS4 gyro) is an
+// IController living in its own mode_*.cpp. Exactly ONE is active per boot, selected by g_usbMode. Methods:
+//   begin()       -- called once from setup(): set the USB VID/PID/strings and register this mode's
+//                    interface(s). Shared USB lifecycle (detach, clear/keep CDC, serial suffix, WebUSB,
+//                    attach) stays in setup(); begin() only adds what's specific to this controller.
+//   onReport45()  -- called from rf_link.cpp each time a controller input report 0x45 is decoded. The `slot`
+//                    argument is the bond slot the report arrived on (0..NSLOT-1, or -1 for the pre-bond
+//                    catch-all). PUSH-style modes (Xbox, puck/lizard) build + send their host report here;
+//                    the puck/lizard mode uses `slot` to fan out to hid[slot]. STREAM-style modes ignore
+//                    `slot`; STREAM-style modes emit at a fixed cadence from task() reading g_in[s].
+//   task()        -- called every loop(): streaming emit, handshake/subcommand draining, mode-specific upkeep.
+//   isPuck()      -- true for Steam/Lizard (keep the boot CDC composite; different USB lifecycle in setup()).
+//
+// To add a new controller: implement IController in a new mode_*.cpp, give it a singleton, and wire it into
+// controllerFor() in controllers.cpp.
+#pragma once
+#include <stdint.h>
+
+class IController {
+    public:
+	virtual ~IController()
+	{
+	}
+	virtual void begin() = 0;
+	virtual void onReport45(int slot, const uint8_t *rep, bool fresh,
+				uint8_t bodyTlen)
+	{
+		(void)slot;
+		(void)rep;
+		(void)fresh;
+		(void)bodyTlen;
+	}
+	// Other controller->host input reports decoded from the F1 reply (NOT 0x45): power/battery status report
+	// 0x43, status event 0x44. The real puck forwards these to Steam verbatim -- that's how Steam reads battery.
+	// rid = report id, data/n = body after the id. Default no-op (clean modes don't expose these reports).
+	virtual void onAuxReport(int slot, uint8_t rid, const uint8_t *data,
+				 uint8_t n)
+	{
+		(void)slot;
+		(void)rid;
+		(void)data;
+		(void)n;
+	}
+	virtual void task()
+	{
+	}
+
+	// a wake gesture fired while suspended; queue any post-resume input the host needs to actually wake
+	virtual void wakeEvent()
+	{
+	}
+	virtual bool isPuck() const
+	{
+		return false;
+	}
+
+	// ---- Dynamic USB mounting (see usb_mount.h). Emulated modes that present only CONNECTED controllers
+	//      override these; the defaults keep the legacy "begin() mounts everything" behavior for puck/lizard
+	//      and any not-yet-converted mode. ----
+	// true => this mode uses the dynamic mount/unmount path (usbReenumerate driven by the connectivity watcher)
+	// rather than registering its slot interfaces in begin(). maxSlots() then caps the mounted count.
+	virtual bool dynamicMount() const
+	{
+		return false;
+	}
+	// HID-instance budget for slot interfaces (CFG_TUD_HID minus any fixed HID like the wake mouse).
+	virtual uint8_t maxSlots() const
+	{
+		return 0;
+	}
+	// Re-apply this mode's USB identity (VID/PID, manufacturer/product strings, bcdDevice). Called by
+	// usbReenumerate after clearConfiguration (which resets them). begin() also calls it.
+	virtual void usbIdentity()
+	{
+	}
+	// One-time at boot: create the slot interface/HID object pool and lock their TinyUSB instance indices
+	// (begin() each). Adds them to the throwaway descriptor; usbReenumerate then presents the live prefix.
+	virtual void beginPool()
+	{
+	}
+	// (Re)add the first k slot interfaces (a dense prefix of the pool) to the freshly-cleared config descriptor.
+	virtual void mountSlots(uint8_t k)
+	{
+		(void)k;
+	}
+};
+
+// The singleton for a given mode (nullptr if the mode is unknown). Defined in controllers.cpp.
+IController *controllerFor(uint8_t mode);
+
+// The active controller for this boot (set in setup()). rf_link/loop dispatch through it.
+extern IController *g_active;

+ 245 - 0
OpenPuckZephyr/src/gamepad_util.cpp

@@ -0,0 +1,245 @@
+#include "gamepad_util.h"
+#include "triton.h"
+#include "config.h"
+
+void psNeutralCalib(uint8_t *buf)
+{
+	// Payload offsets = kernel buf[] index minus 1. buf[0..5] gyro bias stays zero (caller memset). Symmetric
+	// ranges -> non-zero divisors on the host.
+	le16(buf + 6, 2844);
+	le16(buf + 8, -2844); // gyro pitch +/-
+	le16(buf + 10, 2844);
+	le16(buf + 12, -2844); // gyro yaw +/-
+	le16(buf + 14, 2844);
+	le16(buf + 16, -2844); // gyro roll +/-
+	le16(buf + 18, 2844);
+	le16(buf + 20, 2844); // gyro speed +/- (sum != 0)
+	le16(buf + 22, 8192);
+	le16(buf + 24, -8192); // accel X +/-
+	le16(buf + 26, 8192);
+	le16(buf + 28, -8192); // accel Y +/-
+	le16(buf + 30, 8192);
+	le16(buf + 32, -8192); // accel Z +/-
+}
+
+uint8_t swStick(int16_t v, bool invert)
+{
+	int32_t a = 0x80 + (invert ? -((int32_t)v >> 8) : ((int32_t)v >> 8));
+	if (a < 0)
+		a = 0;
+	if (a > 255)
+		a = 255;
+	return (uint8_t)a;
+}
+
+// Map Steam trackpad s16 coords into a 0..max axis (centered touch -> mid-range).
+uint16_t padNormU16(int16_t v, uint16_t maxv)
+{
+	int32_t t = ((int32_t)v + 32768);
+	if (t < 0)
+		t = 0;
+	if (t > 65535)
+		t = 65535;
+	return (uint16_t)((t * (int32_t)maxv) / 65535);
+}
+uint16_t touchHalfX(int16_t v, bool rightHalf)
+{
+	uint16_t halfMax = TOUCH_PAD_W / 2 - 1;
+	uint16_t x = padNormU16(v, halfMax);
+	return rightHalf ? (uint16_t)(TOUCH_PAD_W / 2 + x) : x;
+}
+uint16_t touchYInv(int16_t v, uint16_t height)
+{
+	uint16_t maxy = height - 1;
+	return (uint16_t)(maxy - padNormU16(v, maxy));
+}
+void touchPackPoint(uint8_t *base, int finger, bool touch, uint16_t x,
+		    uint16_t y)
+{
+	uint8_t *f = base + finger * 4;
+	if (!touch) {
+		f[0] = 0x80;
+		f[1] = 0;
+		f[2] = 0;
+		f[3] = 0;
+		return;
+	}
+	f[0] = (uint8_t)(finger & 0x7F);
+	f[1] = (uint8_t)(x & 0xFF);
+	f[2] = (uint8_t)(((x >> 8) & 0x0F) | ((y & 0x0F) << 4));
+	f[3] = (uint8_t)((y >> 4) & 0xFF);
+}
+void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch, uint16_t lx,
+		   uint16_t ly, uint16_t rx, uint16_t ry)
+{
+	touchPackPoint(pts, 0, false, 0, 0);
+	touchPackPoint(pts, 1, false, 0, 0);
+	if (lTouch && rTouch) {
+		touchPackPoint(pts, 0, true, lx, ly);
+		touchPackPoint(pts, 1, true, rx, ry);
+	} else if (lTouch) {
+		touchPackPoint(pts, 0, true, lx, ly);
+	} else if (rTouch) {
+		touchPackPoint(pts, 0, true, rx, ry);
+	}
+}
+void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy,
+		      int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
+		      uint16_t *rx, uint16_t *ry)
+{
+	bool lt = (b & TB_LPADT) || (b & TB_LPADC),
+	     rt = (b & TB_RPADT) || (b & TB_RPADC);
+	*lx = touchHalfX(lpx, false);
+	*ly = touchYInv(lpy, touchH);
+	*rx = touchHalfX(rpx, true);
+	*ry = touchYInv(rpy, touchH);
+	if (lt && !(b & TB_LPADT)) {
+		*lx = TOUCH_PAD_W / 4;
+		*ly = touchH / 2;
+	}
+	if (rt && !(b & TB_RPADT)) {
+		*rx = TOUCH_PAD_W / 4 * 3;
+		*ry = touchH / 2;
+	}
+}
+
+// Map g_back[] paddle code -> Steam button flags (same codes as codeToXB / codeToSwitch).
+static void psOrBackCode(uint32_t *b, uint8_t c)
+{
+	switch (c) {
+	case 1:
+		*b |= TB_A;
+		break;
+	case 2:
+		*b |= TB_B;
+		break;
+	case 3:
+		*b |= TB_X;
+		break;
+	case 4:
+		*b |= TB_Y;
+		break;
+	case 5:
+		*b |= TB_LB;
+		break;
+	case 6:
+		*b |= TB_RB;
+		break;
+	case 7:
+		*b |= TB_L3;
+		break;
+	case 8:
+		*b |= TB_R3;
+		break;
+	case 9:
+		*b |= TB_VIEW;
+		break;
+	case 10:
+		*b |= TB_QAM;
+		break;
+	case 11:
+		*b |= TB_STEAM;
+		break;
+	case 12:
+		*b |= TB_DUP;
+		break;
+	case 13:
+		*b |= TB_DDN;
+		break;
+	case 14:
+		*b |= TB_DLF;
+		break;
+	case 15:
+		*b |= TB_DRT;
+		break;
+	case 16:
+		*b |= TB_TOUCH;
+		break;
+	case 17:
+		*b |= TB_MUTE;
+		break;
+	case 19:
+		*b |= TB_L2; // left trigger (L2)
+		break;
+	case 20:
+		*b |= TB_R2; // right trigger (R2)
+		break;
+	default:
+		break;
+	}
+}
+uint32_t psButtonsFromSteam(uint32_t raw)
+{
+	uint32_t b = raw;
+	if (g_qamMap && (b & TB_QAM)) {
+		b &= ~(uint32_t)TB_QAM;
+		b |= tritonFromCode(g_qamMap);
+	}
+	if ((b & CHORD_BACK4) == CHORD_BACK4)
+		b &= ~(uint32_t)(TB_A | TB_B | TB_X | TB_Y);
+	if (b & TB_L4)
+		psOrBackCode(&b, g_back[0]);
+	if (b & TB_R4)
+		psOrBackCode(&b, g_back[1]);
+	if (b & TB_L5)
+		psOrBackCode(&b, g_back[2]);
+	if (b & TB_R5)
+		psOrBackCode(&b, g_back[3]);
+	return b;
+}
+// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start). The analog trigger values come from
+// the per-slot `g_in[slot]` (pass lt/rt explicitly so a single shoulder byte never leaks across slots).
+uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt)
+{
+	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
+	       ((lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
+	       ((rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
+	       ((b & TB_MENU) ? 0x10 : 0) |
+	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
+	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
+}
+uint8_t psHatNibble(uint32_t b)
+{
+	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF, r = b & TB_DRT;
+	if (u && r)
+		return 1;
+	if (r && d)
+		return 3;
+	if (d && l)
+		return 5;
+	if (l && u)
+		return 7;
+	if (u)
+		return 0;
+	if (r)
+		return 2;
+	if (d)
+		return 4;
+	if (l)
+		return 6;
+	return 8;
+}
+uint8_t psFaceNibble(uint32_t b)
+{
+	uint8_t f = 0;
+	if (g_abSwap) {
+		if (b & TB_A)
+			f |= 0x40;
+		if (b & TB_B)
+			f |= 0x20;
+		if (b & TB_X)
+			f |= 0x80;
+		if (b & TB_Y)
+			f |= 0x10;
+	} else {
+		if (b & TB_A)
+			f |= 0x20;
+		if (b & TB_B)
+			f |= 0x40;
+		if (b & TB_X)
+			f |= 0x10;
+		if (b & TB_Y)
+			f |= 0x80;
+	}
+	return f;
+}

+ 97 - 0
OpenPuckZephyr/src/gamepad_util.h

@@ -0,0 +1,97 @@
+// gamepad_util.h -- shared helpers for building host USB gamepad reports from g_in.
+//
+// These are reused across the DualSense / DS4 / Switch personalities: analog-stick rescaling, Steam-trackpad
+// -> absolute-touch mapping, and the PlayStation-layout button/hat/face/shoulder packers. Per-target button
+// code maps (codeToXB / codeToSwitch / codeToJc) live with their own controllers; only the cross-mode pieces
+// are here.
+#pragma once
+#include <stdint.h>
+#include "triton.h"
+
+// int16 stick (center 0) -> uint8 (center 0x80), optional axis invert (HID Y is down-positive).
+uint8_t swStick(int16_t v, bool invert);
+
+// Write a signed 16-bit value little-endian.
+static inline void le16(uint8_t *p, int16_t v)
+{
+	p[0] = (uint8_t)(v & 0xFF);
+	p[1] = (uint8_t)((v >> 8) & 0xFF);
+}
+
+// Fill a DS4/DualSense motion-calibration feature report PAYLOAD. The TinyUSB GET_REPORT path already wrote the
+// report id and hands us the buffer PAST it, so our offsets = the kernel's buf[] index minus one (bias at 0..5,
+// stays zero from the caller's memset). Zero bias + symmetric non-zero ranges so hid-playstation's/hid-sony's
+// divisor (|plus-bias|+|minus-bias|, and gyro speed_plus+speed_minus) is never zero. Shared by report 0x02 (DS4)
+// and 0x05 (DualSense) -- identical layout; the caller returns size-1 and leaves trailing bytes zero.
+void psNeutralCalib(uint8_t *buf);
+
+// Steam trackpad s16 coords -> absolute touch surface. TOUCH_PAD_W is split into left/right halves so both
+// pads can co-exist as two contacts on a single DualSense/DS4 touchpad.
+#define TOUCH_PAD_W 1920u
+uint16_t padNormU16(int16_t v, uint16_t maxv);
+uint16_t touchHalfX(int16_t v, bool rightHalf);
+uint16_t touchYInv(int16_t v, uint16_t height);
+void touchPackPoint(uint8_t *base, int finger, bool touch, uint16_t x,
+		    uint16_t y);
+// DualSense / DS4: many hosts only honor contact slot 0 -- put a lone pad in slot 0, both pads in 0+1.
+void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch, uint16_t lx,
+		   uint16_t ly, uint16_t rx, uint16_t ry);
+void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy,
+		      int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
+		      uint16_t *rx, uint16_t *ry);
+
+// Convert configurable button code -> TB_* flag (shared across modes).
+static inline uint32_t tritonFromCode(uint8_t c)
+{
+	switch (c) {
+	case 1:
+		return TB_A;
+	case 2:
+		return TB_B;
+	case 3:
+		return TB_X;
+	case 4:
+		return TB_Y;
+	case 5:
+		return TB_LB;
+	case 6:
+		return TB_RB;
+	case 7:
+		return TB_L3;
+	case 8:
+		return TB_R3;
+	case 9:
+		return TB_VIEW;
+	case 10:
+		return TB_QAM;
+	case 11:
+		return TB_STEAM;
+	case 12:
+		return TB_DUP;
+	case 13:
+		return TB_DDN;
+	case 14:
+		return TB_DLF;
+	case 15:
+		return TB_DRT;
+	case 16:
+		return TB_TOUCH;
+	case 17:
+		return TB_MUTE;
+	case 19:
+		return TB_L2; // left trigger (LT / L2 / ZL)
+	case 20:
+		return TB_R2; // right trigger (RT / R2 / ZR)
+	default:
+		return 0;
+	}
+}
+
+// PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
+uint32_t psButtonsFromSteam(
+	uint32_t raw); // apply back-paddle + chord-guard + QAM remapping
+// L1..R3 + Create/Options byte. lt/rt are the per-slot analog trigger values so the digital ZL/ZR trip
+// doesn't leak across slots when several slots are active.
+uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt);
+uint8_t psHatNibble(uint32_t b); // d-pad -> 8-way hat (8 = neutral)
+uint8_t psFaceNibble(uint32_t b); // face buttons (with A/B + X/Y swap)

+ 537 - 0
OpenPuckZephyr/src/haptics.cpp

@@ -0,0 +1,537 @@
+#include "haptics.h"
+#include "bonds.h"
+#include "config.h"
+#include "rf_link.h"
+
+// USBDevice.suspended() -> autonomous controller power-off on host sleep
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+uint8_t g_relayOp = 0xE3; // E3 poll
+// relay sub-TLV TYPE byte. Vestigial: rfConnFlushRelay derives the on-air type from the report id (0x05 for
+// actuators/haptics <0x87, 0x01 for config/settings/LED >=0x87). Still exposed over WebUSB diagnostics.
+uint8_t g_relaySub = 0x05;
+volatile uint8_t g_testHaptic = 0;
+volatile uint8_t g_hapticStop = 0;
+// Per-slot reconnect block. 0 = idle; non-zero = drop haptics aimed at this slot until millis() catches up.
+unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
+
+// Controller power-off. CONFIRMED from a real Windows USB capture of the Valve puck: Steam's "turn off
+// controller" is the single feature-0x01 command 0x9F with payload ASCII "off!" (6F 66 66 21). The dongle
+// forwards host feature reports verbatim, so the controller acts on report 0x9F directly -- we relay it the
+// same way (E3 SET sub-TLV). The wire relay is NO-ACK, so send a small burst: a single lost frame must not
+// leave the controller on.
+void hapticSendShutdown()
+{
+	static const uint8_t OFF[4] = { 0x6f, 0x66, 0x66, 0x21 }; // "off!"
+	// broadcast: every connected controller should power off, not just the slot the shutdown was triggered
+	// from (the trigger can come from a Steam interface, the test button, or host-suspend -- any of which
+	// "logically" means "all controllers off").
+	for (uint8_t i = 0; i < HAPTIC_SHUTDOWN_SHOTS; i++)
+		relayEnqueue(0x9F, OFF, sizeof OFF, 0xFF);
+}
+
+// millis of last 0x82 haptic OUTPUT relayed (Steam mode)
+static unsigned long g_haptic82Ms = 0;
+
+// a non-zero 0x82 haptic is currently active (awaiting host stop)
+static bool g_haptic82On = false;
+
+// millis of last translated host rumble (0x80), per-slot (4 XInput interfaces each have their own stream)
+static unsigned long g_rumble80Ms[NSLOT] = { 0 };
+
+// Steam/Triton rumble is latched on until an explicit zero report; tracked per-slot so each controller's
+// stuck-rumble watchdog is independent
+static bool g_rumble80On[NSLOT] = { false, false, false, false };
+
+// when to fire the next post-reconnect haptic re-init (0 = none scheduled)
+static unsigned long g_reinitAt = 0;
+
+// how many re-init shots remain in this connect window
+static uint8_t g_reinitLeft = 0;
+
+// haptic activity happened -> arm a clear once it goes idle (catches a
+// latch that engaged during/after use, even seconds after connect)
+static bool g_hapClearArmed = false;
+
+// ---- relay rings: one per bond slot. Multi-producer (USB ISR + loop-context console/xinput), one consumer
+// per slot (rfConnFlushRelay on that slot's poll turn). Producers serialize under PRIMASK.
+struct RelayMsg {
+	uint8_t rid, len;
+	uint8_t data[RELAY_MAXP];
+};
+// deep enough to hold a full Steam settings/LED transaction burst without loss
+#define RELAY_QLEN 32
+static RelayMsg g_rq[NSLOT][RELAY_QLEN];
+static volatile uint8_t g_rqHead[NSLOT];
+static volatile uint8_t
+	g_rqTail[NSLOT]; // head=next write, tail=next read; empty when equal
+static inline uint8_t rqNext(uint8_t i)
+{
+	return (uint8_t)((i + 1) % RELAY_QLEN);
+}
+
+bool relayPending()
+{
+	// check the current slot's queue; called from rfConnQueueHapticRelay which runs with g_curSlot set
+	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	return g_rqHead[cur] != g_rqTail[cur];
+}
+bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
+		  uint8_t slot)
+{
+	if (plen > RELAY_MAXP)
+		plen = RELAY_MAXP;
+	if (slot != 0xFF && slot >= NSLOT)
+		return false;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	// slot=0xFF: broadcast -- enqueue into every slot's ring.
+	// Full queue: evict the oldest entry, never the newest. Steam bursts end with the commit/stop, so
+	// dropping the oldest keeps the most-recent (meaningful) frame.
+	uint8_t s0 = (slot == 0xFF) ? 0 : slot;
+	uint8_t s1 = (slot == 0xFF) ? NSLOT : slot + 1;
+	for (uint8_t s = s0; s < s1; s++) {
+		uint8_t h = g_rqHead[s], nx = rqNext(h);
+		if (nx == g_rqTail[s])
+			g_rqTail[s] = rqNext(g_rqTail[s]);
+		g_rq[s][h].rid = rid;
+		g_rq[s][h].len = plen;
+		if (plen)
+			memcpy(g_rq[s][h].data, payload, plen);
+		g_rqHead[s] = nx;
+	}
+	// Any haptic relay arms the idle-clear (so the during-use latch gets cleared in every mode).
+	if (rid == 0x82 || rid == 0x80) {
+		g_haptic82Ms = millis();
+		g_hapClearArmed = true;
+	}
+	__set_PRIMASK(pm);
+	return true;
+}
+
+#if OPK_LOG
+// diagnostic capture: a ring of the last OUTPUT reports Steam sends (rid/slot/bytes/ms), dumped with 'H'.
+struct HapLog {
+	uint32_t ms;
+	uint8_t slot, rid, n, b[16];
+}; // 16 payload bytes: capture full 0x87 settings frames
+// Big always-on ring: log EVERYTHING from boot (Steam writes, our TX-to-controller, link edges) so a rare
+// reconnect-buzz can be caught after the fact -- the trigger happens moments after boot, while this RAM is
+// fresh, and we dump it once the panel reconnects. 4096 * 20B ~= 80KB.
+#define HAPLOG_N 4096
+static HapLog g_hapLog[HAPLOG_N];
+static uint16_t g_hapHead = 0;
+static uint16_t g_hapTail =
+	0; // live/dump drain cursor (loop-context reader; chases g_hapHead)
+
+void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t *b, uint16_t n)
+{
+	// Written from the USB SET ISR (handleSet) AND loop context (relay flush / link edges) -> guard g_hapHead.
+	// Special slot markers for the diagnostic capture: 0xFE = a frame WE transmitted to the controller (TX
+	// relay); 0xFD = a link state edge (b[0]=1 up, 0 down). Real Steam writes use the interface index (0..3).
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	HapLog &e = g_hapLog[g_hapHead];
+	e.ms = millis();
+	e.slot = slot;
+	e.rid = rid;
+	e.n = (uint8_t)(n > 255 ? 255 : n);
+	for (int i = 0; i < 16; i++)
+		e.b[i] = (i < (int)n) ? b[i] : 0;
+	g_hapHead = (uint8_t)((g_hapHead + 1) %
+			      (sizeof g_hapLog / sizeof g_hapLog[0]));
+	__set_PRIMASK(pm);
+}
+void hapticDumpLog()
+{
+	const uint16_t N = HAPLOG_N;
+	uint32_t now = millis();
+	Serial.printf("# --- capture history (now=%lu, curSlot=%d) ---\n",
+		      (unsigned long)now, g_curSlot);
+	for (uint16_t i = 0; i < N; i++) {
+		HapLog &e = g_hapLog[(uint16_t)((g_hapHead + i) % N)];
+		if (!e.ms && !e.rid)
+			continue;
+		Serial.printf("# -%lums if%u rid=%02X n=%u:",
+			      (unsigned long)(now - e.ms), e.slot, e.rid, e.n);
+		for (uint8_t j = 0; j < 16 && j < e.n; j++)
+			Serial.printf(" %02X", e.b[j]);
+		Serial.println();
+	}
+	Serial.println("# --- end ---");
+}
+// ---- drain cursor: stream entries to the WebUSB panel. resetDrain(false)=from "now" (live only);
+//      resetDrain(true)=from the OLDEST entry (dump the whole ring from boot). pull skips empty slots. ----
+void hapLogResetDrain(bool fromBoot)
+{
+	// oldest slot (the next-to-overwrite holds it)
+	g_hapTail = fromBoot ? (uint16_t)((g_hapHead + 1) % HAPLOG_N) :
+			       // "now"
+			       g_hapHead;
+}
+bool hapLogPull(uint32_t *logMs, uint8_t *slot, uint8_t *rid, uint8_t *n,
+		uint8_t bytes16[16])
+{
+	while (g_hapTail != g_hapHead) {
+		HapLog &e = g_hapLog[g_hapTail];
+		g_hapTail = (uint16_t)((g_hapTail + 1) % HAPLOG_N);
+		if (!e.ms && !e.rid)
+			continue; // skip empty slot (ring not yet full)
+		// Return the ABSOLUTE log time (millis since boot), not "age now". The panel drains in batches 100ms
+		// apart, so an age computed here would jump between batches; the panel computes age vs the newest entry.
+		*logMs = e.ms;
+		*slot = e.slot;
+		*rid = e.rid;
+		*n = (e.n > 16) ? 16 : e.n;
+		memcpy(bytes16, e.b, 16);
+		return true;
+	}
+	return false;
+}
+#endif // OPK_LOG
+
+// Per-slot helpers. slot==-1 (default) checks the CURRENT poll slot (g_curSlot), used by flush-time code
+// paths that don't have a slot in hand. Callers with a real slot pass it in.
+bool hapticLinkUp(int slot)
+{
+	int s = (slot >= 0) ? slot : g_curSlot;
+	if (s < 0 || s >= NSLOT)
+		return false;
+	return g_slot[s].used && (millis() - g_connReplyMs[s]) < 300;
+}
+bool haptic82Blocked(int slot)
+{
+	int s = (slot >= 0) ? slot : g_curSlot;
+	if (s < 0 || s >= NSLOT)
+		return true;
+	return !hapticLinkUp(s) ||
+	       (g_hapticBlockUntil[s] &&
+		(int32_t)(millis() - g_hapticBlockUntil[s]) < 0);
+}
+// "Is haptics from this USB interface's slot allowed through?" -- the slot must be currently connected.
+bool hapticRelaySlotOk(int slot)
+{
+	return slot >= 0 && slot < NSLOT && hapticLinkUp(slot);
+}
+static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
+{
+	if (n < 3)
+		return false;
+	for (uint16_t i = 2; i < n; i++)
+		if (p[i])
+			// observed form is [01 01 gain], but treat any trailing non-zero as active
+			return true;
+	return false;
+}
+static void hapticCancelPendingOn()
+{
+	// void queued ON entries across all slot queues (stale haptics / rumble across a reconnect)
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	for (int s = 0; s < NSLOT; s++) {
+		for (uint8_t i = g_rqTail[s]; i != g_rqHead[s]; i = rqNext(i)) {
+			RelayMsg &m = g_rq[s][i];
+			if (m.rid == 0x82) {
+				bool on = false;
+				for (uint8_t j = 2; j < m.len; j++)
+					if (m.data[j]) {
+						on = true;
+						break;
+					}
+				if (on)
+					m.rid = 0;
+			}
+			if (m.rid == 0x80) {
+				bool on = false;
+				for (uint8_t j = 0; j < m.len; j++)
+					if (m.data[j]) {
+						on = true;
+						break;
+					}
+				if (on)
+					m.rid = 0;
+			}
+		}
+	}
+	__set_PRIMASK(pm);
+}
+void haptic82HostReport(const uint8_t *p, uint16_t n)
+{
+	if (n < 3)
+		return;
+	g_haptic82Ms = millis();
+
+	// any haptic activity arms a clear when it next goes idle (kills a latch from this use)
+	g_hapClearArmed = true;
+	// Track on/off only. Do NOT synthesize a stop burst: Steam's own stop is forwarded verbatim, and each extra
+	// 0x82 is a discrete pad click -- the spurious end-of-movement "click"/buzz the real puck never produces.
+	g_haptic82On = haptic82PayloadOn(p, n);
+}
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
+{
+	if (slot >= NSLOT)
+		return false;
+	// user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
+	if (g_rumbleScale != 100) {
+		uint32_t l = (uint32_t)lowFreq * g_rumbleScale / 100,
+			 h = (uint32_t)highFreq * g_rumbleScale / 100;
+		lowFreq = (l > 0xFFFF) ? 0xFFFF : (uint16_t)l;
+		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
+	}
+	bool on = lowFreq || highFreq;
+	// Per-slot settle gate (the per-slot reconnect block + link-up check). 0x82 haptics in Steam mode use the
+	// same gate; for XInput, the host only sends a stream while a controller is connected, so this also doubles
+	// as "no controller here, no relay".
+	if (on && haptic82Blocked(slot))
+		return false;
+	if (!on && !hapticLinkUp(slot))
+		return false;
+
+	// SDL's current Steam/Triton structs define output report 0x80 as:
+	//   type, uint16 intensity, {uint16 speed, int8 gain} left/right.
+	// We map conventional gamepad low/high-frequency motors to left/right speeds and use max as intensity.
+	uint16_t intensity = lowFreq > highFreq ? lowFreq : highFreq;
+	uint8_t p[9];
+
+	// haptic_type_t::HAPTIC_TYPE_RUMBLE; 0 is the off/zero report
+	p[0] = on ? 0x04 : 0x00;
+	p[1] = (uint8_t)(intensity & 0xFF);
+	p[2] = (uint8_t)(intensity >> 8);
+	p[3] = (uint8_t)(lowFreq & 0xFF);
+	p[4] = (uint8_t)(lowFreq >> 8);
+	p[5] = 0;
+	p[6] = (uint8_t)(highFreq & 0xFF);
+	p[7] = (uint8_t)(highFreq >> 8);
+	p[8] = 0;
+	if (!relayEnqueue(0x80, p, sizeof p, slot))
+		return false;
+	g_rumble80Ms[slot] = millis();
+	g_rumble80On[slot] = on;
+	return true;
+}
+// Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate). Test
+// haptics broadcast to all connected slots (slot 0xFF); the stop frame is broadcast too (a stuck latch can
+// affect any controller, and the haptic-engine clear-re-init is settings-only so it's harmless on healthy
+// ones).
+void rfConnQueueHapticRelay()
+{
+	if (relayPending())
+		return; // host relays first; injectables wait for an idle cycle
+	static const uint8_t HAP_ON[3] = { 0x01, 0x01, 0xF7 };
+	static const uint8_t HAP_OFF[3] = { 0x01, 0x01, 0x00 };
+	if (g_testHaptic) {
+		if (relayEnqueue(0x82, HAP_ON, 3, 0xFF))
+			g_testHaptic--;
+	} else if (g_hapticStop && !g_xbox) {
+		if (relayEnqueue(0x82, HAP_OFF, 3, 0xFF))
+			g_hapticStop--;
+	}
+}
+// rfConnFlushRelay(ch, s1): drain one entry from the current slot's relay queue and TX it. Each slot's queue
+// is independent, so each controller only sees its own commands. With N connected slots the per-slot relay
+// rate is 1/N of the per-cycle rate; sustained buzz streams are still 1 packet/cycle/slot.
+void rfConnFlushRelay(uint8_t ch, uint8_t s1)
+{
+	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	while (g_rqTail[cur] != g_rqHead[cur]) {
+		RelayMsg &m = g_rq[cur][g_rqTail[cur]];
+
+		// rid 0 = entry voided by hapticCancelPendingOn -> skip
+		if (m.rid) {
+			uint8_t rl = m.len;
+			if (rl > RELAY_MAXP)
+				rl = RELAY_MAXP;
+			// On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs: a command LANDS on
+			// the controller only with the type-01 + inner-len form E3 [2+rl][01][rid][innerlen][data];
+			// the legacy form E3 [1+rl][05][rid][data] makes the controller DISCARD any 0x87+ command.
+			//
+			// Whitelist type-01 to only two commands: LED brightness (0x87 reg 0x2D) and power-off
+			// (0x9F). Other 0x87 writes (e.g. reg 0x30 IMU enable, 0x34/0x35 haptic amplitude) must
+			// stay on legacy form -- landing 0x30 freezes the gyro; landing 0x34/0x35 causes the buzz.
+			bool land01 =
+				(m.rid == 0x9F) ||
+				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
+			uint8_t p[5 + RELAY_MAXP], plen;
+			if (land01) {
+				p[0] = g_relayOp;
+				p[1] = (uint8_t)(2 + rl);
+				p[2] = 0x01;
+				p[3] = m.rid;
+				p[4] = rl;
+				memcpy(p + 5, m.data, rl);
+				plen = (uint8_t)(5 + rl);
+			} else {
+				p[0] = g_relayOp;
+				p[1] = (uint8_t)(1 + rl);
+				p[2] = 0x05;
+				p[3] = m.rid;
+				memcpy(p + 4, m.data, rl);
+				plen = (uint8_t)(4 + rl);
+			}
+			hapLogAdd(0xFE, m.rid, m.data, rl);
+			// release slot before TX
+			g_rqTail[cur] = rqNext(g_rqTail[cur]);
+			// s1 carries a PID distinct from the GET poll (caller cycles it) so the controller's ESB
+			// dedup never treats the GET as a retransmit of this relay. 80us RX: relay is NO-ACK.
+			rfConnTx(ch, s1, p, plen, 80);
+			return; // one relay per poll cycle
+		}
+		g_rqTail[cur] = rqNext(g_rqTail[cur]);
+	}
+}
+
+// Haptic-subsystem re-init: the captured sequence Steam sends when it (re)takes control (0x81 reset + 0x87
+// register writes). Brightness (reg 0x2D) is omitted to avoid stomping the LED. The 0x87 frames go out on
+// legacy framing so the controller discards them; the three 0x81 frames are the effective reset. slot=0xFF
+// broadcasts to all connected controllers (the re-init is settings-only and harmless on healthy controllers).
+// One-shot SET_SETTINGS (0x87) write of the controller's GLOBAL trackpad-haptics enable. The controller plays
+// touchpad haptic sequences autonomously (haptics-sequencer-touchpad) gated by settings/haptics/enabled; in the
+// emulated (non-Steam) modes the host never sends SC haptic reports, so the only way to silence the pad buzz is
+// to write this setting. Wire format [id][val u16 LE] -- confirmed from the controller's SET_SETTINGS handler
+// (decomp FUN_0001f61c; id 0x30 == settings/sensors/imu/mode is the cross-check anchor in the same id space).
+// The exact id for settings/haptics/enabled needs a one-time `scmd labels` HW read (candidates from the captured
+// reinit: 0x18 / 0x2e / 0x34 / 0x35). Until it is set, SETTING_HAPTICS_ENABLED stays 0xFF and this is inert.
+#define SETTING_HAPTICS_ENABLED 0xFF
+void hapticSetPadEnabled(uint8_t slot, bool on)
+{
+#if SETTING_HAPTICS_ENABLED != 0xFF
+	uint8_t pl[3] = { SETTING_HAPTICS_ENABLED, (uint8_t)(on ? 1 : 0), 0 };
+	relayEnqueue(0x87, pl, 3, slot);
+#else
+	(void)slot;
+	(void)on;
+#endif
+}
+void hapticReinit(uint8_t slot)
+{
+	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
+				       0x00, 0x08, 0x07, 0x00, 0x31,
+				       0x02, 0x00, 0x52, 0x03, 0x00 };
+	static const uint8_t H18[] = { 0x18, 0x00, 0x00, 0x2e, 0x00,
+				       0x00, 0x34, 0xff, 0xff, 0x35,
+				       0xff, 0xff, 0x34, 0xff, 0xff };
+	static const uint8_t H35[] = { 0x35, 0xff, 0xff, 0x2e, 0x00, 0x00 };
+	static const uint8_t T81A[] = {
+		0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+	};
+	static const uint8_t T81B[] = {
+		0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+	};
+	// reset action (FUN_0001f554) -- Steam sends this first
+	relayEnqueue(0x81, nullptr, 0, slot);
+	relayEnqueue(0x87, H30, sizeof H30, slot);
+
+	// haptic config (enabled/amplifier/gain): the part that clears a latch
+	relayEnqueue(0x87, H18, sizeof H18, slot);
+	relayEnqueue(0x87, H35, sizeof H35, slot);
+	relayEnqueue(0x81, T81A, sizeof T81A, slot);
+	relayEnqueue(0x81, T81B, sizeof T81B, slot);
+	// Re-apply the active emulated type's trackpad-haptics preference last (after the haptic-config writes
+	// above, which would otherwise re-enable it). Default-on types send "enable"; Switch (padHaptics=0)
+	// disables. Inert until the setting id is captured.
+	hapticSetPadEnabled(slot, g_padHaptics != 0);
+	// Apply the configured LED brightness for the active emulated type. Steam sets the
+	// brightness each session; emulated modes never do, so the controller comes up at
+	// full brightness. 0 = no override (preserve controller default).
+	if (g_etype < ET_COUNT && g_ledBright > 0) {
+		uint8_t pl[3] = { 0x2D, g_ledBright, 0x00 };
+		relayEnqueue(0x87, pl, sizeof pl, slot);
+	}
+}
+void hapticInit()
+{
+	g_haptic82On = false;
+	g_hapticStop = 0;
+	for (int s = 0; s < NSLOT; s++) {
+		g_rqHead[s] = g_rqTail[s] = 0;
+		g_rumble80On[s] = false;
+		g_rumble80Ms[s] = 0;
+		// block stale Steam 0x82 until the link is stable after boot
+		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	}
+}
+// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called on the reliable first-reply
+// signal from rf_link and again from hapticTask's link-up edge detector. Per-slot: only the reconnected
+// slot is blocked; the broadcast re-init covers all slots.
+void hapticOnReconnect(int slot)
+{
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	g_haptic82On = false;
+	g_rumble80On[slot] = false;
+	g_rumble80Ms[slot] = 0;
+	hapticCancelPendingOn();
+	// Re-init repeatedly across the settle window: the connect buzz engages early (during the block,
+	// controller-internal), so a single late shot misses it.
+	g_reinitAt = millis() + 200u;
+	g_reinitLeft = HAPTIC_REINIT_SHOTS;
+	uint8_t mk = 2;
+	hapLogAdd(0xFD, 0xEE, &mk, 1);
+}
+void hapticTask()
+{
+	// Per-slot link-edge detect (backup for hapticOnReconnect in rf_link).
+	static bool wasHapticLinkUp[NSLOT] = { 0 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_slot[s].used)
+			continue;
+		bool up = hapticLinkUp(s);
+		if (up && !wasHapticLinkUp[s]) {
+			uint8_t mk = 1;
+			hapLogAdd(0xFD, 0xEE, &mk, 1);
+			hapticOnReconnect(s);
+		}
+		if (!up && wasHapticLinkUp[s]) {
+			uint8_t mk = 0;
+			hapLogAdd(0xFD, 0xEE, &mk, 1);
+		}
+		wasHapticLinkUp[s] = up;
+	}
+	if (g_reinitAt && anySlotLinkUp() &&
+	    (int32_t)(millis() - g_reinitAt) >= 0) {
+		hapticReinit();
+		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
+				     (millis() + HAPTIC_REINIT_GAP_MS) :
+				     0;
+	}
+	// Power-off on host sleep: only when VBUS is present (genuine sleep, not a cable unplug which also
+	// trips the suspend edge briefly) AND the suspend has PERSISTED >= SUSPEND_OFF_MS. A brief USB
+	// selective-suspend (host idle power-management) resumes in <1s; firing the power-off on its edge
+	// powered the controllers off ourselves -> random drop/reconnect churn. Arm only on a genuine
+	// resume->suspend edge (wasSusp=true at boot suppresses a false fire on boot-into-suspended).
+	static bool wasSusp = true;
+	static unsigned long suspSinceMs = 0;
+	static bool suspArmed = false;
+	bool susp = USBDevice.suspended();
+	bool vbus = (NRF_POWER->USBREGSTATUS &
+		     POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0;
+	if (susp && !wasSusp) {
+		suspSinceMs = millis();
+		suspArmed = true;
+	}
+	if (!susp)
+		suspArmed = false;
+	if (suspArmed && vbus && (millis() - suspSinceMs) >= SUSPEND_OFF_MS) {
+		hapticSendShutdown();
+		suspArmed = false; // fire once per suspend
+	}
+	wasSusp = susp;
+	// Steam-mode quiet timeout: mark 0x82 stream inactive. No synthesized stop -- Steam forwards its own.
+	if (!g_xbox && g_haptic82On &&
+	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
+		g_haptic82On = false;
+	// Per-slot stuck-rumble watchdog: force zero after 2.5s without a refresh.
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)
+			hapticSteamRumble(0, 0, (uint8_t)s);
+	}
+	// Idle-clear: after haptic activity goes quiet, re-init once to clear any latch left behind.
+	if (g_hapClearArmed &&
+	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
+		g_hapClearArmed = false;
+		hapticReinit();
+	}
+}

+ 120 - 0
OpenPuckZephyr/src/haptics.h

@@ -0,0 +1,120 @@
+// haptics.h -- host -> controller relay queue (haptics + settings) + the watchdogs that stop a stuck buzz.
+//
+// Steam and translated host rumble send OUTPUT/feature reports the real dongle forwards to the controller as
+// a SET sub-TLV inside the E3 poll. We do the same: handleSet()/the console enqueue with relayEnqueue(), and
+// rfConnFlushRelay() emits ONE entry per poll cycle (rf_link.cpp), never at raw loop rate.
+//
+// One ring per bond slot: the consumer (rfConnFlushRelay) only drains the current slot's queue, so commands
+// addressed to other slots are never consumed out of turn. ISR producers write under PRIMASK; the consumer
+// runs in loop context and never races the same slot's head pointer.
+//
+// The controller's haptic LATCHES until told to stop. If the host's stop is lost over RF (or the link drops
+// mid-buzz) the actuator whines forever -- so we send 0x82-zero stop bursts on reconnect, but ONLY when a
+// haptic was actually active when the link dropped: every extra 0x82 frame is an audible click on the
+// controller (captured), so an unconditional burst on each link-up edge is itself a buzz source on a flapping
+// link. The g_hapLog ring captures recent OUTPUT reports for the 'H' dump.
+#pragma once
+#include <stdint.h>
+#include "config.h" // OPK_LOG
+#include "bonds.h" // NSLOT
+
+// after this much host silence, consider the current 0x82 haptic stream inactive
+#define HAPTIC_QUIET_MS 300u
+// Block ALL haptic relays for this long after a (re)connect: a just-powered-on controller's haptic engine
+// isn't ready, and feeding it haptics in that window leaves it in a degraded/latched state (slow/stuck/missing
+// haptics until a re-init). 3s gives it time to initialize.
+#define HAPTIC_RECONNECT_BLOCK_MS 3000u
+// 0x82-zero relays per stop event (sent at poll cadence -- not loop rate)
+#define HAPTIC_STOP_BURST 4u
+// max relayed payload bytes per entry: RF frame = [E3][len][05][rid][payload] and MAXLEN=64 -> 60
+#define RELAY_MAXP 60u
+// Proactive post-(re)connect haptic re-init: this many shots, this far apart, starting ~200ms after the link
+// comes up -- covers ~0.2s..3s so the brief controller-side connect buzz gets reset before it can sustain.
+#define HAPTIC_REINIT_SHOTS 8u
+#define HAPTIC_REINIT_GAP_MS 350u
+// After haptic activity, if it's been idle this long, fire one clear-re-init -- kills a latch that engaged
+// during use (a buzz that starts seconds after connect and won't self-clear). Long enough not to fire between
+// rapid in-game haptics; short enough to clear a stuck buzz soon after the user pauses.
+#define HAPTIC_CLEAR_IDLE_MS 1200u
+// Controller power-off: hapticSendShutdown() relays Steam's confirmed "turn off controller" command (feature-0x01
+// cmd 0x9F, payload "off!" -- captured from the real puck). Sent as a small burst because the RF relay is NO-ACK.
+#define HAPTIC_SHUTDOWN_SHOTS 3u
+
+// ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
+// Enqueue one host->controller report. `slot` = bond slot (0..NSLOT-1) or 0xFF to broadcast to every
+// connected controller (used by hapticSendShutdown / hapticReinit / test haptics). ISR-safe (PRIMASK).
+bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
+		  uint8_t slot = 0xFF);
+
+// anything still queued (xinput uses it to pace rumble re-queues)
+bool relayPending();
+extern uint8_t g_relayOp; // relay frame opcode (E3 poll)
+extern uint8_t g_relaySub; // relay sub-TLV type byte = SET
+extern volatile uint8_t g_testHaptic; // 't<n>' injects n test haptics
+// pending haptic-STOP frames to relay (kill a latched whine)
+extern volatile uint8_t g_hapticStop;
+// Per-slot block: arm after a (re)connect, drop haptics aimed at the slot for HAPTIC_RECONNECT_BLOCK_MS.
+extern unsigned long g_hapticBlockUntil[NSLOT];
+
+// relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
+void hapticSendShutdown();
+
+// ---- diagnostic capture (compiled in only when OPK_LOG): a ring of recent host->controller commands +
+//      link/TX markers, dumped over WebUSB. No-ops in a production build so call sites vanish. ----
+#if OPK_LOG
+void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t *b, uint16_t n);
+void hapticDumpLog(); // 'H' console dump of the recent OUTPUT-report history
+// WebUSB capture drain: resetDrain(true) rewinds to the OLDEST entry (dump the whole ring from boot);
+// resetDrain(false) starts at "now" (live only). pull yields each entry once, oldest->newest, skipping empties.
+void hapLogResetDrain(bool fromBoot);
+bool hapLogPull(uint32_t *logMs, uint8_t *slot, uint8_t *rid, uint8_t *n,
+		uint8_t bytes16[16]);
+#else
+static inline void hapLogAdd(uint8_t, uint8_t, const uint8_t *, uint16_t)
+{
+}
+static inline void hapticDumpLog()
+{
+}
+static inline void hapLogResetDrain(bool)
+{
+}
+static inline bool hapLogPull(uint32_t *, uint8_t *, uint8_t *, uint8_t *,
+			      uint8_t *)
+{
+	return false;
+}
+#endif
+
+bool hapticLinkUp(int slot = -1);
+bool haptic82Blocked(int slot = -1);
+bool hapticRelaySlotOk(int slot);
+void haptic82HostReport(const uint8_t *p, uint16_t n);
+// queue a Steam/Triton 0x80 rumble frame. `slot` = bond slot of the originating controller (0..NSLOT-1);
+// defaults to 0 for the legacy single-controller callers. Per-slot so each connected controller can have its
+// own active rumble stream when the host presents multiple gamepads (e.g. 4 XInput devices).
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
+
+// queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
+// rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
+// PID counter), so the controller doesn't dedup the GET as a retransmit of the relay.
+void rfConnQueueHapticRelay();
+void rfConnFlushRelay(uint8_t ch, uint8_t s1);
+
+// boot reset: clear relay/active flags, arm the reconnect block
+void hapticInit();
+
+// per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
+void hapticTask();
+
+// replay Steam's haptic-subsystem re-init to the controller -> clears a latched/stuck buzz.
+// `slot` defaults to 0xFF (broadcast to all connected) -- the re-init is a settings-only reset and is
+// harmless on healthy controllers, so it's worth re-initializing every slot the firmware knows about.
+void hapticReinit(uint8_t slot = 0xFF);
+// Called from rf_link the instant a controller (re)connect is detected (an F-reply after a gap): blocks haptic
+// relays for HAPTIC_RECONNECT_BLOCK_MS and schedules a re-init just after, to keep the freshly-booted
+// controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
+// Per-slot: only the slot that just reconnected is blocked, the others keep relaying.
+void hapticOnReconnect(int slot);
+// Write the controller's global trackpad-haptics enable setting (per active emulated type). slot 0xFF = broadcast.
+void hapticSetPadEnabled(uint8_t slot, bool on);

+ 23 - 0
OpenPuckZephyr/src/identity.cpp

@@ -0,0 +1,23 @@
+#include "identity.h"
+#include <Arduino.h>
+#include <stdio.h>
+#include <string.h>
+
+char g_unit[16];
+char g_board[16];
+char g_usbSerial[18];
+
+const uint8_t ATTR83[25] = { // 0x83 attributes (product 0x1304 = Proteus puck)
+	0x01, 0x04, 0x13, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
+	0x00, 0x0A, 0xF2, 0xF9, 0xD2, 0x68, 0x04, 0x53, 0xD0,
+	0x18, 0x6A, 0x09, 0x47, 0x00, 0x00, 0x00
+};
+
+void genSerial()
+{
+	uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
+	snprintf(g_unit, sizeof g_unit, "FXB99602%05lX",
+		 (unsigned long)(id & 0xFFFFF));
+	snprintf(g_board, sizeof g_board, "MXB99602%05lX",
+		 (unsigned long)(id & 0xFFFFF));
+}

+ 16 - 0
OpenPuckZephyr/src/identity.h

@@ -0,0 +1,16 @@
+// identity.h -- device identity derived from the nRF52 FICR DEVICEID.
+//
+// A UNIQUE serial (never clashes with a real puck) is generated at boot. Steam mode reports g_unit as its
+// USB serial (its pairing identity); the other modes append a 1-char suffix (setup() in OpenPuck.ino).
+#pragma once
+#include <stdint.h>
+
+extern char g_unit[16]; // "FXB99602xxxxx"
+extern char g_board[16]; // "MXB99602xxxxx"
+extern char g_usbSerial
+	[18]; // per-mode USB serial (Steam uses g_unit; others get a suffix)
+
+// 0x83 attributes, returned by the puck feature command channel.
+extern const uint8_t ATTR83[25];
+
+void genSerial();

+ 19 - 0
OpenPuckZephyr/src/main.c

@@ -0,0 +1,19 @@
+/*
+ * OpenPuck — Zephyr port entry point (bring-up stub).
+ *
+ * This will grow into the cooperative pump that the Arduino loop() was. For now
+ * it only proves the toolchain + board build and links.
+ */
+#include <zephyr/kernel.h>
+#include <zephyr/logging/log.h>
+
+LOG_MODULE_REGISTER(openpuck, LOG_LEVEL_INF);
+
+int main(void)
+{
+	LOG_INF("OpenPuck Zephyr port: bring-up stub up");
+	while (1) {
+		k_sleep(K_SECONDS(1));
+	}
+	return 0;
+}

+ 241 - 0
OpenPuckZephyr/src/mode_hidgyro.cpp

@@ -0,0 +1,241 @@
+#include "mode_hidgyro.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "usb_mount.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+HidGyroController g_hidGyroCtl;
+
+static const uint8_t GYRO_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09,
+	0x31, 0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x25, 0x07, 0x75, 0x04,
+	0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05, 0x09, 0x19, 0x01, 0x29,
+	0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0E, 0x81, 0x02,
+	0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95, 0x01, 0x15, 0x00,
+	0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09, 0x34, 0x15,
+	0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, 0x06,
+	0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
+	0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24,
+	0xB1, 0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0xC0
+};
+#define DS4_TOUCH_H 942
+#define DS4_STATUS_USB 0x1B // cable + level 11 (full)
+static unsigned long g_gyroLastMs[NSLOT] = { 0 };
+// NSLOT HID instances: 4 DS4 gamepads on the wire, one per bond slot. The host enumerates each as a
+// separate input device (hid-playstation, hid-sony, SDL, Steam Input).
+static Adafruit_USBD_HID g_hidGyro[NSLOT];
+
+// Per-slot MAC base: 4 distinct NICs so the host sees 4 different devices (real DS4s have unique MACs).
+// OUI 0x001BDC is Sony's; vary the last byte per slot.
+static const uint8_t DS4_MAC_BASE[5] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55 };
+static uint8_t g_ds4Mac[NSLOT][6];
+static bool g_ds4MacInit = false;
+static void initDs4Macs()
+{
+	if (g_ds4MacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_ds4Mac[s], DS4_MAC_BASE, 5);
+		g_ds4Mac[s][5] = (uint8_t)(0x50 + s); // 0x50, 0x51, 0x52, 0x53
+	}
+	g_ds4MacInit = true;
+}
+
+// GET_FEATURE handler. Per-slot dispatch via per-instance callback (ps5Get##N passes the slot index).
+// Sizes per drivers/hid/hid-playstation.c: 0x02=37, 0x12=16, 0xA3=49; legacy hid-sony MAC 0x81=7.
+static uint16_t hidGyroGetCommon(uint8_t slot, uint8_t rid,
+				 hid_report_type_t type, uint8_t *buf,
+				 uint16_t reqlen)
+{
+	(void)slot;
+	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
+		return 0;
+	memset(buf, 0, reqlen);
+	switch (rid) {
+	case 0x02: // motion calibration (37 incl id)
+		if (reqlen < 36)
+			return 0;
+		psNeutralCalib(buf);
+		return 36;
+	case 0x12: // pairing info / MAC, hid-playstation (16 incl id)
+		if (reqlen < 15)
+			return 0;
+		memcpy(buf, g_ds4Mac[slot], 6);
+		return 15;
+	case 0x81: // MAC, legacy hid-sony USB (7 incl id)
+		if (reqlen < 6)
+			return 0;
+		memcpy(buf, g_ds4Mac[slot], 6);
+		return 6;
+	case 0xA3: // firmware / hardware info (49 incl id)
+		if (reqlen < 48)
+			return 0;
+		// non-zero version (contents not validated over USB)
+		buf[0] = 0x01;
+		return 48;
+	default:
+		return 0;
+	}
+}
+static void hidGyroSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			     uint8_t const *b, uint16_t n)
+{
+	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
+		return;
+	uint8_t id;
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		id = b[0];
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} else {
+		id = rid;
+		p = b;
+		pn = n;
+	}
+	// DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
+	if (id != 0x05 || pn < 5)
+		return;
+	// `slot` is the USB slot the report arrived on -> route rumble to its mapped bond slot.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
+	hapticSteamRumble((uint16_t)p[4] * 257u, (uint16_t)p[3] * 257u,
+			  (uint8_t)bond); // DS4: left=low, right=high
+}
+// Per-slot callback trampolines -- the Adafruit HID class has ONE _get/_set pair shared across instances,
+// and the lib's tud_hid_*_cb doesn't pass the interface index to the user callback. Generate a
+// per-instance trampoline that closes over the slot index, matching the pattern in puck_hid.cpp.
+#define HIDGYROCB(N)                                                  \
+	static uint16_t hidGyroGet##N(uint8_t r, hid_report_type_t t, \
+				      uint8_t *bf, uint16_t rl)       \
+	{                                                             \
+		return hidGyroGetCommon(N, r, t, bf, rl);             \
+	}                                                             \
+	static void hidGyroSet##N(uint8_t r, hid_report_type_t t,     \
+				  uint8_t const *b, uint16_t n)       \
+	{                                                             \
+		hidGyroSetCommon(N, r, t, b, n);                      \
+	}
+// clang-format off
+HIDGYROCB(0)
+HIDGYROCB(1)
+HIDGYROCB(2)
+HIDGYROCB(3)
+// clang-format on
+typedef uint16_t (*ds4_getcb_t)(uint8_t, hid_report_type_t, uint8_t *,
+				uint16_t);
+typedef void (*ds4_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			    uint16_t);
+static ds4_getcb_t const DS4_GETCB[NSLOT] = { hidGyroGet0, hidGyroGet1,
+					      hidGyroGet2, hidGyroGet3 };
+static ds4_setcb_t const DS4_SETCB[NSLOT] = { hidGyroSet0, hidGyroSet1,
+					      hidGyroSet2, hidGyroSet3 };
+
+// usbSlot drives the per-HID counters; slot (bond) is the controller whose decoded input feeds the report.
+static void hidGyroBuild(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
+{
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
+	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
+	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
+	memset(out, 0, 63);
+	out[0] = swStick(g_in[slot].lx, false);
+	out[1] = swStick(g_in[slot].ly, true);
+	out[2] = swStick(g_in[slot].rx, false);
+	out[3] = swStick(g_in[slot].ry, true);
+	out[4] = psHatNibble(b) | psFaceNibble(b);
+	out[5] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
+	static uint8_t ctr[NSLOT] = { 0 };
+	out[6] = ((ctr[usbSlot]++ & 0x0F) << 4) |
+		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
+		 ((b & TB_STEAM) ? 0x01 : 0);
+	out[7] = g_in[slot].lt;
+	out[8] = g_in[slot].rt;
+	out[12] = g_in[slot].gx & 0xFF;
+	out[13] = g_in[slot].gx >> 8;
+	out[14] = g_in[slot].gz & 0xFF;
+	out[15] = g_in[slot].gz >> 8;
+	out[16] = (-g_in[slot].gy) & 0xFF;
+	out[17] = (-g_in[slot].gy) >> 8;
+	out[18] = g_in[slot].ax & 0xFF;
+	out[19] = g_in[slot].ax >> 8;
+	out[20] = g_in[slot].ay & 0xFF;
+	out[21] = g_in[slot].ay >> 8;
+	out[22] = g_in[slot].az & 0xFF;
+	out[23] = g_in[slot].az >> 8;
+	out[29] = DS4_STATUS_USB;
+	if (lTouch || rTouch) {
+		uint16_t lx, ly, rx, ry;
+		steamPadsToTouch(b, DS4_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+				 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx,
+				 &ry);
+		static uint8_t tstamp[NSLOT] = { 0 };
+		out[32] = 1;
+		out[33] = tstamp[usbSlot]++;
+		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
+	} else {
+		out[32] = 0;
+		touchPackPads(
+			out + 34, false, false, 0, 0, 0,
+			0); // contact 0x80 -- memset(0) reads as touch @0,0
+	}
+}
+
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
+void HidGyroController::begin()
+{
+}
+// HID budget: clean DS4 (MODE_DS4_GAME) has no wake mouse; normal HIDGYRO keeps it (1 HID).
+uint8_t HidGyroController::maxSlots() const
+{
+	uint8_t cap = modeIsCleanPS(g_usbMode) ? (uint8_t)CFG_TUD_HID :
+						 (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void HidGyroController::usbIdentity()
+{
+	USBDevice.setID(0x054C, 0x05C4);
+	USBDevice.setDeviceVersion(0x0120);
+	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
+	USBDevice.setProductDescriptor("Wireless Controller");
+}
+void HidGyroController::beginPool()
+{
+	initDs4Macs();
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_hidGyro[s].enableOutEndpoint(true);
+		g_hidGyro[s].setReportCallback(DS4_GETCB[s], DS4_SETCB[s]);
+		g_hidGyro[s].setReportDescriptor(GYRO_HID_DESC,
+						 sizeof GYRO_HID_DESC);
+		g_hidGyro[s].setPollInterval(1);
+		g_hidGyro[s].begin();
+	}
+}
+void HidGyroController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_hidGyro[u]);
+}
+void HidGyroController::task()
+{
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_hidGyro[u].ready())
+			continue;
+		if (millis() - g_gyroLastMs[u] < USB_STREAM_MS)
+			continue;
+		int bond = g_usbToBond[u];
+		if (bond < 0)
+			continue;
+		g_gyroLastMs[u] = millis();
+		uint8_t p[63];
+		hidGyroBuild(u, (uint8_t)bond, p);
+		g_hidGyro[u].sendReport(0x01, p, sizeof p);
+	}
+}

+ 22 - 0
OpenPuckZephyr/src/mode_hidgyro.h

@@ -0,0 +1,22 @@
+// mode_hidgyro.h -- generic HID gyro personality (MODE_HIDGYRO): DS4 (054C:05C4) USB report layout + motion.
+//
+// A standard DS4 descriptor (input report 0x01, 64 B) with gyro/accel at bytes 12-23 (GIMX layout). Aimed at
+// motion-aware PC games (e.g. Fortnite) that read the DS4 report directly. Streamed at ~250Hz from task();
+// reuses the shared PS-layout button packers + Steam-trackpad touch mapping.
+#pragma once
+#include "controllers.h"
+
+class HidGyroController : public IController {
+    public:
+	void begin() override;
+	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
+};
+extern HidGyroController g_hidGyroCtl;

+ 174 - 0
OpenPuckZephyr/src/mode_lizard.cpp

@@ -0,0 +1,174 @@
+#include "mode_lizard.h"
+#include "triton.h"
+#include "config.h"
+
+void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
+	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid)
+{
+	uint32_t b = btnsOf(r);
+	bool qamHeld = (b & TB_QAM) != 0;
+	// --- right pad -> mouse motion with glide (mirrors mode_xinput's rfXboxMouse) ---
+	static int prx = 0, pry = 0;
+	static bool prt = false;
+	static float vx = 0, vy = 0, rmx = 0, rmy = 0;
+	bool rtouch = b & TB_RPADT;
+	int rx = s16off(r, 22), ry = s16off(r, 24);
+	if (rtouch) {
+		if (prt) {
+			vx += (rx - prx);
+			vy += (ry - pry);
+		}
+		prx = rx;
+		pry = ry;
+	}
+	prt = rtouch;
+
+	// Y inverted; *10 = desktop-cursor sensitivity (g_mDiv 64 -> eff 640). Lower g_mDiv via WebUI slider = faster.
+	float mxf = vx / (float)(g_mDiv * 10) + rmx,
+	      myf = -(vy / (float)(g_mDiv * 10)) + rmy;
+	int dx = (int)mxf, dy = (int)myf;
+	rmx = mxf - dx;
+	rmy = myf - dy; // sub-pixel carry
+	if (dx > 127)
+		dx = 127;
+	if (dx < -127)
+		dx = -127;
+	if (dy > 127)
+		dy = 127;
+	if (dy < -127)
+		dy = -127;
+	float f = g_mFric / 100.0f;
+	vx *= f;
+	vy *= f;
+	if (vx > -1 && vx < 1)
+		vx = 0;
+	if (vy > -1 && vy < 1)
+		vy = 0; // friction = glide/decay
+	// --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
+	static int ply = 0;
+	static bool plt = false;
+	static float sacc = 0;
+	bool ltouch = b & TB_LPADT;
+	int ly = s16off(r, 18);
+	if (ltouch) {
+		if (plt) {
+			sacc += (ly - ply) / (float)(g_mDiv * 24);
+		}
+		ply = ly;
+	} else
+		sacc = 0;
+	plt = ltouch;
+	int dw = (int)sacc;
+	sacc -= dw;
+	if (dw > 15)
+		dw = 15;
+	if (dw < -15)
+		dw = -15; // finger up = wheel up (positive)
+	// --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
+	// trigU8 scales full-range so a full pull reaches ~0xFF; a raw >>8 tops out ~0x80, never crossing threshold.
+	uint8_t rtrig = trigU8(u16off(r, 6)), ltrig = trigU8(u16off(r, 4));
+	uint8_t mbtn = 0;
+	if ((b & TB_RPADC) || rtrig > 180)
+		mbtn |= 1; // right trigger -> left (primary) click
+	if (ltrig > 180)
+		mbtn |= 2; // left trigger  -> right click
+	if (b & TB_LPADC)
+		mbtn |= 4;
+	static uint8_t pmbtn = 0;
+	if (dx || dy || dw || mbtn != pmbtn) {
+		pmbtn = mbtn;
+		hid_mouse_report_t m;
+		m.buttons = mbtn;
+		m.x = (int8_t)dx;
+		m.y = (int8_t)dy;
+		m.wheel = (int8_t)dw;
+		m.pan = 0;
+		if (mdev->ready())
+			mdev->sendReport(mrid, &m, sizeof m);
+	}
+	// --- keyboard: modifiers + up to 6 keycodes ---
+	uint8_t mod = 0, kc[6] = { 0, 0, 0, 0, 0, 0 }, nk = 0;
+	if (b & TB_LB)
+		mod |= KEYBOARD_MODIFIER_LEFTCTRL;
+	if (b & TB_RB)
+		mod |= KEYBOARD_MODIFIER_LEFTALT;
+#define LZK(cond, code)                    \
+	do {                               \
+		if ((cond) && nk < 6)      \
+			kc[nk++] = (code); \
+	} while (0)
+	LZK(b & TB_A, HID_KEY_ENTER);
+	LZK(b & TB_B, HID_KEY_ESCAPE);
+	LZK(b & TB_X, HID_KEY_PAGE_UP);
+	LZK(b & TB_Y, HID_KEY_PAGE_DOWN);
+	LZK(b & TB_VIEW,
+	    (g_usbMode == MODE_LIZARD) ? HID_KEY_ESCAPE : HID_KEY_TAB);
+	LZK(b & TB_MENU,
+	    (g_usbMode == MODE_LIZARD) ? HID_KEY_TAB : HID_KEY_ESCAPE);
+
+	// left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
+	int sx = s16off(r, 8), sy = s16off(r, 10);
+	LZK((b & TB_DUP) || sy > 12000, HID_KEY_ARROW_UP);
+	LZK((b & TB_DDN) || sy < -12000, HID_KEY_ARROW_DOWN);
+	LZK((b & TB_DLF) || sx < -12000, HID_KEY_ARROW_LEFT);
+	LZK((b & TB_DRT) || sx > 12000, HID_KEY_ARROW_RIGHT);
+#undef LZK
+	if (g_usbMode == MODE_LIZARD) {
+		static bool prevL5 = false, prevR5 = false;
+		bool mh = (b & TB_STEAM) || qamHeld;
+		bool nL5 = mh && (b & TB_L5), nR5 = mh && (b & TB_R5);
+		if (nL5 && !prevL5) {
+			uint8_t cc = 0x02;
+			if (mdev->ready())
+				mdev->sendReport(0x03, &cc, 1);
+		}
+		if (nR5 && !prevR5) {
+			uint8_t cc = 0x01;
+			if (mdev->ready())
+				mdev->sendReport(0x03, &cc, 1);
+		}
+		if ((!nL5 && prevL5) || (!nR5 && prevR5)) {
+			uint8_t cc = 0x00;
+			if (mdev->ready())
+				mdev->sendReport(0x03, &cc, 1);
+		}
+		prevL5 = nL5;
+		prevR5 = nR5;
+		if (mh && (b & TB_X)) {
+			mod = KEYBOARD_MODIFIER_LEFTGUI |
+			      KEYBOARD_MODIFIER_LEFTCTRL;
+			kc[0] = HID_KEY_O;
+			kc[1] = 0;
+			kc[2] = 0;
+			kc[3] = 0;
+			kc[4] = 0;
+			kc[5] = 0;
+			nk = 1;
+		}
+		if (mh && (b & TB_L4)) {
+			mod = KEYBOARD_MODIFIER_LEFTCTRL |
+			      KEYBOARD_MODIFIER_LEFTALT;
+			kc[0] = HID_KEY_DELETE;
+			kc[1] = 0;
+			kc[2] = 0;
+			kc[3] = 0;
+			kc[4] = 0;
+			kc[5] = 0;
+			nk = 1;
+		}
+	}
+	static uint8_t pmod = 0, pkc[6] = { 0, 0, 0, 0, 0, 0 };
+	bool chg = (mod != pmod);
+	for (int i = 0; i < 6; i++)
+		if (kc[i] != pkc[i])
+			chg = true;
+	if (chg) {
+		pmod = mod;
+		for (int i = 0; i < 6; i++)
+			pkc[i] = kc[i];
+		uint8_t krep[8] = { mod,   0,	  kc[0], kc[1],
+				    kc[2], kc[3], kc[4], kc[5] };
+		if (kdev->ready())
+			kdev->sendReport(krid, krep, 8);
+	}
+}

+ 13 - 0
OpenPuckZephyr/src/mode_lizard.h

@@ -0,0 +1,13 @@
+// mode_lizard.h -- the controller's native "lizard" desktop behavior (driverless keyboard + mouse).
+//
+// Not a standalone IController: lizard rides ON the puck interface (puck_hid.cpp calls rfLizard when Steam is
+// closed), driving mouse(0x40)+keyboard(0x41) on the same puck HID slot. Canonical Valve SC1 map: right pad ->
+// mouse (trackball glide), left pad -> scroll wheel + middle-click, R-trigger/R-pad-click -> left mouse,
+// L-trigger -> right mouse; A=Enter B=Esc X=PgUp Y=PgDn, d-pad/left-stick -> arrows, LB=LeftCtrl RB=LeftAlt,
+// View=Tab, Menu=Esc. Mouse reuses the Xbox-mode velocity+friction+sub-pixel glide model (g_mDiv / g_mFric).
+#pragma once
+#include <Adafruit_TinyUSB.h>
+#include <stdint.h>
+
+void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
+	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid);

+ 252 - 0
OpenPuckZephyr/src/mode_ps5.cpp

@@ -0,0 +1,252 @@
+#include "mode_ps5.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "usb_mount.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+Ps5Controller g_ps5Ctl;
+
+static const uint8_t PS5_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
+	0x09, 0x32, 0x09, 0x35, 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF,
+	0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20,
+	0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
+	0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81,
+	0x42, 0x65, 0x00, 0x05, 0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x21,
+	0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23,
+	0x95, 0x2F, 0x91, 0x02, 0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02,
+	0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02, 0x85, 0x09, 0x09, 0x24,
+	0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
+	0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27,
+	0x95, 0x04, 0xB1, 0x02, 0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x81, 0x09, 0x29,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
+	0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02,
+	0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xE0, 0x09, 0x2F,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32,
+	0x95, 0x0F, 0xB1, 0x02, 0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02, 0xC0
+};
+#define PS5_TOUCH_H 1080
+#define PS5_STATUS_USB 0x1A // charging + level 10 (~100%)
+static unsigned long g_ps5LastMs[NSLOT] = { 0 };
+// NSLOT DualSense HID instances, one per bond slot. The host enumerates each as a separate DualSense
+// (hid-playstation on Linux/SteamOS, SDL on Windows).
+static Adafruit_USBD_HID g_ps5[NSLOT];
+
+// Per-slot MAC base: 4 distinct NICs. OUI 0x001BDC is Sony's; vary the last byte per slot.
+static const uint8_t PS5_MAC_BASE[5] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55 };
+static uint8_t g_ps5Mac[NSLOT][6];
+static bool g_ps5MacInit = false;
+static void initPs5Macs()
+{
+	if (g_ps5MacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_ps5Mac[s], PS5_MAC_BASE, 5);
+		g_ps5Mac[s][5] = (uint8_t)(0x60 + s); // 0x60, 0x61, 0x62, 0x63
+	}
+	g_ps5MacInit = true;
+}
+
+// GET_FEATURE handler. Per-slot dispatch via per-instance callback. Sizes per drivers/hid/hid-playstation.c:
+// 0x05=41, 0x09=20, 0x20=64. TinyUSB writes the report id itself and hands us the buffer PAST it, so we
+// fill only the PAYLOAD and return size-1.
+static uint16_t ps5GetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			     uint8_t *buf, uint16_t reqlen)
+{
+	(void)slot;
+	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
+		return 0;
+	memset(buf, 0, reqlen);
+	switch (rid) {
+	// capabilities: identify as DualSense-capable (SDL-only probe; hid-playstation never reads 0x03)
+	case 0x03: {
+		if (reqlen < 47)
+			return 0;
+		buf[0] = 0x00;
+		buf[1] = 0x28;
+		buf[2] = 0x01;
+		buf[3] = 0x00;
+		buf[4] = 0x0E; // sensors + lightbar + vibration capability bits
+		return 47;
+	}
+	case 0x05: // motion calibration (41 incl id)
+		if (reqlen < 40)
+			return 0;
+		psNeutralCalib(buf);
+		return 40;
+	case 0x09: // pairing info / MAC (20 incl id)
+		if (reqlen < 19)
+			return 0;
+		// MAC at kernel buf[1..6] = payload[0..5]
+		memcpy(buf, g_ps5Mac[slot], 6);
+		return 19;
+	case 0x20: // firmware info (64 incl id)
+		if (reqlen < 63)
+			return 0;
+		buf[23] = 0x01; // hw_version (le32 @ kernel buf[24]) non-zero
+		buf[27] = 0x01; // fw_version (le32 @ kernel buf[28]) non-zero
+		return 63;
+	default:
+		return 0;
+	}
+}
+static void ps5SetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			 uint8_t const *b, uint16_t n)
+{
+	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
+		return;
+	uint8_t id;
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		id = b[0];
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} else if (rid == 0x02 && b[0] == 0x02 && n >= 5) {
+		id = rid;
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} // some paths leave report id in b
+	else {
+		id = rid;
+		p = b;
+		pn = n;
+	}
+	if (id != 0x02 || pn < 4)
+		return;
+	// `slot` here is the USB slot the report arrived on -> route rumble to the bond slot it's mapped to.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
+	hapticSteamRumble((uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u,
+			  (uint8_t)bond);
+	// DualSense: left=low, right=high
+}
+#define PS5CB(N)                                                               \
+	static uint16_t ps5Get##N(uint8_t r, hid_report_type_t t, uint8_t *bf, \
+				  uint16_t rl)                                 \
+	{                                                                      \
+		return ps5GetCommon(N, r, t, bf, rl);                          \
+	}                                                                      \
+	static void ps5Set##N(uint8_t r, hid_report_type_t t,                  \
+			      uint8_t const *b, uint16_t n)                    \
+	{                                                                      \
+		ps5SetCommon(N, r, t, b, n);                                   \
+	}
+// clang-format off
+PS5CB(0)
+PS5CB(1)
+PS5CB(2)
+PS5CB(3)
+// clang-format on
+typedef uint16_t (*ps5_getcb_t)(uint8_t, hid_report_type_t, uint8_t *,
+				uint16_t);
+typedef void (*ps5_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			    uint16_t);
+static ps5_getcb_t const PS5_GETCB[NSLOT] = { ps5Get0, ps5Get1, ps5Get2,
+					      ps5Get3 };
+static ps5_setcb_t const PS5_SETCB[NSLOT] = { ps5Set0, ps5Set1, ps5Set2,
+					      ps5Set3 };
+
+// usbSlot drives the per-HID sequence counter; bond is the controller whose decoded input feeds the report.
+static void ps5Build(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
+{
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
+	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
+	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
+	memset(out, 0, 63);
+	out[0] = swStick(g_in[slot].lx, false);
+	out[1] = swStick(g_in[slot].ly, true);
+	out[2] = swStick(g_in[slot].rx, false);
+	out[3] = swStick(g_in[slot].ry, true);
+	out[4] = g_in[slot].lt;
+	out[5] = g_in[slot].rt;
+	static uint8_t seq[NSLOT] = { 0 };
+	out[6] = seq[usbSlot]++;
+	out[7] = psHatNibble(b) | psFaceNibble(b);
+	out[8] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
+	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
+		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
+		 ((b & TB_MUTE) ? 0x04 : 0);
+	out[15] = g_in[slot].gx & 0xFF;
+	out[16] = g_in[slot].gx >> 8;
+	out[17] = g_in[slot].gz & 0xFF;
+	out[18] = g_in[slot].gz >> 8;
+	out[19] = (-g_in[slot].gy) & 0xFF;
+	out[20] = (-g_in[slot].gy) >> 8;
+	out[21] = g_in[slot].ax & 0xFF;
+	out[22] = g_in[slot].ax >> 8;
+	out[23] = g_in[slot].ay & 0xFF;
+	out[24] = g_in[slot].ay >> 8;
+	out[25] = g_in[slot].az & 0xFF;
+	out[26] = g_in[slot].az >> 8;
+	uint16_t lx, ly, rx, ry;
+	steamPadsToTouch(b, PS5_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+			 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx, &ry);
+	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
+	out[52] = PS5_STATUS_USB;
+}
+
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
+void Ps5Controller::begin()
+{
+}
+// HID budget: clean PS modes have no wake mouse (CFG_TUD_HID slots); normal PS5 keeps the wake mouse (1 HID).
+uint8_t Ps5Controller::maxSlots() const
+{
+	uint8_t cap = modeIsCleanPS(g_usbMode) ? (uint8_t)CFG_TUD_HID :
+						 (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void Ps5Controller::usbIdentity()
+{
+	USBDevice.setID(0x054C, 0x0CE6);
+	USBDevice.setDeviceVersion(0x0110);
+	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
+	USBDevice.setProductDescriptor("DualSense Wireless Controller");
+}
+// One-time: create the DualSense HID pool and lock instance indices (wake mouse, if any, was begun first).
+void Ps5Controller::beginPool()
+{
+	initPs5Macs();
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_ps5[s].enableOutEndpoint(true);
+		g_ps5[s].setReportCallback(PS5_GETCB[s], PS5_SETCB[s]);
+		g_ps5[s].setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
+		g_ps5[s].setPollInterval(1);
+		g_ps5[s].begin();
+	}
+}
+void Ps5Controller::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_ps5[u]);
+}
+void Ps5Controller::task()
+{
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_ps5[u].ready())
+			continue;
+		if (millis() - g_ps5LastMs[u] < USB_STREAM_MS)
+			continue;
+		int bond = g_usbToBond[u];
+		if (bond < 0)
+			continue;
+		g_ps5LastMs[u] = millis();
+		uint8_t p[63];
+		ps5Build(u, (uint8_t)bond, p);
+		g_ps5[u].sendReport(0x01, p, sizeof p);
+	}
+}

+ 22 - 0
OpenPuckZephyr/src/mode_ps5.h

@@ -0,0 +1,22 @@
+// mode_ps5.h -- Sony DualSense personality (MODE_PS5): 054C:0CE6 + gyro + split trackpad.
+//
+// Streams the DualSense USB input report (id 0x01) at ~250Hz from task(): sticks, triggers, PS-layout buttons,
+// gyro/accel, and BOTH Steam trackpads mapped onto the single DualSense touchpad (left pad -> left half, right
+// pad -> right half) via the shared touch packers in gamepad_util.
+#pragma once
+#include "controllers.h"
+
+class Ps5Controller : public IController {
+    public:
+	void begin() override;
+	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
+};
+extern Ps5Controller g_ps5Ctl;

+ 229 - 0
OpenPuckZephyr/src/mode_switch_hori.cpp

@@ -0,0 +1,229 @@
+#include "mode_switch_hori.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "bonds.h"
+#include "usb_mount.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+
+SwitchHoriController g_switchHori;
+
+// 8-byte input report: [btn_lo][btn_hi][hat][LX][LY][RX][RY][vendor], sticks uint8 center 0x80.
+static const uint8_t SWITCH_HID_DESC[] = {
+
+	// Usage Page (Generic Desktop), Usage (Game Pad), Collection (App)
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x15, 0x00, 0x25, 0x01, 0x35, 0x00,
+	0x45, 0x01, 0x75, 0x01, 0x95, 0x10, // 16 x 1-bit buttons
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x10, 0x81, 0x02, 0x05, 0x01, 0x25, 0x07,
+	0x46, 0x3B, 0x01, 0x75, 0x04, 0x95, 0x01, 0x65, 0x14, 0x09, 0x39, 0x81,
+	0x42, // hat switch (4 bits)
+	0x65, 0x00, 0x95, 0x01, 0x81, 0x01, // 4-bit padding to a byte
+	0x26, 0xFF, 0x00, 0x46, 0xFF, 0x00, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32,
+	0x09, 0x35, 0x75, 0x08, 0x95, 0x04, 0x81, 0x02, // X,Y,Z,Rz
+
+	// vendor byte (input)
+	0x06, 0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02,
+
+	// vendor (output, 8 bytes; unused)
+	0x0A, 0x21, 0x26, 0x95, 0x08, 0x91, 0x02, 0xC0
+};
+// NSLOT HORIPAD HID instances -- one per bond slot. The Switch console binds each as a separate gamepad.
+static Adafruit_USBD_HID g_switch[NSLOT];
+static unsigned long g_swLastMs[NSLOT] = { 0 };
+
+// back-paddle / QAM code (g_back[], g_qamMap) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home) 18=Capture
+static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
+			     uint16_t fY)
+{
+	switch (c) {
+	case 1:
+		return fA;
+	case 2:
+		return fB;
+	case 3:
+		return fX;
+	case 4:
+		return fY;
+	case 5:
+		return 0x10;
+	case 6:
+		return 0x20;
+	case 7:
+		return 0x400;
+	case 8:
+		return 0x800;
+	case 9:
+		return 0x100;
+	case 10:
+		return 0x200;
+	case 11:
+		return 0x1000;
+	case 18:
+		return 0x2000; // Capture / Screenshot (Switch-only target)
+	case 19:
+		return 0x40; // ZL (left trigger)
+	case 20:
+		return 0x80; // ZR (right trigger)
+	default:
+		return 0;
+	}
+}
+// Back-paddle code 12..15 map to D-pad Up/Down/Left/Right; fold them into the hat direction flags.
+static inline void backCodeToHatDirs(uint8_t c, bool &u, bool &d, bool &l,
+				     bool &r)
+{
+	if (c == 12)
+		u = true;
+	else if (c == 13)
+		d = true;
+	else if (c == 14)
+		l = true;
+	else if (c == 15)
+		r = true;
+}
+// HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
+// Per-slot: each controller's decoded input is in g_in[slot].
+static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
+{
+	uint32_t b = g_in[slot].buttons;
+	uint16_t btn = 0;
+	// QAM (3 dots) remap: the configured code is applied to the Switch output just like a back paddle below
+	// (so Capture(18) and every Switch target work). 0 = unmapped -> QAM does nothing on Switch.
+	bool qam = g_qamMap && (b & TB_QAM);
+	// Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
+	if ((b & CHORD_BACK4) == CHORD_BACK4)
+		b &= ~(uint32_t)(TB_A | TB_X | TB_Y);
+	// face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)
+	uint16_t fY = g_abSwap ? 0x08 : 0x01, fB = g_abSwap ? 0x04 : 0x02,
+		 fA = g_abSwap ? 0x02 : 0x04, fX = g_abSwap ? 0x01 : 0x08;
+	if (b & TB_Y)
+		btn |= fY;
+	if (b & TB_B)
+		btn |= fB;
+	if (b & TB_A)
+		btn |= fA;
+	if (b & TB_X)
+		btn |= fX;
+	if (b & TB_LB)
+		btn |= 0x10;
+	if (b & TB_RB)
+		btn |= 0x20; // L, R
+	// ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
+		btn |= 0x40;
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
+		btn |= 0x80;
+	if (b & TB_MENU)
+		btn |= 0x100;
+	if (b & TB_VIEW)
+		btn |= 0x200; // Minus, Plus
+	if (b & TB_L3)
+		btn |= 0x400;
+	if (b & TB_R3)
+		btn |= 0x800; // LClick, RClick
+	if (b & TB_STEAM)
+		btn |= 0x1000; // Home
+	// back paddles -> configurable mapping (same g_back[] as Xbox: default L4->LB R4->RB L5->L3 R5->R3)
+	if (b & TB_L4)
+		btn |= codeToSwitch(g_back[0], fA, fB, fX, fY);
+	if (b & TB_R4)
+		btn |= codeToSwitch(g_back[1], fA, fB, fX, fY);
+	if (b & TB_L5)
+		btn |= codeToSwitch(g_back[2], fA, fB, fX, fY);
+	if (b & TB_R5)
+		btn |= codeToSwitch(g_back[3], fA, fB, fX, fY);
+	if (qam)
+		btn |= codeToSwitch(g_qamMap, fA, fB, fX, fY);
+	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF,
+	     r = b & TB_DRT; // hat: 0=N..7=NW, 8=neutral
+	if (b & TB_L4)
+		backCodeToHatDirs(g_back[0], u, d, l, r);
+	if (b & TB_R4)
+		backCodeToHatDirs(g_back[1], u, d, l, r);
+	if (b & TB_L5)
+		backCodeToHatDirs(g_back[2], u, d, l, r);
+	if (b & TB_R5)
+		backCodeToHatDirs(g_back[3], u, d, l, r);
+	if (qam)
+		backCodeToHatDirs(g_qamMap, u, d, l, r);
+	uint8_t hat = 8;
+	if (u && r)
+		hat = 1;
+	else if (r && d)
+		hat = 3;
+	else if (d && l)
+		hat = 5;
+	else if (l && u)
+		hat = 7;
+	else if (u)
+		hat = 0;
+	else if (r)
+		hat = 2;
+	else if (d)
+		hat = 4;
+	else if (l)
+		hat = 6;
+	out[0] = btn & 0xFF;
+	out[1] = btn >> 8;
+	out[2] = hat;
+	out[3] = swStick(g_in[slot].lx, false);
+	out[4] = swStick(g_in[slot].ly,
+			 true); // HID Y is down-positive -> invert
+	out[5] = swStick(g_in[slot].rx, false);
+	out[6] = swStick(g_in[slot].ry, true);
+	out[7] = 0;
+}
+
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
+void SwitchHoriController::begin()
+{
+}
+// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the HORIPAD pool.
+uint8_t SwitchHoriController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SwitchHoriController::usbIdentity()
+{
+	USBDevice.setID(0x0F0D, 0x0092);
+	USBDevice.setDeviceVersion(0x0210);
+	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
+	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
+}
+void SwitchHoriController::beginPool()
+{
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_switch[s].enableOutEndpoint(true);
+		g_switch[s].setReportDescriptor(SWITCH_HID_DESC,
+						sizeof SWITCH_HID_DESC);
+		g_switch[s].setPollInterval(1);
+		g_switch[s].begin();
+	}
+}
+void SwitchHoriController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_switch[u]);
+}
+void SwitchHoriController::task()
+{
+	// stream the 8-byte HORIPAD report at ~250Hz per CONNECTED controller. usbSlot u -> bond slot for input;
+	// switchBuildHoripad reads g_in[bond] (no per-USB-slot state in the HORIPAD report).
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_switch[u].ready())
+			continue;
+		if (millis() - g_swLastMs[u] < USB_STREAM_MS)
+			continue;
+		int bond = g_usbToBond[u];
+		if (bond < 0)
+			continue;
+		g_swLastMs[u] = millis();
+		uint8_t p[8];
+		switchBuildHoripad((uint8_t)bond, p);
+		g_switch[u].sendReport(0, p,
+				       sizeof p); // report-id-less descriptor
+	}
+}

+ 23 - 0
OpenPuckZephyr/src/mode_switch_hori.h

@@ -0,0 +1,23 @@
+// mode_switch_hori.h -- Switch HORIPAD / Pokkén Tournament Pro Pad personality (MODE_SW_HORI).
+//
+// Targets a REAL Switch console: a plain HID gamepad (HORI 0F0D:0092) the console accepts on plug-in with NO
+// handshake / SPI calibration / encryption -- unlike the genuine Pro Controller. This is the descriptor every
+// DIY Switch controller copies (the LUFA "Splatoon printer" descriptor); also recognized by SDL/Steam on PC.
+// 8-byte input report streamed at ~250Hz from task().
+#pragma once
+#include "controllers.h"
+
+class SwitchHoriController : public IController {
+    public:
+	void begin() override;
+	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
+};
+extern SwitchHoriController g_switchHori;

+ 786 - 0
OpenPuckZephyr/src/mode_switch_pro.cpp

@@ -0,0 +1,786 @@
+#include "mode_switch_pro.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "usb_mount.h"
+#include <Adafruit_TinyUSB.h>
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+#include <Arduino.h>
+#include <string.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+SwitchProController g_switchPro;
+
+// Switch full-report (0x30) cadence. The console integrates the 3 IMU samples per report assuming ~5 ms/sample
+// (3 samples / 15 ms genuine); too high a rate over-integrates the gyro. 120 Hz is drift-free and lower-latency
+// (default); 66 Hz is the genuine compat fallback; "full" is the 4 ms PC rate (lowest latency).
+#define SW_PRO_REPORT_MS 15u // 66 Hz
+#define SW_PRO_REPORT_MS_120 8u // ~120 Hz
+// SC2 accel is +/-2g (16384/g); /4 -> the genuine Pro +/-8g (4096/g) the Switch cal expects
+#define SW_ACCEL_DIV 4
+
+uint8_t g_swProRate =
+	2; // 0 = 66Hz, 1 = 120Hz, 2 = full (~250Hz / USB_STREAM_MS, default)
+uint8_t g_swGyroScale10 = 10; // gyro sensitivity x10 (10 = 1.0x)
+
+// Persist the two Switch Pro motion settings in their own tiny file so they never trigger a global-Cfg reset.
+#define SWPRO_CFG_FILE "/swprocfg.bin"
+void swProSaveCfg()
+{
+	uint8_t b[3] = { 0x01, g_swProRate,
+			 g_swGyroScale10 }; // [ver][rate 0/1/2][gyroScale x10]
+	InternalFS.remove(SWPRO_CFG_FILE);
+	File f(InternalFS);
+	if (f.open(SWPRO_CFG_FILE, FILE_O_WRITE)) {
+		f.write(b, sizeof b);
+		f.close();
+	}
+}
+static void swProLoadCfg()
+{
+	File f(InternalFS);
+	uint8_t b[3];
+	if (f.open(SWPRO_CFG_FILE, FILE_O_READ)) {
+		if (f.read(b, 3) == 3 && b[0] == 0x01) {
+			// 0=66Hz, 1=120Hz, 2=full; bad value -> default full
+			g_swProRate = (b[1] <= 2) ? b[1] : 2;
+			g_swGyroScale10 =
+				(b[2] >= 5 && b[2] <= 30) ?
+					b[2] :
+					10; // sane bounds (0.5x..3.0x)
+		}
+		f.close();
+	}
+}
+// Scale a gyro axis by g_swGyroScale10/10, clamped to int16 (3x can saturate at high rates -- expected).
+static int16_t gscale(int16_t v)
+{
+	if (g_swGyroScale10 == 10)
+		return v;
+	int32_t s = (int32_t)v * (int32_t)g_swGyroScale10 / 10;
+	if (s > 32767)
+		s = 32767;
+	else if (s < -32768)
+		s = -32768;
+	return (int16_t)s;
+}
+
+static const uint8_t SWPRO_HID_DESC[] = {
+	0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0B,
+	0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
+	0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1,
+	0x00, 0x0B, 0x30, 0x00, 0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B,
+	0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01, 0x00, 0x15, 0x00, 0x27,
+	0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
+	0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B,
+	0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x05, 0x09, 0x19,
+	0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81,
+	0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
+	0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02,
+	0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08,
+	0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04, 0x75, 0x08, 0x95, 0x3F,
+	0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
+	0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0
+};
+#define JC_BTN_Y (1u << 0)
+#define JC_BTN_X (1u << 1)
+#define JC_BTN_B (1u << 2)
+#define JC_BTN_A (1u << 3)
+#define JC_BTN_R (1u << 6)
+#define JC_BTN_ZR (1u << 7)
+#define JC_BTN_MINUS (1u << 8)
+#define JC_BTN_PLUS (1u << 9)
+#define JC_BTN_RSTICK (1u << 10)
+#define JC_BTN_LSTICK (1u << 11)
+#define JC_BTN_HOME (1u << 12)
+#define JC_BTN_CAPTURE (1u << 13)
+#define JC_BTN_DOWN (1u << 16)
+#define JC_BTN_UP (1u << 17)
+#define JC_BTN_RIGHT (1u << 18)
+#define JC_BTN_LEFT (1u << 19)
+#define JC_BTN_L (1u << 22)
+#define JC_BTN_ZL (1u << 23)
+static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
+			 uint32_t fY)
+{
+	switch (c) {
+	case 1:
+		return fA;
+	case 2:
+		return fB;
+	case 3:
+		return fX;
+	case 4:
+		return fY;
+	case 5:
+		return JC_BTN_L;
+	case 6:
+		return JC_BTN_R;
+	case 7:
+		return JC_BTN_LSTICK;
+	case 8:
+		return JC_BTN_RSTICK;
+	case 9:
+		return JC_BTN_MINUS;
+	case 10:
+		return JC_BTN_PLUS;
+	case 11:
+		return JC_BTN_HOME;
+	case 18:
+		return JC_BTN_CAPTURE; // Capture / Screenshot (Switch-only target)
+	case 19:
+		return JC_BTN_ZL; // left trigger
+	case 20:
+		return JC_BTN_ZR; // right trigger
+	case 12:
+		return JC_BTN_UP;
+	case 13:
+		return JC_BTN_DOWN;
+	case 14:
+		return JC_BTN_LEFT;
+	case 15:
+		return JC_BTN_RIGHT;
+	default:
+		return 0;
+	}
+}
+// NSLOT Pro-Controller HIDs (one per bond slot) + per-slot handshake state (timer, report-mode gate,
+// subcommand-reply FIFO), per-slot reply queue indices, per-slot last-stream millis, per-slot MAC, and
+// per-slot user-cal SPI mirror.
+static Adafruit_USBD_HID g_swPro[NSLOT];
+static unsigned long g_swProLastMs[NSLOT] = { 0 };
+// 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
+static uint8_t g_swProReportMode[NSLOT] = { 0 };
+static uint8_t g_jcTimer[NSLOT] = { 0 };
+// Per-slot MAC. The console uses the BT addr it reads from subcommand 0x02 to identify the controller;
+// real Pro Controllers each have their own. Last byte varies per slot.
+static const uint8_t JC_MAC_BASE[5] = { 0x7C, 0xBB, 0x8A, 0x00, 0x00 };
+static uint8_t g_jcMac[NSLOT][6];
+static bool g_jcMacInit = false;
+static void initJcMacs()
+{
+	if (g_jcMacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_jcMac[s], JC_MAC_BASE, 5);
+		g_jcMac[s][5] = (uint8_t)(0x10 + s);
+	}
+	g_jcMacInit = true;
+}
+// Dynamic mount: the handshake/state arrays are keyed by USB slot, but the controller INPUT (and rumble target)
+// belongs to the bond slot the USB slot is mapped to. This resolves usbSlot -> bondSlot (identity fallback if
+// somehow unmapped, e.g. legacy paths).
+static inline uint8_t jcBondOf(uint8_t usbSlot)
+{
+	int b = (usbSlot < NSLOT) ? g_usbToBond[usbSlot] : -1;
+	return (b >= 0) ? (uint8_t)b : usbSlot;
+}
+static uint16_t jcRumbleAmp(const uint8_t r[4])
+{
+	// Nintendo packs a high band (r[0],r[1]) and low band (r[2],r[3]) of frequency+amplitude. We only need a
+	// magnitude for the Steam motor, so pull the two amplitude fields: HF amp = r[1]>>1, LF amp = r[3]&0x3F.
+	// Every canonical idle/neutral frame (00 01 40 40, 00 00 01 40, all-zero) decodes to 0 this way, so a steady
+	// idle rumble stream maps to "off" instead of latching the motor on. No forced floor: 0 amplitude -> 0.
+	uint8_t hf = r[1] >> 1; // high-band amplitude (neutral 0x01 -> 0)
+	uint8_t lf = r[3] & 0x3F; // low-band amplitude  (neutral 0x40 -> 0)
+	uint8_t a = hf > lf ? hf : lf; // 0..0x7F
+	return (uint16_t)a << 9; // scale to ~16-bit motor speed
+}
+// Per-slot: each Pro Controller has its own rumble stream, so the "last" relay tracking must be per-slot.
+static uint16_t g_jcLastLo[NSLOT] = { 0 };
+static uint16_t g_jcLastHi[NSLOT] = { 0 };
+static void jcRumble(uint8_t slot, const uint8_t *p, uint16_t pn)
+{
+	if (pn < 9)
+		return; // [timer][left rumble x4][right rumble x4]
+	uint16_t lo = jcRumbleAmp(p + 1), hi = jcRumbleAmp(p + 5);
+	// only relay on change: the Switch streams rumble every frame; re-sending
+	// unchanged values would flood the RF relay and loop the motor
+	if (lo == g_jcLastLo[slot] && hi == g_jcLastHi[slot])
+		return;
+	g_jcLastLo[slot] = lo;
+	g_jcLastHi[slot] = hi;
+	hapticSteamRumble(lo, hi,
+			  jcBondOf(slot)); // route to the mapped controller
+}
+static int jcStick12(int16_t v, bool inv)
+{ // steam int16 (center 0) -> 12-bit (center 0x800), clamped
+	int a = 2048 + (inv ? -((int)v >> 4) : ((int)v >> 4));
+	return a < 0 ? 0 : (a > 4095 ? 4095 : a);
+}
+// pack two 12-bit axes into 3 bytes (both normal; Switch Y polarity is opposite DS4)
+static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
+{
+	int X = jcStick12(x, false), Y = jcStick12(y, false);
+	s[0] = (uint8_t)(X & 0xFF);
+	s[1] = (uint8_t)(((Y & 0x0F) << 4) | ((X >> 8) & 0x0F));
+	s[2] = (uint8_t)((Y >> 4) & 0xFF);
+}
+// Standard input-report prefix [0..11] (timer, battery/conn, 3 button bytes, both packed sticks, vibrator),
+// shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
+static void jcInputPrefix(uint8_t slot, uint8_t *out)
+{
+	uint8_t bond = jcBondOf(
+		slot); // input data comes from the mapped controller; timer/state stay per USB slot
+	uint32_t b = g_in[bond].buttons;
+	// QAM (3 dots) remap -> applied via codeToJc below like a back paddle (so Capture(18)/any target work).
+	bool qam = g_qamMap && (b & TB_QAM);
+	if ((b & CHORD_BACK4) == CHORD_BACK4)
+		b &= ~(uint32_t)(TB_A | TB_B | TB_X | TB_Y);
+	uint32_t fA = g_abSwap ? JC_BTN_B : JC_BTN_A,
+		 fB = g_abSwap ? JC_BTN_A : JC_BTN_B;
+	uint32_t fX = g_abSwap ? JC_BTN_Y : JC_BTN_X,
+		 fY = g_abSwap ? JC_BTN_X : JC_BTN_Y;
+	uint32_t jc = 0;
+	if (b & TB_Y)
+		jc |= fY;
+	if (b & TB_B)
+		jc |= fB;
+	if (b & TB_A)
+		jc |= fA;
+	if (b & TB_X)
+		jc |= fX;
+	if (b & TB_LB)
+		jc |= JC_BTN_L;
+	if (b & TB_RB)
+		jc |= JC_BTN_R;
+	if ((g_in[bond].lt >= SW_TRIG_ON) || (b & 0x8000000u))
+		jc |= JC_BTN_ZL;
+	if ((g_in[bond].rt >= SW_TRIG_ON) || (b & 0x800000u))
+		jc |= JC_BTN_ZR;
+	if (b & TB_VIEW)
+		jc |= JC_BTN_PLUS;
+	if (b & TB_MENU)
+		jc |= JC_BTN_MINUS;
+	if (b & TB_L3)
+		jc |= JC_BTN_LSTICK;
+	if (b & TB_R3)
+		jc |= JC_BTN_RSTICK;
+	if (b & TB_STEAM)
+		jc |= JC_BTN_HOME;
+	if (b & TB_DDN)
+		jc |= JC_BTN_DOWN;
+	if (b & TB_DUP)
+		jc |= JC_BTN_UP;
+	if (b & TB_DRT)
+		jc |= JC_BTN_RIGHT;
+	if (b & TB_DLF)
+		jc |= JC_BTN_LEFT;
+	if (b & TB_L4)
+		jc |= codeToJc(g_back[0], fA, fB, fX, fY);
+	if (b & TB_R4)
+		jc |= codeToJc(g_back[1], fA, fB, fX, fY);
+	if (b & TB_L5)
+		jc |= codeToJc(g_back[2], fA, fB, fX, fY);
+	if (b & TB_R5)
+		jc |= codeToJc(g_back[3], fA, fB, fX, fY);
+	if (qam)
+		jc |= codeToJc(g_qamMap, fA, fB, fX, fY);
+	out[0] = g_jcTimer[slot]++;
+
+	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
+	// A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too.
+	out[1] = 0x91;
+	out[2] = (uint8_t)(jc);
+	out[3] = (uint8_t)(jc >> 8);
+	out[4] = (uint8_t)(jc >> 16);
+
+	// charging_grip bit (button "common" byte, bit7): genuine Pro Controller always sets it on USB; real
+	// Switch uses it to recognise a wired controller. Not a button, so hid-nintendo ignores it.
+	out[3] |= 0x80;
+	jcPackStick(out + 5, g_in[bond].lx, g_in[bond].ly);
+	jcPackStick(out + 8, g_in[bond].rx, g_in[bond].ry);
+	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
+	out[11] = 0x09;
+}
+static void switchProBuild(uint8_t slot, uint8_t out[63])
+{
+	uint8_t bond =
+		jcBondOf(slot); // IMU data comes from the mapped controller
+	memset(out, 0, 63);
+	jcInputPrefix(slot, out);
+	// Gyro slot order follows hid-nintendo: raw+6 = ROLL, raw+8 = PITCH, raw+10 = YAW. Source routing (a proper
+	// rotation, det +1): SwitchX<-+gy, SwitchY<--gx, SwitchZ<-+gz.
+	//
+	// The accel MUST use the SAME signed permutation as the gyro. Steam reads the gyro raw and ignores this, but the
+	// Switch console FUSES accel (gravity) with gyro to anchor absolute orientation -- if the accel frame has the
+	// opposite handedness, the gravity-correction term points the wrong way and the attitude estimate latches into a
+	// rotated solution (intermittent ~45deg axis-mixing, per-unit roll bias, cleared only by a replug). So accel Y
+	// must be -ax to match the gyro's -gx pitch axis; resting gravity then lands cleanly on Switch Z (the up axis).
+	// The SC2 accelerometer is +/-2g full-scale (~16384 counts = 1g). The factory cal we present (sensitivity 0x4000)
+	// tells the Switch it's a genuine +/-8g Pro Controller (4096 counts = 1g), so divide by SW_ACCEL_DIV=4 to report
+	// ~1g. Without this the console reads gravity as ~4g, REJECTS the accel for drift correction (it must be linear
+	// accel, not gravity), and gyro roll error accumulates into a slow ~45deg lean. Gyro is left at native scale.
+	// accel X <- +ay, accel Y <- -ax, accel Z <- +az (signs match gyro)
+	int16_t aX = (int16_t)(g_in[bond].ay / SW_ACCEL_DIV);
+	int16_t aY = (int16_t)((-(int16_t)g_in[bond].ax) / SW_ACCEL_DIV);
+	int16_t aZ = (int16_t)(g_in[bond].az / SW_ACCEL_DIV);
+
+	// gyro outputs scaled by the user sensitivity factor
+	int16_t groll = gscale((int16_t)g_in[bond].gy);
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[bond].gx));
+	int16_t gyaw = gscale((int16_t)g_in[bond].gz);
+	for (int k = 0; k < 3; k++) {
+		int o = 12 + k * 12;
+		out[o + 0] = aX & 0xFF;
+		out[o + 1] = (aX >> 8) & 0xFF;
+		out[o + 2] = aY & 0xFF;
+		out[o + 3] = (aY >> 8) & 0xFF;
+		out[o + 4] = aZ & 0xFF;
+		out[o + 5] = (aZ >> 8) & 0xFF;
+		out[o + 6] = groll & 0xFF;
+		out[o + 7] = (groll >> 8) & 0xFF;
+		out[o + 8] = gpitch & 0xFF;
+		out[o + 9] = (gpitch >> 8) & 0xFF;
+		out[o + 10] = gyaw & 0xFF;
+		out[o + 11] = (gyaw >> 8) & 0xFF;
+	}
+}
+// --- Canonical factory SPI dumps the host reads for calibration. Neutral IMU + centered sticks so a fresh
+// "device" calibrates sane; user-cal regions (0x80xx) read 0xFF so the host falls back to these factory blocks.
+static const uint8_t SPI_IMU_CAL[24] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					 0x00, 0x40, 0x00, 0x40, 0x00, 0x40,
+					 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					 0x3B, 0x34, 0x3B, 0x34, 0x3B, 0x34 };
+static const uint8_t SPI_PARAMS1[24] = { 0x50, 0xFD, 0x00, 0x00, 0xC6, 0x0F,
+					 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
+					 0xD4, 0x14, 0x54, 0x41, 0x15, 0x54,
+					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
+static const uint8_t SPI_PARAMS2[18] = { 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
+					 0xD4, 0x14, 0x54, 0x41, 0x15, 0x54,
+					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
+static const uint8_t SPI_COLOR[13] = { 0x32, 0x32, 0x32, 0xE6, 0xE6, 0xE6, 0x32,
+				       0x32, 0x32, 0x32, 0x32, 0x32, 0xFF };
+// built at boot: left[9]+right[9] factory stick calibration (packed 12-bit). Shared across slots -- it's a
+// factory stub the host reads to seed its cal, and we present the same neutral stub for all controllers.
+static uint8_t g_spiStickCal[18];
+static void jcPack12(uint8_t *o9, const uint16_t v[6])
+{ // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
+	o9[0] = v[0] & 0xFF;
+	o9[1] = ((v[1] & 0x0F) << 4) | ((v[0] >> 8) & 0x0F);
+	o9[2] = (v[1] >> 4) & 0xFF;
+	o9[3] = v[2] & 0xFF;
+	o9[4] = ((v[3] & 0x0F) << 4) | ((v[2] >> 8) & 0x0F);
+	o9[5] = (v[3] >> 4) & 0xFF;
+	o9[6] = v[4] & 0xFF;
+	o9[7] = ((v[5] & 0x0F) << 4) | ((v[4] >> 8) & 0x0F);
+	o9[8] = (v[5] >> 4) & 0xFF;
+}
+static void jcBuildStickCal()
+{
+	const uint16_t C = 2048, R = 1800; // center, +/- range per axis
+	uint16_t L[6] = {
+		R, R, C, C, R, R
+	}; // left  order: max(x,y), center(x,y), min(x,y)
+	uint16_t Rr[6] = {
+		C, C, R, R, R, R
+	}; // right order: center(x,y), min(x,y), max(x,y)
+	jcPack12(&g_spiStickCal[0], L);
+	jcPack12(&g_spiStickCal[9], Rr);
+}
+// Manual-pairing (subcommand 0x01) reply payloads. A real Switch runs this 3-stage BT key exchange over USB so it
+// can register the pad (Steam/hid-nintendo do not). The Switch only validates the shape, not the key contents. Each
+// is the 31-byte reply body following the 0x21 input prefix + ack(0x81) + echoed-subcommand(0x01).
+static const uint8_t BT_PAIR_2[31] = { // type 2: LTK exchange
+	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
+	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t BT_PAIR_3[31] = {
+	0x03
+}; // type 3: save pairing (all zero body)
+// User-calibration SPI mirror (0x8000-0x80FF). A real Pro Controller stores the gyro/accel (and stick) calibration
+// the Switch writes during "Calibrate Motion Controls" here, then reads it back and applies it -- that's how a
+// resting IMU/neutral offset gets cancelled. Per-slot: each Pro Controller has its own SPI mirror, so each
+// gets its own persisted file (NSLOT files; the same 0x100 bytes as before, partitioned by slot index).
+#define SWCAL_FILE_BASE "/swimucal"
+static uint8_t g_userCal[NSLOT][0x100];
+static bool g_userCalLoaded[NSLOT] = { false, false, false, false };
+static void swCalFileName(uint8_t slot, char *out)
+{
+	// "/swimucal0.bin" .. "/swimucal3.bin"
+	const char *base = SWCAL_FILE_BASE;
+	int i = 0;
+	while (base[i]) {
+		out[i] = base[i];
+		i++;
+	}
+	out[i++] = (char)('0' + slot);
+	out[i++] = '.';
+	out[i++] = 'b';
+	out[i++] = 'i';
+	out[i++] = 'n';
+	out[i] = '\0';
+}
+static void loadUserCal()
+{
+	for (int s = 0; s < NSLOT; s++) {
+		memset(g_userCal[s], 0xFF, sizeof g_userCal[s]);
+		char fn[16];
+		swCalFileName((uint8_t)s, fn);
+		File f(InternalFS);
+		if (f.open(fn, FILE_O_READ)) {
+			f.read(g_userCal[s], sizeof g_userCal[s]);
+			f.close();
+		}
+		g_userCalLoaded[s] = true;
+	}
+}
+static void saveUserCal(uint8_t slot)
+{
+	char fn[16];
+	swCalFileName(slot, fn);
+	InternalFS.remove(fn);
+	File f(InternalFS);
+	if (f.open(fn, FILE_O_WRITE)) {
+		f.write(g_userCal[slot], sizeof g_userCal[slot]);
+		f.close();
+	}
+}
+// jcSpiWrite runs in the USB ISR (via jcSet). NEVER do flash I/O here -- a blocking LittleFS erase/write in the
+// interrupt wedges USB + the RF poll and corrupts state (device drops into a bad state needing a replug). Only
+// update the RAM mirror and flag it dirty; task() (main loop) does the actual save, debounced so a calibration
+// write-burst coalesces into one flash write. Per-slot: each Pro Controller's ISR write targets its own mirror.
+static volatile bool g_userCalDirty[NSLOT] = { false, false, false, false };
+static volatile unsigned long g_userCalDirtyMs[NSLOT] = { 0 };
+static void jcSpiWrite(uint8_t slot, uint32_t addr, uint8_t len,
+		       const uint8_t *data, uint16_t avail)
+{
+	bool changed = false;
+	for (uint8_t i = 0; i < len && i < avail; i++) {
+		uint32_t a = addr + i;
+		if (a >= 0x8000 && a < 0x8100) {
+			if (g_userCal[slot][a - 0x8000] != data[i]) {
+				g_userCal[slot][a - 0x8000] = data[i];
+				changed = true;
+			}
+		}
+	}
+	if (changed) {
+		g_userCalDirty[slot] = true;
+		g_userCalDirtyMs[slot] = millis();
+	} // defer the flash write to task()
+}
+static void spiRead(uint8_t slot, uint32_t addr, uint8_t len, uint8_t *dst)
+{
+	for (uint8_t i = 0; i < len; i++) {
+		uint32_t a = addr + i;
+		uint8_t v = 0xFF;
+		if (a >= 0x6020 && a < 0x6020 + 24)
+			v = SPI_IMU_CAL[a - 0x6020];
+		else if (a >= 0x603D && a < 0x603D + 18)
+			v = g_spiStickCal[a - 0x603D];
+		else if (a >= 0x6050 && a < 0x6050 + 13)
+			v = SPI_COLOR[a - 0x6050];
+		else if (a >= 0x6080 && a < 0x6080 + 24)
+			v = SPI_PARAMS1[a - 0x6080];
+		else if (a >= 0x6098 && a < 0x6098 + 18)
+			v = SPI_PARAMS2[a - 0x6098];
+		else if (a >= 0x8000 && a < 0x8100)
+			// persisted user cal (0xFF blank -> factory fallback)
+			v = g_userCal[slot][a - 0x8000];
+		dst[i] = v;
+	}
+}
+// Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
+// 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Per-slot: each
+// Pro Controller runs its own handshake state machine, so each has its own reply queue. Every reply is sent as
+// a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter than the
+// descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
+#define JC_REPLEN 63
+struct JcRep {
+	uint8_t rid;
+	uint8_t data[JC_REPLEN];
+};
+#define JCQ_N 8
+static volatile uint8_t g_jcQh[NSLOT] = { 0, 0, 0, 0 };
+static volatile uint8_t g_jcQt[NSLOT] = { 0, 0, 0, 0 };
+static JcRep g_jcQ[NSLOT][JCQ_N];
+static void jcEnq(uint8_t slot, uint8_t rid, const uint8_t *d, uint8_t len)
+{
+	uint8_t t = g_jcQt[slot];
+	uint8_t nt = (uint8_t)((t + 1) % JCQ_N);
+	if (nt == g_jcQh[slot])
+		return; // full -> drop; the host re-requests on timeout
+	if (len > JC_REPLEN)
+		len = JC_REPLEN;
+	g_jcQ[slot][t].rid = rid;
+	memset(g_jcQ[slot][t].data, 0, JC_REPLEN);
+	memcpy(g_jcQ[slot][t].data, d, len); // zero-pad to full report length
+	g_jcQt[slot] = nt;
+}
+// Build the 0x21 reply (standard input prefix + ACK + echoed subcommand id + reply data) for a report-0x01 subcommand.
+static void jcSubcmd(uint8_t slot, uint8_t sub, const uint8_t *args,
+		     uint16_t alen)
+{
+	uint8_t p[JC_REPLEN];
+	memset(p, 0, sizeof p);
+	jcInputPrefix(slot, p);
+	p[13] = sub;
+	switch (sub) {
+	// manual BT pairing: 3-stage key exchange a real Switch runs over USB
+	case 0x01: {
+		uint8_t t = (alen >= 1) ? args[0] : 3;
+		p[12] = 0x81; // pairing ACK
+		if (t == 1) {
+			// echo type + this slot's MAC + "Pro Controller" name
+			p[14] = 0x01;
+			memcpy(&p[15], g_jcMac[slot], 6);
+			p[21] = 0x00;
+			p[22] = 0x25;
+			p[23] = 0x08;
+			// "Pro Controller" = 50 72 6F 20 43 6F 6E 74 72 6F 6C 6C 65 72
+			static const uint8_t NAME[14] = {
+				0x50, 0x72, 0x6F, 0x20, 0x43, 0x6F, 0x6E,
+				0x74, 0x72, 0x6F, 0x6C, 0x6C, 0x65, 0x72
+			};
+			memcpy(&p[24], NAME, 14);
+			p[38] = 0x00;
+			p[39] = 0x00;
+			p[40] = 0x00;
+			p[41] = 0x00;
+			p[42] = 0x00;
+			p[43] = 0x68;
+		} else if (t == 2) {
+			memcpy(&p[14], BT_PAIR_2, 31);
+		} else {
+			memcpy(&p[14], BT_PAIR_3, 31);
+		}
+		break;
+	}
+	case 0x02: // request device info
+		p[12] = 0x82;
+		p[14] = 0x03;
+
+		// firmware version 3.72 (genuine Pro Controller value)
+		p[15] = 0x48;
+		p[16] = 0x03; // controller type: Pro Controller
+		p[17] = 0x02; // fixed
+		memcpy(&p[18], g_jcMac[slot], 6);
+		p[24] = 0x01; // colors stored in SPI (0x6050)
+		p[25] = 0x01; // fixed
+		break;
+	case 0x10: { // SPI flash read -> echo [addr][len] then the data
+		if (alen < 5) {
+			p[12] = 0x80;
+			break;
+		}
+		uint32_t a = (uint32_t)args[0] | ((uint32_t)args[1] << 8) |
+			     ((uint32_t)args[2] << 16) |
+			     ((uint32_t)args[3] << 24);
+		uint8_t rl = args[4];
+		if (rl > 0x1D)
+			rl = 0x1D;
+		p[12] = 0x90;
+		p[14] = args[0];
+		p[15] = args[1];
+		p[16] = args[2];
+		p[17] = args[3];
+		p[18] = rl;
+		spiRead(slot, a, rl, &p[19]);
+		break;
+	}
+	// SPI flash write -> persist user calibration so Switch motion-cal sticks
+	case 0x11:
+		if (alen >= 5) {
+			uint32_t a = (uint32_t)args[0] |
+				     ((uint32_t)args[1] << 8) |
+				     ((uint32_t)args[2] << 16) |
+				     ((uint32_t)args[3] << 24);
+			jcSpiWrite(slot, a, args[4], &args[5],
+				   (alen > 5) ? (uint16_t)(alen - 5) : 0);
+		}
+		p[12] = 0x80; // write ACK
+		break;
+
+	// set input report mode (0x30 = standard full) -> begin streaming
+	case 0x03:
+		if (alen >= 1 && args[0] == 0x30)
+			g_swProReportMode[slot] = 0x30;
+		p[12] = 0x80;
+		break;
+	// trigger buttons elapsed time -> canned reply (genuine pad returns data)
+	case 0x04:
+		p[12] = 0x83;
+		p[14] = 0x00;
+		p[15] = 0xCC;
+		p[16] = 0x00;
+		p[17] = 0xEE;
+		p[18] = 0x00;
+		p[19] = 0xFF;
+		break;
+	case 0x21:
+		p[12] = 0xA0;
+		break; // set NFC/IR config
+	default:
+		p[12] = 0x80;
+
+		// 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
+		break;
+	}
+	jcEnq(slot, 0x21, p, JC_REPLEN);
+}
+// USB-ISR callback for the Pro Controller's OUT endpoint. Per-slot dispatch via per-instance callback
+// (jcSet##N closes over the slot index). Report 0x80 = USB handshake; 0x01 = subcommand.
+static void jcSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			uint8_t const *b, uint16_t n)
+{
+	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
+		return;
+	uint8_t id;
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		id = b[0];
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} // EP-OUT: id is the first payload byte
+	else {
+		id = rid;
+		p = b;
+		pn = n;
+	} // control SET_REPORT: id already split out
+	if (id == 0x80) {
+		if (pn < 1)
+			return;
+		if (p[0] == 0x01) {
+			uint8_t d[9] = { 0x01,
+					 0x00,
+					 0x03,
+					 g_jcMac[slot][0],
+					 g_jcMac[slot][1],
+					 g_jcMac[slot][2],
+					 g_jcMac[slot][3],
+					 g_jcMac[slot][4],
+					 g_jcMac[slot][5] };
+			jcEnq(slot, 0x81, d, 9);
+		} // device type + MAC
+		else if (p[0] == 0x02) {
+			uint8_t d[1] = { 0x02 };
+			jcEnq(slot, 0x81, d, 1);
+		} // handshake
+		else if (p[0] == 0x03) {
+			uint8_t d[1] = { 0x03 };
+			jcEnq(slot, 0x81, d, 1);
+		} // set baudrate
+		// 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
+		return;
+	}
+	if (id == 0x01) { // [timer][rumble x8][subcmd][args...]
+		if (pn < 10)
+			return;
+		jcRumble(slot, p, pn);
+		jcSubcmd(slot, p[9], p + 10,
+			 (pn > 10) ? (uint16_t)(pn - 10) : 0);
+		return;
+	}
+	if (id == 0x10) {
+		jcRumble(slot, p, pn);
+		return;
+	}
+	// report 0x82: ignored
+}
+#define JCCB(N)                                                                \
+	static void jcSet##N(uint8_t r, hid_report_type_t t, uint8_t const *b, \
+			     uint16_t n)                                       \
+	{                                                                      \
+		jcSetCommon(N, r, t, b, n);                                    \
+	}
+// clang-format off
+JCCB(0)
+JCCB(1)
+JCCB(2)
+JCCB(3)
+// clang-format on
+typedef void (*jc_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			   uint16_t);
+static jc_setcb_t const JC_SETCB[NSLOT] = { jcSet0, jcSet1, jcSet2, jcSet3 };
+
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
+void SwitchProController::begin()
+{
+}
+// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the Pro Controller pool.
+uint8_t SwitchProController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SwitchProController::usbIdentity()
+{
+	USBDevice.setID(0x057E, 0x2009);
+	USBDevice.setDeviceVersion(0x0220);
+	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
+	USBDevice.setProductDescriptor("Pro Controller");
+}
+void SwitchProController::beginPool()
+{
+	jcBuildStickCal();
+	loadUserCal();
+	swProLoadCfg();
+	initJcMacs();
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_swPro[s].enableOutEndpoint(true);
+		g_swPro[s].setReportCallback(NULL, JC_SETCB[s]);
+		g_swPro[s].setReportDescriptor(SWPRO_HID_DESC,
+					       sizeof SWPRO_HID_DESC);
+		g_swPro[s].setPollInterval(1);
+		g_swPro[s].begin();
+	}
+}
+void SwitchProController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++) {
+		// Each re-enumeration restarts the host handshake -- clear this USB slot's gate + reply FIFO so we
+		// don't stream 0x30 before the (new) host has re-selected report mode.
+		g_swProReportMode[u] = 0;
+		g_jcQh[u] = g_jcQt[u] = 0;
+		USBDevice.addInterface(g_swPro[u]);
+	}
+}
+void SwitchProController::task()
+{
+	for (uint8_t s = 0; s < g_usbMountCount; s++) {
+		if (!g_swPro[s].ready())
+			continue;
+		// Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst
+		// is one save). Per-slot: each Pro Controller saves its own mirror.
+		if (g_userCalDirty[s] &&
+		    (unsigned long)(millis() - g_userCalDirtyMs[s]) > 250u) {
+			g_userCalDirty[s] = false;
+			saveUserCal((uint8_t)s);
+		}
+		// drain handshake/subcommand replies first (ordered) -- one report per slot per call so a
+		// bursty host-init doesn't starve the streamed 0x30
+		if (g_jcQh[s] != g_jcQt[s]) {
+			JcRep *r = &g_jcQ[s][g_jcQh[s]];
+			if (g_swPro[s].sendReport(r->rid, r->data, JC_REPLEN))
+				g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
+			// one report per slot per call; the rest next loop
+			continue;
+		}
+		// not until the host has finished init + selected 0x30
+		if (g_swProReportMode[s] != 0x30)
+			continue;
+		// The Switch integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores
+		// the timer byte and assumes a 3-samples-per-15ms genuine cadence). Streaming faster (e.g. 250 Hz x 3
+		// = 750 samples/s) over-credits gyro rotation ~3.75x, so residual bias accumulates into the slow
+		// orientation lean that builds over minutes and resets on replug. 15 ms matches the genuine push,
+		// making integration 1:1.
+		uint32_t interval = (g_swProRate == 2) ? USB_STREAM_MS :
+				    (g_swProRate == 1) ? SW_PRO_REPORT_MS_120 :
+							 SW_PRO_REPORT_MS;
+		if (millis() - g_swProLastMs[s] < interval)
+			continue;
+		g_swProLastMs[s] = millis();
+		uint8_t p[63];
+		switchProBuild((uint8_t)s, p);
+		g_swPro[s].sendReport(0x30, p, sizeof p);
+	}
+}

+ 24 - 0
OpenPuckZephyr/src/mode_switch_pro.h

@@ -0,0 +1,24 @@
+// mode_switch_pro.h -- Nintendo Switch Pro Controller personality (MODE_SW_PRO): 057E:2009, report 0x30 + IMU.
+//
+// The genuine Pro Controller requires a USB handshake + subcommand exchange (device info, SPI calibration reads,
+// set-report-mode) before any host (or Steam) will bind it. jcSet() answers that handshake from the USB ISR by
+// enqueuing canonical 0x81/0x21 replies; task() drains them (and, once the host selects report mode 0x30, streams
+// the standard input report with gyro). Linux hid-nintendo hardcodes the 0x30 layout, so we follow it exactly.
+// macOS drops input reports shorter than the descriptor length, so every reply is padded to the full 63 bytes.
+#pragma once
+#include "controllers.h"
+
+class SwitchProController : public IController {
+    public:
+	void begin() override;
+	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
+};
+extern SwitchProController g_switchPro;

+ 502 - 0
OpenPuckZephyr/src/mode_xinput.cpp

@@ -0,0 +1,502 @@
+// mode_xinput.cpp -- Xbox 360 wired (XInput) personality (MODE_XBOX): one XInput gamepad per bonded controller.
+//
+// Real Xbox 360 pads are NOT HID: they use a vendor interface (class 0xFF / sub 0x5D / proto 0x01) carrying a
+// 20-byte XInput report. A custom TinyUSB class driver serves that interface plus a second boot-mouse interface
+// for the right trackpad. Host binds by VID/PID (045E:028E) + the FF/5D/01 interface. The OUT endpoint carries
+// rumble, relayed to the controller as a haptic by task().
+//
+// Multi-controller: one XInput interface per CONNECTED controller (dynamic mount -- see usb_mount.h; the device
+// re-enumerates without rebooting as controllers connect/disconnect). The single class driver routes xfer/open
+// callbacks by endpoint address into the per-bond-slot state; xi_open claims slots in connection order. The
+// right-pad mouse is a single shared interface (only bond slot 0's right-pad drives it).
+#include "mode_xinput.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "usb_mount.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+// custom class-driver API for the XInput interface
+extern "C" {
+#include "device/usbd_pvt.h"
+}
+
+XboxController g_xboxCtl;
+
+// XInput button bits
+enum {
+	XB_DUP = 0x0001,
+	XB_DDOWN = 0x0002,
+	XB_DLEFT = 0x0004,
+	XB_DRIGHT = 0x0008,
+	XB_START = 0x0010,
+	XB_BACK = 0x0020,
+	XB_L3 = 0x0040,
+	XB_R3 = 0x0080,
+	XB_LB = 0x0100,
+	XB_RB = 0x0200,
+	XB_GUIDE = 0x0400,
+	XB_A = 0x1000,
+	XB_B = 0x2000,
+	XB_X = 0x4000,
+	XB_Y = 0x8000
+};
+
+// ===================== XInput custom TinyUSB class driver =====================
+// Custom class driver + an Adafruit_USBD_Interface subclass emitting the interface + 0x21-blob + 2 endpoints.
+// Per-slot state lives in g_xiSlot[]: each XInput interface gets one. The class driver dispatches open/xfer
+// by endpoint address; xinputSend() targets a specific slot's IN endpoint.
+#define XINPUT_DESC_LEN \
+	(9 + 17 + 7 + 7) // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
+
+// per-slot state. `inUse` is set in xi_open from the USB ISR and read in xinputSend from the loop -- mark
+// volatile so the loop doesn't observe a stale 0 across a freshly-attached interface.
+struct XiSlot {
+	uint8_t itf, epIn, epOut;
+	uint8_t inBuf[32], outBuf[32];
+	volatile uint16_t rumbleLow,
+		rumbleHigh; // last host rumble values, x 257
+	volatile unsigned long
+		rumbleMs; // millis of last OUT packet (stuck-rumble watchdog)
+	volatile bool inUse;
+};
+static XiSlot g_xiSlot[NSLOT];
+// Dynamic mount: xi_open is called once per XInput interface in descriptor order; this counts them so the
+// u-th interface claims the u-th connected controller (g_usbToBond[u]). Reset on every (re)enumeration.
+static uint8_t g_xiOpenU = 0;
+// release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
+#define RUMBLE_STUCK_MS 2500u
+
+static void xi_init(void)
+{
+	for (int s = 0; s < NSLOT; s++)
+		g_xiSlot[s].inUse = false;
+}
+static bool xi_deinit(void)
+{
+	return true;
+}
+static void xi_reset(uint8_t rhport)
+{
+	(void)rhport;
+	g_xiOpenU =
+		0; // restart per-interface claim ordering for this enumeration
+	for (int s = 0; s < NSLOT; s++) {
+		g_xiSlot[s].itf = 0;
+		g_xiSlot[s].epIn = g_xiSlot[s].epOut = 0;
+		g_xiSlot[s].inUse = false;
+	}
+}
+// TinyUSB calls xi_open once per XInput interface in the config descriptor. Claim the first free BONDED
+// slot so the xiSlot index matches the bond-slot index (begin() registers them in ascending bond-slot order).
+// Fallback to any free slot when there are no bonds (fresh device).
+static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
+			uint16_t max_len)
+{
+	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
+	      itf->bInterfaceProtocol == 0x01))
+		return 0;
+	// The u-th XInput interface in the descriptor serves the u-th connected controller. g_xiSlot is keyed by
+	// BOND slot so onReport45(bond)/rumble route straight through; only connected bonds get an interface.
+	uint8_t u = g_xiOpenU++;
+	int slot = (u < g_usbMountCount) ? g_usbToBond[u] : -1;
+	if (slot < 0) { // fallback (e.g. map not built): first free slot
+		for (int s = 0; s < NSLOT; s++)
+			if (!g_xiSlot[s].inUse) {
+				slot = s;
+				break;
+			}
+	}
+	if (slot < 0 || slot >= NSLOT || g_xiSlot[slot].inUse)
+		return 0;
+	XiSlot &S = g_xiSlot[slot];
+	S.itf = itf->bInterfaceNumber;
+	uint8_t const *p = (uint8_t const *)itf;
+	uint8_t const *end = p + max_len;
+	uint16_t used = itf->bLength;
+	p += itf->bLength;
+	uint8_t opened = 0;
+	while (p < end && opened < itf->bNumEndpoints) {
+		uint8_t blen = p[0], btype = p[1];
+		if (btype == TUSB_DESC_ENDPOINT) {
+			tusb_desc_endpoint_t const *ep =
+				(tusb_desc_endpoint_t const *)p;
+			usbd_edpt_open(rhport, ep);
+			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
+				S.epIn = ep->bEndpointAddress;
+			else
+				S.epOut = ep->bEndpointAddress;
+			opened++;
+		}
+		used += blen;
+		p += blen;
+	}
+	S.inUse = true;
+	// arm OUT (rumble/LED)
+	if (S.epOut)
+		usbd_edpt_xfer(rhport, S.epOut, S.outBuf, sizeof S.outBuf);
+	return used;
+}
+static bool xi_ctrl(uint8_t rhport, uint8_t stage,
+		    tusb_control_request_t const *req)
+{
+	(void)rhport;
+	(void)req;
+	return stage != CONTROL_STAGE_SETUP;
+}
+// Route an endpoint xfer callback to the slot that owns that endpoint.
+static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t res, uint32_t n)
+{
+	(void)res;
+	for (int s = 0; s < NSLOT; s++) {
+		XiSlot &S = g_xiSlot[s];
+		if (!S.inUse)
+			continue;
+		if (ep == S.epOut) {
+			// XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
+			if (n >= 5 && S.outBuf[0] == 0x00 &&
+			    S.outBuf[1] == 0x08) {
+				uint8_t big = S.outBuf[3], sml = S.outBuf[4];
+				uint16_t lo = (uint16_t)big * 257u;
+				uint16_t hi = (uint16_t)sml * 257u;
+				S.rumbleLow = lo;
+				S.rumbleHigh = hi;
+				S.rumbleMs = millis();
+				hapticSteamRumble(lo, hi, (uint8_t)s);
+			}
+			usbd_edpt_xfer(rhport, S.epOut, S.outBuf,
+				       sizeof S.outBuf);
+			return true;
+		}
+	}
+	return true;
+}
+static const usbd_class_driver_t g_xiDriver = {
+#if CFG_TUSB_DEBUG >= 2
+	.name = "XINPUT",
+#endif
+	.init = xi_init,
+	.deinit = xi_deinit,
+	.reset = xi_reset,
+	.open = xi_open,
+	.control_xfer_cb = xi_ctrl,
+	.xfer_cb = xi_xfer,
+	.sof = NULL
+};
+extern "C" const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *count)
+{
+	*count = 1;
+	return &g_xiDriver;
+}
+
+class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
+    public:
+	uint16_t getInterfaceDescriptor(uint8_t, uint8_t *buf,
+					uint16_t bufsize) override
+	{
+		if (!buf)
+			return XINPUT_DESC_LEN;
+		if (bufsize < XINPUT_DESC_LEN)
+			return 0;
+		uint8_t itfnum = TinyUSBDevice.allocInterface(1);
+		uint8_t epin = TinyUSBDevice.allocEndpoint(TUSB_DIR_IN),
+			epout = TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+		const uint8_t t[XINPUT_DESC_LEN] = {
+			9, TUSB_DESC_INTERFACE, itfnum, 0x00, 0x02, 0xFF, 0x5D,
+			0x01, _strid, 0x11, 0x21, 0x00, 0x01, 0x01, 0x25, epin,
+			0x14, 0x00, 0x00, 0x00, 0x00, 0x13, epout, 0x08, 0x00,
+			0x00, 7, TUSB_DESC_ENDPOINT, epin, TUSB_XFER_INTERRUPT,
+			U16_TO_U8S_LE(0x20),
+
+			// bInterval 1ms (1000Hz) so the RF rate is the only limit
+			1, 7, TUSB_DESC_ENDPOINT, epout, TUSB_XFER_INTERRUPT,
+			U16_TO_U8S_LE(0x20), 8
+		};
+		memcpy(buf, t, XINPUT_DESC_LEN);
+		return XINPUT_DESC_LEN;
+	}
+	bool begin()
+	{
+		return TinyUSBDevice.addInterface(*this);
+	}
+};
+// NSLOT instances: each registers its own XInput interface (own itfnum + IN/OUT ep pair) during begin().
+// begin() in XboxController::begin() runs in order 0..NSLOT-1, so xi_open's first-free assignment lines up.
+static Adafruit_USBD_XInput g_xinput[NSLOT];
+static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
+		       int16_t lx, int16_t ly, int16_t rx, int16_t ry)
+{
+	if (slot >= NSLOT || !g_xiSlot[slot].inUse)
+		return;
+	XiSlot &S = g_xiSlot[slot];
+	if (!tud_mounted() || S.epIn == 0 || usbd_edpt_busy(0, S.epIn))
+		return;
+	uint8_t *r = S.inBuf;
+	r[0] = 0x00;
+	r[1] = 0x14;
+	r[2] = buttons & 0xFF;
+	r[3] = buttons >> 8;
+	r[4] = lt;
+	r[5] = rt;
+	r[6] = lx & 0xFF;
+	r[7] = lx >> 8;
+	r[8] = ly & 0xFF;
+	r[9] = ly >> 8;
+	r[10] = rx & 0xFF;
+	r[11] = rx >> 8;
+	r[12] = ry & 0xFF;
+	r[13] = ry >> 8;
+	memset(r + 14, 0, 6);
+	if (usbd_edpt_claim(0, S.epIn)) {
+		if (!usbd_edpt_xfer(0, S.epIn, r, 20))
+			usbd_edpt_release(0, S.epIn);
+	}
+}
+
+// ===================== right-pad mouse interface =====================
+static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
+static Adafruit_USBD_HID
+	g_mouse; // Xbox-mode mouse interface (right trackpad, slot 0 only)
+
+// ===================== report 0x45 -> XInput + mouse =====================
+// button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright
+static uint16_t codeToXB(uint8_t c)
+{
+	switch (c) {
+	case 1:
+		return XB_A;
+	case 2:
+		return XB_B;
+	case 3:
+		return XB_X;
+	case 4:
+		return XB_Y;
+	case 5:
+		return XB_LB;
+	case 6:
+		return XB_RB;
+	case 7:
+		return XB_L3;
+	case 8:
+		return XB_R3;
+	case 9:
+		return XB_BACK;
+	case 10:
+		return XB_START;
+	case 11:
+		return XB_GUIDE;
+	case 12:
+		return XB_DUP;
+	case 13:
+		return XB_DDOWN;
+	case 14:
+		return XB_DLEFT;
+	case 15:
+		return XB_DRIGHT;
+	default:
+		return 0;
+	}
+}
+static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
+{
+	uint32_t b = btnsOf(r);
+	if (g_qamMap && (b & TB_QAM)) {
+		b &= ~(uint32_t)TB_QAM;
+		b |= tritonFromCode(g_qamMap);
+	}
+	uint16_t btn = 0;
+	if (b & TB_DUP)
+		btn |= XB_DUP;
+	if (b & TB_DDN)
+		btn |= XB_DDOWN;
+	if (b & TB_DLF)
+		btn |= XB_DLEFT;
+	if (b & TB_DRT)
+		btn |= XB_DRIGHT;
+	if (b & TB_VIEW)
+		btn |= XB_START;
+	if (b & TB_MENU)
+		btn |= XB_BACK;
+	if (b & TB_STEAM)
+		btn |= XB_GUIDE;
+	if (b & TB_LB)
+		btn |= XB_LB;
+	if (b & TB_RB)
+		btn |= XB_RB;
+	if (b & TB_L3)
+		btn |= XB_L3;
+	if (b & TB_R3)
+		btn |= XB_R3;
+	// face buttons, with optional A/B + X/Y swap (Nintendo layout)
+	uint16_t fA = g_abSwap ? XB_B : XB_A, fB = g_abSwap ? XB_A : XB_B,
+		 fX = g_abSwap ? XB_Y : XB_X, fY = g_abSwap ? XB_X : XB_Y;
+	if (b & TB_A)
+		btn |= fA;
+	if (b & TB_B)
+		btn |= fB;
+	if (b & TB_X)
+		btn |= fX;
+	if (b & TB_Y)
+		btn |= fY;
+	// back paddles -> configurable mapping (default L4->LB, R4->RB, L5->L3, R5->R3)
+	if (b & TB_L4)
+		btn |= codeToXB(g_back[0]);
+	if (b & TB_R4)
+		btn |= codeToXB(g_back[1]);
+	if (b & TB_L5)
+		btn |= codeToXB(g_back[2]);
+	if (b & TB_R5)
+		btn |= codeToXB(g_back[3]);
+	uint8_t lt = trigU8(u16off(r, 4)),
+		rt = trigU8(u16off(
+			r, 6)); // triggers u16 (half-scale) -> full-range u8
+	// Trigger remaps (codes 19=LT, 20=RT): XInput triggers are analog bytes, not buttons, so a back paddle /
+	// QAM mapped to a trigger pulls it full. QAM arrives as TB_L2/TB_R2 (folded into b via tritonFromCode);
+	// back paddles are matched by their configured code.
+	if (b & TB_L2)
+		lt = 0xFF;
+	if (b & TB_R2)
+		rt = 0xFF;
+	const uint8_t bc[4] = { g_back[0], g_back[1], g_back[2], g_back[3] };
+	const uint32_t bm[4] = { TB_L4, TB_R4, TB_L5, TB_R5 };
+	for (int i = 0; i < 4; i++) {
+		if (!(b & bm[i]))
+			continue;
+		if (bc[i] == 19)
+			lt = 0xFF;
+		else if (bc[i] == 20)
+			rt = 0xFF;
+	}
+	xinputSend(slot, btn, lt, rt, (int16_t)s16off(r, 8),
+		   (int16_t)s16off(r, 10), // L stick X/Y
+		   (int16_t)s16off(r, 12),
+		   (int16_t)s16off(r, 14)); // R stick X/Y
+}
+// Right pad -> mouse on a second HID-mouse interface alongside the XInput gamepad. Same glide model as Lizard's
+// right pad; RPad click = left button, LPad click = right. SINGLE shared mouse: the desktop can only consume
+// one mouse. Slot 0's right-pad drives it; other slots' right-pad input is intentionally ignored here.
+static void rfXboxMouse(const uint8_t *r)
+{
+	uint32_t b = btnsOf(r);
+	static int prx = 0, pry = 0;
+	static bool prt = false;
+	static float vx = 0, vy = 0, rmx = 0, rmy = 0;
+	static uint8_t pmb = 0;
+	bool rtouch = b & TB_RPADT;
+	int rx = s16off(r, 22), ry = s16off(r, 24);
+	if (rtouch) {
+		if (prt) {
+			vx += (rx - prx);
+			vy += (ry - pry);
+		}
+		prx = rx;
+		pry = ry;
+	}
+	prt = rtouch;
+	float mxf = vx / (float)(g_mDiv * 10) + rmx,
+	      myf = -(vy / (float)(g_mDiv * 10)) +
+		    rmy; // Y inverted for screen coords
+	int dx = (int)mxf, dy = (int)myf;
+	rmx = mxf - dx;
+	rmy = myf - dy; // sub-pixel carry
+	if (dx > 127)
+		dx = 127;
+	if (dx < -127)
+		dx = -127;
+	if (dy > 127)
+		dy = 127;
+	if (dy < -127)
+		dy = -127;
+	float f = g_mFric / 100.0f;
+	vx *= f;
+	vy *= f;
+	if (vx > -1 && vx < 1)
+		vx = 0;
+	if (vy > -1 && vy < 1)
+		vy = 0; // friction = glide/decay
+	uint8_t mb = ((b & TB_RPADC) ? 1 : 0) |
+		     ((b & TB_LPADC) ?
+			      2 :
+			      0); // RPad click = left, LPad click = right
+	if (dx || dy || mb != pmb) {
+		pmb = mb;
+		hid_mouse_report_t m;
+		m.buttons = mb;
+		m.x = (int8_t)dx;
+		m.y = (int8_t)dy;
+		m.wheel = 0;
+		m.pan = 0;
+		if (g_mouse.ready())
+			g_mouse.sendReport(0, &m, sizeof m);
+	}
+}
+
+// ===================== IController =====================
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
+void XboxController::begin()
+{
+}
+// XInput slot interfaces are a custom (non-HID) class, so they don't draw on the CFG_TUD_HID budget (only the
+// wake mouse + right-pad mouse do). Slots are limited by NSLOT / USB endpoints, not HID instances.
+uint8_t XboxController::maxSlots() const
+{
+	return (uint8_t)NSLOT;
+}
+void XboxController::usbIdentity()
+{
+	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
+	USBDevice.setID(0x045E, 0x028E);
+	USBDevice.setDeviceVersion(0x0120);
+	USBDevice.setManufacturerDescriptor("Microsoft");
+	USBDevice.setProductDescriptor("Controller");
+}
+void XboxController::beginPool()
+{
+	// Lock the right-pad mouse HID instance (wake mouse was begun first by setup -> this is HID instance 1).
+	g_mouse.setStringDescriptor("OpenPuck Mouse");
+	g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
+	g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC);
+	g_mouse.setPollInterval(1);
+	g_mouse.begin();
+	for (int s = 0; s < NSLOT; s++)
+		g_xinput[s].setStringDescriptor("Controller");
+}
+void XboxController::mountSlots(uint8_t k)
+{
+	// Right-pad mouse first (fixed HID), then one XInput interface per connected controller (claimed in
+	// order by xi_open). The wake mouse + WebUSB are added around this by usbReenumerate.
+	USBDevice.addInterface(g_mouse);
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_xinput[u]);
+}
+void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
+				uint8_t bodyTlen)
+{
+	(void)fresh;
+	(void)bodyTlen;
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	rfXboxGamepad((uint8_t)slot, rep);
+	// Shared desktop mouse: slot 0's right-pad only. Other slots' right-pad is still part of the
+	// per-slot XInput (TB_RPADT in the gamepad report is unused today but kept for forward-compat).
+	if (slot == 0)
+		rfXboxMouse(rep);
+}
+// Lost-stop watchdog per slot: Steam/Triton rumble is latched, so force a zero report if the host stops
+// refreshing on a particular XInput. The haptics.cpp global watchdog fires too, but it skips while the slot
+// is in the post-reconnect block -- this catches a host that kept streaming while we were blocked.
+void XboxController::task()
+{
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_xiSlot[s].inUse)
+			continue;
+		if ((g_xiSlot[s].rumbleLow || g_xiSlot[s].rumbleHigh) &&
+		    millis() - g_xiSlot[s].rumbleMs > RUMBLE_STUCK_MS) {
+			g_xiSlot[s].rumbleLow = g_xiSlot[s].rumbleHigh = 0;
+			hapticSteamRumble(0, 0, (uint8_t)s);
+		}
+	}
+}

+ 28 - 0
OpenPuckZephyr/src/mode_xinput.h

@@ -0,0 +1,28 @@
+// mode_xinput.h -- Xbox 360 wired (XInput) personality (MODE_XBOX): XInput gamepad + right-pad mouse.
+//
+// Real Xbox 360 pads are NOT HID: they use a vendor interface (class 0xFF / sub 0x5D / proto 0x01) carrying a
+// 20-byte XInput report. A custom TinyUSB class driver serves that interface plus a second boot-mouse interface
+// for the right trackpad. Host binds by VID/PID (045E:028E) + the FF/5D/01 interface. The OUT endpoint carries
+// rumble, relayed to the controller as a haptic by task().
+#pragma once
+#include "controllers.h"
+#include <stdint.h>
+
+class XboxController : public IController {
+    public:
+	void begin() override;
+	void onReport45(int slot, const uint8_t *rep, bool fresh,
+			uint8_t bodyTlen) override;
+
+	// legacy XInput rumble -> haptic relay + stuck-rumble watchdog
+	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
+};
+extern XboxController g_xboxCtl;

+ 648 - 0
OpenPuckZephyr/src/puck_hid.cpp

@@ -0,0 +1,648 @@
+#include "puck_hid.h"
+#include "bonds.h"
+#include "config.h"
+#include "identity.h"
+#include "haptics.h"
+#include "rf_link.h"
+#include "triton.h"
+#include "mode_lizard.h"
+#include "wake_hid.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+uint8_t g_fwdNewOnly = 1;
+SteamPuckController g_steamPuck;
+
+// Cloned puck HID report descriptor: mouse(0x40)+keyboard(0x41)+vendor(FF00) with the 63-byte FEATURE
+// command reports on report id 1/2. Each of the 4 interfaces uses this.
+static const uint8_t PUCK_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
+	0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
+	0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
+	0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
+	0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
+	0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
+	0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
+	0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
+	0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
+	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
+	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
+	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
+	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
+	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0
+};
+
+// Pure MODE_LIZARD only: same puck/lizard descriptor plus a tiny Consumer Control report for media keys.
+// MODE_STEAM must keep PUCK_HID_DESC verbatim so Steam's controller/haptics path sees the normal puck shape.
+static const uint8_t PUCK_LIZARD_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
+	0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
+	0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
+	0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
+	0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
+	0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
+	0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
+	0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
+	0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
+	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
+	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
+	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
+	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
+	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
+	0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x03, 0x15, 0x00, 0x25, 0x01,
+	0x09, 0xE9, 0x09, 0xEA, 0x75, 0x01, 0x95, 0x02, 0x81, 0x02, 0x75, 0x06,
+	0x95, 0x01, 0x81, 0x01, 0xC0
+};
+
+static Adafruit_USBD_HID hid[NSLOT];
+
+// ===================== seamless LIZARD decision =====================
+// Steam, while running, re-sends settings report 0x87 (lizard-off) every ~3s as a heartbeat (captured on HW),
+// and ANY OUTPUT report likewise stamps g_steamAliveMs. When the heartbeat stops we fall back to lizard, so the
+// controller drives desktop keyboard+mouse whenever Steam isn't running. MODE_LIZARD forces lizard always.
+
+// millis of last Steam OUTPUT/settings write; 0 at boot => lizard until Steam appears
+static unsigned long g_steamAliveMs = 0;
+// Fall back to lizard this long after Steam's ~3s settings heartbeat stops. Keep >2x the cadence: the haptic
+// relay is gated by !lizardActive(), so a shorter window lets a jittered/delayed heartbeat flip lizard
+// mid-session while Steam is still running -> a haptic arriving in that window gets gated out (dropped). 7s
+// tolerates one missed beat. Do not shorten without a lizard trigger independent of the heartbeat timeout.
+#define LIZARD_WD_MS 7000u
+static bool g_autoLizard =
+	true; // master switch; false => Steam mode always forwards 0x45
+// Single source of truth, shared by the USB input path AND the haptic relay gate: if we ever relay a 0x82 to
+// the controller while presenting lizard (Steam isn't reading 0x45 back), Steam loops the same haptic -> buzz.
+static inline bool steamDrivingGamepad()
+{
+	return g_steamAliveMs && (millis() - g_steamAliveMs < LIZARD_WD_MS);
+}
+static inline bool lizardActive()
+{
+	return modeIsPuck(g_usbMode) &&
+	       (g_usbMode == MODE_LIZARD ||
+		(g_autoLizard && !steamDrivingGamepad()));
+}
+static inline void hostStampAlive()
+{
+	g_steamAliveMs = millis();
+}
+// Right after the host resumes from suspend, MUTE input forwarding briefly. Otherwise the controller's input
+// in that instant (a trackpad click/trigger, or residual button state from the wake gesture) gets forwarded
+// as a real click/keypress into the just-woken desktop. Set when task() sees the suspended->active transition.
+static unsigned long g_resumeMs = 0;
+#define POST_RESUME_MUTE_MS 1500u
+
+// ===================== puck feature command channel =====================
+// `slot` is the interface index (interface N == bond slot N).
+static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
+		      uint8_t const *b, uint16_t n)
+{
+	// Steam OUTPUT reports 0x80-0x89. The haptic/actuator reports (0x80-0x86) are relayed to the controller,
+	// and ONLY when they arrive on the CONNECTED slot's interface: we have one controller but expose 4 puck
+	// slots, and a report aimed at a DIFFERENT slot made the controller buzz at random (the slot gate below
+	// fixes that). Must NOT clamp to 0x82-only: ping/grip/test haptics ride other report IDs (0x85/0x86). The
+	// 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and reach the controller via the
+	// feature-0x01 passthrough path instead.
+	if (type == HID_REPORT_TYPE_OUTPUT) {
+		if (rid >= 0x80 && rid <= 0x89) {
+			// capture ALL OUTPUT reports (even un-relayed) for the 'H' dump
+			hapLogAdd((uint8_t)slot, rid, b, n);
+
+			// ANY Steam OUTPUT report (not just the 0x87 heartbeat) means Steam is present and driving ->
+			// leave lizard for gamepad NOW, so a haptic arriving before the first 0x87 isn't relayed while
+			// we're still presenting lizard (-> buzz loop).
+			hostStampAlive();
+		}
+		// Post-resume mute also gates haptics: while onReport45 is muted Steam reads NO 0x45 back, which is the
+		// exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop).
+		bool muted = g_resumeMs &&
+			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
+		if (rid >= 0x80 && rid <= 0x86 && n >= 1 &&
+		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
+			if (!haptic82Blocked(slot)) {
+				relayEnqueue(rid, b,
+					     (uint8_t)(n > RELAY_MAXP ?
+							       RELAY_MAXP :
+							       n),
+					     (uint8_t)slot);
+				// Track on/off from what was actually RELAYED (= the controller's believed state): a
+				// BLOCKED stop must leave "on" set (controller may be latched -> reconnect stop-burst),
+				// a blocked ON must not set it (nothing reached the controller -> no spurious clicks).
+				if (rid == 0x82)
+					haptic82HostReport(b, n);
+			}
+		}
+
+		if (Serial.availableForWrite() > 80) {
+			Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid,
+				      n);
+			for (uint16_t i = 0; i < n && i < 14; i++)
+				Serial.printf(" %02X", b[i]);
+			Serial.println();
+		}
+		return;
+	}
+	if (type != HID_REPORT_TYPE_FEATURE || n < 1)
+		return;
+	Slot &S = g_slot[slot];
+	uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
+	const uint8_t *pl = b + 2;
+	uint16_t pln = (n >= 2) ? n - 2 : 0;
+
+	// Capture EVERY feature SET (the whole cmd channel: 0x83 attr, 0xAE strings, 0xB4 conn, 0xA2/A3 bond, the
+	// feature-1 relay, AND any host battery query) to the WebUSB ring so the panel's capture view shows it.
+	hapLogAdd((uint8_t)slot, cmd, b, n);
+
+	// settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
+	if (cmd >= 0x80 && cmd <= 0x89)
+		hostStampAlive();
+	// Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
+	// confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown()
+	// bursts it for NO-ACK reliability (the single hook the test button + host-suspend also drive).
+	if (rid == 1 && cmd == 0x9F)
+		hapticSendShutdown();
+
+	// report 0x01 = raw passthrough -> queue for RF relay to the controller
+	if (rid == 1 && n >= 2) {
+		// (feature-1 commands -- haptics, LED, 0x87 settings, 0x9F power-off -- are captured by the
+		// general feature-SET hapLogAdd above.)
+		bool haptic82 = (cmd == 0x82 && len <= pln);
+
+		bool muted = g_resumeMs &&
+			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
+
+		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
+		bool relayOk = hapticRelaySlotOk(slot) &&
+			       !(haptic82 && (lizardActive() || muted));
+		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
+			// Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's
+			// multi-register 0x87 settings blocks (LED brightness) and calibration writes exceed the old
+			// 18B cap, and the chopped frames were why those settings never landed on the controller.
+			uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
+			if (len > RELAY_MAXP && Serial.availableForWrite() > 60)
+				Serial.printf(
+					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
+					cmd, len, (unsigned)RELAY_MAXP);
+			relayEnqueue(cmd, pl, rl, (uint8_t)slot);
+
+			// track from RELAYED frames only (see the OUTPUT path)
+			if (haptic82)
+				haptic82HostReport(pl, len);
+		}
+	}
+	if (Serial.availableForWrite() > 80) {
+		Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid,
+			      cmd, len);
+		for (uint16_t i = 0; i < n && i < 14; i++)
+			Serial.printf(" %02X", b[i]);
+		Serial.println();
+	}
+	memset(S.resp, 0, sizeof S.resp);
+	S.resp_len = 0;
+	switch (cmd) {
+	case 0x83:
+		S.resp[0] = 0x83;
+		S.resp[1] = sizeof ATTR83;
+		memcpy(S.resp + 2, ATTR83, sizeof ATTR83);
+		S.resp_len = 63;
+		break;
+	case 0xAE: {
+		uint8_t idx = pln > 0 ? pl[0] : 1;
+		const char *s = (idx == 0) ? g_board :
+				(idx == 1) ? g_unit :
+					     "NA";
+		S.resp[0] = 0xAE;
+		S.resp[1] = 0x14;
+		S.resp[2] = idx;
+		memset(S.resp + 3, 0, 60);
+		memcpy(S.resp + 3, s, strlen(s));
+		S.resp_len = 63;
+		break;
+	}
+
+	// connection/version state per slot: value 0x02 = controller connected, 0x01 = not
+	case 0xB4:
+		// SDL Triton polls this on init; treat like Steam contact so we forward 0x45
+		hostStampAlive();
+		S.resp[0] = 0xB4;
+		S.resp[1] = 0x01;
+		S.resp[2] = (slot >= 0 && slot < NSLOT && !g_xbox &&
+			     g_slot[slot].used &&
+			     (millis() - g_connReplyMs[slot] < 500)) ?
+				    0x02 :
+				    0x01;
+		S.resp_len = 63;
+		break;
+	case 0xAD:
+		g_pairing = (pln > 0 && pl[0] != 0);
+		Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
+		S.resp[0] = 0xAD;
+		S.resp[1] = 0;
+		S.resp_len = 63;
+		break;
+	case 0xA2: // write/clear THIS interface's slot
+		if (len >= 24 && pln >= 24) {
+			if (recEmpty(pl)) {
+				S.used = false;
+				memset(S.rec, 0, 24);
+			} else {
+				memcpy(S.rec, pl, 24);
+				S.used = true;
+			}
+			g_dirty = true;
+			Serial.printf("# slot %d %s\n", slot,
+				      recEmpty(pl) ? "cleared" : "bonded");
+		}
+		S.resp[0] = 0xA2;
+		S.resp[1] = 0;
+		S.resp_len = 63;
+		break;
+	case 0xA3: // read THIS interface's slot
+		S.resp[0] = 0xA3;
+		S.resp[1] = 0x18;
+		memset(S.resp + 2, 0, 24);
+		if (S.used)
+			memcpy(S.resp + 2, S.rec, 24);
+		S.resp_len = 63;
+		break;
+	default:
+		S.resp[0] = cmd;
+		S.resp[1] = len;
+		if (pln)
+			memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln);
+		S.resp_len = 63;
+		break;
+	}
+}
+static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
+			  uint8_t *buf, uint16_t reqlen)
+{
+	(void)rid;
+	if (type != HID_REPORT_TYPE_FEATURE)
+		return 0;
+
+	// host reading feature data => gamepad consumer active (Steam/SDL)
+	if (g_usbMode == MODE_STEAM)
+		hostStampAlive();
+	Slot &S = g_slot[slot];
+	uint16_t n = S.resp_len ? S.resp_len : 63;
+	if (n > reqlen)
+		n = reqlen;
+	memcpy(buf, S.resp, n);
+	// Battery diagnostic: in gamepad (Steam) mode battery is read host-side via the feature channel, NOT the
+	// forwarded 0x43 (that path is verbatim-identical to lizard mode, where battery works). Capture what Steam
+	// GETs so a WebUSB-panel (or CDC) capture in Steam mode shows the report id it polls for battery (then we
+	// answer it with g_battery in handleSet). De-duped by report id so the high-rate polling doesn't flood the
+	// ring/console -- a freshly-requested id is logged once, then again only after 1s.
+	{
+		static uint8_t lastRid = 0xFF;
+		static unsigned long lastMs = 0;
+		if (rid != lastRid || millis() - lastMs > 1000) {
+			lastRid = rid;
+			lastMs = millis();
+			// ring marker 0xFC = "host feature GET" (panel renders it as "GET rid=.."); payload = what we returned
+			hapLogAdd(0xFC, rid, S.resp, n);
+			if (Serial.availableForWrite() > 80)
+				Serial.printf(
+					"# GET if%d rid=%02X reqlen=%u -> %02X %02X %02X (batt=%u%%)\n",
+					slot, rid, reqlen, S.resp[0], S.resp[1],
+					S.resp[2],
+					(slot >= 0 && slot < NSLOT) ?
+						g_battery[slot] :
+						0);
+		}
+	}
+	return n;
+}
+
+// one callback pair per interface (the Adafruit core routes by interface to the matching object)
+#define SLOTCB(N)                                                              \
+	static void setcb##N(uint8_t r, hid_report_type_t t, uint8_t const *b, \
+			     uint16_t n)                                       \
+	{                                                                      \
+		handleSet(N, r, t, b, n);                                      \
+	}                                                                      \
+	static uint16_t getcb##N(uint8_t r, hid_report_type_t t, uint8_t *bf,  \
+				 uint16_t rl)                                  \
+	{                                                                      \
+		return handleGet(N, r, t, bf, rl);                             \
+	}
+// clang-format off
+SLOTCB(0)
+SLOTCB(1)
+SLOTCB(2)
+SLOTCB(3)
+// clang-format on
+typedef uint16_t (*getcb_t)(uint8_t, hid_report_type_t, uint8_t *, uint16_t);
+typedef void (*setcb_t)(uint8_t, hid_report_type_t, uint8_t const *, uint16_t);
+static getcb_t GETCB[NSLOT] = { getcb0, getcb1, getcb2, getcb3 };
+static setcb_t SETCB[NSLOT] = { setcb0, setcb1, setcb2, setcb3 };
+
+// ===================== IController =====================
+void SteamPuckController::begin()
+{
+	USBDevice.setID(0x28DE, 0x1304);
+	// Distinct bcdDevice so Windows keys a FRESH usbflags entry (cache is VID:PID:bcdDevice) and actually runs
+	// MS OS 2.0 / WinUSB binding for the WebUSB vendor interface, instead of reusing a stale "no WinUSB" entry
+	// tied to the real Steam Controller (28DE:1304, no WebUSB interface). The normal (wake-mouse) and one-shot
+	// debug (CDC) boots present DIFFERENT interface sets, so they need DIFFERENT bcdDevice values or Windows
+	// serves one's cached descriptor for the other across a reboot.
+	USBDevice.setDeviceVersion(
+		g_debugCdcThisBoot ?
+			0x0212 :
+			(g_usbMode == MODE_LIZARD ? 0x0213 : 0x0211));
+	USBDevice.setManufacturerDescriptor("Valve Software");
+	USBDevice.setProductDescriptor("Steam Controller Puck");
+	const uint8_t *desc = (g_usbMode == MODE_LIZARD) ?
+				      PUCK_LIZARD_HID_DESC :
+				      PUCK_HID_DESC;
+	const uint16_t descLen = (g_usbMode == MODE_LIZARD) ?
+					 sizeof PUCK_LIZARD_HID_DESC :
+					 sizeof PUCK_HID_DESC;
+	for (int i = 0; i < NSLOT; i++) {
+		hid[i].setReportDescriptor(desc, descLen);
+		hid[i].setReportCallback(GETCB[i], SETCB[i]);
+
+		// 1ms USB poll (was default 10ms = 100/s cap -> choppy)
+		hid[i].setPollInterval(1);
+		hid[i].begin();
+	}
+}
+
+// Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. PURELY a
+// USB-side decision -- changes nothing about the RF poll or the host->controller relay. Per-slot: each
+// controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard remains
+// single-controller (slot 0); the other slots stay quiet in lizard mode -- one mouse + one keyboard is
+// the only thing a desktop can consume, and the comment in mode_lizard.h documents this.
+void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
+				     uint8_t bodyTlen)
+{
+	(void)fresh;
+	(void)bodyTlen;
+	// Host asleep -> forward NOTHING. While suspended, every sendReport attempt can translate into a host wake,
+	// making the PC wake on any controller movement. Waking is an explicit gesture only (Steam-button short
+	// press / controller connect), handled in rf_link.cpp via the device-level USB resume signal.
+	if (USBDevice.suspended())
+		return;
+	// Just woke? Hold off forwarding so the wake gesture's residual controller input doesn't click/type into
+	// the freshly-woken desktop.
+	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
+		return;
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	if (!g_slot[slot].used)
+		return;
+	if (lizardActive()) {
+		// Lizard single-controller: only slot 0's interface drives the mouse/keyboard. Other slots
+		// stay quiet in lizard mode.
+		if (slot == 0)
+			rfLizard(rep, &hid[0], &hid[0], 0x40, 0x41);
+	} else {
+		uint8_t blen = bodyTlen - 1;
+		if (blen > 45)
+			blen = 45; // body after the 0x45 id byte
+		// forward the puck's raw pad coords untouched (Steam does its own interpolation/smoothing). Forward
+		// only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
+		// stair-step. g_fwdNewOnly toggles for A/B.
+		if ((fresh || !g_fwdNewOnly) && hid[slot].ready())
+			hid[slot].sendReport(
+				0x45, rep + 1,
+				blen); // Steam/SDL Triton: input report 0x45
+	}
+}
+
+// Forward the controller's NON-input status reports (0x43 power/battery, 0x44) to Steam verbatim -- the real
+// puck does this and it's how Steam reads battery. Same host-asleep / post-resume gating as 0x45; no lizard
+// path (status reports aren't input, so they forward regardless of the lizard decision). Per-slot: each
+// controller's status goes to its own hid[slot].
+void SteamPuckController::onAuxReport(int slot, uint8_t rid,
+				      const uint8_t *data, uint8_t n)
+{
+	if (USBDevice.suspended())
+		return;
+	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
+		return;
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	// Forward the controller's status report VERBATIM (the real puck does this; it's how the host reads
+	// battery). Padding the report to the descriptor-declared length broke battery in both lizard and
+	// Steam, so it's reverted -- send exactly what the controller sent.
+	if (g_slot[slot].used && hid[slot].ready()) {
+		// capture the pushed status report (0x43 battery / 0x44) device->host for the WebUSB panel: this is
+		// the channel Steam actually reads battery from; marker 0xFB = "->host push".
+		hapLogAdd(0xFB, rid, data, n);
+		hid[slot].sendReport(rid, data, n);
+	}
+}
+
+// wake nudge: a bare USB resume signal is NOT enough to wake some hosts (Windows in particular) -- they only
+// wake when actual mouse/keyboard input follows. So on a deliberate wake gesture we play a HARMLESS mouse
+// JIGGLE (move a few px right, then back -- NET ZERO cursor, NO button): real mouse activity wakes the host
+// but clicks/activates nothing (an open Start menu stays open). Queued by wakeEvent() (rf_link, on a Steam
+// short press / controller connect while suspended); delivered once the suspended bus has resumed. Per-slot
+// so every connected interface gets the jiggle (Windows can credit any of them with the wake).
+static uint8_t g_nudgeStep[NSLOT] = { 0 }; // 0=idle; 1=jiggle+, 2=jiggle-
+static unsigned long g_nudgeMs[NSLOT] = { 0 };
+#define NUDGE_JIGGLE_PX 10
+void SteamPuckController::wakeEvent()
+{
+	// arm the jiggle on every connected slot -- any one of them is enough to wake Windows
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used) {
+			g_nudgeStep[s] = 1;
+			g_nudgeMs[s] = millis();
+		}
+	}
+}
+static void wakeNudgeTask()
+{
+	if (USBDevice.suspended())
+		return; // wait for resume; reports can't cross a suspended bus
+	// Expire stale arms (bus never resumed) and see if any slot still wants a wake nudge.
+	bool armed = false;
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_nudgeStep[s])
+			continue;
+		if (millis() - g_nudgeMs[s] > 5000) {
+			g_nudgeStep[s] = 0;
+			continue;
+		}
+		armed = true;
+	}
+	if (!armed)
+		return;
+	// Ride the BOOT MOUSE -- the interface Windows armed as the wake source; a gamepad-slot report does not
+	// wake Modern Standby. A single jiggle on it wakes the host regardless of how many slots are connected.
+	if (wakeHidPresent()) {
+		if (!wakeHidReady())
+			return;
+		static unsigned long stepMs = 0;
+		static uint8_t step = 1;
+		if (millis() - stepMs < 15)
+			return; // pace the edges
+		stepMs = millis();
+		wakeHidMove((step == 1) ? NUDGE_JIGGLE_PX : -NUDGE_JIGGLE_PX,
+			    0);
+		if (step >=
+		    2) { // jiggle (right, then back) delivered -> disarm every slot
+			step = 1;
+			for (int s = 0; s < NSLOT; s++)
+				g_nudgeStep[s] = 0;
+		} else
+			step++;
+		return;
+	}
+	// Fallback (debug-CDC boot: no wake mouse): per-slot jiggle on the gamepad slot HIDs.
+	static unsigned long stepMs[NSLOT] = { 0 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_nudgeStep[s])
+			continue;
+		if (!hid[s].ready())
+			continue;
+		if (millis() - stepMs[s] < 15)
+			continue; // pace the edges
+		stepMs[s] = millis();
+		hid_mouse_report_t m;
+		m.buttons = 0;
+		m.x = (g_nudgeStep[s] == 1) ? NUDGE_JIGGLE_PX :
+					      -NUDGE_JIGGLE_PX;
+		m.y = 0;
+		m.wheel = 0;
+		m.pan = 0;
+		hid[s].sendReport(0x40, &m,
+				  sizeof m); // jiggle right, then back
+		g_nudgeStep[s] = (g_nudgeStep[s] >= 2) ?
+					 0 :
+					 (uint8_t)(g_nudgeStep[s] + 1);
+	}
+}
+
+// USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
+// edge-triggered, + periodic 0x7B status. Live-captured: this is what Steam reads to mark the controller
+// connected. Without it Steam shows disconnected even though 0x45 input is streaming.
+void SteamPuckController::task()
+{
+	wakeNudgeTask();
+	{
+		static bool wasSusp = false;
+
+		// stamp the suspended->active edge for the post-resume mute
+		bool susp = USBDevice.suspended();
+		if (wasSusp && !susp)
+			g_resumeMs = millis();
+		wasSusp = susp;
+	}
+
+	// no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
+	if (USBDevice.suspended())
+		return;
+	// Per-slot 0x79/0x7B: each connected slot reports its OWN edge and its OWN status. State arrays are
+	// per-slot so each controller's "connected" edge fires once and is re-sent only until Steam acks THAT
+	// slot. The real puck's per-slot edge-triggered 0x79 prevents re-triggering Steam's connect-chime loop.
+	static bool usbConn[NSLOT] = { 0 };
+	static unsigned long last79[NSLOT] = { 0 }, last7B[NSLOT] = { 0 },
+			     connEdgeMs[NSLOT] = { 0 }, last43[NSLOT] = { 0 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_slot[s].used || !hid[s].ready())
+			continue;
+		bool conn = (millis() - g_connReplyMs[s] < 300);
+		// 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
+		// settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; an
+		// unconditional forever-resend re-triggers Steam's connect handling (connect chime) every 750ms before
+		// Steam consumes 0x45 -> a loop of connect-time haptic buzzes. Resending until acked still covers
+		// "Steam missed the edge".
+		bool steamAcked = g_steamAliveMs &&
+				  (int32_t)(g_steamAliveMs - connEdgeMs[s]) >=
+					  0;
+		if (conn != usbConn[s] ||
+		    (conn && !steamAcked && millis() - last79[s] >= 750)) {
+			if (conn && !usbConn[s])
+				connEdgeMs[s] = millis();
+			uint8_t st = conn ? 0x02 : 0x01;
+			hapLogAdd(0xFB, 0x79, &st, 1); // ->host push (capture)
+			hid[s].sendReport(0x79, &st, 1);
+			usbConn[s] = conn;
+			last79[s] = millis();
+		} else if (conn && millis() - last7B[s] >= 2000) {
+			// 0x7B status, live-captured template. Byte 8 is the controller->puck signal strength as signed
+			// dBm (capture showed 0xDD = -35) -- patch in the smoothed RSSI the radio samples on each
+			// controller reply (rf_link). 0 = no sample yet -> keep the capture value rather than garbage.
+			//
+			// CALIBRATION: our raw RSSISAMPLE reads ~RSSI_DBM_OFFSET dB lower than the real Valve puck at the
+			// same distance (Pro Micro PCB-trace antenna vs Valve's tuned front-end). The 2Mbit ESB link has
+			// ~55dB margin, so a -75dBm reading still works across a house, but Steam's bar maps raw dBm to
+			// "weak" long before that. The offset lines our close-range value up with the puck's captured -35
+			// so the bar tracks usable range, not antenna gain. Clamp keeps it in a sane window.
+			uint8_t s7b[12] = { 0xF7, 0x01, 0x89, 0x00, 0x00, 0x00,
+					    0x03, 0x00, 0xDD, 0x00, 0x3A, 0x02 };
+			if (g_linkRssi[s]) {
+				int mag = (int)g_linkRssi[s] - RSSI_DBM_OFFSET;
+				if (mag < 25)
+					mag = 25;
+				else if (mag > 95)
+					mag = 95;
+				s7b[8] = (uint8_t)(0u - (uint8_t)mag);
+			}
+			hapLogAdd(0xFB, 0x7B, s7b, 12); // ->host push (capture)
+			hid[s].sendReport(0x7B, s7b, 12);
+			last7B[s] = millis();
+		}
+		// Synthesized 0x43 = ID_TRITON_BATTERY_STATUS for SDL/Steam's gamepad driver. The verbatim forward of
+		// the controller's own 0x43 (onAuxReport) is what the LIZARD/kernel path reads, but SDL's Triton driver
+		// requires the FULL TritonBatteryStatus_t length (r >= 1 + 14) and lapses to the "wired" glyph without a
+		// fresh one -- so we push a clean 14-byte report from g_battery every 2s. Body: [ucChargeState][ucBattery
+		// Level][voltages/current/temp = 0]; SDL only reads the first two. Map unknown/reset state -> discharging
+		// so it shows ON_BATTERY + % rather than UNKNOWN. Skipped until the controller has reported a level.
+		if (conn && g_battery[s] && millis() - last43[s] >= 2000) {
+			uint8_t st = g_batteryState[s];
+			// EChargeState discharging -> SDL_POWERSTATE_ON_BATTERY
+			if (st != 1 && st != 2 && st != 4)
+				st = 1;
+			uint8_t b43[14] = { 0 };
+			b43[0] = st; // ucChargeState
+			b43[1] = g_battery[s]; // ucBatteryLevel (percent)
+			hapLogAdd(0xFB, 0x43, b43, 14); // ->host push (capture)
+			hid[s].sendReport(0x43, b43, sizeof b43);
+			last43[s] = millis();
+		}
+	}
+	// Reset edge state for slots that are no longer used/ready (so a re-bond sees a fresh edge).
+	for (int s = 0; s < NSLOT; s++)
+		if (!g_slot[s].used || !hid[s].ready())
+			usbConn[s] = false;
+}

+ 36 - 0
OpenPuckZephyr/src/puck_hid.h

@@ -0,0 +1,36 @@
+// puck_hid.h -- the Steam Controller puck personality (MODE_STEAM + MODE_LIZARD).
+//
+// This is the controller that wears Valve's Proteus puck identity (28DE:1304) and exposes the four puck HID slot interfaces
+// (interface N owns bond slot N). It implements:
+//   - the puck feature COMMAND CHANNEL (handleSet/handleGet): reports 0x83/0xAE/0xB4/0xAD/0xA2/0xA3 the host
+//     uses to read attributes/serials and read/write/clear bond slots, plus the host->controller haptic relay
+//     ride-along on OUTPUT reports 0x80-0x86 and feature passthrough (report 0x01).
+//   - the seamless LIZARD decision: when Steam is driving the gamepad (recent OUTPUT/heartbeat) we forward the
+//     gamepad report 0x45; when Steam is closed we present keyboard+mouse on the SAME interface. MODE_LIZARD
+//     forces lizard always. lizardActive() is the single source of truth, shared with the haptic gate so we
+//     never relay haptics while presenting lizard (Steam isn't reading 0x45 back -> would buzz-loop).
+//   - the USB connection-state presentation (reports 0x79 / 0x7B) Steam reads to mark the controller connected.
+#pragma once
+#include "controllers.h"
+#include <stdint.h>
+
+// Forward report 0x45 only when the seq advanced (dedupe like the real puck); sending stale
+// repeats makes Steam's trackpad smoothing stair-step. (WebUSB field 14)
+extern uint8_t g_fwdNewOnly;
+
+class SteamPuckController : public IController {
+    public:
+	void begin() override;
+	void onReport45(int slot, const uint8_t *rep, bool fresh,
+			uint8_t bodyTlen) override;
+	void onAuxReport(int slot, uint8_t rid, const uint8_t *data, uint8_t n)
+		override; // forward controller 0x43/0x44 status to Steam
+	void task() override;
+
+	void wakeEvent() override;
+	bool isPuck() const override
+	{
+		return true;
+	}
+};
+extern SteamPuckController g_steamPuck;

+ 148 - 0
OpenPuckZephyr/src/radio.cpp

@@ -0,0 +1,148 @@
+#include "radio.h"
+#include "bonds.h" // g_slot[]
+
+// Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key preceding "ibex" is a DIFFERENT key,
+// NOT the discovery address.
+const uint8_t PAIR_BASE[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
+uint8_t g_rfPrefix = 0x10;
+uint8_t g_rfCh = 2;
+uint8_t g_rfBase[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
+// ch18: clean channel in the real puck's active hop set {18,2,80}; bad channels collide with trackpad
+// data bursts -> reply-rate crash. Tunable 'C'.
+uint8_t g_sessCh = 18;
+
+// Safe defaults; rfGenSessionAddr(slot) overwrites each used slot at boot. Discovery base "ibex" as a
+// degenerate starting point means an UN-bonded slot (and a not-yet-initialized one) won't accidentally
+// cross-talk with itself before the real address is set.
+uint8_t g_sessBase[NSLOT][4];
+uint8_t g_sessPrefix[NSLOT];
+static void sessSetDefault(int slot)
+{
+	for (int i = 0; i < 4; i++)
+		g_sessBase[slot][i] = g_rfBase[i];
+	g_sessPrefix[slot] = g_rfPrefix;
+}
+void rfGenSessionAddr(int slot)
+{
+	if (slot < 0 || slot >= NSLOT) {
+		sessSetDefault(0);
+		return;
+	}
+	sessSetDefault(slot);
+	// Per-bond UUID (rec[0..8]) + FICR DEVICEID (8 bytes) -> 5-byte on-air address. Mixing the bond UUID in
+	// makes the address deterministic per (puck, controller) pair: a slot's address is stable across reboots
+	// without storing anything new in bonds.bin, and the same controller bonded to two OpenPucks gets two
+	// different session addresses (DEVICEID differs) so the two pucks never collide on-air. The original
+	// generator (UUID-free) is preserved as the no-UUID fallback for an empty/zero bond record.
+	const uint8_t *uuid = g_slot[slot].rec;
+	bool uuidLive = false;
+	for (int i = 0; i < 8; i++)
+		if (uuid[i]) {
+			uuidLive = true;
+			break;
+		}
+	uint32_t h, h2;
+	if (uuidLive) {
+		h = (uint32_t)uuid[0] * 0x9E3779B1u ^
+		    (uint32_t)uuid[2] * 0x85EBCA6Bu ^
+		    (uint32_t)uuid[4] * 0xC2B2AE35u ^ (uint32_t)uuid[6] ^
+		    NRF_FICR->DEVICEID[0];
+		h2 = (uint32_t)uuid[1] * 0x27D4EB2Fu ^
+		     (uint32_t)uuid[3] * 0x165667B1u ^ (uint32_t)uuid[5] ^
+		     (uint32_t)uuid[7] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
+	} else {
+		h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
+		h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^
+		     NRF_FICR->DEVICEID[0];
+	}
+	// Scrub degenerate bytes (0x00/0xFF) for clean preamble correlation.
+	for (int i = 0; i < 4; i++) {
+		uint8_t b = (uint8_t)(h >> (i * 8));
+		if (b == 0x00 || b == 0xFF)
+			b ^= 0x5A;
+		g_sessBase[slot][i] = b;
+	}
+	uint8_t p = (uint8_t)(h2 >> 16);
+	if (p == 0x00 || p == 0xFF)
+		p = 0x5C;
+	g_sessPrefix[slot] = p;
+	// Never reproduce the shared discovery base "ibex" (else the on-air address is shared with every other
+	// controller scanning the rendezvous -> isolation is lost).
+	if (g_sessBase[slot][0] == g_rfBase[0] &&
+	    g_sessBase[slot][1] == g_rfBase[1] &&
+	    g_sessBase[slot][2] == g_rfBase[2] &&
+	    g_sessBase[slot][3] == g_rfBase[3])
+		g_sessBase[slot][0] ^= 0x80;
+}
+
+uint8_t rfrx[100], rftx[100];
+uint32_t g_rfRxCount = 0;
+
+// Puck-link config decoded from real-puck capture: puck TX = Ble_2Mbit, ENDIAN=Big (MSB-first), NO whitening,
+// addr "ibex", CRC16 0x11021/0xFFFF; static-length frame.
+uint32_t g_crcinit = 0xFFFF;
+uint8_t g_whiteiv = 37;
+uint8_t g_mode = RADIO_MODE_MODE_Ble_2Mbit;
+
+// S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+uint32_t g_pcnf0 = 0x00030008UL;
+uint8_t g_statlen = 0x20;
+
+// ENDIAN=Big, BALEN4, MAXLEN=96. MAXLEN must be >=66 or the controller's 0x43-battery-augmented F1 (66B)
+// truncates -> CRC-fail -> battery never arrives.
+uint32_t g_pcnf1 = 0x01040060;
+uint32_t g_crcpoly = 0x11021UL;
+uint16_t g_crccnf = 0x2; // CRC16, address included
+uint8_t g_pid = 0;
+uint8_t g_balen = 4; // ESB 5-byte addr
+bool g_prefixRaw = false;
+
+uint8_t rfBitrev8(uint8_t x)
+{
+	uint8_t y = 0;
+	for (int i = 0; i < 8; i++) {
+		y = (y << 1) | (x & 1);
+		x >>= 1;
+	}
+	return y;
+}
+
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix)
+{
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(b4[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1u << 0;
+}
+
+void rfConfig(uint8_t ch)
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->TXPOWER =
+		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
+#endif
+	NRF_RADIO->PCNF0 = g_pcnf0;
+
+	// g_pcnf1=0 -> build static-length config from g_statlen/g_balen (ENDIAN=Big, WHITEEN=0)
+	NRF_RADIO->PCNF1 =
+		g_pcnf1 ? g_pcnf1 :
+			  ((1u << RADIO_PCNF1_ENDIAN_Pos) |
+			   ((uint32_t)g_balen << RADIO_PCNF1_BALEN_Pos) |
+			   ((uint32_t)g_statlen << RADIO_PCNF1_STATLEN_Pos) |
+			   ((uint32_t)g_statlen << RADIO_PCNF1_MAXLEN_Pos));
+	NRF_RADIO->CRCCNF = g_crccnf; // CRC16, address included
+	NRF_RADIO->CRCPOLY = g_crcpoly;
+	NRF_RADIO->CRCINIT = g_crcinit;
+	NRF_RADIO->DATAWHITEIV = g_whiteiv;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+}

+ 67 - 0
OpenPuckZephyr/src/radio.h

@@ -0,0 +1,67 @@
+// radio.h -- bare-metal nRF52 RADIO (ESB-style) hardware layer.
+//
+// Drives the RADIO peripheral directly with NO SoftDevice (BLE is never started), coexisting with TinyUSB.
+// Owns the register-level config (PHY/CRC/whitening/address, all live-tunable from the CDC console),
+// rfConfig()/rfSetAddr() to program the radio, and the rfrx/rftx DMA buffers. The puck protocol on top lives
+// in rf_link.cpp; the RE/calibration tooling in rf_diag.cpp.
+//
+// Register-confirmed config (decoded from real-puck capture): PHY=Ble_2Mbit, ENDIAN=Big (MSB-first), whitening
+// off, addr "ibex", CRC16 poly 0x11021 init 0xFFFF (address included). On-air address = bitrev8 of stored bytes.
+#pragma once
+#include <stdint.h>
+#include <Arduino.h>
+
+// Bounded radio-disable wait: NEVER spin forever. A wedged RADIO would hang the main loop (USB stops being
+// serviced -> device "dies" until replug). On timeout we bail; the next rfConfig re-inits the radio.
+#define RWAIT_DISABLED()                                   \
+	do {                                               \
+		uint32_t _w = micros();                    \
+		while (!NRF_RADIO->EVENTS_DISABLED &&      \
+		       (uint32_t)(micros() - _w) < 3000) { \
+		}                                          \
+	} while (0)
+
+// Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
+//   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
+extern const uint8_t PAIR_BASE[4]; // "ibex"
+extern uint8_t g_rfPrefix; // discovery prefix (rodata byte @0x56f98)
+extern uint8_t g_rfCh; // current TX/RX channel (hopped during beacon/poll)
+extern uint8_t g_rfBase[4]; // "ibex"
+// connected-session channel: a CLEAN data channel off the congested adv ch2 (shared by all slots on a puck)
+extern uint8_t g_sessCh;
+
+// Per-bond SESSION address (base+prefix). Discovery stays on the SHARED "ibex" address (g_rfBase) so any
+// controller can find us; the host frame advertises THIS slot's unique address and the controller adopts it
+// for the session. Two OpenPucks never share an on-air session address, and two slots on the SAME puck have
+// distinct addresses so each controller only hears its own polls -> no crosstalk, no cross-wake, no per-slot
+// relay broadcast. Derived from the bond UUID (8 bytes) + FICR DEVICEID (8 bytes) so the address is
+// deterministic per (puck, controller) pair and stable across reboots without a bonds.bin schema bump.
+#include "bonds.h" // NSLOT
+extern uint8_t g_sessBase[NSLOT][4];
+extern uint8_t g_sessPrefix[NSLOT];
+void rfGenSessionAddr(int slot);
+
+// RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
+extern uint8_t rfrx[100], rftx[100];
+extern uint32_t g_rfRxCount;
+
+// ---- tunable radio parameters (CDC console M/0/2/i/w/I/P/N/T/A) ----
+extern uint32_t g_crcinit;
+extern uint8_t g_whiteiv;
+extern uint8_t g_mode; // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
+extern uint32_t g_pcnf0; // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
+extern uint8_t g_statlen; // static-mode payload length
+extern uint32_t g_pcnf1; // ENDIAN=Big, BALEN4, MAXLEN64 -- CRC-VALIDATED
+extern uint32_t g_crcpoly;
+extern uint16_t g_crccnf; // CRC16, address included
+extern uint8_t g_pid;
+extern uint8_t g_balen; // ESB 5-byte addr
+// false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+extern bool g_prefixRaw;
+
+uint8_t rfBitrev8(uint8_t x);
+// Access address (IBEX FUN_00037530): BASE0 = bitrev8 each base byte packed big-endian; PREFIX0 = raw or
+// bitrev8 prefix per g_prefixRaw. "ibex" 69 62 65 78 -> BASE0=0x9646A61E, PREFIX0=0x10.
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix);
+// Program MODE/FREQUENCY/PCNF/CRC/whitening from the tunables above onto channel ch (radio left disabled).
+void rfConfig(uint8_t ch);

+ 540 - 0
OpenPuckZephyr/src/rf_diag.cpp

@@ -0,0 +1,540 @@
+#include "rf_diag.h"
+#include "radio.h"
+#include "bonds.h"
+#include <Arduino.h>
+#include <string.h>
+
+bool g_rfListen = false;
+bool g_rfBeacon = false;
+uint8_t g_plen = 0x18, g_s1incl = 0;
+bool g_rfRaw = false;
+bool g_rfSweep = false;
+bool g_rfCap = false, g_rfCapOne = false, g_rfReplay = false;
+uint8_t g_replayLen = 0;
+bool g_rfAuto = false;
+uint8_t g_capV = 0, g_cfgIdx = 0;
+bool g_rfRespond = false;
+bool g_sniff = false;
+uint8_t g_sniffPh = 0, g_sniffPark = 0;
+
+static uint8_t g_seq = 0;
+static unsigned long g_lastBeacon = 0, g_lastHop = 0, g_lastCfg = 0;
+static uint8_t g_replay[48];
+static uint32_t g_capPass = 0;
+static uint32_t g_advCount = 0;
+static uint8_t g_hostTx[48];
+static int g_hostLen = 0;
+static uint32_t g_sniffN = 0;
+static uint8_t g_sbase[4] = { 0, 0, 0, 0 }, g_sprefix = 0;
+static uint8_t g_schan[3] = { 78, 2, 80 };
+static uint8_t g_schi = 0;
+
+// ===================== promiscuous raw capture (calibration only) =====================
+// Preamble-match on 0x55, fixed-length grab, no CRC. Catches any 2Mbit packet on a channel so we can read the
+// controller's reconnect addr/prefix/framing.
+#define RAWCAP 48
+void rfRawStart(uint8_t ch)
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = (RADIO_MODE_MODE_Nrf_2Mbit << RADIO_MODE_MODE_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
+#endif
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->TXPOWER =
+		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+	NRF_RADIO->CRCCNF = 0;
+	NRF_RADIO->PCNF0 = 0;
+	NRF_RADIO->PCNF1 =
+		(RADIO_PCNF1_WHITEEN_Disabled << RADIO_PCNF1_WHITEEN_Pos) |
+		(RADIO_PCNF1_ENDIAN_Big << RADIO_PCNF1_ENDIAN_Pos) |
+		(2u << RADIO_PCNF1_BALEN_Pos) |
+		(RAWCAP << RADIO_PCNF1_STATLEN_Pos) |
+		(RAWCAP << RADIO_PCNF1_MAXLEN_Pos);
+	NRF_RADIO->BASE0 = 0x55555555;
+	NRF_RADIO->PREFIX0 = 0x00000055;
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+}
+
+// ===================== CRC-validating framing sweep =====================
+// ESB DPL (S0L0, LFLEN6or8, S1LEN3), BALEN4 (5-byte addr), CRC8/16 addr-included.
+// Sweep PCNF0{LFLEN6,8} x CRC{8,16} x BALEN{4,3} x ENDIAN{Big,Lit}.
+static const uint32_t CAPP0B[] = {
+	0x00030006, 0x00030008
+}; // S1LEN3 + LFLEN6 / LFLEN8 (dynamic length)
+static const uint32_t CAPCRCB[][3] = { { 0x1, 0x107, 0xFF },
+				       { 0x2, 0x11021, 0xFFFF } };
+void rfCapStart(uint8_t ch)
+{
+	uint8_t ci = g_capV & 1;
+	uint8_t balen = ((g_capV >> 1) & 1) ? 3 : 4; // BALEN 4 first
+	uint8_t endbig = ((g_capV >> 2) & 1) ^ 1; // ENDIAN Big first
+	uint32_t p0 = CAPP0B[(g_capV >> 3) & 1];
+	const uint32_t *cr = CAPCRCB[ci];
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = (RADIO_MODE_MODE_Ble_2Mbit << RADIO_MODE_MODE_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
+#endif
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->PCNF0 = p0;
+	NRF_RADIO->PCNF1 = ((uint32_t)endbig << RADIO_PCNF1_ENDIAN_Pos) |
+			   ((uint32_t)balen << RADIO_PCNF1_BALEN_Pos) |
+			   (60u << RADIO_PCNF1_MAXLEN_Pos);
+	NRF_RADIO->CRCCNF = cr[0];
+	NRF_RADIO->CRCPOLY = cr[1];
+	NRF_RADIO->CRCINIT = cr[2];
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(g_rfBase[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = rfBitrev8(g_rfPrefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	memset(rfrx, 0, 8);
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+}
+// REPLAY: capture one real-puck frame (raw, ENDIAN=Little) then re-transmit it VERBATIM to impersonate the puck
+// bit-for-bit -- sidesteps unknown framing/CRC. Controller must be bonded to the REAL puck (replayed frame
+// carries real-puck uuids + its valid CRC).
+static void rfCapPoll()
+{
+	if (!g_rfCap)
+		return;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		g_capPass++;
+		if (g_rfCapOne) {
+			memcpy(g_replay, rfrx, sizeof g_replay);
+			g_replayLen = 32;
+			g_rfCapOne = false;
+			g_rfCap = false;
+			Serial.printf("# captured replay frame (%uB): ",
+				      g_replayLen);
+			for (int i = 0; i < g_replayLen; i++)
+				Serial.printf("%02X", g_replay[i]);
+			Serial.println();
+			return;
+		}
+		if (NRF_RADIO->CRCSTATUS & 1) {
+			uint8_t ci = g_capV & 1;
+			uint8_t balen = ((g_capV >> 1) & 1) ? 3 : 4;
+			uint8_t eb = ((g_capV >> 2) & 1) ^ 1;
+			uint32_t p0 = CAPP0B[(g_capV >> 3) & 1];
+			Serial.printf(
+				"@@@ CRCOK PCNF0=%lX BALEN=%u ENDIAN=%s POLY=%lX: ",
+				(unsigned long)p0, balen, eb ? "Big" : "Lit",
+				(unsigned long)CAPCRCB[ci][1]);
+			for (int i = 0; i < 28; i++)
+				Serial.printf("%02X", rfrx[i]);
+			Serial.println();
+		}
+	}
+}
+void rfReplayOnce()
+{
+	if (!g_replayLen)
+		return;
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = (RADIO_MODE_MODE_Ble_2Mbit << RADIO_MODE_MODE_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
+#endif
+	NRF_RADIO->FREQUENCY = 2;
+	NRF_RADIO->PCNF0 = 0; // static, ENDIAN=Little -> reproduce on-air bits
+	NRF_RADIO->PCNF1 = (3u << RADIO_PCNF1_BALEN_Pos) |
+			   ((uint32_t)g_replayLen << RADIO_PCNF1_STATLEN_Pos) |
+			   ((uint32_t)g_replayLen << RADIO_PCNF1_MAXLEN_Pos);
+	NRF_RADIO->CRCCNF = 0; // CRC already inside the captured bytes
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(g_rfBase[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = rfBitrev8(g_rfPrefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)g_replay;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+}
+
+// ===================== address/CRC listen =====================
+void rfListenStart()
+{
+	rfConfig(g_rfCh);
+	rfSetAddr(g_rfBase, g_rfPrefix);
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	g_rfListen = true;
+	Serial.printf("# RF listen ch%u base %02X%02X%02X%02X prefix %02X\n",
+		      g_rfCh, g_rfBase[0], g_rfBase[1], g_rfBase[2],
+		      g_rfBase[3], g_rfPrefix);
+}
+static void rfPoll()
+{
+	if (g_rfRaw) {
+		if (NRF_RADIO->EVENTS_END) {
+			NRF_RADIO->EVENTS_END = 0;
+			// filter noise: require non-preamble/idle byte structure
+			int nz = 0;
+			for (int i = 0; i < RAWCAP; i++) {
+				uint8_t b = rfrx[i];
+				if (b && b != 0x55 && b != 0xAA && b != 0xFF)
+					nz++;
+			}
+			if (nz >= 6) {
+				g_rfRxCount++;
+				Serial.printf("RAW#%lu ch%u: ",
+					      (unsigned long)g_rfRxCount,
+					      g_rfCh);
+				for (int i = 0; i < RAWCAP; i++)
+					Serial.printf("%02X", rfrx[i]);
+				Serial.println();
+			}
+		}
+		return;
+	}
+	if (!g_rfListen)
+		return;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t len = rfrx[0];
+		if (len && len < 64) {
+			g_rfRxCount++;
+			Serial.printf("RF#%lu ch%u crc%d len%u: ",
+				      (unsigned long)g_rfRxCount, g_rfCh, crcok,
+				      len);
+			for (uint8_t i = 0; i <= len && i < 40; i++)
+				Serial.printf("%02X ", rfrx[i]);
+			Serial.println();
+		}
+	}
+}
+
+// ===================== autosweep candidate radio configs =====================
+// Cycle configs while beaconing so ONE controller search window covers the space. S0LEN=1 / CRC16 0x11021 /
+// addr "ibex" held fixed; sweep the residual unknowns (PHY, whitening, BALEN, prefix).
+struct RfCfg {
+	uint8_t mode;
+	uint32_t pcnf0, pcnf1;
+	uint8_t whiteiv;
+	uint16_t crccnf;
+	bool prefRaw;
+	const char *tag;
+};
+static const RfCfg SWEEP[] = {
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, false,
+	  "1M BALEN4 whiv37 pfxRev" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0, 0x2, false,
+	  "1M BALEN4 whOFF pfxRev" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, true,
+	  "1M BALEN4 whiv37 pfxRAW" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0, 0x2, true,
+	  "1M BALEN4 whOFF pfxRAW" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02030040, 37, 0x2, false,
+	  "1M BALEN3 whiv37 pfxRev" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00030040, 0, 0x2, true,
+	  "1M BALEN3 whOFF pfxRAW" },
+	{ RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x02040040, 37, 0x2, false,
+	  "2M BALEN4 whiv37 pfxRev" },
+	{ RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x00040040, 0, 0x2, true,
+	  "2M BALEN4 whOFF pfxRAW" },
+};
+void applyCfg(uint8_t i)
+{
+	const RfCfg &c = SWEEP[i];
+	g_mode = c.mode;
+	g_pcnf0 = c.pcnf0;
+	g_pcnf1 = c.pcnf1;
+	g_whiteiv = c.whiteiv;
+	g_crccnf = c.crccnf;
+	g_prefixRaw = c.prefRaw;
+	Serial.printf("# cfg[%u] %s\n", i, c.tag);
+}
+#define SWEEP_N (sizeof SWEEP / sizeof SWEEP[0])
+
+// ===================== dongle beacon experiment =====================
+// Dongle is PTX: TX a beacon [01][seq][..][E2@5] then RX the controller's response (0xF0...).
+void rfBeaconOnce()
+{
+	uint8_t pl[48];
+	memset(pl, 0, sizeof pl);
+	pl[0] = 0x01;
+	pl[1] = g_seq++;
+	pl[5] = 0xE2; // dongle-beacon marker
+	rfConfig(g_rfCh);
+	rfSetAddr(g_rfBase, g_rfPrefix);
+	if (g_s1incl)
+		NRF_RADIO->PCNF0 |= (1u << RADIO_PCNF0_S1INCL_Pos);
+	// RAM: [LENGTH][ (S1 if S1INCL) ][payload]
+	rftx[0] = 0;
+	rftx[1] = g_plen;
+	memcpy(rftx + 2, pl, g_plen);
+	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	// RX window for the controller's response
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	rfrx[0] = 0;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	uint32_t t0 = micros();
+	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < 500) {
+	}
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t len = rfrx[0];
+		if (len && len < 64) {
+			g_rfRxCount++;
+			Serial.printf("RESP#%lu crc%d len%u: ",
+				      (unsigned long)g_rfRxCount, crcok, len);
+			for (uint8_t i = 0; i <= len && i < 40; i++)
+				Serial.printf("%02X ", rfrx[i]);
+			Serial.println();
+		}
+	}
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+}
+
+// ===================== scan-then-respond (dongle role) =====================
+// Listen on ch2 for the controller's advertisement; the instant one arrives, TX the host frame (its ack).
+static void buildHostTx()
+{
+	int slot = -1;
+	for (int i = 0; i < NSLOT; i++)
+		if (g_slot[i].used) {
+			slot = i;
+			break;
+		}
+	memset(g_hostTx, 0, sizeof g_hostTx);
+	g_hostLen = 0;
+	if (slot < 0)
+		return;
+	// S0LEN=1 RAM layout: [0]=S0=0x12, [1]=LENGTH, raw RAM offsets validated by controller.
+	memset(g_hostTx, 0, sizeof g_hostTx);
+	g_hostTx[0] = 0x12;
+	g_hostTx[1] = 0x20;
+	g_hostTx[3] = 1;
+	g_hostTx[5] = 0xE1;
+	memcpy(g_hostTx + 6, g_slot[slot].rec, 4);
+	memcpy(g_hostTx + 10, g_slot[slot].rec + 4, 4);
+	g_hostTx[0xe] = 2;
+	memcpy(g_hostTx + 0x12, g_rfBase, 4);
+	g_hostTx[0x16] = g_rfPrefix;
+	g_hostLen = 0x22;
+}
+void rfRespondStart()
+{
+	buildHostTx();
+	rfConfig(2);
+	rfSetAddr(g_rfBase, g_rfPrefix); // RX ch2 / 91A2A793
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	g_rfRespond = true;
+	Serial.printf(
+		"# RESPOND mode: RX ch2/91A2A793, reply host frame (len%d)\n",
+		g_hostLen);
+}
+static void rfRespondPoll()
+{
+	if (!g_rfRespond)
+		return;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		uint8_t len = rfrx[0];
+		if (len && len < 64) {
+			g_advCount++;
+			// reply: TX the host frame immediately (ack)
+			NRF_RADIO->TASKS_DISABLE = 1;
+			RWAIT_DISABLED();
+			NRF_RADIO->EVENTS_DISABLED = 0;
+			NRF_RADIO->PACKETPTR = (uint32_t)g_hostTx;
+			NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+					    RADIO_SHORTS_END_DISABLE_Msk;
+			NRF_RADIO->EVENTS_DISABLED = 0;
+			NRF_RADIO->TASKS_TXEN = 1;
+			RWAIT_DISABLED();
+			NRF_RADIO->EVENTS_DISABLED = 0;
+			Serial.printf("ADV#%lu len%u: ",
+				      (unsigned long)g_advCount, len);
+			for (uint8_t i = 0; i <= len && i < 32; i++)
+				Serial.printf("%02X", rfrx[i]);
+			Serial.println();
+			NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+			NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+					    RADIO_SHORTS_END_START_Msk;
+			NRF_RADIO->EVENTS_END = 0;
+			NRF_RADIO->TASKS_RXEN = 1;
+		}
+	}
+}
+
+// ===================== live-session sniffer =====================
+// phase 0 RX the real puck's host frame on "ibex"/ch2 to learn the random session base/prefix/channel; phase 1
+// RX that session address on the primary channel to capture the live connected exchange (E3 poll + F1 reply).
+void rfSniffStart()
+{
+	// PARK (no hop): primary carries the traffic
+	uint8_t ch =
+		(g_sniffPh == 0) ? 2 : (g_sniffPark ? g_sniffPark : g_schan[0]);
+	const uint8_t *base = (g_sniffPh == 0) ? g_rfBase : g_sbase;
+	uint8_t pfx = (g_sniffPh == 0) ? g_rfPrefix : g_sprefix;
+
+	// Ble_2Mbit, PCNF0=0x30008, ENDIAN big, BALEN4, CRC16 0x11021
+	rfConfig(ch);
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(base[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = rfBitrev8(pfx);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	memset(rfrx, 0, 4);
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+}
+static void rfSniffPoll()
+{
+	if (!g_sniff)
+		return;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t len = rfrx[0];
+		if (crcok && len && len < 40) {
+			g_sniffN++;
+			// host frame -> learn session params + DUMP FULL (app-data may ride here)
+			if (g_sniffPh == 0 && rfrx[2] == 0xE1) {
+				memcpy(g_sbase, rfrx + 15, 4);
+				g_sprefix = rfrx[19];
+				g_schan[0] = rfrx[11];
+				Serial.printf(
+					"# HOSTFRAME ch=%u sbase=%02X%02X%02X%02X prefix=%02X len%u FULL: ",
+					rfrx[11], g_sbase[0], g_sbase[1],
+					g_sbase[2], g_sbase[3], g_sprefix, len);
+				{
+					uint8_t dn = (len + 2 < 64) ? len + 2 :
+								      64;
+					for (uint8_t i = 0; i < dn; i++)
+						Serial.printf("%02X", rfrx[i]);
+				}
+				Serial.println();
+				g_sniffPh = 1;
+				g_schi = 0;
+				rfSniffStart();
+			} else if (g_sniffPh == 1) {
+				// 0xF1=input, 0x12/0xE1/0xE4=puck host-frame w/ app-data
+				uint8_t ty = rfrx[2];
+				const char *tag = (ty == 0xF1) ? "<<<INPUT" :
+						  (ty == 0x12 || ty == 0xE1 ||
+						   ty == 0xE4) ?
+								 ">>>HOSTFR" :
+								 "  pkt";
+				Serial.printf("%s SNIFF#%lu ch%u len%u: ", tag,
+					      (unsigned long)g_sniffN,
+					      g_schan[g_schi], len);
+				// FULL frame (app-data TLVs sit near the end)
+				uint8_t dn = (len + 2 < 64) ? len + 2 : 64;
+				for (uint8_t i = 0; i < dn; i++)
+					Serial.printf("%02X", rfrx[i]);
+				Serial.println();
+			}
+		}
+	}
+}
+
+void rfDiagTask()
+{
+	rfPoll();
+	rfRespondPoll();
+	rfCapPoll();
+	if (g_rfCap && millis() - g_lastHop >=
+			       90) { // sweep config combos on ch2, CRC-validate
+		g_lastHop = millis();
+		g_capV = (uint8_t)(g_capV + 1);
+		rfCapStart(g_rfCh);
+	}
+	if (g_rfBeacon && millis() - g_lastBeacon >= 5) {
+		g_lastBeacon = millis();
+		rfBeaconOnce();
+	}
+	if (g_rfSweep && millis() - g_lastHop >= 60) {
+		g_lastHop = millis();
+		g_rfCh += 2;
+		if (g_rfCh > 80)
+			g_rfCh = 2;
+		rfRawStart(g_rfCh);
+	}
+	if (g_rfAuto &&
+	    millis() - g_lastCfg >=
+		    500) { // advance to next candidate config every 500ms
+		g_lastCfg = millis();
+		g_cfgIdx = (g_cfgIdx + 1) % SWEEP_N;
+		applyCfg(g_cfgIdx);
+	}
+	if (g_rfReplay) {
+		rfReplayOnce();
+	} // bit-perfect replay of a captured real-puck frame on ch2
+	rfSniffPoll();
+	if (g_rfRespond &&
+	    millis() - g_lastHop >=
+		    25) { // scan the 3 BLE adv channels w/ matched whitening
+		g_lastHop = millis();
+		static const uint8_t advf[3] = { 2, 26, 80 },
+				     advw[3] = { 37, 38, 39 };
+		static uint8_t ai = 0;
+		ai = (ai + 1) % 3;
+		g_rfCh = advf[ai];
+		g_whiteiv = advw[ai];
+		rfConfig(g_rfCh);
+		rfSetAddr(g_rfBase, g_rfPrefix);
+		NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+		NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+				    RADIO_SHORTS_END_START_Msk;
+		NRF_RADIO->EVENTS_END = 0;
+		NRF_RADIO->TASKS_RXEN = 1;
+	}
+}

+ 37 - 0
OpenPuckZephyr/src/rf_diag.h

@@ -0,0 +1,37 @@
+// rf_diag.h -- RF reverse-engineering & calibration scaffolding (NOT used in normal operation).
+//
+// Tooling that solved the puck protocol, preserved here: promiscuous raw capture, CRC-validating config sweeps,
+// bit-exact frame replay, address/CRC listen, the scan-then-respond dongle experiment, the dongle beacon, and
+// the live-session sniffer. Each is driven by a flag toggled from the CDC console (serial_console.cpp) and
+// serviced by rfDiagTask() from loop().
+#pragma once
+#include <stdint.h>
+
+// ---- mode flags (toggled by the CDC console; mutually managed there) ----
+extern bool g_rfListen;
+extern bool g_rfBeacon;
+extern uint8_t g_plen, g_s1incl; // dongle-beacon payload length / S1INCL toggle
+extern bool g_rfRaw;
+extern bool g_rfSweep;
+extern bool g_rfCap, g_rfCapOne, g_rfReplay;
+extern uint8_t g_replayLen;
+extern bool g_rfAuto;
+extern uint8_t g_capV, g_cfgIdx;
+extern bool g_rfRespond;
+extern bool g_sniff;
+extern uint8_t g_sniffPh, g_sniffPark;
+
+// ---- start hooks (called from the console on command) ----
+void rfListenStart(); // RX + log on the discovery addr/channel
+void rfRawStart(uint8_t ch); // promiscuous preamble-match capture (no CRC)
+void rfCapStart(
+	uint8_t ch); // CRC-validating framing sweep (cycles config combos)
+void rfReplayOnce(); // bit-exact re-transmit of a captured real-puck frame
+void rfBeaconOnce(); // dongle beacon experiment ([01][seq][..][E2@5])
+void applyCfg(uint8_t i); // apply autosweep candidate radio config i
+void rfRespondStart(); // scan-then-respond (RX ch2 adv, reply host frame)
+// session sniffer (learn session params, then capture the live exchange)
+void rfSniffStart();
+
+// Per-loop: service whichever diag mode is active.
+void rfDiagTask();

+ 857 - 0
OpenPuckZephyr/src/rf_link.cpp

@@ -0,0 +1,857 @@
+#include "rf_link.h"
+#include "radio.h"
+#include "bonds.h"
+#include "config.h"
+#include "triton.h"
+#include "haptics.h"
+#include "controllers.h"
+#include "status_led.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+bool g_rfHost = true;
+bool g_connOn = true;
+uint8_t g_connType = 0xE7; // start with protocol-version handshake, then 0xE3
+// 0=current(slow/awake), 1=protocol-version-1. 'V<n>' to toggle.
+uint8_t g_e7b = 0;
+uint8_t g_connLen = 0x08;
+// GET report 0x45 param byte. 'q' cmd.
+uint8_t g_getParam = 0x00;
+// cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID). 'e<n>' selects.
+uint8_t g_e3mode = 1;
+
+// ---- real-puck alignment (sniff1.json: a bonded controller RECONNECTING) ----
+// The live air capture of a real puck<->controller reconnect shows the controller streams 0xF1 input in
+// response to a BARE 0xE3 poll (1-byte payload, just the opcode) with NO 0xE7 awake-announce and NO
+// GET-report-0x45 sub-TLV -- 1857 of the puck's 2003 polls were bare E3, and the very first session frame
+// (a bare E3) was answered by F1 immediately. That contradicts the earlier RE "recipe" (rf_link.h) which
+// assumed E7 + GET-0x45 were required. So these now default to the real-puck behavior; flip them at runtime
+// ('d'/'n' console cmds) to fall back to the legacy GET/E7 path for an A/B comparison on hardware.
+bool g_pollGet =
+	false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
+bool g_e7announce =
+	false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
+// Session-channel E1 host-frame keepalive. The real puck sends NO E1 on its session channel (the bonded
+// controller already knows the per-bond address and just resumes). OpenPuck still needs E1 because it runs
+// the SHARED "ibex" address, not the (un-reversed) per-bond address -- E1 is how the controller learns this
+// puck's session base/prefix/channel. So this defaults ON; turn it off ('m') to test the real-puck "no
+// session E1" model once per-bond addressing exists. Discovery on ch2 is separate and always runs.
+bool g_e1keepalive = true;
+
+bool g_connVerbose = false;
+// poll RX-window (us); shorter=more polls/s but may miss DELAYED replies. Tunable 'r'.
+uint32_t g_rxWin = 1200;
+unsigned long g_connCooldown = 0;
+
+uint8_t g_connSt = 0; // 0=announce awake, 1=poll loop
+uint8_t g_connStep = 0; // repeat counter within a state
+uint16_t g_connPoll = 0; // poll counter (re-assert awake every 32nd)
+uint32_t g_connF1 = 0;
+uint8_t g_connF3v = 0xFF;
+
+uint8_t g_qos = 0;
+// clean, spread channels (from the puck's RSSI/PER scan)
+static const uint8_t g_hopCand[] = { 18, 46, 76, 22, 68 };
+uint8_t g_hopIdx = 0;
+volatile uint16_t g_qosBad = 0;
+unsigned long g_qosCheckMs = 0, g_qosLastHopMs = 0;
+
+uint16_t g_f1ps = 0;
+uint16_t g_newps = 0;
+// polls/s (GET+relay TXs) last second -- distinguishes loop-starvation from reply-loss
+uint16_t g_pollsps = 0;
+// measured avg us between GET-poll fires (vs intended g_pollUs)
+uint16_t g_pollPeriodUs = 0;
+static uint32_t g_pollDtSum = 0;
+static uint16_t g_pollDtCnt = 0;
+// smoothed |dBm| of the controller's replies, per slot (0 = none yet)
+volatile uint8_t g_linkRssi[NSLOT] = { 0 };
+// battery % from the controller's report 0x43 (body[1]); 0 = none yet. Per-slot -- the active controller's
+// battery is the most recently seen one (other slots' values stay in their own array slots).
+volatile uint8_t g_battery[NSLOT] = { 0 };
+// charge state from report 0x43 body[0] (EChargeState: 1=discharging 2=charging 4=charging-done; 0=unknown).
+volatile uint8_t g_batteryState[NSLOT] = { 0 };
+
+// Slot the poll loop is currently driving. Set by rfConnStep before each E7/relay/E3, consumed by the
+// decode (g_in[g_curSlot]), the haptic flush (per-slot session address), and the per-second stat dump.
+int g_curSlot = -1;
+
+// ---- internal counters / timers ----
+// ESB PID is 2 bits. The controller dequeues a FRESH report only when the poll's PID differs from the
+// last one it saw on that pipe; a repeated PID reads as a retransmit and returns the SAME (stale) report.
+// PER SLOT, because a single shared counter advances by (2 * nWarm) per cycle -- with 2 controllers that's
+// +4 per cycle = 0 mod 4, so each slot's GET PID is constant => the controller never dequeues => ~60 new/s
+// instead of ~400. Each slot's counter increments once per poll-of-that-slot so it cycles 0,1,2,3 cleanly.
+static uint8_t g_pollPid[NSLOT] = {};
+static uint8_t g_relayPid[NSLOT] = {};
+static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
+static unsigned long g_stMs = 0;
+// Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
+// slots into a single sequence makes one controller "swallow" the other's frame).
+static uint8_t g_lastSeq[NSLOT] = { 0 };
+static uint32_t g_stNew = 0;
+static uint32_t g_stCrc = 0, g_stNoRx = 0;
+static uint32_t g_chF1[3] = { 0, 0, 0 };
+// Cycle gate: fires once per g_pollUs; each fire polls every warm slot so all run at ~250 Hz.
+static uint32_t g_lastPollUs = 0;
+static uint32_t g_connRx = 0;
+static unsigned long g_lastSessBeacon = 0, g_lastDisc = 0;
+static unsigned long g_lastStream = 0;
+
+// HOST FRAME the bonded controller waits for (IBEX FUN_00019000 verify: b[0]=0x12, b[5]=0xE1, b[6..10]=
+// proteus_uuid, b[10..14]=ibex_uuid). Built like PROTEUS FUN_00027e9a. Sent on the shared rendezvous addr;
+// the controller filters by the uuids in the payload, then connects.
+// Transmit one host frame. `discovery`=true sends it on the SHARED rendezvous address ("ibex"/ch2) where a
+// searching controller looks; =false sends it on this slot's unique SESSION address (the keepalive once the
+// controller has adopted the session). EITHER way the payload advertises the session base/prefix/channel,
+// so the controller always learns the unique address to connect on.
+
+// Any-slot link helper: true if ANY bonded slot is currently hearing F-type replies (within 300 ms).
+// Used for the "we're connected to at least one controller" decisions (beacon pacing, wake detect).
+bool anySlotLinkUp()
+{
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && millis() - g_connReplyMs[s] < 300)
+			return true;
+	return false;
+}
+
+static void rfHostFrameOnce(int slot, bool discovery)
+{
+	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
+		return;
+	// [proteus_uuid 4][ibex_uuid 4][serial 16]
+	uint8_t *rec = g_slot[slot].rec;
+	// CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)]. payload:
+	// [0]=0xE1, [1..5]=proteus_uuid LE, [5..9]=ibex_uuid LE, [9]=session channel, [10..13]=0, [13..17]=session
+	// base, [17]=session prefix. Radio auto-appends CRC16 0x11021.
+	memset(rftx, 0, sizeof rftx);
+	// LENGTH = 18 (controller's buf[0]==0x12 check validates this)
+	rftx[0] = 0x12;
+	// S1 = PID<<1 | noack0  (matches real puck 00/02/04/06)
+	rftx[1] = (uint8_t)((g_pid++ & 3) << 1);
+	rftx[2] = 0xE1; // payload[0] marker
+	// payload[1..5] proteus_uuid (LE, as bonded)
+	memcpy(rftx + 3, rec + 0, 4);
+	memcpy(rftx + 7, rec + 4, 4); // payload[5..9] ibex_uuid
+
+	// payload[9] session channel: controller runs the session on this clean
+	// channel (adopts buf[0xe]); discovery beacon still TXes on ch2
+	rftx[11] = g_sessCh;
+	// payload[13..17] session base  (the per-bond UNIQUE address; each controller adopts its own)
+	memcpy(rftx + 15, g_sessBase[slot], 4);
+	rftx[19] = g_sessPrefix[slot]; // payload[17] session prefix
+	// TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses this slot's
+	// unique address (where THIS controller now listens). The advertised session params (above) are
+	// identical either way -- so the discovery frame can also double as a re-advertisement if needed.
+	const uint8_t *txBase = discovery ? g_rfBase : g_sessBase[slot];
+	uint8_t txPfx = discovery ? g_rfPrefix : g_sessPrefix[slot];
+	rfConfig(g_rfCh);
+	rfSetAddr(txBase, txPfx);
+	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	rfrx[0] = 0;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	// Discovery/pairing beacons listen for the controller's response (matters for RE/pairing); the connected
+	// session keepalive expects NO response (the controller answers E3 polls, not the beacon), so don't burn
+	// 800us of dead air per frame -- that was the bulk of the idle poll-rate deficit (40 beacons/s x slots).
+	uint16_t bwin = discovery ? 800u : 150u;
+	uint32_t t0 = micros();
+	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < bwin) {
+	}
+	if (NRF_RADIO->EVENTS_END) {
+		// any reception = controller answered our frame
+		NRF_RADIO->EVENTS_END = 0;
+		g_rfRxCount++;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t len = rfrx[0];
+		// non-blocking: don't stall the loop on CDC backpressure
+		if (Serial.availableForWrite() > 90) {
+			Serial.printf(
+				"*** RESP#%lu ch%u crc%d rxmatch%lu len%u: ",
+				(unsigned long)g_rfRxCount, g_rfCh, crcok,
+				(unsigned long)NRF_RADIO->RXMATCH, len);
+			for (uint8_t i = 0; i < (len < 40 ? len + 2 : 40); i++)
+				Serial.printf("%02X ", rfrx[i]);
+			Serial.println();
+		}
+	}
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+}
+
+void rfHopTo(uint8_t newCh)
+{
+	// QoS hop is shared across all slots -- we run all connected sessions on the same channel for simplicity.
+	// The per-slot session ADDRESS is what isolates the controllers from each other; the channel is global.
+	if (g_curSlot < 0 || newCh == g_sessCh)
+		return;
+	uint8_t cur = g_sessCh, savedRfCh = g_rfCh;
+	g_sessCh = newCh;
+	// host frame now advertises newCh but is TXed on cur (per-slot session addr)
+	g_rfCh = cur;
+	for (int s = 0; s < NSLOT; s++)
+		for (int k = 0; k < 6; k++) {
+			rfHostFrameOnce(s, false);
+			delayMicroseconds(700);
+		}
+	g_rfCh = savedRfCh; // poll + session beacon now run on g_sessCh=newCh
+}
+
+// TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
+// rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect
+// no reply, so they don't burn a full ~1.2ms window of dead air per haptic. Per-slot: the connected poll runs
+// on this slot's UNIQUE session address (one address per bonded controller); replies are demuxed by which
+// address the controller answers on (each controller only hears its own).
+uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
+		 uint16_t rxWinUs)
+{
+	// relays pass a tiny window (no reply expected); polls use g_rxWin
+	uint16_t win = rxWinUs ? rxWinUs : g_rxWin;
+	memset(rftx, 0, sizeof rftx);
+	rftx[0] = plen; // LENGTH = payload byte count
+	rftx[1] = s1; // S1 (type-specific)
+	memcpy(rftx + 2, payload, plen); // payload[0]=type byte, then data/TLVs
+	rfConfig(ch);
+	// connected poll runs on this slot's UNIQUE session addr (per-bond). g_curSlot is set by rfConnStep
+	// before each call; fall back to slot 0 if not (e.g. rf_diag paths).
+	int slot = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	rfSetAddr(g_sessBase[slot], g_sessPrefix[slot]);
+	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	rfrx[0] = 0;
+	// RXEN->READY->START; catch the reply. ADDRESS->RSSISTART samples the reply's signal strength (read from
+	// RSSISAMPLE below, surfaced to Steam via report 0x7B); DISABLED->RSSISTOP closes the measurement.
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_ADDRESS_RSSISTART_Msk |
+			    RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	g_stPoll++;
+	uint32_t t0 = micros();
+	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < win) {
+	} // RX window (tunable 'r'; or relay override)
+	uint8_t rxlen = 0;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		rxlen = rfrx[0];
+		// reply arrived but CRC failed -> RF quality (channel/interference)
+		if (!crcok) {
+			g_stCrc++;
+			g_qosBad++;
+		}
+		// F1 input ~46B; 0x43-augmented ~66B -> allow up to MAXLEN(96)
+		if (crcok && rxlen && rxlen <= 96) {
+			// reply type byte (proven offset from captures)
+			uint8_t rtype = rfrx[2];
+			// Only OUR controller's replies (F-type: 0xF1 input / 0xF2 disconnect / 0xF3 status) mark the link
+			// alive. Every OpenPuck shares the same RF address "ibex" + CRC config, and a puck transmits host-frame
+			// beacons (0xE1) + polls (0xE2/E3/E7) -- all E-type. Without this gate, puck A receives a SECOND puck's
+			// 0xE1 beacon (e.g. one just plugged into another computer), bumps g_connReplyMs, and the "new RF
+			// connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
+			if (rtype >= 0xF0) {
+				int s = (g_curSlot >= 0 && g_curSlot < NSLOT) ?
+						g_curSlot :
+						0;
+				// A reply after a long gap (or the first ever) = a (re)connect. Arm the haptic block + re-init HERE,
+				// directly off the reply stream -- reliable even when hapticTask's 300ms link-up edge doesn't fire
+				// (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
+				if (g_connReplyMs[s] == 0 ||
+				    (uint32_t)(millis() - g_connReplyMs[s]) >
+					    1500u)
+					hapticOnReconnect(s);
+				g_connRx++;
+				// link alive -> loop() suppresses the redundant E1 beacon
+				g_connReplyMs[s] = millis();
+				// |dBm| of this reply (started by the ADDRESS short)
+				uint8_t rs =
+					(uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
+				// EWMA, ~8-sample horizon, per-slot
+				if (rs)
+					g_linkRssi[s] =
+						g_linkRssi[s] ?
+							(uint8_t)((g_linkRssi[s] *
+									   7u +
+								   rs + 4u) /
+								  8u) :
+							rs;
+			}
+			if (rtype == 0xF1)
+				g_stF1++;
+			// controller disconnecting/powering off -> back off 2.5s so we don't immediately re-wake it.
+			// BUT only when no OTHER slot is still live: g_connCooldown is global and gates ALL polling +
+			// beacons, so backing off because ONE controller powered off would drop every other connected
+			// controller for 2.5s (a real multi-controller disconnect). The powering-off slot goes silent
+			// and ages out via SLOT_COLD on its own.
+			if (rtype == 0xF2) {
+				int others = 0;
+				for (int i = 0; i < NSLOT; i++)
+					if (i != g_curSlot && g_slot[i].used &&
+					    millis() - g_connReplyMs[i] < 300)
+						others++;
+				if (others == 0)
+					g_connCooldown = millis();
+			}
+			// F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
+			if (rtype == 0xF3) {
+				g_stF3++;
+				g_connF3v = rfrx[6];
+				if (g_connVerbose &&
+				    Serial.availableForWrite() > 40) {
+					Serial.print("  F3 ");
+					for (uint8_t i = 0;
+					     i <
+					     (rxlen + 2 < 32 ? rxlen + 2 : 32);
+					     i++)
+						Serial.printf("%02X", rfrx[i]);
+					Serial.println();
+				}
+			}
+			bool isF1 = (rtype == 0xF1);
+			if (isF1) {
+				g_connF1++;
+				// walk ALL type6 TLVs (= HID report 0x45); taking only [0] halves the rate. idx is INT,
+				// not uint8_t: tlen 0xFE would make idx+=tlen+2 wrap mod-256 -> infinite loop -> USB hang.
+				int idx = 3, end = rxlen + 2;
+				const uint8_t *lastRep = nullptr;
+				uint8_t lastTlen = 0;
+				while (idx + 1 < end) {
+					uint8_t tlen = rfrx[idx],
+						ttype = rfrx[idx + 1];
+					if (tlen == 0)
+						break;
+					// Only a FULL 0x45 report that fits entirely in rfrx: a short or late/garbled TLV must not let the
+					// decode read past the RF buffer (corrupts rftx/RAM -> eventual crash).
+					if (ttype == 6 && tlen >= 28 &&
+					    (size_t)(idx + 2) + tlen <=
+						    sizeof(rfrx) &&
+					    rfrx[idx + 2] == 0x45) {
+						// report 0x45: [0x45][seq][buttons u32]...
+						const uint8_t *rep =
+							&rfrx[idx + 2];
+						bool fresh =
+							(rep[1] !=
+							 g_lastSeq[g_curSlot]);
+						// genuine new report vs stale poll-repeat
+						if (fresh) {
+							g_stNew++;
+							g_lastSeq[g_curSlot] =
+								rep[1];
+						}
+						uint32_t bb = btnsOf(rep);
+						// USB remote wakeup on Steam button short press (down + up within 1 s). A long press likely means
+						// the user is powering off the controller, so we ignore it.
+						{
+							// per-slot: with round-robin polling, a shared static gets
+							// reset by other slots' reports between press and release.
+							static bool steamWasDown
+								[NSLOT] = {};
+							static unsigned long steamDownMs
+								[NSLOT] = {};
+							if (fresh) {
+								bool steamNow =
+									(bb &
+									 TB_STEAM) !=
+									0;
+								if (steamNow &&
+								    !steamWasDown
+									    [g_curSlot])
+									steamDownMs[g_curSlot] =
+										millis();
+								if (!steamNow &&
+								    steamWasDown
+									    [g_curSlot] &&
+								    millis() - steamDownMs[g_curSlot] <
+									    1000u &&
+								    USBDevice
+									    .suspended()) {
+									USBDevice
+										.remoteWakeup();
+									ledWakePulse();
+									if (g_active)
+										g_active->wakeEvent();
+								}
+								steamWasDown[g_curSlot] =
+									steamNow;
+							}
+						}
+						// Decode the report into the shared g_in (one source, read by every IController).
+						g_in[g_curSlot].buttons = bb;
+						// Global power-off chord: Steam + Y held 2 s -> shut the controller down (any mode). Detect on the raw
+						// `bb` (pre-mask), time-based (poll rate varies), fires once per hold, re-arms only after release. While
+						// held, mask Steam+Y out of g_in[g_curSlot].buttons so the press doesn't leak to the host -- in EVERY mode except
+						// regular Steam (mode 0 forwards the raw 0x45 to Steam, which owns the Steam button). Runs before
+						// onReport45 below so push modes (Xbox) see the mask too; stream modes read g_in in task() (also masked).
+						{
+							// Per-slot: each controller's Steam+Y hold is independent, so the debounce timer must
+							// be per-slot. With multiple controllers, a shared timer gets reset every time ANOTHER
+							// slot's poll (without the hold) runs -- and the round-robin poll cycles through every
+							// used slot, so the timer never reaches 2s.
+							static unsigned long
+								offHoldMs[NSLOT] = {
+									0, 0, 0,
+									0
+								};
+							static bool offFired
+								[NSLOT] = {
+									false,
+									false,
+									false,
+									false
+								};
+							if ((bb & (TB_STEAM |
+								   TB_Y)) ==
+							    (TB_STEAM | TB_Y)) {
+								if (offHoldMs[g_curSlot] ==
+								    0)
+									offHoldMs[g_curSlot] =
+										millis();
+								else if (
+									!offFired[g_curSlot] &&
+									(unsigned long)(millis() -
+											offHoldMs[g_curSlot]) >=
+										2000u) {
+									offFired[g_curSlot] =
+										true;
+									hapticSendShutdown();
+								}
+								if (g_usbMode !=
+								    MODE_STEAM) {
+									// stream modes read g_in
+									g_in[g_curSlot]
+										.buttons &=
+										~(uint32_t)(TB_STEAM |
+											    TB_Y);
+									// push modes read btnsOf(rep): TB_Y in rep[2],
+									((uint8_t *)
+										 rep)
+										[2] &=
+										~(uint8_t)
+											TB_Y;
+									// TB_STEAM in rep[4]
+									((uint8_t *)
+										 rep)
+										[4] &=
+										~(uint8_t)(TB_STEAM >>
+											   16);
+								}
+							} else {
+								offHoldMs[g_curSlot] =
+									0;
+								offFired[g_curSlot] =
+									false;
+							}
+						}
+						g_in[g_curSlot].lx =
+							(int16_t)s16off(rep, 8);
+						g_in[g_curSlot].ly =
+							(int16_t)s16off(rep,
+									10);
+						g_in[g_curSlot].rx =
+							(int16_t)s16off(rep,
+									12);
+						g_in[g_curSlot].ry =
+							(int16_t)s16off(rep,
+									14);
+						g_in[g_curSlot].lt =
+							trigU8(u16off(rep, 4));
+						// for the Switch digital-trigger threshold
+						g_in[g_curSlot].rt =
+							trigU8(u16off(rep, 6));
+						g_in[g_curSlot].lpx =
+							(int16_t)s16off(rep,
+									16);
+						g_in[g_curSlot].lpy =
+							(int16_t)s16off(rep,
+									18);
+						g_in[g_curSlot].rpx =
+							(int16_t)s16off(rep,
+									22);
+						g_in[g_curSlot].rpy =
+							(int16_t)s16off(rep,
+									24);
+						// IMU lives at report bytes 0x22..0x2D (rep[34..45]). Decode it ONLY when a FULL 46-byte report was
+						// actually received -- bounded by `end` (the received length), NOT sizeof(rfrx). The outer gate is
+						// tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a short 0x45 (button-only, or
+						// one whose IMU tail was lost) still passes it; without this guard imuFrom45 would read STALE bytes
+						// past the received data and clobber g_in's gyro/accel. On a short frame, hold the last good IMU.
+						if (tlen >= 46 &&
+						    (size_t)(idx + 2) + 46 <=
+							    (size_t)end)
+							imuFrom45(
+								rep,
+								&g_in[g_curSlot]
+									 .ax,
+								&g_in[g_curSlot]
+									 .ay,
+								&g_in[g_curSlot]
+									 .az,
+								&g_in[g_curSlot]
+									 .gx,
+								&g_in[g_curSlot]
+									 .gy,
+								&g_in[g_curSlot]
+									 .gz);
+						// Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in[g_curSlot].buttons stays
+						// intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
+						if ((bb & CHORD_BACK4) ==
+						    CHORD_BACK4)
+							((uint8_t *)rep)[2] &= ~(
+								uint8_t)(TB_A |
+									 TB_B |
+									 TB_X |
+									 TB_Y);
+						// Hand the report to the active controller. STREAM modes ignore it (they emit from task() reading
+						// g_in); PUSH modes (Xbox, puck/lizard) build + send their host report here.
+						if (g_active)
+							g_active->onReport45(
+								g_curSlot, rep,
+								fresh, tlen);
+						lastRep = rep;
+						lastTlen = tlen;
+					} else if (ttype == 6 &&
+						   (size_t)(idx + 2) + tlen <=
+							   sizeof(rfrx) &&
+						   tlen >= 2 &&
+						   (rfrx[idx + 2] == 0x43 ||
+						    rfrx[idx + 2] == 0x44)) {
+						// Controller STATUS reports (0x43 = periodic power/battery, ~every 2s; 0x44 = status event). The real
+						// puck forwards these verbatim (onAuxReport) -- that's how Steam reads battery; also snapshot the
+						// battery % for the WebUSB panel.
+						// [rid][body...]
+						const uint8_t *rep =
+							&rfrx[idx + 2];
+						// 0x43 body[0] = ucChargeState (EChargeState), body[1] = ucBatteryLevel % (sniff-derived).
+						// rep[0]=rid, rep[1]=body[0], rep[2]=body[1]. Snapshot both for the WebUSB panel and the
+						// synthesized full-length 0x43 the puck pushes for SDL (see puck_hid.cpp task()).
+						if (rep[0] == 0x43 &&
+						    tlen >= 3 &&
+						    g_curSlot >= 0 &&
+						    g_curSlot < NSLOT) {
+							g_batteryState[g_curSlot] =
+								rep[1];
+							g_battery[g_curSlot] =
+								rep[2];
+						}
+						if (g_active)
+							g_active->onAuxReport(
+								g_curSlot,
+								rep[0], rep + 1,
+								(uint8_t)(tlen -
+									  1));
+					}
+					idx += tlen + 2;
+				}
+				// mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.
+				{
+					// Per-slot debounce: the chord input is per-slot (g_in[g_curSlot]), so the debounce counter
+					// must be too. The shared-static form worked with 1 controller because slot 0 polled
+					// back-to-back; with N>1, the round-robin poll cycles through every used slot, and the OTHER
+					// slots' non-chord reports reset the counter on every iteration. The counter could never
+					// reach 12 with multiple controllers, making the chord effectively dead.
+					static uint8_t chWant[NSLOT] = {
+						0xFF, 0xFF, 0xFF, 0xFF
+					};
+					static uint8_t chCnt[NSLOT] = { 0, 0, 0,
+									0 };
+					uint8_t want = 0xFF;
+					if ((g_in[g_curSlot].buttons &
+					     CHORD_BACK4) == CHORD_BACK4) {
+						if (g_in[g_curSlot].buttons &
+						    TB_A)
+							want = MODE_STEAM;
+						else if (g_in[g_curSlot].buttons &
+							 TB_B)
+							want = g_chordBtn[0];
+						else if (g_in[g_curSlot].buttons &
+							 TB_X)
+							want = g_chordBtn[1];
+						else if (g_in[g_curSlot].buttons &
+							 TB_Y)
+							want = g_chordBtn[2];
+					}
+					if (want != 0xFF &&
+					    want == chWant[g_curSlot]) {
+						if (++chCnt[g_curSlot] >= 12 &&
+						    want != g_usbMode &&
+						    modeValid(want) &&
+						    !USBDevice.suspended()) {
+							saveMode(want);
+							delay(40);
+							NVIC_SystemReset();
+						}
+					} else {
+						chWant[g_curSlot] = want;
+						chCnt[g_curSlot] =
+							(want != 0xFF) ? 1 : 0;
+					}
+				}
+				// compact stream for rf_controller_ui.py -- NON-BLOCKING: skip if CDC TX is backed up (a blocking
+				// Serial.print stalls the RF+USB loop -> jaggy input). One line/frame using the last record.
+				if (lastRep && !g_connVerbose &&
+				    Serial.availableForWrite() > 110 &&
+				    millis() - g_lastStream >= 4) {
+					g_lastStream = millis();
+					Serial.print("I45 ");
+					for (uint8_t i = 0; i < lastTlen; i++)
+						Serial.printf("%02X",
+							      lastRep[i]);
+					Serial.println();
+				}
+			}
+			if (g_connVerbose) {
+				Serial.printf(
+					"%s CRX#%lu txtype%02X ch%u len%u: ",
+					isF1 ? "<<<F1" :
+					       (rtype == 0xF3 ? "  F3" :
+								"  rx"),
+					(unsigned long)g_connRx, payload[0], ch,
+					rxlen);
+				for (uint8_t i = 0;
+				     i < (rxlen + 2 <= 66 ? rxlen + 2 : 66);
+				     i++)
+					Serial.printf("%02X", rfrx[i]);
+				Serial.println();
+			}
+		} else
+			rxlen = 0;
+		// RX window expired with no packet at all
+	} else {
+		g_stNoRx++;
+		g_qosBad++;
+	}
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	return rxlen;
+}
+
+// Throttle: bonded slots that haven't replied in SLOT_COLD_MS are polled at most every SLOT_COLD_RETRY_MS
+// instead of every cycle. This keeps the online controllers at full 250 Hz while barely touching offline ones.
+#define SLOT_COLD_MS 5000u
+#define SLOT_COLD_RETRY_MS 2000u
+static unsigned long g_slotLastAttemptMs[NSLOT] = {};
+
+// Drive the connected-mode sequence. The cycle gate fires once per g_pollUs (250 Hz); each fire
+// polls EVERY warm slot back-to-back so all bonded controllers run at full rate regardless of count.
+// "Cold" means the slot WAS connected but has been silent for > SLOT_COLD_MS; slots that have never
+// replied stay warm so new controllers can connect at any time after boot. Cold slots retry every
+// SLOT_COLD_RETRY_MS.
+static void rfConnStep()
+{
+	int firstUsed = -1;
+	for (int k = 0; k < NSLOT; k++)
+		if (g_slot[k].used) {
+			firstUsed = k;
+			break;
+		}
+	if (firstUsed < 0) {
+		g_curSlot = -1;
+		return;
+	}
+
+	uint8_t ch = g_sessCh;
+	// announce HOST AWAKE: E7 00 00, a few times. The real puck does NOT do this (the controller streams F1 to a
+	// bare E3 with no E7) -- skipped unless g_e7announce ('n') re-enables the legacy handshake.
+	if (g_connSt == 0) {
+		g_curSlot = firstUsed;
+		if (g_e7announce) {
+			uint8_t p[3] = { 0xE7, 0x00, g_e7b };
+			rfConnTx(ch, 0x01, p, 3);
+			if (++g_connStep >= 4) {
+				g_connSt = 1;
+				g_connStep = 0;
+				Serial.println(
+					"# CONN: awake announced -> polling");
+			}
+		} else {
+			// real-puck path: straight to the bare-E3 poll loop, no E7
+			g_connSt = 1;
+			g_connStep = 0;
+		}
+		return;
+	}
+
+	// Cycle gate: fires once per g_pollUs (250 Hz). On each fire we poll EVERY warm slot
+	// back-to-back so all bonded controllers run at full rate -- the real puck services all
+	// controllers per cycle. Polling one-slot-per-call instead tied the per-slot rate to the
+	// loop frequency AND split it across slots, collapsing 2 controllers to ~90 Hz.
+	uint32_t nowUs = micros();
+	if ((uint32_t)(nowUs - g_lastPollUs) < (uint32_t)g_pollUs)
+		return;
+	{
+		// Cycle period stat: time between gate fires (= each slot's poll period, intended g_pollUs).
+		static uint32_t lastCycleUs = 0;
+		if (lastCycleUs) {
+			g_pollDtSum += (uint32_t)(nowUs - lastCycleUs);
+			g_pollDtCnt++;
+		}
+		lastCycleUs = nowUs;
+	}
+	g_lastPollUs += g_pollUs;
+	if ((uint32_t)(nowUs - g_lastPollUs) >= (uint32_t)g_pollUs)
+		g_lastPollUs = nowUs; // catch-up reset when a cycle overran
+
+	unsigned long nowMs = millis();
+
+	// Inline poll helper; emits E7 re-assert (every 32 polls, bounded reply window so a
+	// missed F3 doesn't burn the whole slot budget), queues haptics, flushes relay, sends E3 GET.
+	auto doPoll = [&](int k) {
+		g_slotLastAttemptMs[k] = nowMs;
+		g_curSlot = k;
+		// re-assert awake/version every 32 polls (legacy; real puck never sends E7 -- gated by g_e7announce)
+		if (g_e7announce && (g_connPoll & 0x1F) == 0) {
+			uint8_t pa[3] = { 0xE7, 0x00, g_e7b };
+			rfConnTx(ch, 0x01, pa, 3, 600);
+		}
+		rfConnQueueHapticRelay();
+		{
+			uint8_t rs1 =
+				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
+			rfConnFlushRelay(ch, rs1);
+		}
+		// per-slot PID cycle so each bonded controller's polls stay distinct
+		uint8_t pidv = g_pollPid[k]++;
+		uint8_t s1 = (g_e3mode == 1) ?
+				     (uint8_t)(((pidv & 3) << 1) | 1) :
+			     (g_e3mode == 2) ? (uint8_t)((pidv & 3) << 1) :
+					       0x07;
+		uint8_t rx;
+		if (g_pollGet) {
+			// legacy: E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
+			uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45, g_getParam };
+			rx = rfConnTx(ch, s1, p, 5);
+		} else {
+			// real puck: BARE E3 (just the opcode) -- the controller streams F1 to any E3 ack
+			uint8_t p[1] = { 0xE3 };
+			rx = rfConnTx(ch, s1, p, 1);
+		}
+		if (rx)
+			g_chF1[0]++;
+		g_connPoll++;
+	};
+
+	// Poll every warm slot this cycle. "Cold" = was connected, now silent > SLOT_COLD_MS;
+	// those are retried only every SLOT_COLD_RETRY_MS. Never-replied slots count as warm so
+	// a controller that connects at any time gets the full poll rate to establish its link.
+	for (int k = 0; k < NSLOT; k++) {
+		if (!g_slot[k].used)
+			continue;
+		bool cold = g_connReplyMs[k] != 0 &&
+			    nowMs - g_connReplyMs[k] > SLOT_COLD_MS;
+		if (cold && nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+			continue;
+		doPoll(k);
+	}
+}
+
+void rfLinkTask()
+{
+	// Host-frame beacon: sent continuously, INCLUDING while connected. The controller uses the periodic E1 (the
+	// real puck's per-hop-cycle announce) to stay synced and keep answering polls at full rate; suppressing it
+	// drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect cooldown so a controller
+	// that's powering off isn't immediately re-woken/reconnected.
+	if (g_rfHost && millis() - g_connCooldown > 2500) {
+		bool connNow = anySlotLinkUp();
+		// session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
+		// (every-loop beaconing also hammers the session ch and steals reply slots from the poll). The real puck
+		// sends NO E1 on its session channel; gated by g_e1keepalive ('m') so this can be A/B'd on hardware --
+		// but it stays ON by default because OpenPuck's shared-address model relies on E1 to advertise the session.
+		if (g_e1keepalive &&
+		    millis() - g_lastSessBeacon >= (connNow ? 25u : 0u)) {
+			g_lastSessBeacon = millis();
+			g_rfCh = g_sessCh;
+			for (int s = 0; s < NSLOT; s++)
+				rfHostFrameOnce(s, false);
+		}
+		// discovery beacon on ch2 (where a searching controller looks): every loop when nothing is
+		// connected (fastest cold-boot/late-joiner connect), every 200ms once ANY controller is linked.
+		// Matches main: while a slot is connected, ch2 discovery shares g_sessCh's air budget, so beaconing
+		// faster than 200ms (the old allUp-gated 40ms path) just steals reply windows from the connected
+		// controller -> reply-rate sag -> >1500ms gaps -> spurious hapticOnReconnect re-init. A late joiner
+		// still enumerates within ~1s at 200ms.
+		if (millis() - g_lastDisc >= (connNow ? 200u : 0u)) {
+			g_lastDisc = millis();
+			g_rfCh = 2;
+			for (int s = 0; s < NSLOT; s++)
+				rfHostFrameOnce(s, true);
+		}
+	}
+	if (g_connOn && millis() - g_connCooldown > 2500) {
+		rfConnStep();
+	} // connected-mode: poll controller, read input
+	{
+		// remote wakeup on new RF controller connection (any slot)
+		static bool wasRfConn = false;
+		bool nowRfConn = anySlotLinkUp();
+		if (nowRfConn && !wasRfConn && USBDevice.suspended()) {
+			USBDevice.remoteWakeup();
+			ledWakePulse();
+			// post-resume nudge (host needs real input to actually wake)
+			if (g_active)
+				g_active->wakeEvent();
+		}
+		wasRfConn = nowRfConn;
+	}
+	// QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
+	if (g_qos && g_curSlot >= 0 && millis() - g_qosCheckMs >= 600) {
+		uint16_t bad = g_qosBad;
+		g_qosBad = 0;
+		g_qosCheckMs = millis();
+		if (bad > 20 && millis() - g_qosLastHopMs > 2000) {
+			for (int k = 0; k < (int)sizeof g_hopCand; k++) {
+				g_hopIdx = (g_hopIdx + 1) % (sizeof g_hopCand);
+				if (g_hopCand[g_hopIdx] != g_sessCh)
+					break;
+			}
+			if (Serial.availableForWrite() > 60)
+				Serial.printf(
+					"# QoS: ch%u bad=%u -> hop ch%u\n",
+					g_sessCh, bad, g_hopCand[g_hopIdx]);
+			rfHopTo(g_hopCand[g_hopIdx]);
+			g_qosLastHopMs = millis();
+		}
+	}
+	if (g_connOn && millis() - g_stMs >= 1000) {
+		g_f1ps = g_stF1;
+		g_newps = g_stNew;
+		g_pollsps = (uint16_t)g_stPoll;
+		g_pollPeriodUs =
+			g_pollDtCnt ? (uint16_t)(g_pollDtSum / g_pollDtCnt) : 0;
+		g_pollDtSum = 0;
+		g_pollDtCnt = 0;
+		if (Serial.availableForWrite() > 70)
+			Serial.printf(
+				"# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",
+				(unsigned long)g_stPoll, (unsigned long)g_stF1,
+				(unsigned long)g_stNew, (unsigned long)g_stF3,
+				(int8_t)g_connF3v, g_e7b,
+				(unsigned long)g_stCrc, (unsigned long)g_stNoRx,
+				g_curSlot);
+		g_stPoll = 0;
+		g_stF1 = 0;
+		g_stNew = 0;
+		g_stF3 = 0;
+		g_stCrc = 0;
+		g_stNoRx = 0;
+		g_chF1[0] = g_chF1[1] = g_chF1[2] = 0;
+		g_stMs = millis();
+	}
+}

+ 109 - 0
OpenPuckZephyr/src/rf_link.h

@@ -0,0 +1,109 @@
+// rf_link.h -- the operational puck<->controller RF protocol (the "dongle" role).
+//
+// Built on the radio HW layer (radio.cpp), this is what makes the device act as a puck once a controller is
+// bonded: it transmits the host frame the bonded controller reconnects on, runs the connected-mode poll loop
+// (E7 awake announce -> E3 + GET-report-0x45 each cycle), and decodes the controller's 0xF1 input reply. The
+// decode fills the shared g_in (triton.h) and dispatches to the active IController; it also handles the
+// mode-switch chord, USB remote-wakeup on a Steam short-press, adaptive channel hopping (QoS), and the
+// host-frame keepalive beacons. RE/calibration scaffolding lives separately in rf_diag.cpp.
+//
+// RECIPE: a live air capture of a real puck<->controller RECONNECT (sniff1.json) shows the controller streams
+// 0xF1 input in response to a BARE 0xE3 poll -- with NO 0xE7 awake-announce and NO GET-report-0x45 sub-TLV.
+// So the only thing OpenPuck genuinely needs to (re)establish the stream is connstate==3, which the host frame
+// (0xE1) sets up; once connected, bare E3 polls keep F1 flowing. (The earlier RE hypothesis that E7+GET-0x45
+// were required is contradicted by the capture -- see g_pollGet/g_e7announce.) The real puck also sends no E1
+// on its session channel (its bonded controller resumes on the known per-bond address); OpenPuck still beacons
+// E1 only because it runs the SHARED "ibex" address, not the per-bond one (g_e1keepalive).
+// F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
+#pragma once
+#include <stdint.h>
+#include "bonds.h" // NSLOT
+
+// ---- operational toggles + tunables (set from the CDC console / WebUSB; read here + by webusb status) ----
+// auto-start host beacon on boot (resumes puck role after a USB replug)
+extern bool g_rfHost;
+extern bool g_connOn; // auto-start connected-mode poll on boot
+extern uint8_t g_connType; // start packet type (E7 handshake, then E3)
+
+// E7 payload B-byte: 0=current(slow/awake), 1=protocol-version-1
+extern uint8_t g_e7b;
+extern uint8_t g_connLen;
+extern uint8_t g_getParam; // GET report 0x45 param byte
+
+// E3-poll PID/S1 mode (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0)
+extern uint8_t g_e3mode;
+
+// Real-puck alignment toggles (default to the captured real-puck behavior; flip on the CDC console to A/B):
+extern bool
+	g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
+extern bool
+	g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
+extern bool
+	g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
+
+// full multi-line debug dump vs compact "I45 <hex>" stream
+extern bool g_connVerbose;
+extern uint32_t g_rxWin; // poll RX-window (us): caps poll rate (~1e6/rxWin)
+
+// set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
+extern unsigned long g_connCooldown;
+
+// connected-mode state (reset by the 'k' console toggle)
+extern uint8_t g_connSt, g_connStep;
+extern uint16_t g_connPoll;
+extern uint32_t
+	g_connF1; // count of 0xF1 input reports seen (aggregate across all slots)
+
+// last protocol version the controller reported in an F3 reply (0xFF=none) -- aggregate (last writer wins)
+extern uint8_t g_connF3v;
+
+// Slot the poll loop is currently driving (set by rfConnStep before each E7/relay/E3). -1 = idle. Used by
+// the decode path (g_in[g_curSlot]), the haptic flush (per-slot session address), the wake detect, and the
+// stat dump.
+extern int g_curSlot;
+// True if ANY bonded slot is currently link-up (heard an F-reply within 300 ms). Used by the haptic
+// re-init gating (we only re-init on a real link) and the beacon pacing.
+bool anySlotLinkUp();
+
+// QoS adaptive channel hopping
+extern uint8_t g_qos; // 0=off (static g_sessCh), 1=auto-hop on degradation
+extern uint8_t g_hopIdx;
+extern volatile uint16_t g_qosBad;
+extern unsigned long g_qosCheckMs, g_qosLastHopMs;
+
+// per-second rate readouts for the WebUSB status blob
+extern uint16_t g_f1ps; // last completed second's F1 rate
+// genuine new-report rate (report 0x45 seq byte changes)
+extern uint16_t g_newps;
+
+// last second's poll TX count (GET+relay) -- vs F1 tells starvation from reply-loss
+extern uint16_t g_pollsps;
+
+// MEASURED avg us between GET-poll fires (compare to the intended g_pollUs=4000)
+extern uint16_t g_pollPeriodUs;
+
+// Smoothed controller->puck signal strength, sampled by the radio (RSSISAMPLE) on each CRC-good controller
+// reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. Per-slot: each
+// controller's RSSI is reported on its own 0x7B byte 8 (puck_hid reports it to Steam, after subtracting
+// RSSI_DBM_OFFSET).
+extern volatile uint8_t g_linkRssi[NSLOT];
+// dB to subtract from our raw RSSI magnitude before reporting, to match the real puck's close-range -35dBm
+// (compensates the Pro Micro antenna vs Valve's front-end). Tune against one known-distance reading.
+#define RSSI_DBM_OFFSET 20
+// Battery percent from the controller's periodic report 0x43 (body[1]); 0 = none yet. Per-slot: each
+// controller's battery is read on its own 0x43 reply and stored against its slot index. The WebUSB panel
+// reads g_battery[g_curSlot] (the most recently active one). The raw 0x43 report is also forwarded to
+// Steam verbatim (puck_hid onAuxReport) so Steam reads it itself.
+extern volatile uint8_t g_battery[NSLOT];
+// charge state from report 0x43 body[0] (EChargeState: 1=discharging, 2=charging, 4=charging-done; 0=unknown)
+extern volatile uint8_t g_batteryState[NSLOT];
+
+// TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
+// rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect
+// no reply, so they don't burn a full ~1.2ms window of dead air per haptic.
+uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
+		 uint16_t rxWinUs = 0);
+// Mid-session channel hop: advertise newCh on the current channel a few times, then move the poll to newCh.
+void rfHopTo(uint8_t newCh);
+// Per-loop: host-frame beacons + connected-mode poll + remote-wakeup + QoS hop + per-second stats.
+void rfLinkTask();

+ 475 - 0
OpenPuckZephyr/src/serial_console.cpp

@@ -0,0 +1,475 @@
+#include "serial_console.h"
+#include "radio.h"
+#include "rf_diag.h"
+#include "rf_link.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "config.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <stdlib.h>
+#include <string.h>
+
+// CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds, etc.
+// Command order matters: else-if chain, FIRST matching letter wins. 'C' and 'H' appear twice; the second
+// occurrence is unreachable.
+void serialConsolePoll()
+{
+	static char line[24];
+	static uint8_t li = 0;
+	while (Serial.available()) {
+		char c = Serial.read();
+		if (c == '\n' || c == '\r') {
+			line[li] = 0;
+			// FULL factory wipe -- requires the exact word "ERASE-ALL" so it can't be fat-fingered. Reformats the
+			// internal FS (cfg.bin + bonds.bin gone), reboots into clean defaults; controller must be re-paired.
+			if (!strcmp(line, "ERASE-ALL")) {
+				Serial.println(
+					"# ERASING ALL persistent storage (config + bonds)...");
+				factoryErase();
+				Serial.println(
+					"# done -- rebooting into clean defaults; re-pair the controller");
+				delay(40);
+				NVIC_SystemReset();
+			} else if (line[0] == 'l')
+				rfListenStart();
+			else if (line[0] == 'B') {
+				g_rfBeacon = !g_rfBeacon;
+				g_rfListen = false;
+				Serial.printf("# beacon %s ch%u\n",
+					      g_rfBeacon ? "ON" : "off",
+					      g_rfCh);
+			} else if (line[0] == 'L') {
+				g_plen = strtol(line + 1, 0, 16);
+				Serial.printf("# plen=%02X\n", g_plen);
+			} else if (line[0] == '1') {
+				g_s1incl = !g_s1incl;
+				Serial.printf("# s1incl=%u\n", g_s1incl);
+			} else if (line[0] == 'R') {
+				g_rfCh = atoi(line + 1);
+				g_rfRaw = true;
+				g_rfListen = false;
+				g_rfBeacon = false;
+				rfRawStart(g_rfCh);
+				Serial.printf("# raw cap ch%u\n", g_rfCh);
+			} else if (line[0] == 'Z') {
+				g_rfRaw = true;
+				g_rfSweep = true;
+				g_rfListen = false;
+				g_rfBeacon = false;
+				g_rfCh = 2;
+				rfRawStart(g_rfCh);
+				Serial.println("# raw SWEEP");
+			} else if (line[0] == 'H') {
+				g_rfHost = !g_rfHost;
+				g_rfBeacon = false;
+				g_rfListen = false;
+				g_rfRaw = false;
+				g_rfSweep = false;
+				g_rfCh = 2;
+				Serial.printf(
+					"# HOSTFRAME beacon %s base %02X%02X%02X%02X (slot0 uuids %02X%02X%02X%02X/%02X%02X%02X%02X)\n",
+					g_rfHost ? "ON" : "off", g_rfBase[0],
+					g_rfBase[1], g_rfBase[2], g_rfBase[3],
+					g_slot[0].rec[0], g_slot[0].rec[1],
+					g_slot[0].rec[2], g_slot[0].rec[3],
+					g_slot[0].rec[4], g_slot[0].rec[5],
+					g_slot[0].rec[6], g_slot[0].rec[7]);
+			} else if (line[0] == 'C') {
+				g_rfBeacon = g_rfListen = g_rfRaw = g_rfSweep =
+					g_rfHost = false;
+				rfRespondStart();
+			} else if (line[0] == 's') {
+				g_rfListen = false;
+				g_rfBeacon = false;
+				g_rfRaw = false;
+				g_rfSweep = false;
+				g_rfHost = false;
+				g_rfRespond = false;
+				NRF_RADIO->TASKS_DISABLE = 1;
+				Serial.println("# RF off");
+			}
+
+			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean
+			else if (line[0] == 'x') {
+				uint8_t m = strtoul(line + 1, 0, 10);
+				if (modeValid(m)) {
+					if (USBDevice.suspended()) {
+						Serial.println(
+							"# mode change blocked: host suspended");
+					} else {
+						Serial.printf(
+							"# switch mode %u (reboot)\n",
+							m);
+						delay(20);
+						saveMode(m);
+						delay(40);
+						NVIC_SystemReset();
+					}
+				}
+			} else if (line[0] == 'c') {
+				g_rfCh = atoi(line + 1);
+				Serial.printf("# ch=%u\n", g_rfCh);
+				if (g_rfListen)
+					rfListenStart();
+			} else if (line[0] == 'p') {
+				g_rfPrefix = strtol(line + 1, 0, 16);
+				Serial.printf("# prefix=%02X\n", g_rfPrefix);
+				if (g_rfListen)
+					rfListenStart();
+			} else if (line[0] == 'i') {
+				g_crcinit = strtoul(line + 1, 0, 16);
+				Serial.printf("# crcinit=%06lX\n",
+					      (unsigned long)g_crcinit);
+			} else if (line[0] == 'w') {
+				g_whiteiv = atoi(line + 1);
+				Serial.printf("# whiteiv=%u\n", g_whiteiv);
+			} else if (line[0] == '0') {
+				g_pcnf0 = strtoul(line + 1, 0, 16);
+				Serial.printf("# pcnf0=%08lX\n",
+					      (unsigned long)g_pcnf0);
+			} else if (line[0] == '2') {
+				g_pcnf1 = strtoul(line + 1, 0, 16);
+				Serial.printf("# pcnf1=%08lX\n",
+					      (unsigned long)g_pcnf1);
+			} else if (line[0] == 'M') {
+				g_mode = strtoul(line + 1, 0, 16);
+				Serial.printf(
+					"# mode=%u (3=Ble1M,4=Ble2M,0/2=Nrf)\n",
+					g_mode);
+			} else if (line[0] == 'I') {
+				g_crcinit = strtoul(line + 1, 0, 16);
+				Serial.printf("# crcinit=%08lX\n",
+					      (unsigned long)g_crcinit);
+			} else if (line[0] == 'P') {
+				g_crcpoly = strtoul(line + 1, 0, 16);
+				Serial.printf("# crcpoly=%lX\n",
+					      (unsigned long)g_crcpoly);
+			} else if (line[0] == 'N') {
+				g_crccnf = strtoul(line + 1, 0, 16);
+				Serial.printf("# crccnf=%X\n", g_crccnf);
+			} else if (line[0] == 'Y') {
+				g_rfCap = true;
+				g_rfCapOne = true;
+				g_rfReplay = g_rfHost = g_rfAuto = false;
+				g_rfCh = 2;
+				rfCapStart(2);
+				Serial.println(
+					"# capture ONE frame for replay...");
+			} else if (line[0] == 'y') {
+				g_rfReplay = !g_rfReplay;
+				g_rfCap = g_rfHost = g_rfAuto = false;
+				Serial.printf("# REPLAY %s (%uB)\n",
+					      g_rfReplay ? "ON" : "off",
+					      g_replayLen);
+			} else if (line[0] == 'T') {
+				g_statlen = strtoul(line + 1, 0, 16);
+				g_pcnf1 = 0;
+				Serial.printf("# statlen=%02X\n", g_statlen);
+			} else if (line[0] == 'k') {
+				g_connOn = !g_connOn;
+				if (g_connOn) {
+					g_connSt = 0;
+					g_connStep = 0;
+					g_connPoll = 0;
+					g_connF1 = 0;
+				}
+				Serial.printf(
+					"# CONN mode %s: %s%s poll on ch%u (param=%02X). F1 seen=%lu\n",
+					g_connOn ? "ON" : "off",
+					g_e7announce ? "E7-awake[00 00] -> " :
+						       "",
+					g_pollGet ? "E3+GET-report-0x45" :
+						    "bare E3",
+					g_rfCh, g_getParam,
+					(unsigned long)g_connF1);
+			} else if (line[0] == 'q') {
+				g_getParam = g_getParam ? 0x00 : 0x2D;
+				Serial.printf("# GET-0x45 param=%02X\n",
+					      g_getParam);
+			} else if (line[0] == 'v') {
+				g_connVerbose = !g_connVerbose;
+				Serial.printf(
+					"# conn verbose %s (F1 seen=%lu)\n",
+					g_connVerbose ? "ON" : "off",
+					(unsigned long)g_connF1);
+			} else if (line[0] == 'C') {
+				g_sessCh = strtoul(line + 1, 0, 10);
+				Serial.printf(
+					"# session channel=%u (re-pair/reconnect to apply)\n",
+					g_sessCh);
+			} else if (line[0] == 'E') {
+				g_mDiv = strtoul(line + 1, 0, 10);
+				if (g_mDiv < 4)
+					g_mDiv = 4;
+				saveCfg();
+				Serial.printf(
+					"# xbox-mouse sensitivity divisor=%d (lower=faster)\n",
+					g_mDiv);
+			} else if (line[0] == 'F') {
+				g_mFric = strtoul(line + 1, 0, 10);
+				if (g_mFric > 99)
+					g_mFric = 99;
+				saveCfg();
+				Serial.printf(
+					"# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",
+					g_mFric);
+			} else if (line[0] == 'W') {
+				// A/B swap edits the ACTIVE emulated type (Xbox/Switch/DS4/DS5); puck modes have none.
+				if (g_etype < ET_COUNT) {
+					g_type[g_etype].abSwap =
+						!g_type[g_etype].abSwap;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# A/B + X/Y swap %s (Nintendo layout) [type %u]\n",
+						g_abSwap ? "ON" : "off",
+						g_etype);
+				} else
+					Serial.println(
+						"# A/B swap N/A in this mode (Steam/Lizard are not emulated types)");
+			} else if (line[0] == 'K') {
+				int i = line[1] - '0';
+				uint8_t code = strtoul(line + 2, 0, 10);
+				// back paddles edit the ACTIVE emulated type. code 18 = Capture/Screenshot (Switch only).
+				if (i >= 0 && i < 4 && g_etype < ET_COUNT) {
+					g_type[g_etype].back[i] = code;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# back[%d] (%s) -> code %u [type %u]  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute 18=Capture(Switch) 19=LT/L2/ZL 20=RT/R2/ZR]\n",
+						i,
+						(const char *[]){ "L4", "R4",
+								  "L5",
+								  "R5" }[i],
+						code, g_etype);
+				} else if (g_etype >= ET_COUNT)
+					Serial.println(
+						"# back map N/A in this mode (Steam/Lizard are not emulated types)");
+				else
+					Serial.println(
+						"# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)");
+			} else if (line[0] == 'Q') {
+				uint8_t code = strtoul(line + 1, 0, 10);
+				if (g_etype < ET_COUNT) {
+					g_type[g_etype].qamMap = code;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# QAM -> code %u [type %u]  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute 18=Capture(Switch) 19=LT/L2/ZL 20=RT/R2/ZR]\n",
+						code, g_etype);
+				} else
+					Serial.println(
+						"# QAM map N/A in this mode (Steam/Lizard are not emulated types)");
+			} else if (line[0] == 'J') {
+				char *sp = 0;
+				uint8_t id = strtoul(line + 1, &sp, 0);
+
+				// inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
+				uint16_t val = sp ? strtoul(sp, 0, 0) : 0;
+				uint8_t pl[3] = { id, (uint8_t)(val & 0xFF),
+						  (uint8_t)(val >> 8) };
+				// console-injected writes go to all connected controllers (no specific slot in scope)
+				relayEnqueue(0x87, pl, 3, 0xFF);
+				Serial.printf(
+					"# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",
+					id, val);
+			} else if (line[0] == 'G') {
+				g_sniff = !g_sniff;
+				g_sniffPh = 0;
+				g_rfHost = g_connOn = g_rfCap = g_rfAuto =
+					g_rfReplay = false;
+				if (g_sniff)
+					rfSniffStart();
+				Serial.printf(
+					"# SESSION SNIFF %s (phase0: catch host frame on ibex/ch2; phase1 PARK ch=%s)\n",
+					g_sniff ? "ON" : "off",
+					g_sniffPark ? "override" : "primary");
+			} else if (line[0] == 'g') {
+				g_sniffPark = strtoul(line + 1, 0, 10);
+				if (g_sniffPh == 1)
+					rfSniffStart();
+				Serial.printf(
+					"# sniff park channel=%u (0=learned primary)\n",
+					g_sniffPark);
+			} else if (line[0] == 'V') {
+				g_e7b = strtoul(line + 1, 0, 10);
+				Serial.printf(
+					"# E7 protocol-version B-byte=%u (0=awake/slow, 1=try v1-fast; watch new=/s; revert if input stops)\n",
+					g_e7b);
+			} else if (line[0] == 'O') {
+				g_relayOp = strtoul(line + 1, 0, 16);
+				Serial.printf("# relay opcode=%02X\n",
+					      g_relayOp);
+			} else if (line[0] == 'o') {
+				g_relaySub = strtoul(line + 1, 0, 16);
+				Serial.printf("# relay sub-type=%02X\n",
+					      g_relaySub);
+			} else if (line[0] == 'h') {
+				uint8_t nc = strtoul(line + 1, 0, 10);
+				Serial.printf(
+					"# HOP %u->%u (advertise on current ch, then poll new). Watch F1=/s\n",
+					g_sessCh, nc);
+				rfHopTo(nc);
+			} else if (line[0] == 'z') {
+				g_qos = !g_qos;
+				g_hopIdx = 0;
+				g_qosBad = 0;
+				g_qosCheckMs = millis();
+				Serial.printf(
+					"# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",
+					g_qos ? "ON" : "off");
+			} else if (line[0] == 'U') {
+				// Per-slot debug dump: which controllers are connected, their RSSI, haptic-block
+				// state, and session address. Quick at-a-glance view for multi-controller testing.
+				Serial.printf("# session ch%u, curSlot=%d\n",
+					      g_sessCh, g_curSlot);
+				for (int s = 0; s < NSLOT; s++) {
+					bool live = g_slot[s].used &&
+						    g_connReplyMs[s] != 0;
+					unsigned long age =
+						live ? (unsigned long)(millis() -
+								       g_connReplyMs
+									       [s]) :
+						       0;
+					int rssi =
+						g_linkRssi[s] ?
+							(int)g_linkRssi[s] -
+								RSSI_DBM_OFFSET :
+							0;
+					unsigned long now_ms = millis();
+					unsigned long blk =
+						(g_hapticBlockUntil[s] &&
+						 (int32_t)(g_hapticBlockUntil[s] -
+							   now_ms) > 0) ?
+							(g_hapticBlockUntil[s] -
+							 now_ms) :
+							0;
+					Serial.printf(
+						"# slot %d: %s addr %02X%02X%02X%02X/%02X rssi=%ddBm conn=%lums ago batt=%u%% haptic-block=%lums uuid %02X%02X%02X%02X %02X%02X%02X%02X\n",
+						s,
+						g_slot[s].used ?
+							(live ? "LIVE" :
+								"BONDED") :
+							"empty",
+						g_sessBase[s][0],
+						g_sessBase[s][1],
+						g_sessBase[s][2],
+						g_sessBase[s][3],
+						g_sessPrefix[s], rssi, age,
+						g_battery[s], blk,
+						g_slot[s].rec[0],
+						g_slot[s].rec[1],
+						g_slot[s].rec[2],
+						g_slot[s].rec[3],
+						g_slot[s].rec[4],
+						g_slot[s].rec[5],
+						g_slot[s].rec[6],
+						g_slot[s].rec[7]);
+				}
+			} else if (line[0] == 'r') {
+				g_rxWin = strtoul(line + 1, 0, 10);
+				if (g_rxWin < 150)
+					g_rxWin = 150;
+				Serial.printf(
+					"# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",
+					(unsigned long)g_rxWin,
+					(unsigned long)(1000000 / g_rxWin));
+			} else if (line[0] == 'e') {
+				g_e3mode = strtoul(line + 1, 0, 10);
+				Serial.printf(
+					"# E3 poll PID mode=%u (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0) - watch new=/s\n",
+					g_e3mode);
+			} else if (line[0] == 'd') {
+				g_pollGet = !g_pollGet;
+				Serial.printf(
+					"# poll = %s (real puck sends BARE E3) - watch F1=/s new=/s\n",
+					g_pollGet ?
+						"E3 + GET-report-0x45 TLV (legacy)" :
+						"bare E3");
+			} else if (line[0] == 'n') {
+				g_e7announce = !g_e7announce;
+				Serial.printf(
+					"# E7 awake-announce %s (real puck never sends E7) - watch F1=/s\n",
+					g_e7announce ? "ON (legacy)" : "off");
+			} else if (line[0] == 'm') {
+				g_e1keepalive = !g_e1keepalive;
+				Serial.printf(
+					"# session-channel E1 keepalive %s (real puck sends none; needed for shared addr) - watch F1=/s\n",
+					g_e1keepalive ? "ON" : "off");
+			} else if (line[0] == 't') {
+				// inject n test haptics (output 0x82 [01 01 F7]) over the relay
+				uint8_t n = line[1] ? strtoul(line + 1, 0, 10) :
+						      40;
+				g_testHaptic = n;
+				Serial.printf(
+					"# test-haptic burst x%u queued (relay 0x82 01 01 F7 via op=%02X sub=%02X)\n",
+					n, g_relayOp, g_relaySub);
+			}
+
+			// dump the captured OUTPUT-report history (oldest->newest)
+			else if (line[0] == 'H') {
+				hapticDumpLog();
+			} else if (line[0] == 'j') {
+				g_connType = strtoul(line + 1, 0, 16);
+				Serial.printf("# connType=%02X\n", g_connType);
+			} else if (line[0] == 'Q') {
+				g_connLen = strtoul(line + 1, 0, 16);
+				Serial.printf("# connLen=%02X\n", g_connLen);
+			} else if (line[0] == 'A') {
+				g_balen = strtoul(line + 1, 0, 16);
+				g_pcnf1 = 0;
+				Serial.printf("# balen=%u\n", g_balen);
+			} else if (line[0] == 'a') {
+				uint32_t v = strtoul(line + 1, 0, 16);
+				g_rfBase[0] = v >> 24;
+				g_rfBase[1] = v >> 16;
+				g_rfBase[2] = v >> 8;
+				g_rfBase[3] = v;
+				Serial.printf("# base=%08lX\n",
+					      (unsigned long)v);
+				if (g_rfListen)
+					rfListenStart();
+			} else if (line[0] == 'b') {
+				for (int i = 0; i < NSLOT; i++)
+					if (g_slot[i].used) {
+						Serial.printf("slot%d ", i);
+						for (int j = 0; j < 24; j++)
+							Serial.printf(
+								"%02X",
+								g_slot[i].rec[j]);
+						Serial.println();
+					}
+			} else if (line[0] == 'D') {
+				armDebugCdcNextBoot();
+				Serial.println(
+					"# debug-CDC armed: reset to KEEP the serial console one more boot (auto-reverts after; the wake mouse is dropped that boot)");
+			} else if (line[0] == 'X') {
+				g_rfCap = !g_rfCap;
+				g_rfHost = g_rfAuto = g_rfBeacon = g_rfListen =
+					g_rfRaw = g_rfSweep = g_rfRespond =
+						false;
+				g_rfCh = 2;
+				g_capV = 0;
+				if (g_rfCap)
+					rfCapStart(g_rfCh);
+				Serial.printf(
+					"# CAPTURE(listen) %s ch%u on \"ibex\", cycling 1M/2M x BALEN3/4 (use cN to set ch)\n",
+					g_rfCap ? "ON" : "off", g_rfCh);
+			} else if (line[0] == 'S') {
+				g_rfAuto = !g_rfAuto;
+				g_rfHost = g_rfAuto;
+				g_rfBeacon = g_rfListen = g_rfRaw = g_rfSweep =
+					false;
+				g_rfCh = 2;
+				g_cfgIdx = 0;
+				if (g_rfAuto)
+					applyCfg(0);
+				Serial.printf("# AUTOSWEEP %s\n",
+					      g_rfAuto ? "ON" : "off");
+			}
+			li = 0;
+		} else if (li < sizeof line - 1)
+			line[li++] = c;
+	}
+}

+ 9 - 0
OpenPuckZephyr/src/serial_console.h

@@ -0,0 +1,9 @@
+// serial_console.h -- the CDC debug command line.
+//
+// Single-letter commands over USB CDC Serial, used during development and protocol RE: toggle RF diag modes,
+// poke radio registers live, switch USB mode, edit tunables, inject test haptics, dump capture history. The
+// operational firmware never needs it; it's the human/agent interface to everything under the hood. Present
+// only in puck modes (clean controller modes drop CDC). See the parser in serial_console.cpp for the full map.
+#pragma once
+
+void serialConsolePoll(); // drain + dispatch CDC commands; call every loop()

+ 38 - 0
OpenPuckZephyr/src/status_led.cpp

@@ -0,0 +1,38 @@
+#include "status_led.h"
+#include <Arduino.h>
+
+#define WAKE_LED_OFF ((WAKE_LED_ON) == HIGH ? LOW : HIGH)
+#define PULSE_MS 500u // wake flash duration
+
+static unsigned long g_pulseMs = 0;
+static bool g_lit = false;
+
+static void ledWrite(int level)
+{
+	digitalWrite(WAKE_LED_PIN_A, level);
+	digitalWrite(WAKE_LED_PIN_B, level);
+}
+
+void ledInit()
+{
+	pinMode(WAKE_LED_PIN_A, OUTPUT);
+	pinMode(WAKE_LED_PIN_B, OUTPUT);
+	ledWrite(WAKE_LED_OFF);
+}
+
+void ledWakePulse()
+{
+	g_pulseMs = millis();
+	g_lit = true;
+
+	// light immediately at the remoteWakeup() call site, not on the next loop
+	ledWrite(WAKE_LED_ON);
+}
+
+void ledTask()
+{
+	if (g_lit && millis() - g_pulseMs >= PULSE_MS) {
+		g_lit = false;
+		ledWrite(WAKE_LED_OFF);
+	}
+}

+ 32 - 0
OpenPuckZephyr/src/status_led.h

@@ -0,0 +1,32 @@
+// status_led.h -- LED indication of wake activity.
+//
+// The LED is DARK in all steady states -- including while wake is armed (host suspended) -- and flashes for
+// half a second when a wake is actually sent (USBDevice.remoteWakeup()). It's a wake debugger: flash + PC
+// stays asleep = resume signal was sent and the HOST ignored it (fix host-side: powercfg /deviceenablewake);
+// no flash = firmware never fired (didn't see the gesture, or didn't consider the bus suspended).
+//
+// Board note: built with the Feather nRF52840 variant, but the usual hardware is a SuperMini "Pro Micro"
+// clone. The Feather's user LED is P1.15 (D3, active high); the SuperMini's blue user LED is P0.15 (= D24 in
+// the Feather pin map -- SPI MISO, unused here). We drive BOTH pins so the indicator works on either board.
+// Override the pins/polarity below if your board differs.
+#pragma once
+
+#ifndef WAKE_LED_PIN_A
+
+// Feather: P1.15 user LED (harmless unconnected pad on SuperMini clones)
+#define WAKE_LED_PIN_A LED_BUILTIN
+#endif
+#ifndef WAKE_LED_PIN_B
+
+// SuperMini "Pro Micro" clone: P0.15 blue user LED (D24 in the Feather map)
+#define WAKE_LED_PIN_B 24
+#endif
+#ifndef WAKE_LED_ON
+#define WAKE_LED_ON HIGH // set LOW if your board's LED is wired active-low
+#endif
+
+void ledInit(); // call once from setup(): pins to output, LED off
+
+// call at each USBDevice.remoteWakeup() site: LED on now, off after 500ms
+void ledWakePulse();
+void ledTask(); // call every loop(): times out the pulse

+ 14 - 0
OpenPuckZephyr/src/triton.cpp

@@ -0,0 +1,14 @@
+#include "triton.h"
+
+PuckInput g_in[NSLOT];
+
+void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
+	       int16_t *gx, int16_t *gy, int16_t *gz)
+{
+	*ax = s16off(r, 32);
+	*ay = s16off(r, 34);
+	*az = s16off(r, 36);
+	*gx = s16off(r, 38);
+	*gy = s16off(r, 40);
+	*gz = s16off(r, 42);
+}

+ 93 - 0
OpenPuckZephyr/src/triton.h

@@ -0,0 +1,93 @@
+// triton.h -- the controller's native input as it comes off the RF link, and the decoders for it.
+//
+// "Triton" is the Steam Controller 2 controller. Its HID input report 0x45 is what the puck relays; rf_link.cpp
+// decodes one into the shared g_in struct each fresh frame, and every USB personality reads g_in to build its
+// own host report. report 0x45 layout:
+//   [0]=0x45 [1]=seq [2..5]=buttons u32; analog offsets below are from the buttons low byte (rep[2]).
+#pragma once
+#include <stdint.h>
+
+// ---- Triton button masks (the 32-bit field at rep[2..5]) ----
+#define TB_A 0x1u
+#define TB_B 0x2u
+#define TB_X 0x4u
+#define TB_Y 0x8u
+#define TB_QAM 0x10u
+#define TB_R3 0x20u
+#define TB_VIEW 0x40u
+#define TB_R4 0x80u
+#define TB_R5 0x100u
+#define TB_RB 0x200u
+#define TB_DDN 0x400u
+#define TB_DRT 0x800u
+#define TB_DLF 0x1000u
+#define TB_DUP 0x2000u
+#define TB_MENU 0x4000u
+#define TB_L3 0x8000u
+#define TB_STEAM 0x10000u
+#define TB_L4 0x20000u
+#define TB_L5 0x40000u
+#define TB_LB 0x80000u
+#define TB_RPADT 0x200000u
+#define TB_RPADC 0x400000u
+#define TB_LPADT 0x2000000u
+#define TB_LPADC 0x4000000u
+// Trigger full-pull / click bits (the controller sets these at a full ZL/ZR pull; also the target of a back-
+// paddle / QAM remap to LT/RT). psShouldersByte / the Switch builders already read these as L2/R2 / ZL/ZR.
+#define TB_R2 0x800000u
+#define TB_L2 0x8000000u
+
+// Virtual PS targets, settable ONLY via a back-paddle/QAM remap (psOrBackCode / tritonFromCode), never by the
+// controller itself. They live on bits 30/31 -- the only bits the SC2 0x45 report does NOT use (see PROTOCOL.md
+// §8.1; bits 0..29 are all real). They originally aliased 0x00100000 (right stick touch) and 0x01000000 (left
+// stick touch), so merely RESTING a thumb on a capacitive stick fired the DualSense trackpad-click / mute.
+#define TB_TOUCH 0x40000000u // PS touchpad click
+#define TB_MUTE 0x80000000u // PS5 mute
+
+// all four back paddles held -> mode-switch chord guard
+#define CHORD_BACK4 (TB_R4 | TB_L4 | TB_R5 | TB_L5)
+
+// analog-trigger fraction (of 0xFF) at which digital ZL/ZR (Switch) etc. trip
+#define SW_TRIG_ON 40
+
+// ---- report 0x45 field decoders (offsets relative to rep[2], the buttons low byte) ----
+static inline int s16off(const uint8_t *r, int off)
+{
+	int v = r[2 + off] | (r[2 + off + 1] << 8);
+	return (v & 0x8000) ? v - 0x10000 : v;
+}
+static inline int u16off(const uint8_t *r, int off)
+{
+	return r[2 + off] | (r[2 + off + 1] << 8);
+}
+// Controller trigger u16 tops out near half-scale (~0x8000) at a full pull, so a straight >>8 reads only ~0x80
+// (host sees a half-pressed trigger). Scale x2 (>>7) and saturate so a full pull maps to 0xFF.
+static inline uint8_t trigU8(int u16v)
+{
+	int v = u16v >> 7;
+	return (uint8_t)(v > 255 ? 255 : v);
+}
+static inline uint32_t btnsOf(const uint8_t *r)
+{
+	return (uint32_t)r[2] | ((uint32_t)r[3] << 8) | ((uint32_t)r[4] << 16) |
+	       ((uint32_t)r[5] << 24);
+}
+// report 0x45 IMU offsets (PROTOCOL.md §8): accel @0x22, gyro @0x28 from report start.
+void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
+	       int16_t *gx, int16_t *gy, int16_t *gz);
+
+// ---- shared decoded input (filled by rf_link.cpp once per fresh report 0x45, read by every mode) ----
+// One PuckInput per bond slot: each controller in a multi-slot puck has its own decoded input. The stream
+// modes (Switch, PS5, DS4) read g_in[s] for each active slot in their task() loop; the puck/lizard mode reads
+// g_in[slot] inside onReport45/onAuxReport to forward to hid[slot].
+struct PuckInput {
+	// raw Triton buttons (TB_*); per-mode builders apply their own chord masking
+	uint32_t buttons;
+	int16_t lx, ly, rx, ry; // sticks (int16, center 0)
+	uint8_t lt, rt; // triggers scaled 0..255 (trigU8)
+	int16_t lpx, lpy, rpx, rpy; // left / right trackpad coords (int16)
+	int16_t ax, ay, az; // accelerometer
+	int16_t gx, gy, gz; // gyroscope
+};
+#include "bonds.h" // NSLOT
+extern PuckInput g_in[NSLOT];

+ 127 - 0
OpenPuckZephyr/src/usb_mount.cpp

@@ -0,0 +1,127 @@
+#include "usb_mount.h"
+#include "bonds.h"
+#include <Arduino.h>
+
+uint8_t g_usbMountCount = 0;
+int8_t g_usbToBond[NSLOT];
+int8_t g_bondToUsb[NSLOT];
+
+static bool g_dynOn = false;
+static uint8_t g_maxSlots = NSLOT;
+
+// A controller counts as "connected" (mount-worthy) while it has replied recently. More lenient than the
+// 300ms link-up check so a brief RF gap doesn't unmount it; the debounce below adds further hysteresis.
+#define CONN_UP_MS 1200u
+// 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
+
+static uint8_t connectedMask()
+{
+	unsigned long now = millis();
+	uint8_t m = 0;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] != 0 &&
+		    (now - g_connReplyMs[s]) < CONN_UP_MS)
+			m |= (uint8_t)(1u << s);
+	return m;
+}
+
+static uint8_t mountedMask()
+{
+	uint8_t m = 0;
+	for (uint8_t u = 0; u < g_usbMountCount; u++)
+		if (g_usbToBond[u] >= 0)
+			m |= (uint8_t)(1u << g_usbToBond[u]);
+	return m;
+}
+
+// Compute the order-preserving, budget-capped mount list for `mask` into out[], returning the count. Ordering
+// follows WHEN each controller was mounted: controllers already mounted keep their current USB-slot order (a
+// departure compacts the rest down but never reorders survivors), and newly-connected controllers are appended
+// at the end (ascending bond order among simultaneous joiners). Does NOT mutate the live map.
+static uint8_t computeOrder(uint8_t mask, int8_t out[NSLOT])
+{
+	uint8_t n = 0;
+	// 1. keep currently-mounted controllers that are still connected, in their existing order
+	for (uint8_t u = 0; u < g_usbMountCount && n < g_maxSlots; u++) {
+		int8_t b = g_usbToBond[u];
+		if (b >= 0 && (mask & (1u << b)))
+			out[n++] = b;
+	}
+	// 2. append newly-connected controllers (not already mounted)
+	for (int s = 0; s < NSLOT && n < g_maxSlots; s++) {
+		if (!(mask & (1u << s)))
+			continue;
+		bool have = false;
+		for (uint8_t i = 0; i < n; i++)
+			if (out[i] == (int8_t)s) {
+				have = true;
+				break;
+			}
+		if (!have)
+			out[n++] = (int8_t)s;
+	}
+	return n;
+}
+static uint8_t maskOfList(const int8_t *list, uint8_t n)
+{
+	uint8_t m = 0;
+	for (uint8_t i = 0; i < n; i++)
+		if (list[i] >= 0)
+			m |= (uint8_t)(1u << list[i]);
+	return m;
+}
+static void commitOrder(const int8_t *list, uint8_t n)
+{
+	for (int i = 0; i < NSLOT; i++) {
+		g_usbToBond[i] = -1;
+		g_bondToUsb[i] = -1;
+	}
+	for (uint8_t u = 0; u < n; u++) {
+		g_usbToBond[u] = list[u];
+		g_bondToUsb[list[u]] = (int8_t)u;
+	}
+	g_usbMountCount = n;
+}
+
+void usbMountRebuildMap()
+{
+	int8_t tmp[NSLOT];
+	uint8_t n = computeOrder(connectedMask(), tmp);
+	commitOrder(tmp, n);
+}
+
+void usbMountEnable(bool on, uint8_t maxSlots)
+{
+	g_dynOn = on;
+	g_maxSlots = maxSlots ? (maxSlots <= NSLOT ? maxSlots : NSLOT) : 1;
+	for (int i = 0; i < NSLOT; i++)
+		g_usbToBond[i] = g_bondToUsb[i] = -1;
+	g_usbMountCount = 0;
+}
+
+void usbMountTask()
+{
+	if (!g_dynOn)
+		return;
+	static uint8_t lastMask = 0xFF; // force first compare
+	static unsigned long stableSince = 0;
+	unsigned long now = millis();
+	uint8_t want = connectedMask();
+	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.
+	if ((now - stableSince) < MOUNT_DEBOUNCE_MS)
+		return;
+	int8_t tmp[NSLOT];
+	uint8_t n = computeOrder(want, tmp);
+	if (maskOfList(tmp, n) == mountedMask())
+		return; // same set already mounted (order is preserved across joins/leaves)
+	commitOrder(tmp, n);
+	usbReenumerate(g_usbMountCount);
+}

+ 38 - 0
OpenPuckZephyr/src/usb_mount.h

@@ -0,0 +1,38 @@
+// usb_mount.h -- dynamic USB mounting of ACTIVELY-CONNECTED controllers (emulated modes only).
+//
+// Problem: a puck can be bonded to several controllers but only some are powered on / linked at a time. The
+// emulated modes used to mount one USB HID gamepad per BONDED slot at boot, so the host saw phantom pads for
+// controllers that were off. This module makes the USB device present only the CONNECTED controllers, and
+// re-enumerate (NO MCU reboot -- RF links stay up) whenever that set changes.
+//
+// Model: a fixed pool of per-mode USB-slot objects (HID/XInput interfaces) is created ONCE at boot (locking
+// their TinyUSB instance indices). At any moment the k connected controllers are presented as a DENSE PREFIX
+// usbSlot 0..k-1 of that pool; usbSlot u is fed from bond slot g_usbToBond[u]. USB can't hold an interface
+// "gap", so the prefix is compacted -> player identity follows CONNECTION ORDER (a mid-set disconnect shifts
+// the rest down; a reconnect appends). Re-enumeration is debounced so an RF blip doesn't reshuffle.
+#pragma once
+#include <stdint.h>
+#include "bonds.h" // NSLOT
+
+// Active mount map. usbSlot (0..g_usbMountCount-1) -> bond slot; and the inverse for output-report routing.
+extern uint8_t g_usbMountCount; // # of currently-mounted (connected) controllers
+extern int8_t g_usbToBond[NSLOT]; // usbSlot -> bondSlot (-1 = unused)
+extern int8_t g_bondToUsb[NSLOT]; // bondSlot -> usbSlot (-1 = not mounted)
+
+// Enable the dynamic watcher for this boot (emulated modes call this; puck/lizard leave it off so their begin()
+// keeps the legacy behavior). maxSlots caps the mounted count to the mode's HID-instance budget (CFG_TUD_HID
+// minus the wake mouse, etc.). When disabled, usbMountTask() is a no-op.
+void usbMountEnable(bool on, uint8_t maxSlots);
+
+// Build g_usbToBond/g_bondToUsb + g_usbMountCount from the CURRENT connected set (bond-slot order). Called by
+// the boot enumerate and on every accepted change.
+void usbMountRebuildMap();
+
+// Per-loop watcher: detect a debounced change in the connected set and trigger usbReenumerate(k). No-op unless
+// dynamic mounting is enabled.
+void usbMountTask();
+
+// Provided by OpenPuck.ino: tear down + rebuild the USB config descriptor presenting `k` slot interfaces
+// (fixed interfaces -- WebUSB / wake mouse -- replayed too), then re-attach. NO MCU reboot. Call with the map
+// already rebuilt for k connected controllers.
+void usbReenumerate(uint8_t k);

+ 44 - 0
OpenPuckZephyr/src/wake_hid.cpp

@@ -0,0 +1,44 @@
+#include "wake_hid.h"
+#include <Adafruit_TinyUSB.h>
+
+// Boot MOUSE descriptor -- proven to enumerate and wake Windows. The host enumerates a "HID-compliant mouse"
+// child and arms IT as the wake source, so the wake nudge (wakeHidMove, driven from the puck wake path) must
+// ride THIS interface -- a report on the gamepad slot lands on an interface the host never allow-listed. (A boot
+// keyboard didn't enumerate on Windows and suppressed the wake the mouse class provides.)
+static const uint8_t WAKE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
+static Adafruit_USBD_HID g_wakeHid;
+static bool g_wakeHidPresent = false;
+
+void wakeHidBegin()
+{
+	// boot mouse = a wake device class honored by Windows + Linux
+	g_wakeHid.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
+	g_wakeHid.setStringDescriptor("OpenPuck Wake");
+	g_wakeHid.setReportDescriptor(WAKE_HID_DESC, sizeof WAKE_HID_DESC);
+	g_wakeHid.setPollInterval(10);
+	g_wakeHid.begin();
+	g_wakeHidPresent = true;
+}
+
+bool wakeHidPresent()
+{
+	return g_wakeHidPresent;
+}
+
+bool wakeHidReady()
+{
+	return g_wakeHidPresent && g_wakeHid.ready();
+}
+
+bool wakeHidMove(int8_t dx, int8_t dy)
+{
+	if (!wakeHidReady())
+		return false;
+	// boot mouse descriptor has no report ID -> report_id 0; buttons=0 so we move but never click
+	return g_wakeHid.mouseReport(0, 0, dx, dy, 0, 0);
+}
+
+void wakeHidAddInterface()
+{
+	TinyUSBDevice.addInterface(g_wakeHid);
+}

+ 35 - 0
OpenPuckZephyr/src/wake_hid.h

@@ -0,0 +1,35 @@
+// wake_hid.h -- a minimal boot-protocol HID interface whose ONLY job is to make the device a host-recognized
+// USB wake source.
+//
+// Every mode already advertises remote-wakeup in its config descriptor, so the device is *armed* to wake the
+// host. But hosts only *honor* a resume signal from an allow-listed input device class -- a HID keyboard/mouse.
+// Windows (especially Modern Standby) ignores the wake from a bare gamepad / vendor / composite presentation
+// even though it's armed. Exposing a boot MOUSE interface puts the device in that allow-list. It never sends
+// reports -- the actual wake is the device-level USBDevice.remoteWakeup() resume signal driven from
+// rf_link.cpp; this interface only changes how the host classifies us. (A boot keyboard didn't enumerate on
+// Windows.)
+//
+// Added for every clean mode AND for puck mode on a normal boot: puck mode drops its CDC serial console by
+// default (freeing the endpoint this interface needs) so it too can wake Windows. The one-shot debug boot keeps
+// CDC and skips this interface (no endpoint room for both) -- see config.h (g_debugCdcThisBoot).
+#pragma once
+
+#include <stdint.h>
+
+// register the boot-mouse wake interface (call from setup() for clean modes + normal puck boot). Locks its
+// TinyUSB HID instance index -- for dynamic-mount modes call this BEFORE the slot pool so it is HID instance 0.
+void wakeHidBegin();
+
+// Re-add the (already-begun) wake-mouse interface to a freshly-cleared config descriptor, for a dynamic
+// re-enumeration that doesn't reboot. begin() is once-only; this re-emits the interface descriptor.
+void wakeHidAddInterface();
+
+// true once wakeHidBegin() has registered the boot mouse this boot (false on the debug-CDC boot, which omits it)
+bool wakeHidPresent();
+
+// boot mouse enumerated and ready to accept a report (false while the bus is suspended/unconfigured)
+bool wakeHidReady();
+
+// send one boot-mouse movement report (buttons=0). This is the input that actually wakes the host: it rides the
+// interface Windows armed as the wake source, unlike a gamepad-slot report. Returns false if not ready.
+bool wakeHidMove(int8_t dx, int8_t dy);

+ 407 - 0
OpenPuckZephyr/src/webusb_config.cpp

@@ -0,0 +1,407 @@
+#include "webusb_config.h"
+#include "config.h"
+#include "bonds.h"
+#include "rf_link.h"
+#include "haptics.h"
+#include "puck_hid.h"
+#include "triton.h" // g_in raw IMU (diagnostic readout)
+#include "build_info.h"
+#include <Arduino.h>
+#include <string.h>
+
+Adafruit_USBD_WebUSB usb_web;
+
+// blob payload = [ver=10][mode][mDiv][mFric][qamMap(active)][abSwap(active)][back0..3(active)][connSlot(0xFF=none)][linkUp]
+//                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
+//                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
+//                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
+//                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
+//                [gitDirty][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
+//                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
+//                [v10: per-type cfg, 4x8B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics, ledBright}]
+// p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v10 extends to 105 bytes (107 total incl header);
+// browser reads with transferIn(128) to span the two USB-FS packets.
+#define WB_PAYLEN 105
+static void webusbSendBlob()
+{
+	if (!usb_web.connected())
+		return;
+	// "connected" = the most recent poll cycle's slot had a fresh F-reply (matches what the firmware
+	// presents to Steam on 0x79). The blob is sent on the panel's poll, so the slot + up flag change
+	// every ~250ms in normal use.
+	int cs = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	bool up = (g_curSlot >= 0 && (millis() - g_connReplyMs[cs]) < 300);
+	uint8_t p[2 + WB_PAYLEN];
+	p[0] = 0xA5;
+	p[1] = WB_PAYLEN;
+
+	// protocol version (10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 10;
+	p[3] = g_usbMode;
+	p[4] = (uint8_t)g_mDiv;
+	p[5] = (uint8_t)g_mFric;
+	p[6] = g_qamMap;
+	p[7] = g_abSwap;
+	p[8] = g_back[0];
+	p[9] = g_back[1];
+	p[10] = g_back[2];
+	p[11] = g_back[3];
+	p[12] = (g_curSlot >= 0) ? (uint8_t)g_curSlot : 0xFF;
+	p[13] = up ? 1 : 0;
+	p[14] = (uint8_t)g_f1ps;
+	p[15] = (uint8_t)(g_f1ps >> 8);
+	p[16] = (uint8_t)(g_pollUs / 100);
+	p[17] = (uint8_t)g_newps;
+	p[18] = (uint8_t)(g_newps >> 8);
+	p[19] = g_e7b;
+	p[20] = g_relayOp;
+	p[21] = g_relaySub;
+	p[22] = g_fwdNewOnly;
+	p[23] = g_qos;
+	p[24] = g_persistMode ? 1 : 0;
+	p[25] = g_chordBtn[0];
+	p[26] = g_chordBtn[1];
+	p[27] = g_chordBtn[2];
+	p[28] = (uint8_t)g_pollsps;
+
+	// poll TX rate (vs delivered/new -> starvation vs reply-loss)
+	p[29] = (uint8_t)(g_pollsps >> 8);
+	p[30] = (uint8_t)g_loopPeriodUs;
+	p[31] = (uint8_t)(g_loopPeriodUs >> 8); // avg loop iteration time
+	p[32] = g_loopWorst;
+	p[33] = (uint8_t)g_loopWorstUs;
+	p[34] = (uint8_t)(g_loopWorstUs >> 8);
+	p[35] = (uint8_t)g_pollPeriodUs;
+	p[36] = (uint8_t)(g_pollPeriodUs >>
+			  8); // measured poll period (intended 4000)
+	p[37] = OPK_LOG; // logging build? panel shows/hides its log UI
+	p[38] = g_battery
+		[g_curSlot >= 0 && g_curSlot < NSLOT ?
+			 g_curSlot :
+			 0]; // controller battery % (report 0x43); 0=unknown
+	p[39] = g_linkRssi[g_curSlot >= 0 && g_curSlot < NSLOT ?
+				   g_curSlot :
+				   0]; // RAW signal strength |dBm| (0=no sample)
+	// git commit this firmware was built from + dirty flag; injected at build time
+	// (build_info.h / gen_version.sh); "unknown" if the version header wasn't generated.
+	p[40] = OPK_GIT_DIRTY ? 1 : 0;
+	memset(&p[41], 0, 12); // 12B ASCII git hash, NUL-padded
+	{
+		const char *h = OPK_GIT_HASH;
+		for (uint8_t i = 0; i < 12 && h[i]; i++)
+			p[41 + i] = (uint8_t)h[i];
+	}
+	p[53] = g_rumbleScale; // rumble strength % (protocol v5)
+
+	// Switch Pro report rate 0=66/1=120/2=full (protocol v6)
+	p[54] = g_swProRate;
+
+	// Switch Pro gyro sensitivity x10 (protocol v6)
+	p[55] = g_swGyroScale10;
+	{
+		int16_t a[3] = { g_in[0].ax, g_in[0].ay, g_in[0].az };
+		memcpy(&p[56], a, 6);
+	} // raw accelerometer for scale diagnostics (protocol v7)
+
+	// per-slot link status for all bond slots (protocol v8)
+	p[62] = (uint8_t)bondedSlotCount();
+	{
+		unsigned long nowMs = millis();
+		for (int s = 0; s < NSLOT; s++) {
+			bool sup = g_slot[s].used && g_connReplyMs[s] != 0 &&
+				   (nowMs - g_connReplyMs[s]) < 300u;
+			p[63 + s * 3] = sup ? 1 : 0;
+			p[64 + s * 3] = g_battery[s];
+			p[65 + s * 3] = g_linkRssi[s];
+		}
+	}
+	// per-emulated-type button config (protocol v10): 4 types x 8 bytes from p[75]
+	for (int et = 0; et < ET_COUNT; et++) {
+		uint8_t *q = &p[75 + et * 8];
+		q[0] = g_type[et].back[0];
+		q[1] = g_type[et].back[1];
+		q[2] = g_type[et].back[2];
+		q[3] = g_type[et].back[3];
+		q[4] = g_type[et].qamMap;
+		q[5] = g_type[et].abSwap;
+		q[6] = g_type[et].padHaptics;
+		q[7] = g_type[et].ledBright;
+	}
+	usb_web.write(p, sizeof p);
+	usb_web.flush();
+}
+#if OPK_LOG
+// Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames. Frame formats:
+//   entry: [0xA6][L][T=1][age u32 LE][slot][rid][n][bytes n]   (L = 8 + n)
+//   end:   [0xA6][1][T=0]
+static void webusbCapFrame(uint32_t ms, uint8_t slot, uint8_t rid, uint8_t nb,
+			   const uint8_t *b)
+{
+	if (nb > 16)
+		nb = 16;
+	uint8_t f[2 + 9 + 16];
+	uint8_t L = (uint8_t)(8 + nb);
+	f[0] = 0xA6;
+	f[1] = L;
+	f[2] = 1;
+	f[3] = (uint8_t)ms;
+	f[4] = (uint8_t)(ms >> 8);
+	f[5] = (uint8_t)(ms >> 16);
+	f[6] = (uint8_t)(ms >> 24); // absolute log time
+	f[7] = slot;
+	f[8] = rid;
+	f[9] = nb;
+	memcpy(f + 10, b, nb);
+	usb_web.write(f, (uint16_t)(2 + L));
+	usb_web.flush();
+}
+static void webusbCapEnd()
+{
+	uint8_t e[3] = { 0xA6, 1, 0 };
+	usb_web.write(e, 3);
+	usb_web.flush();
+}
+// 0x06: drain entries since the last drain (or since the 0x05 start/rewind). The panel polls this on a
+// timer and accumulates, so a dump-from-boot of the whole ring streams over many polls without blocking the
+// loop. A per-call budget bounds how long one poll spends here.
+static void webusbDrainCapture()
+{
+	if (!usb_web.connected())
+		return;
+	uint32_t ms = 0;
+	uint8_t slot = 0, rid = 0, nb = 0, b[16];
+	uint16_t budget = 128;
+	while (budget-- && hapLogPull(&ms, &slot, &rid, &nb, b))
+		webusbCapFrame(ms, slot, rid, nb, b);
+	webusbCapEnd();
+}
+#endif // OPK_LOG
+void webusbPoll()
+{
+	static uint8_t buf[16];
+	static uint8_t n = 0;
+	while (usb_web.available()) {
+		int c = usb_web.read();
+		if (c < 0)
+			break;
+		if (n < sizeof buf)
+			buf[n++] = (uint8_t)c;
+		// process complete commands from the front of buf
+		for (;;) {
+			if (n == 0)
+				break;
+			uint8_t op = buf[0];
+
+			// 0x05 carries a value byte; 0x0A carries a 3-byte magic
+			uint8_t need = (op == 0x02)		  ? 3 :
+				       (op == 0x03 || op == 0x05) ? 2 :
+				       (op == 0x0A)		  ? 4 :
+								    1;
+			if (op < 0x01 || op > 0x0C) { // resync: drop one byte
+				memmove(buf, buf + 1, --n);
+				continue;
+			}
+			if (n < need)
+				break; // wait for more bytes
+			if (op == 0x01) {
+				webusbSendBlob();
+			}
+#if OPK_LOG
+
+			// rewind drain: buf[1]=1 from boot (whole ring), 0 from now
+			else if (op == 0x05) {
+				hapLogResetDrain(buf[1] != 0);
+			} else if (op == 0x06) {
+				webusbDrainCapture();
+			} // drain entries since the rewind (panel polls this)
+#endif
+
+			// clear a stuck/latched haptic buzz on the controller
+			else if (op == 0x07) {
+				hapticReinit();
+			}
+
+			// trigger controller power-off (same path Steam 0x9F / host-suspend use)
+			else if (op == 0x08) {
+				hapticSendShutdown();
+			}
+
+			// factory wipe: erase cfg.bin + bonds.bin, reboot to clean defaults.
+			// Guarded by a 3-byte magic ("ERS") so a stray/corrupt byte can never trigger it.
+			// Irreversible -- the controller must be re-paired afterwards.
+			else if (op == 0x0A) {
+				if (buf[1] == 0x45 && buf[2] == 0x52 &&
+				    buf[3] == 0x53) {
+					usb_web.flush();
+					factoryErase();
+					delay(40);
+					NVIC_SystemReset();
+				}
+
+				// reboot into serial DFU (adafruit-nrfutil)
+			} else if (op == 0x0B) {
+				usb_web.flush();
+				delay(40);
+				enterSerialDfu();
+
+				// reboot into UF2 bootloader (USB mass storage)
+			} else if (op == 0x0C) {
+				usb_web.flush();
+				delay(40);
+				enterUf2Dfu();
+
+			} else if (op == 0x02) {
+				uint8_t f = buf[1], v = buf[2];
+
+				// every settable field persists (poll rate is no longer settable)
+				bool persist = true;
+				// per-type cfg writes (protocol v10): field = 40 + et*8 + k, k: 0..3 back, 4 qam, 5 abSwap,
+				// 6 padHaptics, 7 ledBright. Edits g_type[et]; refresh the live mirrors if it's the active type.
+				if (f >= 40 && f < 40 + ET_COUNT * 8) {
+					uint8_t et = (uint8_t)((f - 40) / 8),
+						k = (uint8_t)((f - 40) % 8);
+					if (et < ET_COUNT) {
+						if (k < 4)
+							g_type[et].back[k] = v;
+						else if (k == 4)
+							g_type[et].qamMap = v;
+						else if (k == 5)
+							g_type[et].abSwap =
+								v ? 1 : 0;
+						else if (k == 6)
+							g_type[et].padHaptics =
+								v ? 1 : 0;
+						else if (k == 7)
+							g_type[et].ledBright =
+								v > 100 ? 100 :
+									  v;
+						if (et == g_etype)
+							applyActiveType();
+					}
+					saveCfg();
+					webusbSendBlob();
+					memmove(buf, buf + need, n - need);
+					n -= need;
+					continue;
+				}
+				switch (f) {
+				case 1:
+					g_mDiv = v < 4 ? 4 : v;
+					break;
+				case 2:
+					g_mFric = v > 99 ? 99 : v;
+					break;
+				// case 3 (padSmooth) removed -- Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
+				// Legacy single-value fields (4 abSwap, 5-8 back, 21 qam) edit the ACTIVE emulated type.
+				case 4:
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].abSwap = v ? 1 :
+									     0;
+						applyActiveType();
+					}
+					break;
+				case 5:
+				case 6:
+				case 7:
+				case 8:
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].back[f - 5] = v;
+						applyActiveType();
+					}
+					break;
+				// case 9 (pollU100) removed -- poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
+
+				// E7 protocol-version B-byte (experimental v1 fast)
+				case 10:
+					g_e7b = v ? 1 : 0;
+					break;
+
+				// haptic-relay opcode
+				case 11:
+					g_relayOp = v;
+					break;
+
+				// haptic-relay sub-type
+				case 12:
+					g_relaySub = v;
+					break;
+				case 13:
+					g_testHaptic = v ? v : 40;
+					break; // inject v test haptics (0->40)
+
+				// Steam: forward only fresh reports (dedupe)
+				case 14:
+					g_fwdNewOnly = v ? 1 : 0;
+					break;
+				case 15:
+					g_qos = v ? 1 : 0;
+					g_hopIdx = 0;
+					g_qosBad = 0;
+					g_qosCheckMs = millis();
+					break; // QoS adaptive channel hopping
+
+				// persist last mode across reboots (else always boot Steam)
+				case 16:
+					g_persistMode = v ? true : false;
+					break;
+
+				// back4+B/X/Y mode assignments
+				case 17:
+				case 18:
+				case 19:
+					if (modeValid(v))
+						g_chordBtn[f - 17] = v;
+					break;
+
+				// reboot once WITH the CDC serial console (puck mode), then auto-revert
+				case 20:
+					armDebugCdcNextBoot();
+					usb_web.flush();
+					delay(40);
+					NVIC_SystemReset();
+					break;
+
+				// QAM physical button remap code (0=default/unmapped) -- active emulated type
+				case 21:
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].qamMap = v;
+						applyActiveType();
+					}
+					break;
+
+				// rumble strength % (0=off, 100=1x, 200=double)
+				case 22:
+					g_rumbleScale = v;
+					break;
+
+				// Switch Pro report rate (0=66Hz,1=120Hz,2=full)
+				case 23:
+					g_swProRate = (v <= 2) ? v : 2;
+					swProSaveCfg();
+					persist = false;
+					break;
+				case 24:
+					g_swGyroScale10 =
+						(v >= 5 && v <= 30) ? v : 10;
+					swProSaveCfg();
+					persist = false;
+					break; // Switch Pro gyro scale x10
+				}
+				if (persist)
+					saveCfg();
+				webusbSendBlob();
+			} else if (op == 0x03) {
+				uint8_t m = buf[1];
+				if (modeValid(m) && !USBDevice.suspended()) {
+					webusbSendBlob();
+					usb_web.flush();
+					saveMode(m);
+					delay(40);
+					NVIC_SystemReset();
+				}
+			}
+			memmove(buf, buf + need, n - need);
+			n -= need;
+		}
+	}
+}

+ 15 - 0
OpenPuckZephyr/src/webusb_config.h

@@ -0,0 +1,15 @@
+// WebUSB configuration channel: a vendor interface present in EVERY mode so the browser config panel can
+// read/set runtime tunables. Binary framed protocol:
+//   host->dev:  0x01                 GET  -> reply status blob
+//               0x02 <field> <value> SET one byte field (see the switch in webusb_config.cpp)
+//               0x03 <mode>          switch USB mode (0..6): persist + reboot
+//               0x0B                 reboot into serial DFU (adafruit-nrfutil)
+//               0x0C                 reboot into UF2 bootloader (USB mass storage)
+//   dev->host:  0xA5 <len> <payload>  status blob (mode/tunables/link state/rates)
+// No setLandingPage() on purpose -- it would pop a Chrome "open <url>?" notification on every plug-in.
+#pragma once
+#include <Adafruit_TinyUSB.h>
+
+extern Adafruit_USBD_WebUSB usb_web;
+
+void webusbPoll(); // drain host commands + reply; call every loop()

+ 19 - 0
OpenPuckZephyr/zbuild.sh

@@ -0,0 +1,19 @@
+#!/bin/bash
+# Convenience build wrapper for the OpenPuck Zephyr port.
+# Sets up the workspace env (venv, ZEPHYR_BASE, SDK) and runs west build.
+# Usage: ./zbuild.sh [extra west build args]   (run from anywhere)
+set -euo pipefail
+export PIP_USER=false
+export ZEPHYR_BASE="$HOME/zephyrproject/zephyr"
+export ZEPHYR_SDK_INSTALL_DIR="$HOME/zephyr-sdk-1.0.1"
+# shellcheck disable=SC1091
+source "$HOME/openpuck-zephyr-venv/bin/activate"
+
+APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# The uf2 variant carries the Adafruit UF2 bootloader partition layout (app at
+# 0x26000, a 32 kB storage partition for littlefs, UF2 output) + CDC-ACM console
+# + LED0 on P1.15 — matching the real puck hardware and the user's DFU workflow.
+BOARD="${BOARD:-adafruit_feather_nrf52840/nrf52840/uf2}"
+
+cd "$APP_DIR"
+exec west build -b "$BOARD" "$APP_DIR" "$@"