Jari 3 månader sedan
förälder
incheckning
56819086f2

+ 68 - 6
ARCHITECTURE.md

@@ -83,6 +83,10 @@ just points at the `OpenPuck` directory). Modules are layered low → high:
 | `mode_hidgyro.{h,cpp}` | **DS4-layout** generic HID gyro personality (motion-aware PC games). |
 | `rf_link.{h,cpp}` | The operational puck protocol: host-frame beacons, connected-mode poll loop, `0xF1` decode + dispatch, chord detection, remote wakeup, QoS hopping, stats. |
 | `rf_diag.{h,cpp}` | RF reverse-engineering / calibration tooling: raw capture, CRC-validating config sweeps, frame replay, address listen, scan-then-respond, live-session sniffer. Not used in normal operation. |
+| `rf_timeslot.{h,cpp}` | SoftDevice radio-timeslot arbitration: with BLE on, the ESB code only touches `NRF_RADIO` inside granted timeslots (`rfRadioOwned()` gates every op). No-op / always-owned with BLE off. |
+| `input_driver.{h,cpp}` | The **input-side** controller abstraction (mirror of `IController`): per-device `IInputDriver`s decode non-SC2 controllers into `g_in`, plus the per-slot source tags (`SRC_RF`/`SRC_BLE`). |
+| `drv_xinput_ble.{h,cpp}` | Driver for the Xbox BT/BLE "xinput" report layout (Xbox One S fw5+/Series over BLE; byte-identical to the 8BitDo SN30 Pro's X-input mode, which is BT-Classic-only and thus unreachable from this radio). |
+| `ble_host.{h,cpp}` | BLE central transport: scanner + pairing/bonding (panel-driven), bond metadata persistence (`blebonds.bin`), auto-reconnect (incl. RPA/IRK resolution), HID-over-GATT subscribe, rumble writes, and the loop-side pump into `g_in`/`onReport45`. |
 | `webusb_config.{h,cpp}` | The WebUSB binary config channel for the browser panel. |
 | `serial_console.{h,cpp}` | The CDC single-letter debug command line. |
 | `wake_hid.{h,cpp}` | A boot-mouse HID interface added to the clean controller modes so the host honors USB remote-wakeup (see "Wake from sleep"). |
@@ -155,11 +159,67 @@ Full details are in `docs/PROTOCOL.md`; the register-level constants and their p
   queue at full rate (~400 new reports/s instead of ~60).
 - **Haptics** ride back as a SET sub-TLV inside the poll (`haptics.cpp` queues, `rf_link` flushes).
 
+## BLE controllers (opt-in)
+
+With the persisted BLE flag on (panel "Bluetooth controllers" card), the firmware also runs the **S140
+SoftDevice as a BLE central** and pairs directly with BLE HID gamepads. Three pieces make it fit the existing
+architecture instead of forking it:
+
+1. **Radio coexistence** (`rf_timeslot.cpp`): the SoftDevice owns `NRF_RADIO`, so the bare-metal ESB link runs
+   inside **radio timeslots** — 15 ms slots requested continuously and extended until BLE needs the air. A
+   priority-0 signal handler stamps an "owned until" deadline; every ESB op (`rfConnTx`, the beacons, the
+   rf_diag tooling, the stall self-heal) checks `rfRadioOwned(worstCaseUs)` before touching the radio and
+   simply skips the pass otherwise. With BLE off nothing changes — the gate is always true and the SoftDevice
+   is never enabled.
+2. **Input normalization** (`input_driver.h`): a BLE device is matched (by DIS PnP VID/PID or name) to an
+   `IInputDriver` that decodes its HID reports into the SAME `g_in[slot]` the RF path fills, stamps
+   `g_connReplyMs[slot]`, and a synthesized report `0x45` (`puckSynth45`) feeds `g_active->onReport45()` — so
+   every USB personality (XInput/Switch/PS5/DS4) presents BLE pads with zero per-mode work. BLE pads claim
+   free bond slots top-down (RF pairing fills bottom-up); `g_slotSrc[]` tags who drives each slot, and the
+   haptic relay / usb_mount / reconnect logic route on it (rumble goes to `bleSetRumble` → a GATT write
+   instead of the ESB relay ring). In the Steam/Lizard puck modes BLE pads stay parked (connected but not
+   presented) — Steam expects a real SC2 bond record behind each slot interface.
+3. **Transport & persistence** (`ble_host.cpp`): pairing is Just Works; LTKs live in the BLE stack's own bond
+   store (`/adafruit/bond_cntr`), our metadata (identity addr, name, VID/PID) in `/blebonds.bin`. A light
+   background scan (only while a bonded pad is offline) auto-reconnects, resolving the RPAs Xbox pads
+   advertise with the cached IRK. The panel drives discovery/pair/forget over WebUSB ops `0x16..0x19` and the
+   `0xAC` status frame.
+
+Caveats: staged firmware updates disable the SoftDevice first (`fw_update` writes flash via raw NVMC, illegal
+under an enabled SD) — BLE returns after the post-update reboot. The one BLE-capable "xinput" driver today is
+the Xbox BT layout; note the plain 8BitDo SN30 Pro speaks that layout only over Bluetooth *Classic*, which the
+nRF52840 cannot do.
+
+**Anti-bootloop latch.** BLE bringup (SoftDevice enable, the priority-0 timeslot handler, the first scan/
+connect) is the riskiest new code, and a crash there would HardFault/watchdog-reset → re-run → crash again =
+bootloop. So `bleHostBegin()` is started **last in `setup()`, after the watchdog is armed**, and writes a
+one-byte flash latch (`/blearm.bin`) *before* enabling the SoftDevice; the latch is cleared only once BLE has
+run `BLE_STABLE_MS` (6 s) without a reset. If a boot armed the latch but never cleared it, the next boot finds
+it set, **disables BLE (persisted) and skips the bringup** instead of repeating it — one failed boot, not a
+loop. Because disabling BLE makes `rfRadioOwned()` unconditionally true and never opens a timeslot session, a
+trip reverts the *entire* coexistence layer to the proven bare-metal radio path. Re-enable from the panel once
+the cause is fixed; a clean `Bluefruit.begin()==false` (e.g. the clock case) is not a crash and clears the
+latch normally.
+
+**Low-frequency clock (build requirement).** The SoftDevice needs a low-frequency clock source, chosen at
+compile time from the board variant's `USE_LFXO`/`USE_LFRC`. The stock `feather52840` variant hardcodes
+`USE_LFXO` (32.768 kHz crystal), but the Pro Micro / SuperMini nRF52840 **clones** this firmware targets have
+no crystal — with LFXO, `Bluefruit.begin()` fails (`BLE_ST_FAILED` on the panel). So `make build` selects
+`variant_lfrc/` (a verbatim copy of the express variant with only the LF clock flipped to the internal **RC**
+oscillator) via `build.variant.path`. RC is within BLE tolerance and is what these boards already run their
+LFCLK on in bare-metal mode, so nothing else changes. A `#error` in `ble_host.cpp` fails the build loudly if
+that override is ever dropped (which would silently re-break BLE). Genuine crystal board without BLE: set
+`LF_VARIANT_PATH=` empty.
+
 ## Timing & concurrency model
 
 - One cooperative `loop()`. Each subsystem's per-loop hook (`g_active->task()`, `rfLinkTask()`, `hapticTask()`,
-  `rfDiagTask()`, `webusbPoll()`, `serialConsolePoll()`) must be **non-blocking** — anything that spins risks
-  starving USB servicing.
+  `rfDiagTask()`, `webusbPoll()`, `serialConsolePoll()`, `bleHostTask()`) must be **non-blocking** — anything
+  that spins risks starving USB servicing.
+- With BLE on, two more FreeRTOS contexts exist (Bluefruit's "BLE" event task and its callback-deferral task):
+  notify callbacks copy raw reports into per-link PRIMASK-guarded rings, and the connect/pair/discovery flow
+  runs (and may block) on the callback task — never on `loop()`. `g_in` stays single-writer-from-loop:
+  `bleHostTask()` does the decode.
 - The RF poll is **busy-wait bounded**: every radio wait (`RWAIT_DISABLED`, the RX windows) has a microsecond
   timeout and bails rather than hanging. A wedged radio must never freeze the loop.
 - A **hardware watchdog** (~8 s, armed in `setup()`) resets the chip if `loop()` ever stops feeding it — so a
@@ -172,11 +232,13 @@ Full details are in `docs/PROTOCOL.md`; the register-level constants and their p
 
 ## Persistence
 
-Two files in the nRF52 internal LittleFS:
+Files in the nRF52 internal LittleFS:
 
-- `cfg.bin` (`config.cpp`) — USB mode, tunables, chord assignments, persistence policy. A magic byte versions
-  the layout; a mismatch falls back to clean defaults.
-- `bonds.bin` (`bonds.cpp`) — the four bond records.
+- `cfg.bin` (`config.cpp`) — USB mode, tunables, chord assignments, persistence policy, the BLE enable flag
+  (riding the legacy `rxWin10` byte). A magic byte versions the layout; a mismatch falls back to clean defaults.
+- `bonds.bin` (`bonds.cpp`) — the four SC2 bond records.
+- `blebonds.bin` (`ble_host.cpp`) — BLE controller metadata (identity address, name, VID/PID); the pairing
+  keys themselves live in the BLE stack's own store under `/adafruit/bond_cntr`.
 
 Mode switches (chord, WebUSB, or CDC) call `saveMode()` then reboot so the next boot enumerates the right
 interface set. By default every cold boot returns to Steam mode unless "persist last mode" is enabled.

+ 53 - 4
CODE_MAP.md

@@ -2,7 +2,9 @@
 
 nRF52840 / Adafruit Arduino core / TinyUSB. The device impersonates a Valve Steam
 Controller 2 "puck" over USB and talks to SC2 controllers over a bare-metal nRF52
-RADIO (ESB-style, **no SoftDevice**) protocol. Built with `-DCFG_TUD_HID=4`.
+RADIO (ESB-style) protocol. Built with `-DCFG_TUD_HID=4`. The SoftDevice is OFF by
+default; the opt-in BLE-controllers feature enables S140 as a central and moves the
+ESB link onto radio timeslots (`rf_timeslot`, `ble_host`, `input_driver` below).
 
 > This map describes what the **code actually does**. Source comments in this repo
 > are known to be misleading and were ignored.
@@ -15,7 +17,7 @@ Three contexts matter for every piece of state below:
 
 | Context | Priority / stack | What runs here |
 |---|---|---|
-| **loop task** | Arduino FreeRTOS "loop" task, low priority, ~4 KB stack | Everything called from `loop()`: `webusbPoll`, `g_active->task()`, `serialConsolePoll`, `rfDiagTask`, `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`. Also all of the RF radio code (it is driven synchronously from `rfLinkTask`/`rfDiagTask`). Also `setup()`. |
+| **loop task** | Arduino FreeRTOS "loop" task, low priority, ~4 KB stack | Everything called from `loop()`: `webusbPoll`, `g_active->task()`, `serialConsolePoll`, `rfDiagTask`, `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`, `bleHostTask`. Also all of the RF radio code (it is driven synchronously from `rfLinkTask`/`rfDiagTask`). Also `setup()`. |
 | **usbd task** | TinyUSB "usbd" task, **high priority, ~800-byte stack** | All TinyUSB HID `set_report`/`get_report` callbacks, the custom XInput class-driver xfer callbacks, and (in the Adafruit core) HID `sendReport` queuing context. This is where host→device control transfers are decoded. **Stack is tiny — a `Serial.printf` here can overflow it (the suspected LOCKUP cause), which is why all per-report logging is `#if OPK_LOG` gated.** |
 | **ISR** | Hardware exception | `HardFault_Handler` (fault_diag.cpp). The RADIO is **polled, not interrupt-driven** — there is no radio ISR. |
 
@@ -56,7 +58,7 @@ re-add wake mouse + `g_active->mountSlots(k)` + WebUSB in locked instance order,
 1. Feeds the watchdog (`NRF_WDT->RR[0]`).
 2. If `g_dirty`, `saveBonds()` (flash write in loop context).
 3. Calls, in order: `webusbPoll`, `g_active->task`, `serialConsolePoll`, `rfDiagTask`,
-   `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`.
+   `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`, `bleHostTask`.
    `#if OPK_LOG` wraps each in `micros()` timing for the WebUSB panel.
 
 ### State owned
@@ -180,6 +182,49 @@ that diag modes are off during normal operation). Buffers: `g_replay[48]`,
 `rfBeaconOnce`. `rfReplayOnce` runs every loop with no rate gate while armed.
 Notable: `rfCapPoll` copies 48 bytes into `g_replay` but sets `g_replayLen=32`.
 
+### `rf_timeslot.cpp` / `rf_timeslot.h` — SoftDevice radio-timeslot arbitration
+- Only active when BLE is on (`g_sdEnabled`); otherwise `rfRadioOwned()` returns true unconditionally and the
+  session is never opened.
+- **Timeslot signal handler `tsSignal` (PRIORITY-0 interrupt — the highest-priority context in the firmware)**:
+  stamps `s_ownedUntil` (micros deadline minus `TS_MARGIN_US`) at slot START/extension, chains EXTEND actions,
+  and hands the slot back via a TIMER0 CC0 end-guard (`REQUEST_AND_END`). Register pokes + volatile stamps
+  only; `micros()` is DWT-based and safe here.
+- `rfRadioOwned(usNeeded)` — the gate every ESB radio op checks (loop task). `rfTsTick()` (loop, from
+  `bleHostTask`) re-requests a slot when a request got BLOCKED/CANCELED (the Adafruit SOC task swallows those
+  SoC events, so the grant path can't self-revive).
+- Gated call sites: `rfLinkTask` entry (6000 µs), `rfHostFrameOnce` (4000), `rfConnTx` backstop (win+1500),
+  the RF stall self-heal (`NRF_RADIO->POWER` cycle), `rfDiagTask` entry (5000), the console 's' radio poke.
+
+### `ble_host.cpp` / `ble_host.h` + `input_driver.{h,cpp}` + `drv_xinput_ble.cpp` — BLE controllers
+Execution contexts: **Bluefruit "BLE" event task** (notify callbacks, `setNotifyCallback(..., false)`),
+**Bluefruit callback task** (scanner rx cb, central connect/disconnect cb — the whole pair/discover flow runs
+and blocks THERE), and the **loop task** (`bleHostTask()`).
+- `s_link[2]` — per-connection state: GATT client objects (DIS/PnP, battery, HID + 6 report chars), claimed
+  slot, raw ring, rumble latch. Report chars are subscribed positionally (handle order); the driver's
+  `inputRidByIndex()` maps subscribe order → HID report id (no Report-Reference descriptor read: the wrapper
+  has no raw-descriptor API). First 0x2A4D without a CCCD = the rumble output char.
+- **Input path**: notify cb (BLE task) → PRIMASK push into `s_link[i].q` (4-deep, evict-oldest) →
+  `bleHostTask` (loop) pops, `drv->decode()` into **`g_in[slot]`** (loop stays the only `g_in` writer), stamps
+  `g_connReplyMs[slot]`/`g_battery[slot]`, synthesizes a 0x45 (`puckSynth45`, triton.cpp) and dispatches
+  `g_active->onReport45()` — push modes parse it exactly like an RF frame. Skipped in puck modes (parked).
+- **Slot model**: `g_slotSrc[NSLOT]` (`input_driver.cpp`, volatile) tags RF vs BLE. BLE claims free
+  (`!g_slot[s].used`) slots top-down; if Steam writes an RF bond into a claimed slot, `bleHostTask` evacuates
+  (disconnect → auto-reconnect re-claims). `usb_mount.connectedMask()` and haptics route on the tag;
+  `relayEnqueue` skips BLE slots, `hapticSteamRumble` forks to `bleSetRumble` (latch; flushed loop-side as a
+  GATT write-no-response, ≥30 ms apart).
+- **Bond persistence**: `/blebonds.bin` (magic 0xB2, 4×28 B recs: addrType+identity addr, vid/pid, name).
+  LTKs/IRKs live in Bluefruit's `/adafruit/bond_cntr`; `s_keys[]` caches them in RAM so the scan callback can
+  resolve RPAs without file I/O. Reconnect: background scanner (600/30 ms duty, only while a bond is offline)
+  matches identity addr or resolves the RPA → `Central.connect`.
+- **Lifecycle**: `bleHostBegin()` (setup or live panel enable) → `Bluefruit.begin(0,2)` → `g_sdEnabled=true` →
+  `rfTsBegin()`. `bleShutdownForUpdate()` (from `fwupBegin`) closes the session + `sd_softdevice_disable()` —
+  raw-NVMC staging is illegal under an enabled SD. Disable-from-panel persists the flag and reboots.
+- `drv_xinput_ble.cpp` — the Xbox BT/BLE "xinput" layout (= SN30 Pro X-input mode, which is BR/EDR-only and
+  cannot reach this radio): input 0x01 (sticks u16, triggers u10, hat, buttons; fw5 guide in-band), 0x02
+  (legacy guide), rumble output 0x03 (magnitudes 0-100). Registry in `input_driver.cpp`.
+- **WebUSB surface**: ops 0x16 (control: disable/enable/scan), 0x17 pair, 0x18 forget, 0x19 → 0xAC status
+  frame (bonded + scan lists, built by `bleStatusFrame` on the usbd task); blob v16 byte p[178] = `bleState()`.
+
 ---
 
 ## 5. Controller abstraction — `controllers.cpp` / `controllers.h`
@@ -495,7 +540,11 @@ State touched by **both** the loop task and the usbd task (the synchronization-c
 | **`g_swProReportMode[NSLOT]`** | usbd `jcSubcmd` + loop `mountSlots` | loop `task` | **not volatile** |
 | **`g_steamAliveMs` / `g_resumeMs`** (puck_hid) | usbd `handleSet`/`handleGet` + loop `task` | both | none |
 | **`g_hapLog[4096]`** (`OPK_LOG`) | usbd + loop `hapLogAdd` | loop drain | PRIMASK in `hapLogAdd` |
-| **`rfrx[100]`/`rftx[100]` + `NRF_RADIO`** | loop `rf_link` AND loop `rf_diag` | same | **no guard** — relies on diag-modes-off assumption (both are loop task, so no preemption between them, but no arbitration of radio state) |
+| **`rfrx[100]`/`rftx[100]` + `NRF_RADIO`** | loop `rf_link` AND loop `rf_diag` | same | **no guard** — relies on diag-modes-off assumption (both are loop task, so no preemption between them, but no arbitration of radio state). With BLE on, the SoftDevice ALSO owns the radio — arbitrated by `rfRadioOwned()` timeslot gates |
+| **`s_ownedUntil`** (rf_timeslot) | timeslot signal handler (priority 0) | loop `rfRadioOwned` | single aligned u32 write, volatile |
+| **`s_link[i].q` raw rings** (ble_host) | BLE task notify cb | loop `bleHostTask` | PRIMASK both sides |
+| **`s_link[i]` rumble latch** | usbd/loop `hapticSteamRumble` → `bleSetRumble` | loop flush | volatile fields, last-writer-wins |
+| **`g_slotSrc[NSLOT]`** (input_driver) | loop (`bleHostTask`) + Bluefruit cb task (connect/release) | loop rf_link/haptics/usb_mount, usbd haptic fork | volatile byte per slot; claims only flip on un-`used` slots |
 
 **`hapticSteamRumble()` is the one app function legitimately entered from the usbd task**
 (via the ps5/hidgyro/xinput/switch-pro rumble callbacks) as well as the loop task; its

+ 12 - 1
Makefile

@@ -39,6 +39,17 @@ EXTRA_FLAGS ?=
 # {build.flags.usb} is expanded by arduino-cli (VID/PID/strings); pass it through verbatim.
 USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
 
+# Low-frequency clock source for the SoftDevice (BLE controllers feature). The feather52840 variant hardcodes
+# USE_LFXO (32.768 kHz crystal), but the Pro Micro / SuperMini nRF52840 CLONES this firmware runs on have NO
+# crystal -- with LFXO the SoftDevice's Bluefruit.begin() fails (BLE_ST_FAILED). variant_lfrc/ is a verbatim
+# copy of the express variant with ONLY the LF clock flipped to the internal RC oscillator (250 ppm, well
+# within BLE tolerance and what these boards already run their LFCLK on in bare-metal mode); the pin map / USB
+# identity are otherwise identical. A compile-time #error in ble_host.cpp guards against this override being
+# dropped (which would silently re-break BLE). Set LF_VARIANT_PATH= empty to fall back to the stock LFXO
+# variant if you build for a genuine crystal board without BLE.
+LF_VARIANT_PATH ?= $(CURDIR)/variant_lfrc
+VARIANT_PROP = $(if $(LF_VARIANT_PATH),--build-property "build.variant.path=$(LF_VARIANT_PATH)",)
+
 # `flash`/`deploy` take the serial port as a REQUIRED POSITIONAL arg: `make flash /dev/cu.usbmodem1101`.
 # (No auto-detect -- uploading to a guessed serial port risks writing to the wrong device. List with
 # `arduino-cli board list`.) FLASH_PORT = whatever goal isn't one of our real targets; the catch-all rule at
@@ -51,7 +62,7 @@ UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" OpenPuck
 ## Compile the firmware with the required USB flags baked in. Override CFG_TUD_HID / CFG_TUD_TASK_QUEUE_SZ /
 ## EXTRA_FLAGS / FQBN as make variables if needed.
 build:
-	arduino-cli compile -b $(FQBN) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" OpenPuck
+	arduino-cli compile -b $(FQBN) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" $(VARIANT_PROP) OpenPuck
 
 ## One-time factory-reset recovery image (wipes persistent storage once on first boot). See §6 of the build doc.
 build-recovery:

+ 12 - 0
OpenPuck/OpenPuck.ino

@@ -41,6 +41,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "fault_diag.h"
 #include "fw_update.h"
 #include "usb_tx.h"
+#include "ble_host.h"
 #include <stdio.h>
 
 #if CFG_TUD_HID < 4
@@ -291,6 +292,15 @@ void setup()
 	// Flash black box: at ~6s of loop stall (2s before the WDT reset) dump both tasks' stacked PCs + vitals
 	// to a reserved flash page -- the post-mortem channel that survives even the boards that wipe .noinit.
 	faultDiagBlackBoxArm();
+
+	// BLE controllers (opt-in, persisted): starts the S140 SoftDevice + central role and moves the ESB link
+	// onto radio timeslots (rf_timeslot.h). With the flag off this is a no-op and the radio stays bare-metal.
+	// Deliberately started LAST -- AFTER the watchdog is armed and the fault/hang capture is set up -- so a
+	// fault OR hang during SoftDevice/central bringup is caught (WDT reset / HardFault) instead of dead-hanging.
+	// A persisted boot-attempt latch inside bleHostBegin() then turns a would-be BOOTLOOP into a single failed
+	// boot that comes up with BLE disabled (see ble_host.cpp): if the prior boot armed BLE but never reached
+	// the "stable" clear, this boot disables it rather than re-running the crashing init.
+	bleHostBegin();
 }
 
 // loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only
@@ -357,6 +367,7 @@ void loop()
 	acc[6] += (uint32_t)(micros() - t);
 	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	bleHostTask(); // BLE controllers: raw-report pump -> g_in -> dispatch (no-op unless BLE on)
 	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
 	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
@@ -397,6 +408,7 @@ void loop()
 	ledTask();
 	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	bleHostTask(); // BLE controllers: raw-report pump -> g_in -> dispatch (no-op unless BLE on)
 	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
 	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)

+ 962 - 0
OpenPuck/ble_host.cpp

@@ -0,0 +1,962 @@
+#include "ble_host.h"
+#include "config.h" // g_bleEn
+#include "bonds.h" // NSLOT, g_slot, g_connReplyMs
+#include "triton.h" // g_in, puckSynth45
+#include "input_driver.h"
+#include "controllers.h" // g_active
+#include "rf_link.h" // g_battery / g_batteryState
+#include "rf_timeslot.h"
+#include "status_led.h" // ledWakePulse
+#include <bluefruit.h>
+#include <utility/bonding.h> // bond_load_keys / bond_remove_key (the stack's LTK store)
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+#include <string.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+// The clone boards this firmware targets (Pro Micro / SuperMini nRF52840) have no 32.768 kHz crystal, so the
+// SoftDevice must run its low-frequency clock on the internal RC oscillator or Bluefruit.begin() fails at
+// enable (the BLE_ST_FAILED the panel shows). The clock source is baked in at compile time from the variant's
+// USE_LFXO/USE_LFRC define; `make build` selects variant_lfrc/ (RC) via build.variant.path. If that override
+// is ever dropped, the stock feather52840 variant (USE_LFXO) would compile fine but silently break BLE at
+// runtime -- so fail the build LOUDLY here instead. (Building for a genuine crystal board? Set
+// LF_VARIANT_PATH= empty in the Makefile AND delete this guard.)
+#if defined(USE_LFXO)
+#error "BLE build selected the LFXO (crystal) clock, but the target clone boards have no 32kHz crystal -> Bluefruit.begin() fails. Build with `make build` (selects variant_lfrc/ = RC clock via build.variant.path)."
+#endif
+#if !defined(USE_LFRC)
+#error "No LF clock source defined -- the variant.h was not seen. Build with `make build`."
+#endif
+
+// ---- sizing ----
+#define BLE_LINKS \
+	2 // concurrent BLE controllers (SoftDevice central link count)
+#define BLE_NREP \
+	6 // HID report characteristics discovered per device (in+out+feature)
+#define BLE_NBOND 4 // persisted BLE controller records (panel list)
+#define BLE_NSCAN 6 // pairing-UI scan table
+#define RAWQ 4 // per-link raw input-report ring (BLE task -> loop)
+#define RAW_MAXP 24 // largest raw HID input report we care about (xinput = 16)
+
+#define BLEBOND_FILE "/blebonds.bin"
+#define BLEBOND_MAGIC 0xB2
+
+// Boot-attempt latch (anti-bootloop). bleHostBegin() writes this flag to flash BEFORE it enables the
+// SoftDevice, and clears it only once BLE has run stably for BLE_STABLE_MS. If a boot enables BLE and then
+// crashes (HardFault) or hangs (watchdog) during SoftDevice/central bringup OR in the first few seconds of
+// running, the flag is left set -- so the NEXT boot finds it set, DISABLES BLE (persisted) and skips the
+// crashing init instead of re-running it. That converts a repeating BLE-bringup crash from a bootloop into a
+// single failed boot that comes up with BLE off; the user re-enables it from the panel once the cause is fixed.
+#define BLEARM_FILE "/blearm.bin"
+// How long BLE must run without a reset before we consider the bringup proven and clear the latch. Comfortably
+// longer than the SoftDevice-enable + first-scan/first-connect window, and short enough that a normal session
+// clears it quickly. The 3 s USB-mount wait in setup() runs BEFORE bleHostBegin, so this is measured from when
+// BLE actually came up, not from boot.
+#define BLE_STABLE_MS 6000u
+
+// Scan/reconnect duty cycles (ms units for setIntervalMS). The scanner only runs at all while a bonded pad is
+// offline (or the panel scan is armed) -- with everything connected it is stopped and the ESB link keeps the
+// whole air. Idle reconnect-watch stays light (5%) because each scan WINDOW is a solid radio blackout for the
+// SC2 poll; the UI scan burns half the air but only while the panel asks for it (auto-stops after 60 s).
+#define SCAN_IDLE_INT 600
+#define SCAN_IDLE_WIN 30
+#define SCAN_UI_INT 160
+#define SCAN_UI_WIN 80
+#define UI_SCAN_MS 60000u
+#define CONNECT_TIMEOUT_MS 10000u
+
+struct BleBondRec {
+	uint8_t used, addrType;
+	uint8_t addr[6]; // identity address (post-bonding)
+	uint16_t vid, pid;
+	char name[13];
+	uint8_t rsvd[3];
+};
+struct ScanEnt {
+	uint8_t used, addrType;
+	uint8_t addr[6];
+	int8_t rssi;
+	uint8_t isHid;
+	char name[13];
+	unsigned long ms;
+};
+struct RawEnt {
+	uint8_t rid, len;
+	uint8_t d[RAW_MAXP];
+};
+
+struct BleLink {
+	volatile bool inUse; // allocated to a live connection
+	volatile bool up; // secured + subscribed + slot claimed (input flowing)
+	volatile uint16_t conn;
+	volatile int8_t slot; // claimed bond slot, -1
+	IInputDriver *drv;
+	uint16_t vid, pid;
+	char name[13];
+	// GATT client objects (registered once at begin; (re)discovered per connection)
+	BLEClientService dis, bat, hid;
+	BLEClientCharacteristic pnp, batc;
+	BLEClientCharacteristic rep[BLE_NREP];
+	int8_t repIn[BLE_NREP]; // subscribe order of rep[i] (-1 = not an input report)
+	int8_t outIdx; // rep[] index of the output (rumble) report, -1 = none
+	// raw input ring: producer = BLE task (notify cb), consumer = loop (bleHostTask); PRIMASK both sides
+	RawEnt q[RAWQ];
+	volatile uint8_t qh, qt;
+	uint8_t seq; // synth45 sequence byte
+	// rumble latch: producers = usbd/loop (hapticSteamRumble), consumer = loop flush
+	volatile uint16_t rumLo, rumHi;
+	volatile bool rumPend;
+	unsigned long rumMs;
+	volatile uint8_t battery;
+
+	BleLink()
+		: inUse(false)
+		, up(false)
+		, conn(BLE_CONN_HANDLE_INVALID)
+		, slot(-1)
+		, drv(nullptr)
+		, vid(0)
+		, pid(0)
+		, dis(BLEUuid((uint16_t)0x180A))
+		, bat(BLEUuid((uint16_t)0x180F))
+		, hid(BLEUuid((uint16_t)0x1812))
+		, pnp(BLEUuid((uint16_t)0x2A50))
+		, batc(BLEUuid((uint16_t)0x2A19))
+		, rep{ BLEUuid((uint16_t)0x2A4D), BLEUuid((uint16_t)0x2A4D),
+		       BLEUuid((uint16_t)0x2A4D), BLEUuid((uint16_t)0x2A4D),
+		       BLEUuid((uint16_t)0x2A4D), BLEUuid((uint16_t)0x2A4D) }
+		, outIdx(-1)
+		, qh(0)
+		, qt(0)
+		, seq(0)
+		, rumLo(0)
+		, rumHi(0)
+		, rumPend(false)
+		, rumMs(0)
+		, battery(0)
+	{
+		name[0] = 0;
+		for (int i = 0; i < BLE_NREP; i++)
+			repIn[i] = -1;
+	}
+};
+
+static BleLink s_link[BLE_LINKS];
+static BleBondRec s_bond[BLE_NBOND];
+// RAM cache of the stack's bond keys (IRK for RPA resolution in the scan callback -- file I/O per adv report
+// would stall the callback task). Refreshed at begin + whenever a bond is added/removed.
+static bond_keys_t s_keys[BLE_NBOND];
+static bool s_keysOk[BLE_NBOND];
+static ScanEnt s_scan[BLE_NSCAN];
+
+static volatile uint8_t s_state = BLE_ST_OFF;
+static volatile bool s_uiScan = false;
+static unsigned long s_uiScanUntil = 0;
+static volatile bool s_scanOn = false;
+static volatile bool s_connecting = false;
+static unsigned long s_connectingMs = 0;
+// anti-bootloop latch bookkeeping: when we armed the flash flag this session + when BLE came up (for the
+// stable-clear timer). s_bootLatched stays true until the stable-clear fires (once).
+static bool s_bootLatched = false;
+static unsigned long s_bootUpMs = 0;
+// Reconnect backoff: a bonded pad whose setup keeps failing (e.g. no free slot -- all four RF-bonded) must not
+// connect/disconnect in a tight loop; skip auto-reconnects to it for a while after a failure.
+#define RECONNECT_BACKOFF_MS 30000u
+static volatile int8_t s_failBond = -1;
+static unsigned long s_failBondMs = 0;
+
+// ---- persistence ----
+static void bleBondsLoad()
+{
+	memset(s_bond, 0, sizeof s_bond);
+	File f(InternalFS);
+	if (f.open(BLEBOND_FILE, FILE_O_READ)) {
+		uint8_t m = 0;
+		if (f.read(&m, 1) == 1 && m == BLEBOND_MAGIC)
+			f.read((uint8_t *)s_bond, sizeof s_bond);
+		else
+			memset(s_bond, 0, sizeof s_bond);
+		f.close();
+	}
+}
+static void bleBondsSave()
+{
+	InternalFS.remove(BLEBOND_FILE);
+	File f(InternalFS);
+	if (f.open(BLEBOND_FILE, FILE_O_WRITE)) {
+		uint8_t m = BLEBOND_MAGIC;
+		f.write(&m, 1);
+		f.write((uint8_t *)s_bond, sizeof s_bond);
+		f.close();
+	}
+}
+static void keysCacheLoad()
+{
+	for (int i = 0; i < BLE_NBOND; i++) {
+		s_keysOk[i] = false;
+		if (!s_bond[i].used)
+			continue;
+		ble_gap_addr_t a;
+		memset(&a, 0, sizeof a);
+		a.addr_type = s_bond[i].addrType;
+		memcpy(a.addr, s_bond[i].addr, 6);
+		s_keysOk[i] =
+			bond_load_keys(BLE_GAP_ROLE_CENTRAL, &a, &s_keys[i]);
+	}
+}
+
+// ---- anti-bootloop boot-attempt latch (see BLEARM_FILE) ----
+static bool bleBootPending()
+{
+	File f(InternalFS);
+	if (!f.open(BLEARM_FILE, FILE_O_READ))
+		return false;
+	uint8_t v = 0;
+	f.read(&v, 1);
+	f.close();
+	return v == 1;
+}
+static void
+bleBootArm() // called before the SoftDevice is enabled -> NVMC flash path
+{
+	InternalFS.remove(BLEARM_FILE);
+	File f(InternalFS);
+	if (f.open(BLEARM_FILE, FILE_O_WRITE)) {
+		uint8_t v = 1;
+		f.write(&v, 1);
+		f.close();
+	}
+}
+static void
+bleBootDisarm() // may run with the SoftDevice enabled -> LittleFS routes via sd_flash_*
+{
+	InternalFS.remove(BLEARM_FILE);
+}
+
+// ---- small lookups ----
+static BleLink *linkByConn(uint16_t ch)
+{
+	for (int i = 0; i < BLE_LINKS; i++)
+		if (s_link[i].inUse && s_link[i].conn == ch)
+			return &s_link[i];
+	return nullptr;
+}
+static BleLink *linkByChr(BLEClientCharacteristic *chr, int *repIdx)
+{
+	for (int i = 0; i < BLE_LINKS; i++) {
+		if (!s_link[i].inUse)
+			continue;
+		if (chr == &s_link[i].batc) {
+			*repIdx = -1;
+			return &s_link[i];
+		}
+		for (int r = 0; r < BLE_NREP; r++)
+			if (chr == &s_link[i].rep[r]) {
+				*repIdx = r;
+				return &s_link[i];
+			}
+	}
+	return nullptr;
+}
+static int bondFind(uint8_t addrType, const uint8_t addr[6])
+{
+	for (int i = 0; i < BLE_NBOND; i++)
+		if (s_bond[i].used && s_bond[i].addrType == addrType &&
+		    memcmp(s_bond[i].addr, addr, 6) == 0)
+			return i;
+	return -1;
+}
+static bool bondConnected(int b)
+{
+	for (int i = 0; i < BLE_LINKS; i++)
+		if (s_link[i].inUse && s_link[i].up) {
+			BLEConnection *c = Bluefruit.Connection(s_link[i].conn);
+			if (!c)
+				continue;
+			ble_gap_addr_t a = c->getPeerAddr();
+			if (a.addr_type == s_bond[b].addrType &&
+			    memcmp(a.addr, s_bond[b].addr, 6) == 0)
+				return true;
+		}
+	return false;
+}
+static bool linkAvail()
+{
+	for (int i = 0; i < BLE_LINKS; i++)
+		if (!s_link[i].inUse)
+			return true;
+	return false;
+}
+
+// Claim a free bond slot for a BLE controller, HIGHEST index first: Steam's RF pairing fills slots from
+// interface 0 up, so top-down claiming keeps the two from colliding until all four are taken.
+static int slotClaim()
+{
+	for (int s = NSLOT - 1; s >= 0; s--)
+		if (!g_slot[s].used && !slotIsBle(s))
+			return s;
+	return -1;
+}
+static void slotRelease(int s)
+{
+	if (s < 0 || s >= NSLOT)
+		return;
+	g_slotSrc[s] = SRC_RF;
+	g_connReplyMs[s] = 0;
+	memset((void *)&g_in[s], 0, sizeof g_in[s]);
+}
+
+// ---- scanning ----
+static void scanApplyParams()
+{
+	Bluefruit.Scanner.setIntervalMS(s_uiScan ? SCAN_UI_INT : SCAN_IDLE_INT,
+					s_uiScan ? SCAN_UI_WIN : SCAN_IDLE_WIN);
+}
+static bool scanWanted()
+{
+	if (s_state != BLE_ST_ON || s_connecting)
+		return false;
+	if (s_uiScan)
+		return true;
+	for (int i = 0; i < BLE_NBOND; i++)
+		if (s_bond[i].used && !bondConnected(i) && linkAvail())
+			return true;
+	return false;
+}
+static void scanEnsure()
+{
+	bool want = scanWanted();
+	if (want && !s_scanOn) {
+		scanApplyParams();
+		if (Bluefruit.Scanner.start(0))
+			s_scanOn = true;
+	} else if (!want && s_scanOn) {
+		Bluefruit.Scanner.stop();
+		s_scanOn = false;
+	}
+}
+
+// Match an adv report against the persisted bonds: exact identity-address match, or an RPA resolved with the
+// cached IRK (Xbox pads advertise resolvable private addresses after bonding).
+static int bondMatchReport(const ble_gap_evt_adv_report_t *rpt)
+{
+	int direct = bondFind(rpt->peer_addr.addr_type, rpt->peer_addr.addr);
+	if (direct >= 0)
+		return direct;
+	if (rpt->peer_addr.addr_type ==
+	    BLE_GAP_ADDR_TYPE_RANDOM_PRIVATE_RESOLVABLE) {
+		for (int i = 0; i < BLE_NBOND; i++)
+			if (s_bond[i].used && s_keysOk[i] &&
+			    Bluefruit.Security.resolveAddress(
+				    &rpt->peer_addr,
+				    &s_keys[i].peer_id.id_info))
+				return i;
+	}
+	return -1;
+}
+
+static void scanAddEntry(ble_gap_evt_adv_report_t *rpt)
+{
+	char nm[13] = { 0 };
+	if (!Bluefruit.Scanner.parseReportByType(
+		    rpt, BLE_GAP_AD_TYPE_COMPLETE_LOCAL_NAME, (uint8_t *)nm,
+		    sizeof nm - 1))
+		Bluefruit.Scanner.parseReportByType(
+			rpt, BLE_GAP_AD_TYPE_SHORT_LOCAL_NAME, (uint8_t *)nm,
+			sizeof nm - 1);
+	bool hid = Bluefruit.Scanner.checkReportForUuid(
+		rpt, BLEUuid((uint16_t)0x1812));
+	uint8_t ap[2];
+	bool gamepad = false;
+	if (Bluefruit.Scanner.parseReportByType(rpt, BLE_GAP_AD_TYPE_APPEARANCE,
+						ap, 2) == 2) {
+		uint16_t a = (uint16_t)(ap[0] | (ap[1] << 8));
+		gamepad = (a >> 6) == 0x00F; // HID category (0x03C0..0x03FF)
+	}
+	if (!hid && !gamepad)
+		return; // not a HID device -> keep the list gamepad-only
+	// dedupe by address; else take a free row; else evict the stalest
+	int idx = -1, oldest = 0;
+	for (int i = 0; i < BLE_NSCAN; i++) {
+		if (s_scan[i].used &&
+		    s_scan[i].addrType == rpt->peer_addr.addr_type &&
+		    memcmp(s_scan[i].addr, rpt->peer_addr.addr, 6) == 0) {
+			idx = i;
+			break;
+		}
+		if (!s_scan[i].used) {
+			if (idx < 0 || s_scan[idx].used)
+				idx = i;
+		} else if (s_scan[i].ms < s_scan[oldest].ms)
+			oldest = i;
+	}
+	if (idx < 0)
+		idx = oldest;
+	ScanEnt *e = &s_scan[idx];
+	bool fresh = !e->used;
+	e->used = 1;
+	e->addrType = rpt->peer_addr.addr_type;
+	memcpy(e->addr, rpt->peer_addr.addr, 6);
+	e->rssi = rpt->rssi;
+	e->isHid = 1;
+	e->ms = millis();
+	if (nm[0] || fresh) {
+		memset(e->name, 0, sizeof e->name);
+		strncpy(e->name, nm, sizeof e->name - 1);
+	}
+}
+
+// Bluefruit callback-task context (deferred). MUST resume the scanner unless a connect was initiated.
+static void scanCb(ble_gap_evt_adv_report_t *rpt)
+{
+	if (s_state == BLE_ST_ON && !s_connecting) {
+		int b = bondMatchReport(rpt);
+		if (b >= 0 && b == s_failBond &&
+		    millis() - s_failBondMs < RECONNECT_BACKOFF_MS)
+			b = -1; // recent setup failure: let the backoff cool down
+		if (b >= 0 && !bondConnected(b) && linkAvail()) {
+			// bonded controller woke up: reconnect (connect implicitly stops the scanner)
+			s_connecting = true;
+			s_connectingMs = millis();
+			s_scanOn = false;
+			Bluefruit.Central.connect(rpt);
+			return;
+		}
+		if (s_uiScan)
+			scanAddEntry(rpt);
+	}
+	Bluefruit.Scanner.resume();
+}
+
+// ---- input path ----
+// BLE task context (notify deferral bypassed for latency): copy the raw report into the link ring. Loop-side
+// pop uses the same PRIMASK guard -- entries are ~28 B, so the IRQ-off windows stay in the low microseconds.
+static void inputNotifyCb(BLEClientCharacteristic *chr, uint8_t *data,
+			  uint16_t len)
+{
+	int ri = -1;
+	BleLink *L = linkByChr(chr, &ri);
+	if (!L || ri < 0 || !L->up || L->repIn[ri] < 0 || !L->drv)
+		return;
+	uint8_t rid = L->drv->inputRidByIndex((uint8_t)L->repIn[ri]);
+	if (len > RAW_MAXP)
+		len = RAW_MAXP;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	uint8_t h = L->qh, next = (uint8_t)((h + 1) % RAWQ);
+	if (next == L->qt) // full: evict oldest (newest state wins)
+		L->qt = (uint8_t)((L->qt + 1) % RAWQ);
+	L->q[h].rid = rid;
+	L->q[h].len = (uint8_t)len;
+	memcpy(L->q[h].d, data, len);
+	L->qh = next;
+	__set_PRIMASK(pm);
+}
+static bool rawPop(BleLink *L, RawEnt *out)
+{
+	bool ok = false;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	if (L->qt != L->qh) {
+		*out = L->q[L->qt];
+		L->qt = (uint8_t)((L->qt + 1) % RAWQ);
+		ok = true;
+	}
+	__set_PRIMASK(pm);
+	return ok;
+}
+static void batNotifyCb(BLEClientCharacteristic *chr, uint8_t *data,
+			uint16_t len)
+{
+	int ri;
+	BleLink *L = linkByChr(chr, &ri);
+	if (L && len >= 1)
+		L->battery = data[0];
+}
+
+// ---- connect / disconnect (Bluefruit callback task; blocking waits are fine here) ----
+static void linkDrop(BleLink *L, uint16_t ch)
+{
+	slotRelease(L->slot);
+	L->slot = -1;
+	L->up = false;
+	L->inUse = false;
+	L->drv = nullptr;
+	L->qh = L->qt = 0;
+	L->rumPend = false;
+	if (ch != BLE_CONN_HANDLE_INVALID) {
+		BLEConnection *c = Bluefruit.Connection(ch);
+		if (c && c->connected())
+			c->disconnect();
+	}
+}
+
+// Stamp the reconnect backoff for the bond behind a failing connection (no-op for never-bonded peers).
+static void failMark(uint16_t ch)
+{
+	BLEConnection *c = Bluefruit.Connection(ch);
+	if (!c)
+		return;
+	ble_gap_addr_t a = c->getPeerAddr();
+	int b = bondFind(a.addr_type, a.addr);
+	if (b >= 0) {
+		s_failBond = (int8_t)b;
+		s_failBondMs = millis();
+	}
+}
+
+static void connectCb(uint16_t ch)
+{
+	s_connecting = false;
+	BLEConnection *conn = Bluefruit.Connection(ch);
+	BleLink *L = nullptr;
+	for (int i = 0; i < BLE_LINKS; i++)
+		if (!s_link[i].inUse) {
+			L = &s_link[i];
+			break;
+		}
+	if (!L || !conn) {
+		if (conn)
+			conn->disconnect();
+		return;
+	}
+	L->inUse = true;
+	L->up = false;
+	L->conn = ch;
+	L->slot = -1;
+	L->outIdx = -1;
+	L->qh = L->qt = 0;
+	L->battery = 0;
+	L->name[0] = 0;
+
+	// 1) Security first: HID pads gate report traffic behind encryption. A bonded peer re-encrypts
+	//    automatically (BLECentral does it on connect); give that 2 s, then fall back to fresh pairing.
+	unsigned long t0 = millis();
+	while (!conn->secured() && conn->connected() && millis() - t0 < 2000)
+		delay(25);
+	if (!conn->secured() && conn->connected())
+		conn->requestPairing();
+	while (!conn->secured() && conn->connected() && millis() - t0 < 12000)
+		delay(50);
+	if (!conn->secured() || !conn->connected()) {
+		linkDrop(L, ch);
+		return;
+	}
+
+	// 2) identity: GAP name + DIS PnP ID (VID/PID) -> driver match
+	conn->getPeerName(L->name, sizeof L->name - 1);
+	uint16_t vid = 0, pid = 0;
+	if (L->dis.discover(ch) && L->pnp.discover()) {
+		uint8_t b[7];
+		if (L->pnp.read(b, sizeof b) >= 5) {
+			vid = (uint16_t)(b[1] | (b[2] << 8));
+			pid = (uint16_t)(b[3] | (b[4] << 8));
+		}
+	}
+	L->drv = inputDriverFor(vid, pid, L->name);
+	if (!L->drv) {
+		// No driver claims it: drop the connection AND the just-created stack bond so it doesn't
+		// auto-reconnect forever. Leaves the scan-table entry for the panel to show.
+		ble_gap_addr_t a = conn->getPeerAddr();
+		bond_remove_key(BLE_GAP_ROLE_CENTRAL, &a);
+		linkDrop(L, ch);
+		return;
+	}
+	L->vid = vid;
+	L->pid = pid;
+
+	// 3) battery service (optional)
+	if (L->bat.discover(ch) && L->batc.discover()) {
+		L->battery = L->batc.read8();
+		L->batc.setNotifyCallback(batNotifyCb, false);
+		L->batc.enableNotify();
+	}
+
+	// 4) HID service: discover up to BLE_NREP report characteristics (positional, handle order), subscribe
+	//    every one that has a CCCD (= input report); the first without one is the output (rumble) report.
+	if (!L->hid.discover(ch)) {
+		failMark(ch);
+		linkDrop(L, ch);
+		return;
+	}
+	BLEClientCharacteristic *arr[BLE_NREP];
+	for (int i = 0; i < BLE_NREP; i++) {
+		L->rep[i].setNotifyCallback(inputNotifyCb, false);
+		L->repIn[i] = -1;
+		arr[i] = &L->rep[i];
+	}
+	Bluefruit.Discovery.discoverCharacteristic(ch, arr, BLE_NREP);
+	uint8_t inN = 0;
+	for (int i = 0; i < BLE_NREP; i++) {
+		if (!L->rep[i].discovered())
+			continue;
+		if (L->rep[i].enableNotify())
+			L->repIn[i] = (int8_t)inN++;
+		else if (L->outIdx < 0)
+			L->outIdx = (int8_t)i;
+	}
+	if (inN == 0) {
+		failMark(ch);
+		linkDrop(L, ch);
+		return;
+	}
+
+	// 5) claim a bond slot + persist the record (identity address is stable once bonded)
+	int slot = slotClaim();
+	if (slot < 0) {
+		failMark(
+			ch); // e.g. all four slots RF-bonded -- back off, don't reconnect-loop
+		linkDrop(L, ch);
+		return;
+	}
+	memset((void *)&g_in[slot], 0, sizeof g_in[slot]);
+	g_slotSrc[slot] = SRC_BLE;
+	L->slot = (int8_t)slot;
+
+	ble_gap_addr_t id = conn->getPeerAddr();
+	int b = bondFind(id.addr_type, id.addr);
+	if (b < 0)
+		for (int i = 0; i < BLE_NBOND; i++)
+			if (!s_bond[i].used) {
+				b = i;
+				break;
+			}
+	if (b >= 0) {
+		s_bond[b].used = 1;
+		s_bond[b].addrType = id.addr_type;
+		memcpy(s_bond[b].addr, id.addr, 6);
+		s_bond[b].vid = vid;
+		s_bond[b].pid = pid;
+		memset(s_bond[b].name, 0, sizeof s_bond[b].name);
+		strncpy(s_bond[b].name, L->name, sizeof s_bond[b].name - 1);
+		bleBondsSave();
+		keysCacheLoad();
+	}
+
+	// input latency: ask for a 15 ms connection interval (units of 1.25 ms)
+	conn->requestConnectionParameter(12);
+	if (b >= 0 && s_failBond == (int8_t)b)
+		s_failBond = -1; // made it through -- clear any earlier backoff
+	L->up = true;
+}
+
+static void disconnectCb(uint16_t ch, uint8_t reason)
+{
+	(void)reason;
+	s_connecting = false;
+	BleLink *L = linkByConn(ch);
+	if (L)
+		linkDrop(L, BLE_CONN_HANDLE_INVALID); // already disconnected
+}
+
+// ---- public API ----
+uint8_t bleState()
+{
+	return s_state;
+}
+
+void bleHostBegin()
+{
+	if (s_state == BLE_ST_ON)
+		return;
+	if (!g_bleEn) {
+		// BLE off: clear any stale boot-latch (e.g. BLE was disabled mid-bringup, before the stable-clear)
+		// so a later re-enable isn't falsely tripped into the "disable" branch below.
+		if (bleBootPending())
+			bleBootDisarm();
+		return;
+	}
+	// Anti-bootloop: if the previous boot armed BLE but never reached the stable-clear, its SoftDevice/central
+	// bringup (or first seconds of running) faulted or hung. Do NOT re-run that init -- disable BLE (persisted)
+	// and come up clean. The user re-enables from the panel once the cause is addressed.
+	if (bleBootPending()) {
+		bleBootDisarm();
+		g_bleEn = 0;
+		saveCfg();
+		s_state = BLE_ST_FAILED;
+		return;
+	}
+	// Arm the latch in flash BEFORE touching the SoftDevice. Written via NVMC here (SD still off); cleared
+	// later (bleHostTask, after BLE_STABLE_MS) once bringup is proven -- by which point a fault/hang would
+	// already have reset us with the flag still set.
+	bleBootArm();
+	s_bootLatched = true;
+	// micros() is only µs-resolution when the DWT cycle counter is on (else it degrades to the ~1ms RTOS
+	// tick -- fatal for the timeslot deadline math in rf_timeslot). The core never enables it itself (only
+	// a debugger does), so force it here.
+	dwt_enable();
+	Bluefruit.autoConnLed(
+		false); // the board LED is the wake debugger (status_led), keep it
+	if (!Bluefruit.begin(0, BLE_LINKS)) {
+		// clean failure (not a crash) -- e.g. SoftDevice absent; disarm so we don't disable BLE next boot
+		bleBootDisarm();
+		s_bootLatched = false;
+		s_state = BLE_ST_FAILED;
+		return;
+	}
+	g_sdEnabled = true;
+	Bluefruit.Central.setConnectCallback(connectCb);
+	Bluefruit.Central.setDisconnectCallback(disconnectCb);
+	Bluefruit.Central.setConnInterval(12, 24); // 15..30 ms
+	for (int i = 0; i < BLE_LINKS; i++) {
+		BleLink &L = s_link[i];
+		L.dis.begin();
+		L.pnp.begin(&L.dis);
+		L.bat.begin();
+		L.batc.begin(&L.bat);
+		L.hid.begin();
+		for (int r = 0; r < BLE_NREP; r++)
+			L.rep[r].begin(&L.hid);
+	}
+	Bluefruit.Scanner.setRxCallback(scanCb);
+	Bluefruit.Scanner.restartOnDisconnect(
+		false); // scanEnsure() owns the scanner
+	Bluefruit.Scanner.useActiveScan(
+		true); // scan responses carry the names the UI shows
+	bleBondsLoad();
+	keysCacheLoad();
+	memset(s_scan, 0, sizeof s_scan);
+	s_state = BLE_ST_ON;
+	s_bootUpMs =
+		millis(); // start the stable-clear timer for the anti-bootloop latch
+	rfTsBegin(); // ESB keeps the radio via timeslots from here on
+	scanEnsure();
+}
+
+void bleShutdownForUpdate()
+{
+	if (!g_sdEnabled)
+		return;
+	rfTsEnd();
+	sd_softdevice_disable();
+	g_sdEnabled = false; // radio gates open again (bare-metal legal)
+	s_state = BLE_ST_OFF_UPDATE;
+	for (int s = 0; s < NSLOT; s++)
+		if (slotIsBle(s))
+			slotRelease(s);
+}
+
+bool bleSetRumble(uint8_t slot, uint16_t lo, uint16_t hi)
+{
+	for (int i = 0; i < BLE_LINKS; i++) {
+		BleLink &L = s_link[i];
+		if (L.inUse && L.up && L.slot == (int8_t)slot) {
+			L.rumLo = lo;
+			L.rumHi = hi;
+			L.rumPend = true;
+			return true;
+		}
+	}
+	return false;
+}
+
+void bleScanUi(bool on)
+{
+	if (s_state != BLE_ST_ON)
+		return;
+	s_uiScan = on;
+	s_uiScanUntil = on ? millis() + UI_SCAN_MS : 0;
+	if (on)
+		memset(s_scan, 0, sizeof s_scan);
+	// re-apply duty cycle: stop -> scanEnsure() restarts with the new params
+	if (s_scanOn) {
+		Bluefruit.Scanner.stop();
+		s_scanOn = false;
+	}
+	scanEnsure();
+}
+
+bool blePairTo(uint8_t addrType, const uint8_t addr[6])
+{
+	if (s_state != BLE_ST_ON || s_connecting || !linkAvail())
+		return false;
+	if (s_scanOn) {
+		Bluefruit.Scanner.stop();
+		s_scanOn = false;
+	}
+	ble_gap_addr_t a;
+	memset(&a, 0, sizeof a);
+	a.addr_type = addrType;
+	memcpy(a.addr, addr, 6);
+	s_connecting = true;
+	s_connectingMs = millis();
+	return Bluefruit.Central.connect(&a);
+}
+
+bool bleForget(uint8_t addrType, const uint8_t addr[6])
+{
+	int b = bondFind(addrType, addr);
+	// drop a live link to it first
+	for (int i = 0; i < BLE_LINKS; i++) {
+		BleLink &L = s_link[i];
+		if (!L.inUse || !L.up)
+			continue;
+		BLEConnection *c = Bluefruit.Connection(L.conn);
+		if (!c)
+			continue;
+		ble_gap_addr_t pa = c->getPeerAddr();
+		if (pa.addr_type == addrType && memcmp(pa.addr, addr, 6) == 0)
+			c->disconnect(); // disconnectCb releases the slot/link
+	}
+	ble_gap_addr_t a;
+	memset(&a, 0, sizeof a);
+	a.addr_type = addrType;
+	memcpy(a.addr, addr, 6);
+	if (g_sdEnabled)
+		bond_remove_key(BLE_GAP_ROLE_CENTRAL, &a);
+	if (b < 0)
+		return false;
+	memset(&s_bond[b], 0, sizeof s_bond[b]);
+	bleBondsSave();
+	keysCacheLoad();
+	return true;
+}
+
+void bleHostTask()
+{
+	rfTsTick();
+	if (s_state != BLE_ST_ON)
+		return;
+	unsigned long now = millis();
+
+	// Anti-bootloop: bringup has now survived BLE_STABLE_MS of running (SoftDevice enable, first scan and any
+	// early connect are all well behind us), so clear the boot-attempt latch -- future boots trust BLE again.
+	// A crash before this point leaves the flag set, so the next boot disables BLE instead of looping.
+	if (s_bootLatched && (now - s_bootUpMs) >= BLE_STABLE_MS) {
+		bleBootDisarm();
+		s_bootLatched = false;
+	}
+
+	// A BLE controller only PRESENTS in the emulated modes; in the puck modes it stays parked (see header).
+	const bool present = g_active && !g_active->isPuck();
+
+	for (int i = 0; i < BLE_LINKS; i++) {
+		BleLink &L = s_link[i];
+		if (!L.inUse || !L.up || L.slot < 0)
+			continue;
+		int slot = L.slot;
+		// Steam wrote an RF bond into our claimed slot (pairing a new SC2 while a BLE pad is live):
+		// the RF poll owns `used` slots, so evacuate -- drop the link; auto-reconnect re-claims a free one.
+		if (g_slot[slot].used) {
+			BLEConnection *c = Bluefruit.Connection(L.conn);
+			slotRelease(slot);
+			L.slot = -1;
+			L.up = false;
+			if (c && c->connected())
+				c->disconnect();
+			continue;
+		}
+		bool got = false;
+		RawEnt e;
+		while (rawPop(&L, &e))
+			if (L.drv->decode(slot, e.rid, e.d, e.len, &g_in[slot]))
+				got = true;
+		if (got && present) {
+			g_connReplyMs[slot] = now;
+			g_battery[slot] = L.battery;
+			g_batteryState[slot] = L.battery ? 1 : 0;
+			// wake gesture parity with the RF path: guide press while the bus sleeps wakes the host
+			if (USBDevice.suspended() &&
+			    (g_in[slot].buttons & TB_STEAM)) {
+				USBDevice.remoteWakeup();
+				ledWakePulse();
+				if (g_active)
+					g_active->wakeEvent();
+			} else {
+				uint8_t r45[PUCK45_LEN];
+				puckSynth45(&g_in[slot], ++L.seq, r45);
+				g_active->onReport45(slot, r45, true,
+						     PUCK45_LEN);
+			}
+		}
+		// rumble latch -> HID output report characteristic, rate-bounded (write-no-response)
+		if (L.rumPend && L.outIdx >= 0 && now - L.rumMs >= 30) {
+			uint8_t p[16];
+			L.rumPend = false;
+			L.rumMs = now;
+			uint8_t n = L.drv->buildRumble(L.rumLo, L.rumHi, p);
+			if (n)
+				L.rep[L.outIdx].write(p, n);
+		}
+	}
+
+	// housekeeping at ~2 Hz: UI-scan timeout, stuck-connect cancel, scanner state
+	static unsigned long hkMs = 0;
+	if (now - hkMs < 500)
+		return;
+	hkMs = now;
+	if (s_uiScan && (long)(now - s_uiScanUntil) >= 0)
+		s_uiScan = false;
+	if (s_connecting && now - s_connectingMs > CONNECT_TIMEOUT_MS) {
+		sd_ble_gap_connect_cancel();
+		s_connecting = false;
+	}
+	scanEnsure();
+}
+
+// ---- panel status frame ----
+// [0xAC][paylen][ver=1][state][uiScan][nScan]
+//   4 x bond: [flags(b0 used, b1 connected)][slot(0xFF)][addrType][addr6][vid lo,hi][pid lo,hi][batt][name12]
+//   nScan x:  [addrType][addr6][rssi s8][isHid][name12]
+uint8_t bleStatusFrame(uint8_t *out, uint8_t maxLen)
+{
+	uint8_t n = 2;
+	out[0] = 0xAC;
+	out[n++] = 1; // ver
+	out[n++] = s_state;
+	out[n++] = s_uiScan ? 1 : 0;
+	uint8_t nScanAt = n++; // patched below
+	for (int b = 0; b < BLE_NBOND; b++) {
+		if ((uint8_t)(n + 26) > maxLen)
+			return 0;
+		uint8_t flags = s_bond[b].used ? 1 : 0;
+		uint8_t slot = 0xFF, batt = 0;
+		if (s_bond[b].used)
+			for (int i = 0; i < BLE_LINKS; i++) {
+				BleLink &L = s_link[i];
+				if (!L.inUse || !L.up)
+					continue;
+				BLEConnection *c = Bluefruit.Connection(L.conn);
+				if (!c)
+					continue;
+				ble_gap_addr_t a = c->getPeerAddr();
+				if (a.addr_type == s_bond[b].addrType &&
+				    memcmp(a.addr, s_bond[b].addr, 6) == 0) {
+					flags |= 2;
+					slot = (uint8_t)L.slot;
+					batt = L.battery;
+					break;
+				}
+			}
+		out[n++] = flags;
+		out[n++] = slot;
+		out[n++] = s_bond[b].addrType;
+		memcpy(&out[n], s_bond[b].addr, 6);
+		n += 6;
+		out[n++] = (uint8_t)(s_bond[b].vid & 0xFF);
+		out[n++] = (uint8_t)(s_bond[b].vid >> 8);
+		out[n++] = (uint8_t)(s_bond[b].pid & 0xFF);
+		out[n++] = (uint8_t)(s_bond[b].pid >> 8);
+		out[n++] = batt;
+		memcpy(&out[n], s_bond[b].name, 12);
+		n += 12;
+	}
+	uint8_t nScan = 0;
+	for (int i = 0; i < BLE_NSCAN; i++) {
+		if (!s_scan[i].used)
+			continue;
+		if ((uint8_t)(n + 21) > maxLen)
+			break;
+		out[n++] = s_scan[i].addrType;
+		memcpy(&out[n], s_scan[i].addr, 6);
+		n += 6;
+		out[n++] = (uint8_t)s_scan[i].rssi;
+		out[n++] = s_scan[i].isHid;
+		memcpy(&out[n], s_scan[i].name, 12);
+		n += 12;
+		nScan++;
+	}
+	out[nScanAt] = nScan;
+	out[1] = (uint8_t)(n - 2);
+	return n;
+}

+ 54 - 0
OpenPuck/ble_host.h

@@ -0,0 +1,54 @@
+// ble_host.h -- BLE central: discover, pair, bond and stream input from BLE HID controllers.
+//
+// The transport half of the BLE-controller feature (the byte-meaning half is input_driver.h). Runs the S140
+// SoftDevice in the CENTRAL role next to the bare-metal ESB link (rf_timeslot.h arbitrates the radio):
+//   - scanner: passive-ish background scan that (a) spots bonded controllers advertising and auto-reconnects,
+//     and (b) while the panel's "scan" is armed, collects nearby HID gamepads for the pairing UI.
+//   - connect flow (runs on the Bluefruit callback task): secure (Just Works pairing / bonded re-encryption;
+//     HID pads refuse report traffic before encryption), read the DIS PnP ID for VID/PID, match an
+//     IInputDriver, subscribe every HID input-report characteristic, claim a free bond slot.
+//   - input path: notify callbacks (BLE task) push raw reports into a per-link ring; bleHostTask() (loop task)
+//     drains it, lets the driver decode into g_in[slot], stamps g_connReplyMs[slot], and dispatches a
+//     synthesized report 0x45 through g_active->onReport45() -- exactly the shape the RF path produces, so
+//     every USB personality works unchanged. g_in stays single-writer-from-loop.
+//   - rumble: hapticSteamRumble() forks BLE slots here (bleSetRumble latch); the latch is flushed to the
+//     device's HID output-report characteristic from loop at a bounded rate.
+//   - persistence: pairing LTKs live in the BLE stack's own bond store (/adafruit/bond_cntr); our metadata
+//     (identity address, name, VID/PID) lives in /blebonds.bin so the panel can list and forget devices.
+//
+// Presentation rule: BLE controllers surface only in the EMULATED modes (XInput/Switch/PS5/DS4 -- everything
+// that reads g_in or the synthesized 0x45). In the Steam/Lizard puck modes they stay parked (connected, not
+// presented): Steam's per-slot feature channel expects a real SC2 bond record behind each slot.
+#pragma once
+#include <stdint.h>
+
+// lifecycle state (panel field)
+#define BLE_ST_OFF \
+	0 // disabled (persisted flag off, or never started this boot)
+#define BLE_ST_ON 1 // SoftDevice up, central running
+#define BLE_ST_FAILED \
+	2 // Bluefruit/SoftDevice refused to start (clone clock issue?)
+#define BLE_ST_OFF_UPDATE 3 // shut down mid-run for a staged firmware update
+
+// Start the SoftDevice + central role. No-op unless g_bleEn (config.h) -- callable both at setup() and live
+// from the panel's enable command.
+void bleHostBegin();
+// Per-loop pump: raw-report drain -> g_in -> dispatch, rumble flush, reconnect/scan upkeep, rfTsTick().
+void bleHostTask();
+// Latch rumble for a BLE-driven slot (called from hapticSteamRumble, any task). False if slot isn't BLE-live.
+bool bleSetRumble(uint8_t slot, uint16_t lo, uint16_t hi);
+// Staged-firmware-update path: close the timeslot session and DISABLE the SoftDevice so fw_update's direct
+// NVMC writes are legal again (raw NVMC under an enabled SD corrupts BLE timing / asserts). BLE drops until
+// the post-update reboot. Safe to call when BLE never started.
+void bleShutdownForUpdate();
+
+uint8_t bleState(); // BLE_ST_*
+void bleScanUi(bool on); // panel scan start/stop (auto-stops after 60 s)
+// Panel "pair": connect + bond to a scanned address. Returns false if BLE is off.
+bool blePairTo(uint8_t addrType, const uint8_t addr[6]);
+// Panel "forget": disconnect if live, drop the stack bond keys + our record.
+bool bleForget(uint8_t addrType, const uint8_t addr[6]);
+
+// Build the panel status frame [0xAC][len][ver][state][uiScan][nScan][4x bond rec][nScan x scan rec] into out
+// (called from the usbd-task WebUSB drain; unsynchronized reads -- cosmetic). Returns total bytes or 0.
+uint8_t bleStatusFrame(uint8_t *out, uint8_t maxLen);

+ 10 - 4
OpenPuck/config.cpp

@@ -65,6 +65,9 @@ void applyActiveType()
 // rumble strength % (200 = double); adjustable from the WebUSB panel
 uint8_t g_rumbleScale = 200;
 
+// BLE controllers enable (rides the legacy rxWin10 byte -- see config.h)
+uint8_t g_bleEn = 0;
+
 // 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;
@@ -76,8 +79,9 @@ const uint32_t g_pollUs = POLL_US_DEFAULT;
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;
-	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the id9=0 hold enable (see
-	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
+	// rxWin10: ex-RF-window tunable, REUSED as the BLE-controllers enable (g_bleEn). Old configs hold stale
+	// window values here, so only an exact 1 loads as enabled. lizKeep: the id9=0 hold enable (see haptics.h
+	// LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
 	uint8_t rxWin10, lizKeep, landAll87;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
@@ -94,7 +98,7 @@ void saveCfg()
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
 		  g_rumbleScale,
-		  (uint8_t)(g_rxWin / 10),
+		  (uint8_t)(g_bleEn ? 1 : 0),
 		  g_lizKeep,
 		  g_landAll87,
 		  {} };
@@ -158,7 +162,9 @@ void loadCfg()
 			// verbatim-0x87-relay experiment toggle (0/1; default off)
 			if (c.landAll87 <= 1)
 				g_landAll87 = c.landAll87;
-			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
+			// BLE-controllers enable, riding the ex-rxWin10 byte: ONLY an exact 1 counts (a pre-BLE
+			// config left a stale window value like 200 here -- must not surprise-enable the SD).
+			g_bleEn = (c.rxWin10 == 1) ? 1 : 0;
 		}
 		f.close();
 	}

+ 5 - 0
OpenPuck/config.h

@@ -156,6 +156,11 @@ extern uint8_t g_swGyroScale10;
 // persist g_swProRate + g_swGyroScale10 to their flash file
 void swProSaveCfg();
 
+// BLE controllers enable (ble_host.h). Persisted in the legacy Cfg.rxWin10 byte: old configs left arbitrary
+// rxWin values there, so ONLY an exact 1 reads back as enabled (see loadCfg) -- an upgrade can never surprise-
+// enable the SoftDevice. Default off: with BLE off the firmware is byte-for-byte the legacy bare-metal radio.
+extern uint8_t g_bleEn;
+
 // 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)

+ 157 - 0
OpenPuck/drv_xinput_ble.cpp

@@ -0,0 +1,157 @@
+#include "drv_xinput_ble.h"
+#include "triton.h"
+#include <string.h>
+#include <ctype.h>
+
+XInputBleDriver g_drvXInputBle;
+
+// newlib has no strcasestr; tiny local case-insensitive substring match
+static bool nameHas(const char *hay, const char *needle)
+{
+	size_t nl = strlen(needle);
+	for (; *hay; hay++) {
+		size_t i = 0;
+		while (i < nl && hay[i] &&
+		       tolower((unsigned char)hay[i]) ==
+			       tolower((unsigned char)needle[i]))
+			i++;
+		if (i == nl)
+			return true;
+	}
+	return false;
+}
+
+// Microsoft's VID (real Xbox pads AND the 8BitDo xinput-mode spoof) plus 8BitDo's own VID, in case a future
+// 8BitDo BLE firmware advertises the honest id. Name fallback covers pads whose DIS/PnP ID read fails (the
+// Xbox pads expose it, but only after encryption -- ble_host reads it post-pairing, so this is a backstop).
+bool XInputBleDriver::match(uint16_t vid, uint16_t pid,
+			    const char *devName) const
+{
+	(void)pid; // all Microsoft gamepad PIDs (02E0/02FD/0B13/...) share the layout
+	if (vid == 0x045E || vid == 0x2DC8)
+		return true;
+	return nameHas(devName, "xbox") || nameHas(devName, "8bitdo");
+}
+
+// HID service report characteristics in declaration order: [0] = input 0x01 (the gamepad state; fw 5.x pads
+// carry guide/share in it too), [1] = input 0x02 (the legacy separate guide-button report; pre-fw5 / 8BitDo).
+// The output (rumble) characteristic is not in this mapping -- it is identified by having no CCCD and always
+// carries report 0x03 on these pads.
+uint8_t XInputBleDriver::inputRidByIndex(uint8_t idx) const
+{
+	return (uint8_t)(idx + 1); // 0 -> 0x01, 1 -> 0x02
+}
+
+// 8-way hat -> TB_ dpad bits. 0 = released, 1..8 = N NE E SE S SW W NW.
+static uint32_t hatBits(uint8_t h)
+{
+	static const uint32_t T[9] = {
+		0,
+		TB_DUP,
+		TB_DUP | TB_DRT,
+		TB_DRT,
+		TB_DRT | TB_DDN,
+		TB_DDN,
+		TB_DDN | TB_DLF,
+		TB_DLF,
+		TB_DLF | TB_DUP,
+	};
+	return h <= 8 ? T[h] : 0;
+}
+
+// Input report 0x01 (Xbox One S / Series BT+BLE layout, 14..16 bytes):
+//   [0..7]  LX LY RX RY  u16 LE, 0..65535, center 32768, HID Y grows DOWN
+//   [8..11] LT RT        u16 LE, 10-bit (0..1023)
+//   [12]    hat          0=idle, 1..8 clockwise from N
+//   [13]    A B X Y LB RB View Menu
+//   [14]    (fw 5.x) bit0 Xbox/guide, bit1 LS, bit2 RS   (pre-fw5: bit0 LS, bit1 RS; guide rides report 0x02)
+//   [15]    (Series 1914) bit0 Share
+// Report 0x02: [0] bit0 = Xbox/guide (the pre-fw5 / 8BitDo location).
+bool XInputBleDriver::decode(int slot, uint8_t rid, const uint8_t *d,
+			     uint16_t len, PuckInput *io)
+{
+	(void)slot;
+	if (rid == 0x02) {
+		if (len < 1)
+			return false;
+		if (d[0] & 0x01)
+			io->buttons |= TB_STEAM;
+		else
+			io->buttons &= ~TB_STEAM;
+		return true;
+	}
+	if (rid != 0x01 || len < 14)
+		return false;
+	uint16_t lx = (uint16_t)(d[0] | (d[1] << 8)),
+		 ly = (uint16_t)(d[2] | (d[3] << 8));
+	uint16_t rx = (uint16_t)(d[4] | (d[5] << 8)),
+		 ry = (uint16_t)(d[6] | (d[7] << 8));
+	uint16_t lt = (uint16_t)(d[8] | (d[9] << 8)),
+		 rt = (uint16_t)(d[10] | (d[11] << 8));
+	uint8_t b1 = d[13];
+	uint8_t b2 = (len > 14) ? d[14] : 0;
+	uint8_t b3 = (len > 15) ? d[15] : 0;
+
+	uint32_t b = hatBits(d[12]);
+	if (b1 & 0x01)
+		b |= TB_A;
+	if (b1 & 0x02)
+		b |= TB_B;
+	if (b1 & 0x04)
+		b |= TB_X;
+	if (b1 & 0x08)
+		b |= TB_Y;
+	if (b1 & 0x10)
+		b |= TB_LB;
+	if (b1 & 0x20)
+		b |= TB_RB;
+	if (b1 & 0x40)
+		b |= TB_VIEW;
+	if (b1 & 0x80)
+		b |= TB_MENU;
+	if (len > 14) {
+		if (b2 & 0x01)
+			b |= TB_STEAM; // fw 5.x: guide is in-band
+		if (b2 & 0x02)
+			b |= TB_L3;
+		if (b2 & 0x04)
+			b |= TB_R3;
+	} else {
+		// 14-byte variant: guide arrives on report 0x02 -- preserve its latched state
+		b |= io->buttons & TB_STEAM;
+	}
+	if (b3 & 0x01)
+		b |= TB_QAM; // Series share button -> QAM (3 dots)
+	// analog triggers 0..1023 -> 0..255, full-pull digital bits like the SC2 sets them
+	if (lt >= 1000)
+		b |= TB_L2;
+	if (rt >= 1000)
+		b |= TB_R2;
+
+	io->buttons = b;
+	io->lx = (int16_t)(lx - 32768);
+	io->ly = (int16_t)(32767 - ly); // HID Y down -> gamepad Y up
+	io->rx = (int16_t)(rx - 32768);
+	io->ry = (int16_t)(32767 - ry);
+	io->lt = (uint8_t)(lt >> 2 > 255 ? 255 : lt >> 2);
+	io->rt = (uint8_t)(rt >> 2 > 255 ? 255 : rt >> 2);
+	// no trackpads / IMU on this device class: leave the zeroed fields alone
+	return true;
+}
+
+// Rumble output report 0x03 (hid-microsoft xb1s_ff_report): [enable][magLT][magRT][magStrong][magWeak]
+// [sustain 10ms][release 10ms][loop]. Magnitudes 0..100. Enable all four motors -- the trigger magnitudes stay
+// 0, so pads without impulse triggers just ignore those bits. Continuous-stream semantics like every other
+// rumble sink here: the host re-sends on change, 0/0 stops.
+uint8_t XInputBleDriver::buildRumble(uint16_t lo, uint16_t hi, uint8_t out[16])
+{
+	out[0] = 0x0F;
+	out[1] = 0;
+	out[2] = 0;
+	out[3] = (uint8_t)((uint32_t)lo * 100 / 0xFFFF);
+	out[4] = (uint8_t)((uint32_t)hi * 100 / 0xFFFF);
+	out[5] = 0xFF; // sustain ~2.5s; the stream refreshes long rumbles
+	out[6] = 0x00;
+	out[7] = 0x00; // no repeat -- a lost "off" must not buzz forever
+	return 8;
+}

+ 28 - 0
OpenPuck/drv_xinput_ble.h

@@ -0,0 +1,28 @@
+// drv_xinput_ble.h -- input driver for the Xbox Bluetooth HID gamepad report layout ("xinput mode").
+//
+// This is the layout the 8BitDo SN30 Pro speaks in its X-input (Start+X) mode: it impersonates an Xbox One S
+// controller, VID 045E PID 02E0, input report 0x01, rumble output report 0x03 (confirmed by the Linux
+// hid-microsoft driver, which quirks 8BitDo pads via the Microsoft ids). NOTE the plain SN30 Pro only speaks it
+// over Bluetooth CLASSIC (BR/EDR), which the nRF52840 radio cannot do -- so the pads this driver actually
+// connects to over BLE are the real Xbox controllers that use the same layout over HID-over-GATT:
+//   - Xbox Series X|S controller (model 1914) -- BLE always (fw >= 5.15)
+//   - Xbox One S controller (model 1708) -- BLE after the firmware 5.x update
+//   - Xbox Adaptive Controller
+// If 8BitDo ever ships a BLE mode it lands here for free (same report bytes, same Microsoft ids).
+#pragma once
+#include "input_driver.h"
+
+class XInputBleDriver : public IInputDriver {
+    public:
+	const char *name() const override
+	{
+		return "xinput";
+	}
+	bool match(uint16_t vid, uint16_t pid,
+		   const char *devName) const override;
+	uint8_t inputRidByIndex(uint8_t idx) const override;
+	bool decode(int slot, uint8_t rid, const uint8_t *d, uint16_t len,
+		    PuckInput *io) override;
+	uint8_t buildRumble(uint16_t lo, uint16_t hi, uint8_t out[16]) override;
+};
+extern XInputBleDriver g_drvXInputBle;

+ 6 - 0
OpenPuck/fw_update.cpp

@@ -3,6 +3,7 @@
 // corruption-safety invariant every step here preserves.
 #include "fw_update.h"
 #include "fault_diag.h"
+#include "ble_host.h" // bleShutdownForUpdate() -- direct NVMC is illegal under an enabled SoftDevice
 #include <Arduino.h>
 #include <string.h>
 
@@ -125,6 +126,11 @@ uint32_t fwupNextOff(void)
 uint8_t fwupBegin(uint32_t size, uint32_t crc32)
 {
 	s_active = false;
+	// BLE coexistence: staging writes flash via NRF_NVMC directly, which is ILLEGAL while the SoftDevice is
+	// enabled (the SD owns flash timing when the radio is active -- direct NVMC asserts/corrupts it). Shut
+	// BLE down for the rest of this session; the ESB link reverts to bare-metal radio ownership and the
+	// update ends in a reboot anyway. No-op when BLE never started.
+	bleShutdownForUpdate();
 	// any BEGIN first disarms a previously staged update -- a restarted transfer must never leave a stale
 	// commit behind (the staged bytes it points at are about to be overwritten).
 	nvmcErasePage(FWUP_META);

+ 14 - 0
OpenPuck/haptics.cpp

@@ -1,6 +1,8 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "config.h"
+#include "input_driver.h" // slotIsBle() -- BLE slots bypass the ESB relay
+#include "ble_host.h" // bleSetRumble() -- the BLE rumble sink
 #include "rf_link.h"
 #include "usb_tx.h" // usbTxBoost/Unboost -- flood-rate CDC prints share the dcd DMA claim window
 #include "puck_hid.h" // puckLizardActive() -- gate the lizard-suppression keepalive
@@ -116,6 +118,10 @@ bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 	uint8_t s0 = (slot == 0xFF) ? 0 : slot;
 	uint8_t s1 = (slot == 0xFF) ? NSLOT : slot + 1;
 	for (uint8_t s = s0; s < s1; s++) {
+		// BLE-driven slots have no ESB relay: their poll never runs, so a queued frame would just rot
+		// in the ring (rumble reaches them via bleSetRumble, and 0x87/0x9F settings are SC2-specific)
+		if (slotIsBle(s))
+			continue;
 		uint8_t h = g_rqHead[s], nx = rqNext(h);
 		if (nx == g_rqTail[s])
 			g_rqTail[s] = rqNext(g_rqTail[s]);
@@ -312,6 +318,10 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 		lowFreq = (l > 0xFFFF) ? 0xFFFF : (uint16_t)l;
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 	}
+	// BLE-driven slot: hand the (already user-scaled) amplitudes to the BLE rumble sink -- no ESB relay,
+	// no reconnect-block gating (that guards the SC2's haptic engine, which doesn't exist here).
+	if (slotIsBle(slot))
+		return bleSetRumble(slot, lowFreq, highFreq);
 	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
@@ -585,6 +595,10 @@ void hapticOnReconnect(int slot)
 {
 	if (slot < 0 || slot >= NSLOT)
 		return;
+	// BLE-driven slot: no SC2 haptic engine to settle/re-init (its link-up edges also hit hapticTask's
+	// detector because g_connReplyMs is stamped for BLE too -- bail before arming anything)
+	if (slotIsBle(slot))
+		return;
 	// post-connect haptic block is permanently disabled -- relay haptics immediately on (re)connect
 	g_hapticBlockUntil[slot] = 0;
 	g_haptic82On = false;

+ 19 - 0
OpenPuck/input_driver.cpp

@@ -0,0 +1,19 @@
+#include "input_driver.h"
+#include "drv_xinput_ble.h"
+
+// Which source drives each bond slot. SRC_RF at boot (matches the legacy behavior for every RF-bonded slot);
+// ble_host flips a slot to SRC_BLE for the lifetime of a BLE connection.
+volatile uint8_t g_slotSrc[NSLOT] = { SRC_RF, SRC_RF, SRC_RF, SRC_RF };
+
+// The driver registry, first match wins. Order specific -> generic if a catch-all is ever added.
+static IInputDriver *const REGISTRY[] = {
+	&g_drvXInputBle,
+};
+
+IInputDriver *inputDriverFor(uint16_t vid, uint16_t pid, const char *devName)
+{
+	for (unsigned i = 0; i < sizeof REGISTRY / sizeof REGISTRY[0]; i++)
+		if (REGISTRY[i]->match(vid, pid, devName ? devName : ""))
+			return REGISTRY[i];
+	return nullptr;
+}

+ 74 - 0
OpenPuck/input_driver.h

@@ -0,0 +1,74 @@
+// input_driver.h -- the input-side controller abstraction (the mirror of controllers.h's IController).
+//
+// controllers.h answers "how do we PRESENT input to the host" (one IController per USB personality). This
+// header answers "where does input COME FROM and how is it normalized". The contract in the middle is
+// PuckInput/g_in[slot] (triton.h): every input source decodes its native reports into g_in[slot] and stamps
+// g_connReplyMs[slot], and every USB personality reads g_in -- so a new input source lights up ALL of the
+// existing output modes (XInput, Switch, PS5, DS4...) with no per-mode work.
+//
+// Sources today:
+//   - the SC2 RF link (rf_link.cpp) -- decodes report 0x45. Not an IInputDriver; it predates this seam and
+//     stays as-is.
+//   - BLE HID controllers (ble_host.cpp) -- each connected device is matched to an IInputDriver that knows its
+//     report layout. ble_host owns the transport (scan/pair/bond/subscribe); the driver owns byte meaning.
+//
+// Slot model: the four bond slots (bonds.h) are shared. RF bonds own the slots marked g_slot[s].used; a BLE
+// controller claims a FREE slot at connect (highest index first, so RF pairing -- which fills from interface 0
+// -- collides last) and releases it at disconnect. g_slotSrc tags who drives each slot so the RF poll, the
+// haptic relay, and usb_mount route correctly.
+//
+// Adding a driver: subclass IInputDriver in a new drv_*.cpp, decode into the PuckInput fields you have (leave
+// the rest alone -- absent sensors stay zero), map buttons onto the TB_* Triton masks (triton.h), and add the
+// singleton to the registry in input_driver.cpp. See drv_xinput_ble.cpp for the exemplar.
+#pragma once
+#include <stdint.h>
+#include "bonds.h" // NSLOT
+
+struct PuckInput;
+
+// ---- slot input-source tags ----
+#define SRC_RF 0 // SC2 over the ESB link (default; all RF-bonded slots)
+#define SRC_BLE 1 // BLE HID controller (runtime claim by ble_host)
+extern volatile uint8_t g_slotSrc[NSLOT];
+static inline bool slotIsBle(int s)
+{
+	return s >= 0 && s < NSLOT && g_slotSrc[s] == SRC_BLE;
+}
+
+// ---- the driver interface ----
+class IInputDriver {
+    public:
+	virtual ~IInputDriver()
+	{
+	}
+	virtual const char *name() const = 0;
+	// Claim a device by USB-IF ids (from the BLE Device Information Service PnP ID; 0/0 if absent) and/or
+	// its advertised name (never NULL, possibly empty).
+	virtual bool match(uint16_t vid, uint16_t pid,
+			   const char *devName) const = 0;
+	// The BLE HID client subscribes to the device's input-report characteristics in service-declaration
+	// (attribute-handle) order but cannot read their Report Reference descriptors (no raw-descriptor API in
+	// the BLE stack wrapper) -- so the driver declares which HID report id the i-th input characteristic
+	// carries. Default: sequential ids starting at 1 (matches most single/dual-report gamepads).
+	virtual uint8_t inputRidByIndex(uint8_t idx) const
+	{
+		return (uint8_t)(idx + 1);
+	}
+	// Decode one input report into io (the slot's live PuckInput). Update only the fields this report
+	// carries -- io persists between reports, which is how multi-report devices (e.g. a separate guide-
+	// button report) merge state. Return true if io changed (gates the host dispatch).
+	virtual bool decode(int slot, uint8_t rid, const uint8_t *d,
+			    uint16_t len, PuckInput *io) = 0;
+	// Rumble: build the device's output report payload (WITHOUT the report id) for the given 16-bit
+	// low/high-frequency motor amplitudes. Return the payload length, 0 = no rumble support.
+	virtual uint8_t buildRumble(uint16_t lo, uint16_t hi, uint8_t out[16])
+	{
+		(void)lo;
+		(void)hi;
+		(void)out;
+		return 0;
+	}
+};
+
+// Registry lookup (input_driver.cpp). NULL = no driver claims this device (ble_host refuses the connection).
+IInputDriver *inputDriverFor(uint16_t vid, uint16_t pid, const char *devName);

+ 6 - 0
OpenPuck/rf_diag.cpp

@@ -1,5 +1,6 @@
 #include "rf_diag.h"
 #include "radio.h"
+#include "rf_timeslot.h" // rfRadioOwned() -- BLE (SoftDevice) radio arbitration
 #include "bonds.h"
 #include <Arduino.h>
 #include <string.h>
@@ -491,6 +492,11 @@ static void rfSniffPoll()
 
 void rfDiagTask()
 {
+	// BLE coexistence gate: every diag mode below programs/starts NRF_RADIO directly, which is only legal
+	// inside a granted timeslot once the SoftDevice is up (rf_timeslot.h). Budget covers the longest single
+	// diag op (replay TX + capture RX + the 3ms bounded disable waits). Always true with BLE off.
+	if (!rfRadioOwned(5000))
+		return;
 	rfPoll();
 	rfRespondPoll();
 	rfCapPoll();

+ 16 - 0
OpenPuck/rf_link.cpp

@@ -1,5 +1,6 @@
 #include "rf_link.h"
 #include "radio.h"
+#include "rf_timeslot.h" // rfRadioOwned() -- BLE (SoftDevice) radio arbitration
 #include "bonds.h"
 #include "config.h"
 #include "triton.h"
@@ -161,6 +162,9 @@ static void rfHostFrameOnce(int slot, bool discovery)
 {
 	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
 		return;
+	// BLE coexistence: whole-op gate (config + TX + the pair-request RX window). See rfLinkTask.
+	if (!rfRadioOwned(4000))
+		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:
@@ -258,6 +262,9 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 {
 	// relays pass a tiny window (no reply expected); polls use g_rxWin
 	uint16_t win = rxWinUs ? rxWinUs : g_rxWin;
+	// BLE coexistence backstop (callers gate coarser): config + TX + the RX window must fit the timeslot.
+	if (!rfRadioOwned(win + 1500u))
+		return 0;
 	memset(rftx, 0, sizeof rftx);
 	rftx[0] = plen; // LENGTH = payload byte count
 	rftx[1] = s1; // S1 (type-specific)
@@ -907,6 +914,13 @@ static void rfConnStep()
 
 void rfLinkTask()
 {
+	// BLE coexistence gate (rf_timeslot.h): with the SoftDevice enabled the radio is only legally ours inside
+	// a granted timeslot. Budget = the longest single pass through this function's radio work (a beacon sweep
+	// or a full-rate poll cycle, each op ~2.5ms worst). Not owned -> skip this loop pass entirely; the poll
+	// pacing gates below re-fire the moment a slot is granted (BLE steals the air for only a few ms at a
+	// time, so this shows up as jitter, not starvation). Always true with BLE off.
+	if (!rfRadioOwned(6000))
+		return;
 	// 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
@@ -1005,6 +1019,8 @@ void rfLinkTask()
 					    RF_RECOVER_MS :
 					    RF_STALL_BACKOFF_MS;
 		if (anyEverConnected && allStalled &&
+		    rfRadioOwned(
+			    500) && // BLE coex: peripheral reset only while the radio is ours
 		    (uint32_t)(nowMs2 - lastRecoverMs) > interval) {
 			lastRecoverMs = nowMs2;
 			g_rfStallRecover++;

+ 151 - 0
OpenPuck/rf_timeslot.cpp

@@ -0,0 +1,151 @@
+#include "rf_timeslot.h"
+#include <Arduino.h>
+#include <nrf_soc.h>
+#include <nrf_sdm.h>
+
+volatile bool g_sdEnabled = false;
+volatile uint16_t g_tsGrants = 0, g_tsExtends = 0, g_tsStarved = 0;
+
+// Slot geometry. 15ms base slots with 15ms chained extensions: long enough that the ~4.5ms worst-case ESB op
+// fits many times per slot, short enough that the first grant after BLE activity is quick. The margin is how
+// long before the true slot end rfRadioOwned() starts refusing new ops -- it must exceed the pessimistic gate
+// the callers pass (they gate on the WHOLE op, so only the last-op-start needs to clear the margin).
+#define TS_LEN_US 15000u
+#define TS_EXT_US 15000u
+#define TS_MARGIN_US 500u
+// EARLIEST-request timeout: how far in the future the SD may schedule us before giving up (then rfTsTick
+// re-arms). Generous -- heavy scanning can legitimately push us out several tens of ms.
+#define TS_TIMEOUT_US 100000u
+
+static volatile bool s_sessionOpen = false;
+// micros() deadline until which the radio is ours (margin already subtracted). 0 = no slot. Written by the
+// priority-0 signal handler, read from loop context -- single aligned 32-bit write, no lock needed.
+static volatile uint32_t s_ownedUntil = 0;
+// current slot length so far (base + granted extensions), for the TIMER0 end-guard compare
+static uint32_t s_slotLen = 0;
+static volatile uint32_t s_lastGrantUs =
+	0; // last slot-start micros() (starvation watchdog)
+static unsigned long s_lastKickMs = 0;
+
+static nrf_radio_request_t s_req;
+static nrf_radio_signal_callback_return_param_t s_ret;
+
+static void tsBuildRequest()
+{
+	s_req.request_type = NRF_RADIO_REQ_TYPE_EARLIEST;
+	s_req.params.earliest.hfclk = NRF_RADIO_HFCLK_CFG_XTAL_GUARANTEED;
+	s_req.params.earliest.priority = NRF_RADIO_PRIORITY_NORMAL;
+	s_req.params.earliest.length_us = TS_LEN_US;
+	s_req.params.earliest.timeout_us = TS_TIMEOUT_US;
+}
+
+// Timeslot signal handler. PRIORITY 0 -- preempts everything, including the SoftDevice's own lower-priority
+// handlers. Keep it register-poke + volatile-stamp only: no SD calls except via the returned action, no RTOS,
+// no logging. micros() is safe (DWT cycle counter read).
+static nrf_radio_signal_callback_return_param_t *tsSignal(uint8_t sig)
+{
+	s_ret.callback_action = NRF_RADIO_SIGNAL_CALLBACK_ACTION_NONE;
+	switch (sig) {
+	case NRF_RADIO_CALLBACK_SIGNAL_TYPE_START:
+		// The SD hands us the radio with ITS interrupt config -- clear it, we poll (no RADIO signals).
+		NRF_RADIO->INTENCLR = 0xFFFFFFFF;
+		s_slotLen = TS_LEN_US;
+		s_lastGrantUs = micros();
+		s_ownedUntil = s_lastGrantUs + TS_LEN_US - TS_MARGIN_US;
+		g_tsGrants++;
+		// TIMER0 runs from 0 (1 MHz) inside the slot. CC0 = the end guard: hand the slot back BEFORE the
+		// scheduled end (overstaying is an SD assert). Extensions push it out.
+		NRF_TIMER0->CC[0] = s_slotLen - (TS_MARGIN_US / 2);
+		NRF_TIMER0->EVENTS_COMPARE[0] = 0;
+		NRF_TIMER0->INTENSET = TIMER_INTENSET_COMPARE0_Msk;
+		// the SD forwards the TIMER0 IRQ to this handler as SIGNAL_TYPE_TIMER0 -- it must be
+		// NVIC-enabled (canonical Nordic timeslot pattern)
+		NVIC_EnableIRQ(TIMER0_IRQn);
+		// chain an extension right away; keeps the radio ours while BLE is idle
+		s_ret.callback_action = NRF_RADIO_SIGNAL_CALLBACK_ACTION_EXTEND;
+		s_ret.params.extend.length_us = TS_EXT_US;
+		break;
+	case NRF_RADIO_CALLBACK_SIGNAL_TYPE_EXTEND_SUCCEEDED:
+		s_slotLen += TS_EXT_US;
+		s_ownedUntil += TS_EXT_US;
+		NRF_TIMER0->CC[0] = s_slotLen - (TS_MARGIN_US / 2);
+		g_tsExtends++;
+		// keep chaining -- the SD fails the extend when a BLE event needs the radio
+		s_ret.callback_action = NRF_RADIO_SIGNAL_CALLBACK_ACTION_EXTEND;
+		s_ret.params.extend.length_us = TS_EXT_US;
+		break;
+	case NRF_RADIO_CALLBACK_SIGNAL_TYPE_EXTEND_FAILED:
+		// BLE needs the radio at the current end: ride out the remainder, the CC0 guard hands it back.
+		break;
+	case NRF_RADIO_CALLBACK_SIGNAL_TYPE_TIMER0:
+		// End guard fired: stop claiming ownership and hand back, asking for the next slot in one action.
+		NRF_TIMER0->INTENCLR = TIMER_INTENCLR_COMPARE0_Msk;
+		NRF_TIMER0->EVENTS_COMPARE[0] = 0;
+		s_ownedUntil = 0;
+		s_ret.callback_action =
+			NRF_RADIO_SIGNAL_CALLBACK_ACTION_REQUEST_AND_END;
+		s_ret.params.request.p_next = &s_req;
+		break;
+	case NRF_RADIO_CALLBACK_SIGNAL_TYPE_RADIO:
+	default:
+		// RADIO signals only fire if something set INTENSET (we never do; START clears the SD's).
+		break;
+	}
+	return &s_ret;
+}
+
+bool rfTsBegin()
+{
+	if (s_sessionOpen)
+		return true;
+	tsBuildRequest();
+	if (sd_radio_session_open(tsSignal) != NRF_SUCCESS)
+		return false;
+	if (sd_radio_request(&s_req) != NRF_SUCCESS) {
+		sd_radio_session_close();
+		return false;
+	}
+	s_sessionOpen = true;
+	s_lastGrantUs = micros();
+	return true;
+}
+
+void rfTsEnd()
+{
+	if (!s_sessionOpen)
+		return;
+	s_ownedUntil = 0;
+	s_sessionOpen = false;
+	sd_radio_session_close(); // async; caller is about to disable the SD anyway
+}
+
+bool rfRadioOwned(uint32_t usNeeded)
+{
+	if (!g_sdEnabled)
+		return true; // bare-metal mode: the radio is unconditionally ours
+	uint32_t d = s_ownedUntil;
+	if (!d)
+		return false;
+	return (int32_t)(d - micros()) > (int32_t)usNeeded;
+}
+
+void rfTsTick()
+{
+	if (!g_sdEnabled || !s_sessionOpen)
+		return;
+	// Starvation watchdog. A request that ends BLOCKED/CANCELED reports via an SOC event the Adafruit core's
+	// SOC task swallows, so a dead session would never self-revive. If no slot has STARTED for a while and we
+	// aren't inside one, fire a fresh request. Errors (one already pending) are expected and ignored.
+	if (s_ownedUntil)
+		return;
+	uint32_t now = micros();
+	if ((uint32_t)(now - s_lastGrantUs) < 20000u)
+		return;
+	unsigned long ms = millis();
+	if (ms - s_lastKickMs < 10)
+		return; // don't SVC-spam while starved
+	s_lastKickMs = ms;
+	g_tsStarved++;
+	tsBuildRequest();
+	(void)sd_radio_request(&s_req);
+}

+ 45 - 0
OpenPuck/rf_timeslot.h

@@ -0,0 +1,45 @@
+// rf_timeslot.h -- SoftDevice radio-timeslot coexistence for the bare-metal ESB link.
+//
+// With BLE off (the default) the S140 SoftDevice is never enabled and the ESB code owns NRF_RADIO outright --
+// nothing here does anything and rfRadioOwned() is always true. With BLE on (ble_host.cpp) the SoftDevice owns
+// the radio, and touching NRF_RADIO outside a granted timeslot corrupts the BLE stack (SD assert -> reboot).
+// This module keeps a radio-timeslot SESSION open and continuously re-requests/extends timeslots, so the ESB
+// poll loop keeps the radio most of the time and the SoftDevice claws it back only for BLE events (connection
+// events, scan windows).
+//
+// Ownership model: the timeslot signal handler (priority-0 interrupt) stamps an "owned until" deadline at each
+// slot start/extension, minus a safety margin. Loop-context radio code calls rfRadioOwned(worstCaseUs) at the
+// START of each self-contained TX/RX sequence (rfConnTx, rfHostFrameOnce, the rf_diag ops, the self-heal); if
+// the remaining window can't hold the whole op it simply skips this loop pass and retries when the next slot is
+// granted. Ops are never interrupted mid-sequence -- the gate + margin guarantee the radio is untouched by the
+// time the SoftDevice takes it back.
+//
+// Slot lifecycle: request EARLIEST 15ms slots; at slot start immediately chain EXTEND requests (each granted
+// extension pushes the deadline out; the SD fails the extension when a BLE event needs the radio, which is what
+// bounds us). A TIMER0 compare fires just before the true end and returns REQUEST_AND_END, so the next slot is
+// queued with no app-side gap. Requests that get BLOCKED/CANCELED by BLE activity would normally be re-armed
+// from the SoftDevice SOC event -- but the Adafruit core's SOC task swallows non-flash events, so rfTsTick()
+// (loop) watchdogs "session open but no slot granted lately" and re-requests instead.
+#pragma once
+#include <stdint.h>
+
+// Open the timeslot session + queue the first request. Call once, AFTER the SoftDevice is enabled
+// (Bluefruit.begin()). Returns false if the SoftDevice refused the session.
+bool rfTsBegin();
+
+// Close the session (firmware-update path: the SoftDevice is about to be disabled).
+void rfTsEnd();
+
+// True if a radio op of `usNeeded` microseconds may start NOW. Always true while the SoftDevice is disabled
+// (bare-metal mode). Cheap enough for per-op gating (one volatile read + micros()).
+bool rfRadioOwned(uint32_t usNeeded);
+
+// Loop-context watchdog: re-request a timeslot if the session is open but no slot has been granted lately
+// (request BLOCKED/CANCELED by BLE activity -- the grant path can't re-arm itself). Called from bleHostTask().
+void rfTsTick();
+
+// Set by ble_host when the SoftDevice comes up / goes down. rfRadioOwned() short-circuits on this.
+extern volatile bool g_sdEnabled;
+
+// diagnostics (CDC "BLE" command / panel): slots granted + extensions + blocked re-requests since boot
+extern volatile uint16_t g_tsGrants, g_tsExtends, g_tsStarved;

+ 26 - 1
OpenPuck/serial_console.cpp

@@ -2,6 +2,9 @@
 #include "radio.h"
 #include "rf_diag.h"
 #include "rf_link.h"
+#include "rf_timeslot.h" // rfRadioOwned() + timeslot stats (BLE coexistence)
+#include "ble_host.h" // bleState() for the "BLE" status dump
+#include "input_driver.h" // slotIsBle()
 #include "haptics.h"
 #include "bonds.h"
 #include "config.h"
@@ -70,6 +73,26 @@ void serialConsolePoll()
 				g_vitals = !g_vitals;
 				Serial.printf("# live vitals line %s\n",
 					      g_vitals ? "ON" : "off");
+			} else if (!strcmp(line, "BLE")) {
+				// BLE-controllers status: lifecycle state + slot sources + timeslot health.
+				// Enable/scan/pair live on the WebUSB panel; this is the debug readout.
+				Serial.printf(
+					"# BLE state=%u (0=off 1=on 2=failed 3=off-for-update) en=%u\n",
+					bleState(), g_bleEn);
+				Serial.printf(
+					"#   timeslots: grants=%u extends=%u starved-rearm=%u sd=%u\n",
+					g_tsGrants, g_tsExtends, g_tsStarved,
+					g_sdEnabled ? 1 : 0);
+				for (int s = 0; s < NSLOT; s++)
+					Serial.printf(
+						"#   slot %d: src=%s used=%d lastReply=%lums\n",
+						s, slotIsBle(s) ? "BLE" : "RF",
+						g_slot[s].used ? 1 : 0,
+						g_connReplyMs[s] ?
+							(unsigned long)(millis() -
+									g_connReplyMs
+										[s]) :
+							0);
 			} else if (line[0] == 'l')
 				rfListenStart();
 			else if (line[0] == 'B') {
@@ -125,7 +148,9 @@ void serialConsolePoll()
 				g_rfSweep = false;
 				g_rfHost = false;
 				g_rfRespond = false;
-				NRF_RADIO->TASKS_DISABLE = 1;
+				// BLE coex: only poke the radio if it's legally ours right now (rf_timeslot.h)
+				if (rfRadioOwned(100))
+					NRF_RADIO->TASKS_DISABLE = 1;
 				Serial.println("# RF off");
 			} else if (line[0] == 'u') {
 				// id9=0 hold (MODE_STEAM only) A/B toggle (persisted; see haptics.h LIZKEEP_MS)

+ 41 - 0
OpenPuck/triton.cpp

@@ -12,3 +12,44 @@ void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 	*gy = s16off(r, 40);
 	*gz = s16off(r, 42);
 }
+
+static inline void put16(uint8_t *r, int off, int v)
+{
+	r[2 + off] = (uint8_t)(v & 0xFF);
+	r[2 + off + 1] = (uint8_t)((v >> 8) & 0xFF);
+}
+
+// Inverse of the rf_link 0x45 decode: re-serialize a PuckInput as a Triton input report so non-RF input
+// sources (BLE controllers) can feed the SAME g_active->onReport45() dispatch the RF path uses -- the push
+// modes (XInput's rfXboxGamepad, lizard) parse raw report bytes, not g_in. Field offsets mirror the decode
+// exactly (rf_link.cpp / imuFrom45): buttons u32 @rep[2], triggers u16 @+4/+6 (trigU8 is >>7, so <<7 here),
+// sticks @+8..+15, trackpads @+16/+18 and +22/+24, IMU @rep[34..45]. Bytes the decode never reads stay zero.
+// Returns the body length (PUCK45_LEN, sized so the tlen>=46 IMU gate in the decode path semantics holds).
+uint8_t puckSynth45(const PuckInput *in, uint8_t seq, uint8_t out[PUCK45_LEN])
+{
+	for (int i = 0; i < PUCK45_LEN; i++)
+		out[i] = 0;
+	out[0] = 0x45;
+	out[1] = seq;
+	out[2] = (uint8_t)(in->buttons & 0xFF);
+	out[3] = (uint8_t)((in->buttons >> 8) & 0xFF);
+	out[4] = (uint8_t)((in->buttons >> 16) & 0xFF);
+	out[5] = (uint8_t)((in->buttons >> 24) & 0xFF);
+	put16(out, 4, (int)in->lt << 7);
+	put16(out, 6, (int)in->rt << 7);
+	put16(out, 8, in->lx);
+	put16(out, 10, in->ly);
+	put16(out, 12, in->rx);
+	put16(out, 14, in->ry);
+	put16(out, 16, in->lpx);
+	put16(out, 18, in->lpy);
+	put16(out, 22, in->rpx);
+	put16(out, 24, in->rpy);
+	put16(out, 32, in->ax);
+	put16(out, 34, in->ay);
+	put16(out, 36, in->az);
+	put16(out, 38, in->gx);
+	put16(out, 40, in->gy);
+	put16(out, 42, in->gz);
+	return PUCK45_LEN;
+}

+ 6 - 0
OpenPuck/triton.h

@@ -91,3 +91,9 @@ struct PuckInput {
 };
 #include "bonds.h" // NSLOT
 extern PuckInput g_in[NSLOT];
+
+// Re-serialize a PuckInput as a Triton report 0x45 (the inverse of the rf_link decode) so non-RF input sources
+// (BLE controllers -- see input_driver.h) can drive the same g_active->onReport45() dispatch as the RF path.
+// 46 bytes = through the IMU tail (rep[34..45]), matching the full-report gate in the decode.
+#define PUCK45_LEN 46
+uint8_t puckSynth45(const PuckInput *in, uint8_t seq, uint8_t out[PUCK45_LEN]);

+ 6 - 2
OpenPuck/usb_mount.cpp

@@ -1,5 +1,6 @@
 #include "usb_mount.h"
 #include "bonds.h"
+#include "input_driver.h" // slotIsBle() -- BLE-driven slots mount like RF ones
 #include "fault_diag.h" // faultDiagTrace() -- flight recorder
 #include <Arduino.h>
 
@@ -27,8 +28,10 @@ static uint8_t connectedMask()
 {
 	unsigned long now = millis();
 	uint8_t m = 0;
+	// a slot is mount-worthy if something drives it: an RF bond (g_slot.used) or a live BLE claim --
+	// both stamp g_connReplyMs, so the same freshness window covers either source
 	for (int s = 0; s < NSLOT; s++)
-		if (g_slot[s].used && g_connReplyMs[s] != 0 &&
+		if ((g_slot[s].used || slotIsBle(s)) && g_connReplyMs[s] != 0 &&
 		    (now - g_connReplyMs[s]) < CONN_UP_MS)
 			m |= (uint8_t)(1u << s);
 	return m;
@@ -127,7 +130,8 @@ void usbMountTask()
 	// New controllers grow the set right away (after the usual debounce) regardless of idle state.
 	unsigned long lastAny = 0;
 	for (int s = 0; s < NSLOT; s++)
-		if (g_slot[s].used && g_connReplyMs[s] > lastAny)
+		if ((g_slot[s].used || slotIsBle(s)) &&
+		    g_connReplyMs[s] > lastAny)
 			lastAny = g_connReplyMs[s];
 	bool idle = (lastAny == 0) || (now - lastAny) >= IDLE_CLEANUP_MS;
 	uint8_t want = idle ? connectedMask() :

+ 66 - 9
OpenPuck/webusb_config.cpp

@@ -10,6 +10,7 @@
 #include "fault_diag.h"
 #include "fw_update.h"
 #include "usb_tx.h"
+#include "ble_host.h" // BLE controllers: ops 0x16..0x19, 0xAC status frame, blob state byte
 #include <Arduino.h>
 #include <string.h>
 
@@ -31,6 +32,8 @@ static volatile bool g_bondExportRequest = false;
 static volatile bool g_fwupAckPend = false;
 static volatile uint8_t g_fwupAckStatus = 0;
 static volatile uint32_t g_fwupAckOff = 0;
+// BLE-controllers status frame (0xAC), same deferred-send shape as the blob: 0x19 sets it, the SOF drain sends.
+static volatile bool g_bleFrameRequest = false;
 static void fwupAckPost(uint8_t status)
 {
 	g_fwupAckStatus = status;
@@ -54,7 +57,8 @@ static void fwupAckPost(uint8_t status)
 //                [v13: per-slot link stats, 4x9B from p[141]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
 //                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
 //                [v14: p[177] landAll87 (verbatim-0x87-relay experiment toggle)]
-#define WB_PAYLEN 176
+//                [v16: p[178] BLE state (BLE_ST_*; panel shows the BLE-controllers card when ver >= 16)]
+#define WB_PAYLEN 177
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -79,10 +83,11 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (15 = +staged firmware-update ops 0x20..0x24, payload unchanged; 14 = +landAll87
-	// toggle; 13 = +per-slot link stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 =
-	// +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 15;
+	// protocol version (16 = +BLE controllers (state byte p[178], ops 0x16..0x19, frame 0xAC); 15 = +staged
+	// firmware-update ops 0x20..0x24, payload unchanged; 14 = +landAll87 toggle; 13 = +per-slot link stats;
+	// 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type cfg;
+	// 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 16;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -250,6 +255,7 @@ static void webusbSendBlob()
 	}
 	// v14: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
 	p[177] = g_landAll87;
+	p[178] = bleState(); // v16: BLE controllers lifecycle (BLE_ST_*)
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -433,6 +439,18 @@ static void webusbSofDrain(void)
 		g_bondExportRequest = false;
 		webusbSendBondExport();
 	}
+	if (g_bleFrameRequest) {
+		// BLE-controllers status frame (0xAC): bonded list + live scan table. Built into a static
+		// buffer (off the 800B usbd stack) and drop-on-full like the blob -- a panel poll re-asks.
+		static uint8_t bf[2 + 250];
+		uint8_t n = bleStatusFrame(bf, sizeof bf);
+		if (n && tud_vendor_write_available() >= n) {
+			usb_web.write(bf, n);
+			usb_web.flush();
+			g_bleFrameRequest = false;
+		} else if (!n)
+			g_bleFrameRequest = false;
+	}
 }
 // ---- live wedge reporter (0xA9) --------------------------------------------------------------------------
 // THE one channel that survives a loop() wedge on boards that wipe .noinit/GPREGRET across the watchdog reset
@@ -585,7 +603,7 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if ((op < 0x01 || op > 0x15) &&
+			if ((op < 0x01 || op > 0x19) &&
 			    (op < 0x20 || op > 0x24)) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
@@ -603,15 +621,19 @@ void webusbPoll()
 			// binding (18 B); 0x05/0x0E/0x0F/0x10/0x13 carry one value byte; 0x02 a field+value; 0x0A a
 			// 3-byte magic. Firmware update: 0x20 begin [size u32][crc32 u32], 0x21 data (6 B header +
 			// payload), 0x22/0x23/0x24 bare. (0x11/0x14/0x15 are bare lizard opcodes -> default 1.)
+			// BLE block: 0x16 control [sub], 0x17 pair / 0x18 forget [addrType][addr6], 0x19 = bare
+			// "send the 0xAC status frame".
 			uint8_t need =
 				(op == 0x0D) ? 27 :
 				(op == 0x12) ? 18 :
 				(op == 0x02) ? 3 :
 				(op == 0x03 || op == 0x05 || op == 0x0E ||
-				 op == 0x0F || op == 0x10 || op == 0x13) ?
+				 op == 0x0F || op == 0x10 || op == 0x13 ||
+				 op == 0x16) ?
 					       2 :
-				(op == 0x0A) ? 4 :
-				(op == 0x20) ? 9 :
+				(op == 0x0A)		   ? 4 :
+				(op == 0x17 || op == 0x18) ? 8 :
+				(op == 0x20)		   ? 9 :
 				(op == 0x21) ? (uint8_t)(6 + (n >= 6 ? buf[5] :
 								       0)) :
 					       1;
@@ -641,6 +663,41 @@ void webusbPoll()
 			else if (op == 0x10) {
 				webusbDrainFlight(buf[1] != 0);
 			}
+			// ---- BLE controllers (ble_host.h) ----
+			// 0x16 control: 0 = disable (persist + reboot for a clean SoftDevice teardown), 1 = enable
+			// (persist + start live -- no reboot needed), 2 = UI scan start, 3 = UI scan stop.
+			else if (op == 0x16) {
+				if (buf[1] == 0) {
+					g_bleEn = 0;
+					saveCfg();
+					usb_web.flush();
+					delay(40);
+					faultDiagArmIntentionalReset();
+					NVIC_SystemReset();
+				} else if (buf[1] == 1) {
+					g_bleEn = 1;
+					saveCfg();
+					bleHostBegin();
+				} else if (buf[1] == 2) {
+					bleScanUi(true);
+				} else if (buf[1] == 3) {
+					bleScanUi(false);
+				}
+				g_bleFrameRequest =
+					true; // ack with fresh state
+			}
+			// 0x17 pair to a scanned device / 0x18 forget a bond: [op][addrType][addr6]
+			else if (op == 0x17) {
+				blePairTo(buf[1], buf + 2);
+				g_bleFrameRequest = true;
+			} else if (op == 0x18) {
+				bleForget(buf[1], buf + 2);
+				g_bleFrameRequest = true;
+			}
+			// 0x19: send the BLE status frame (0xAC) -- the panel polls this while its BLE card is open
+			else if (op == 0x19) {
+				g_bleFrameRequest = true;
+			}
 #if OPK_LOG
 
 			// rewind drain: buf[1]=1 from boot (whole ring), 0 from now

+ 91 - 3
docs/index.html

@@ -249,6 +249,19 @@
       <p class="note">Import <strong>overwrites</strong> all pairings and settings on the connected puck, then reboots it (~2 s — reconnect after). Tip: pair every controller to one puck, export once, then import onto each spare.</p>
     </div>
 
+    <div class="card wide" id="bleCard" style="display:none">
+      <h2>Bluetooth controllers <span class="v" id="bleStatePill" style="font-weight:400">—</span></h2>
+      <p class="note" style="margin-top:0">Pair Bluetooth LE gamepads directly to the puck — they show up alongside the Steam Controller in the emulated modes (Xbox / Switch / PS5 / DS4). Works with pads speaking the Xbox “xinput” BLE layout: <strong>Xbox Series X|S</strong>, <strong>Xbox One S</strong> (firmware 5.x+), Xbox Adaptive. <em>Note:</em> the 8BitDo SN30 Pro’s X-input mode uses the same report layout but only over Bluetooth <em>Classic</em>, which this radio can’t speak — it will not appear in a scan.</p>
+      <div class="row" style="margin:0 0 8px;flex-wrap:wrap;gap:8px">
+        <button id="bleEnable" class="primary" style="display:none">Enable Bluetooth</button>
+        <button id="bleDisable" style="display:none">Disable (reboots)</button>
+        <button id="bleScanBtn" style="display:none">Scan for controllers</button>
+        <span class="note" id="bleHint" style="flex:1"></span>
+      </div>
+      <div id="bleBonds"></div>
+      <div id="bleScanList"></div>
+    </div>
+
     <div class="card wide debugonly">
       <h2>Hang log</h2>
       <p class="note" style="margin-top:0">One row per reset: uptime before it, reason, the stuck loop stage and captured PC (when the watchdog ISR could run), and usbd stack free. Accumulates across resets in this tab — a page refresh clears it. Pair with <em>Test stability</em> above for unattended runs.</p>
@@ -683,10 +696,11 @@ async function send(bytes){
 }
 // Read one framed reply and return its payload (with the 2-byte [marker][len] header stripped). The blob and
 // the bond-export dump share the same framing, distinguished by the marker byte (0xA5 status, 0xA7 bonds).
-async function readFrame(marker, minLen){
+async function readFrame(marker, minLen, rdLen){
   try{
     // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
-    const r = await dev.transferIn(epIn, 192);
+    // (the BLE status frame 0xAC can run to ~236 B — its caller passes rdLen=256.)
+    const r = await dev.transferIn(epIn, rdLen||192);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
     // Live wedge reporter (0xA9): emitted from the firmware's SOF callback (usbd task) while loop() is stalled --
@@ -731,6 +745,8 @@ function applyBlob(p){
   const swProRate=(p.length>52?p[52]:2);
   const swGyro10=(p.length>53?p[53]:10);
   const rssi=(p.length>37?p[37]:0);
+  // BLE controllers state byte (protocol v16, appended at p[176]); null hides the card on older firmware
+  updateBleCard((p[0]>=16 && p.length>176) ? p[176] : null);
 
   // per-slot link status (protocol v8, p.length >= 73)
   const hasSlotsData = p.length >= 73;
@@ -947,11 +963,73 @@ async function refresh(){
   if(!dev||inflight||capturing||backupBusy||flightBusy||lizardBusy) return; inflight=true;
   // try/finally: an exception anywhere in here (applyBlob included) must never leave inflight latched true --
   // that would silently kill all future polls and read as a fake "heartbeat lost".
-  try{ await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
+  try{
+    await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
+    // BLE controllers card (blob v16+): while BLE is ON, chase the blob with a 0xAC status-frame poll so the
+    // bonded/scan lists stay live. Same inflight window as the blob (shared endpoints).
+    if(bleSt===1){ await send([0x19]); const b=await readFrame(0xAC, 4, 256); if(b) applyBleFrame(b); }
+  }
   catch(e){ log("refresh err: "+e.message); }
   finally{ inflight=false; }
 }
 
+// ---- Bluetooth (BLE) controllers ----
+// Firmware v16+: blob byte p[176] = BLE lifecycle state (0 off / 1 on / 2 failed / 3 off-for-update); commands
+// 0x16 (control), 0x17 (pair [addrType][addr6]), 0x18 (forget), 0x19 (request the 0xAC status frame).
+let bleSt=null;        // last blob state byte; null = firmware without BLE support (card hidden)
+let bleInfo=null;      // last parsed 0xAC frame {scanning, bonds[], scan[]}
+function bleAddrStr(at,a){ return [...a].reverse().map(x=>x.toString(16).padStart(2,"0")).join(":").toUpperCase(); }
+function bleNameOf(nb){ let s=""; for(const c of nb){ if(!c) break; s+=String.fromCharCode(c); } return s; }
+function applyBleFrame(p){
+  // [ver][state][uiScan][nScan] + 4×26B bond recs + nScan×21B scan recs
+  if(p[0]!==1) return;
+  const bonds=[], scan=[]; let o=4;
+  for(let b=0;b<4;b++,o+=26){
+    if(o+26>p.length) break;
+    const used=!!(p[o]&1);
+    if(used) bonds.push({ connected:!!(p[o]&2), slot:p[o+1], at:p[o+2], addr:p.slice(o+3,o+9),
+      vid:p[o+9]|(p[o+10]<<8), pid:p[o+11]|(p[o+12]<<8), batt:p[o+13], name:bleNameOf(p.slice(o+14,o+26)) });
+  }
+  for(let s=0;s<p[3] && o+21<=p.length;s++,o+=21)
+    scan.push({ at:p[o], addr:p.slice(o+1,o+7), rssi:(p[o+7]<<24>>24), name:bleNameOf(p.slice(o+9,o+21)) });
+  bleInfo={scanning:!!p[2], bonds, scan};
+  renderBle();
+}
+function updateBleCard(st){
+  const was=bleSt; bleSt=st;
+  const card=$("#bleCard"); if(!card) return;
+  card.style.display = (st===null) ? "none" : "";
+  if(st===null) return;
+  const PILL={0:"off",1:"on",2:"unavailable (SoftDevice failed — clone clock?)",3:"off until reboot (firmware update)"};
+  $("#bleStatePill").textContent="— "+(PILL[st]||st);
+  $("#bleEnable").style.display = (st===0)?"":"none";
+  $("#bleDisable").style.display = (st===1)?"":"none";
+  $("#bleScanBtn").style.display = (st===1)?"":"none";
+  if(st!==1 && was===1){ bleInfo=null; renderBle(); }
+  if(st===0) $("#bleHint").textContent="Off — the RF link runs bare-metal (exactly the pre-BLE firmware behavior).";
+  else if(st===1) $("#bleHint").textContent="";
+  else if(st===3) $("#bleHint").textContent="Disabled for the running firmware update; back after the reboot.";
+}
+function renderBle(){
+  const esc=s=>s.replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
+  const bonds=$("#bleBonds"), scan=$("#bleScanList");
+  if(!bleInfo){ bonds.innerHTML=""; scan.innerHTML=""; return; }
+  bonds.innerHTML = bleInfo.bonds.length ? bleInfo.bonds.map(b=>{
+    const st=b.connected?('<span class="pill up">connected'+(b.batt?(" · "+b.batt+"%"):"")+(b.slot!==255?(" · slot "+(b.slot+1)):"")+"</span>")
+                        :'<span class="pill dn">paired, offline</span>';
+    return '<div class="row" style="margin:4px 0"><span class="v" style="flex:1">'+esc(b.name||"(unnamed)")+' <span class="note">'+bleAddrStr(b.at,b.addr)+
+      ' · '+b.vid.toString(16).padStart(4,"0")+':'+b.pid.toString(16).padStart(4,"0")+'</span> '+st+
+      '</span><button data-bleforget="'+b.at+","+[...b.addr].join(",")+'">Forget</button></div>';
+  }).join("") : '<p class="note">No Bluetooth controllers paired yet.</p>';
+  const rows = bleInfo.scan.map(s=>
+    '<div class="row" style="margin:4px 0"><span class="v" style="flex:1">'+esc(s.name||"(unnamed)")+
+    ' <span class="note">'+bleAddrStr(s.at,s.addr)+' · '+s.rssi+' dBm</span></span>'+
+    '<button class="primary" data-blepair="'+s.at+","+[...s.addr].join(",")+'">Pair</button></div>').join("");
+  scan.innerHTML = bleInfo.scanning
+    ? '<p class="note" style="margin:10px 0 4px">Scanning… put the controller in pairing mode (Xbox: hold the small pair button until the logo flashes fast).</p>'+(rows||'<p class="note">nothing found yet…</p>')
+    : (rows?'<p class="note" style="margin:10px 0 4px">Last scan results:</p>'+rows:"");
+}
+
 // ---- Backup / clone ----
 // A backup file is { magic, version, bonds[4], config{} }. bonds come from the 0x09 export (0xA7 frame); the
 // config fields come from the status blob the panel already reads. Restoring replays every setting via the
@@ -1793,6 +1871,16 @@ for(const t of document.querySelectorAll("#mainTabs .slot-tab")){
     if(t.dataset.tab==="tabUpdate") loadReleases(false); // lazy: first visit fetches the list
   };
 }
+$("#bleEnable").onclick=async()=>{ if(dev){ await send([0x16,1]); log("Bluetooth enabled — starting the BLE stack (no reboot needed)"); } };
+$("#bleDisable").onclick=async()=>{ if(!dev) return;
+  if(!confirm("Disable Bluetooth controllers?\n\nThe puck reboots (~2 s) to tear the BLE stack down cleanly — reconnect after. Paired controllers stay remembered for the next enable.")) return;
+  await send([0x16,0]); log("Bluetooth disabled — puck rebooting"); };
+$("#bleScanBtn").onclick=async()=>{ if(dev){ await send([0x16,2]); log("BLE scan started (auto-stops in 60 s) — put the controller in pairing mode"); } };
+$("#bleScanList").onclick=async(e)=>{ const d=e.target&&e.target.dataset&&e.target.dataset.blepair; if(!d||!dev) return;
+  await send([0x17, ...d.split(",").map(Number)]); log("pairing… (the pad's light goes solid when bonded)"); };
+$("#bleBonds").onclick=async(e)=>{ const d=e.target&&e.target.dataset&&e.target.dataset.bleforget; if(!d||!dev) return;
+  if(!confirm("Forget this Bluetooth controller?\n\nIt will stop auto-reconnecting; re-pair it from a scan to use it again.")) return;
+  await send([0x18, ...d.split(",").map(Number)]); log("Bluetooth bond forgotten"); };
 $("#backupExport").onclick=exportBackup;
 $("#backupImport").onclick=()=>$("#backupFile").click();
 $("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await importBackup(f); e.target.value=""; };

+ 111 - 0
variant_lfrc/variant.cpp

@@ -0,0 +1,111 @@
+/*
+  Copyright (c) 2014-2015 Arduino LLC.  All right reserved.
+  Copyright (c) 2016 Sandeep Mistry All right reserved.
+  Copyright (c) 2018, Adafruit Industries (adafruit.com)
+
+  This library is free software; you can redistribute it and/or
+  modify it under the terms of the GNU Lesser General Public
+  License as published by the Free Software Foundation; either
+  version 2.1 of the License, or (at your option) any later version.
+
+  This library is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+  See the GNU Lesser General Public License for more details.
+
+  You should have received a copy of the GNU Lesser General Public
+  License along with this library; if not, write to the Free Software
+  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+#include "variant.h"
+#include "wiring_constants.h"
+#include "wiring_digital.h"
+#include "nrf.h"
+
+const uint32_t g_ADigitalPinMap[] =
+{
+  // D0 .. D13
+  25,  // D0  is P0.25 (UART TX)
+  24,  // D1  is P0.24 (UART RX 
+  10,  // D2  is P0.10 (NFC2)
+  47,  // D3  is P1.15 (LED1)
+  42,  // D4  is P1.10 (LED2)
+  40,  // D5  is P1.08
+   7,  // D6  is P0.07
+  34,  // D7  is P1.02 (Button)
+  16,  // D8  is P0.16 (NeoPixel)
+  26,  // D9  is P0.26
+  27,  // D10 is P0.27
+   6,  // D11 is P0.06
+   8,  // D12 is P0.08
+  41,  // D13 is P1.09
+
+  // D14 .. D21 (aka A0 .. A7)
+   4,  // D14 is P0.04 (A0)
+   5,  // D15 is P0.05 (A1)
+  30,  // D16 is P0.30 (A2)
+  28,  // D17 is P0.28 (A3)
+   2,  // D18 is P0.02 (A4)
+   3,  // D19 is P0.03 (A5)
+  29,  // D20 is P0.29 (A6, Battery)
+  31,  // D21 is P0.31 (A7, ARef)
+
+  // D22 .. D23 (aka I2C pins)
+  12,  // D22 is P0.12 (SDA)
+  11,  // D23 is P0.11 (SCL)
+
+  // D24 .. D26 (aka SPI pins)
+  15,  // D24 is P0.15 (SPI MISO)
+  13,  // D25 is P0.13 (SPI MOSI)
+  14,  // D26 is P0.14 (SPI SCK )
+
+  // QSPI pins (not exposed via any header / test point)
+  19,  // D27 is P0.19 (QSPI CLK)
+  20,  // D28 is P0.20 (QSPI CS)
+  17,  // D29 is P0.17 (QSPI Data 0)
+  22,  // D30 is P0.22 (QSPI Data 1)
+  23,  // D31 is P0.23 (QSPI Data 2)
+  21,  // D32 is P0.21 (QSPI Data 3)
+
+  // The remaining NFC pin
+   9,  // D33 is P0.09 (NFC1, exposed only via test point on bottom of board)
+
+  32+14, // D34 is P1.14
+
+  // Thus, there are 35 defined pins
+
+  // The remaining pins are not usable:
+  //
+  //
+  // The following pins were never listed as they were considered unusable
+  //  0,      // P0.00 is XL1   (attached to 32.768kHz crystal)
+  //  1,      // P0.01 is XL2   (attached to 32.768kHz crystal)
+  // 18,      // P0.18 is RESET (attached to switch)
+  // 32,      // P1.00 is SWO   (attached to debug header)
+  // 
+  // The remaining pins are not connected (per schematic)
+  // 33,      // P1.01 is not connected per schematic
+  // 35,      // P1.03 is not connected per schematic
+  // 36,      // P1.04 is not connected per schematic
+  // 37,      // P1.05 is not connected per schematic
+  // 38,      // P1.06 is not connected per schematic
+  // 39,      // P1.07 is not connected per schematic
+  // 43,      // P1.11 is not connected per schematic
+  // 44,      // P1.12 is not connected per schematic
+  // 45,      // P1.13 is not connected per schematic
+};
+
+void initVariant()
+{
+  // LED1 & LED2
+  pinMode(PIN_LED1, OUTPUT);
+  ledOff(PIN_LED1);
+
+  pinMode(PIN_LED2, OUTPUT);
+  ledOff(PIN_LED2);
+
+  pinMode(PIN_NEOPIXEL_POWER, OUTPUT);
+  digitalWrite(PIN_NEOPIXEL_POWER, HIGH);
+}
+

+ 148 - 0
variant_lfrc/variant.h

@@ -0,0 +1,148 @@
+ /*
+  Copyright (c) 2014-2015 Arduino LLC.  All right reserved.
+  Copyright (c) 2016 Sandeep Mistry All right reserved.
+  Copyright (c) 2018, Adafruit Industries (adafruit.com)
+
+  This library is free software; you can redistribute it and/or
+  modify it under the terms of the GNU Lesser General Public
+  License as published by the Free Software Foundation; either
+  version 2.1 of the License, or (at your option) any later version.
+  This library is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+  See the GNU Lesser General Public License for more details.
+  You should have received a copy of the GNU Lesser General Public
+  License along with this library; if not, write to the Free Software
+  Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+*/
+
+#ifndef _VARIANT_FEATHER52840_
+#define _VARIANT_FEATHER52840_
+
+/** Master clock frequency */
+#define VARIANT_MCK       (64000000ul)
+
+// OpenPuck: verbatim copy of the core's feather_nrf52840_express variant with ONLY the LF clock
+// source flipped to the internal RC oscillator. The Pro Micro / SuperMini nRF52840 CLONES this firmware
+// targets have NO 32.768 kHz crystal, so the SoftDevice's LFXO config makes Bluefruit.begin() fail. RC works
+// on every board (incl. genuine Feathers); 250 ppm is well within BLE tolerance. Selected via build.variant.path
+// in the Makefile so the stock feather52840 pin map / USB identity are otherwise unchanged.
+// #define USE_LFXO   -- disabled: no crystal on the clone boards
+#define USE_LFRC
+
+/*----------------------------------------------------------------------------
+ *        Headers
+ *----------------------------------------------------------------------------*/
+
+#include "WVariant.h"
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif // __cplusplus
+
+// Number of pins defined in PinDescription array
+#define PINS_COUNT           (35)
+#define NUM_DIGITAL_PINS     (35)
+#define NUM_ANALOG_INPUTS    (6) // A6 is used for battery, A7 is analog reference
+#define NUM_ANALOG_OUTPUTS   (0)
+
+// LEDs
+#define PIN_LED1             (3)
+#define PIN_LED2             (4)
+#define PIN_NEOPIXEL_POWER   (34)
+#define PIN_NEOPIXEL         (8)
+#define NEOPIXEL_NUM         1
+
+#define LED_BUILTIN          PIN_LED1
+#define LED_CONN             PIN_LED2
+
+#define LED_RED              PIN_LED1
+#define LED_BLUE             PIN_LED2
+
+#define LED_STATE_ON         1         // State when LED is litted
+
+/*
+ * Buttons
+ */
+#define PIN_BUTTON1          (7)
+
+/*
+ * Analog pins
+ */
+#define PIN_A0               (14)
+#define PIN_A1               (15)
+#define PIN_A2               (16)
+#define PIN_A3               (17)
+#define PIN_A4               (18)
+#define PIN_A5               (19)
+#define PIN_A6               (20)
+#define PIN_A7               (21)
+
+static const uint8_t A0  = PIN_A0 ;
+static const uint8_t A1  = PIN_A1 ;
+static const uint8_t A2  = PIN_A2 ;
+static const uint8_t A3  = PIN_A3 ;
+static const uint8_t A4  = PIN_A4 ;
+static const uint8_t A5  = PIN_A5 ;
+static const uint8_t A6  = PIN_A6 ;
+static const uint8_t A7  = PIN_A7 ;
+#define ADC_RESOLUTION    14
+
+// Other pins
+#define PIN_AREF           PIN_A7
+#define PIN_VBAT           PIN_A6
+#define PIN_NFC1           (33)
+#define PIN_NFC2           (2)
+
+static const uint8_t AREF = PIN_AREF;
+
+/*
+ * Serial interfaces
+ */
+#define PIN_SERIAL1_RX       (1)
+#define PIN_SERIAL1_TX       (0)
+
+/*
+ * SPI Interfaces
+ */
+#define SPI_INTERFACES_COUNT 1
+
+#define PIN_SPI_MISO         (24)
+#define PIN_SPI_MOSI         (25)
+#define PIN_SPI_SCK          (26)
+
+static const uint8_t SS   = (5);
+static const uint8_t MOSI = PIN_SPI_MOSI ;
+static const uint8_t MISO = PIN_SPI_MISO ;
+static const uint8_t SCK  = PIN_SPI_SCK ;
+
+/*
+ * Wire Interfaces
+ */
+#define WIRE_INTERFACES_COUNT 1
+
+#define PIN_WIRE_SDA         (22)
+#define PIN_WIRE_SCL         (23)
+
+// QSPI Pins
+#define PIN_QSPI_SCK         27
+#define PIN_QSPI_CS          28
+#define PIN_QSPI_IO0         29
+#define PIN_QSPI_IO1         30
+#define PIN_QSPI_IO2         31
+#define PIN_QSPI_IO3         32
+
+// On-board QSPI Flash
+#define EXTERNAL_FLASH_DEVICES   GD25Q16C
+#define EXTERNAL_FLASH_USE_QSPI
+
+#ifdef __cplusplus
+}
+#endif
+
+/*----------------------------------------------------------------------------
+ *        Arduino objects - C++ only
+ *----------------------------------------------------------------------------*/
+
+#endif