Jari 3 місяців тому
батько
коміт
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). |
 | `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_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_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. |
 | `webusb_config.{h,cpp}` | The WebUSB binary config channel for the browser panel. |
 | `serial_console.{h,cpp}` | The CDC single-letter debug command line. |
 | `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"). |
 | `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).
   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).
 - **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
 ## Timing & concurrency model
 
 
 - One cooperative `loop()`. Each subsystem's per-loop hook (`g_active->task()`, `rfLinkTask()`, `hapticTask()`,
 - 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
 - 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.
   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
 - 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
 ## 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
 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.
 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
 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
 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
 > This map describes what the **code actually does**. Source comments in this repo
 > are known to be misleading and were ignored.
 > 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 |
 | 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.** |
 | **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. |
 | **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]`).
 1. Feeds the watchdog (`NRF_WDT->RR[0]`).
 2. If `g_dirty`, `saveBonds()` (flash write in loop context).
 2. If `g_dirty`, `saveBonds()` (flash write in loop context).
 3. Calls, in order: `webusbPoll`, `g_active->task`, `serialConsolePoll`, `rfDiagTask`,
 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.
    `#if OPK_LOG` wraps each in `micros()` timing for the WebUSB panel.
 
 
 ### State owned
 ### 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.
 `rfBeaconOnce`. `rfReplayOnce` runs every loop with no rate gate while armed.
 Notable: `rfCapPoll` copies 48 bytes into `g_replay` but sets `g_replayLen=32`.
 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`
 ## 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_swProReportMode[NSLOT]`** | usbd `jcSubcmd` + loop `mountSlots` | loop `task` | **not volatile** |
 | **`g_steamAliveMs` / `g_resumeMs`** (puck_hid) | usbd `handleSet`/`handleGet` + loop `task` | both | none |
 | **`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` |
 | **`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**
 **`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
 (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.
 # {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)
 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`.
 # `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
 # (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
 # `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 /
 ## 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.
 ## EXTRA_FLAGS / FQBN as make variables if needed.
 build:
 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.
 ## One-time factory-reset recovery image (wipes persistent storage once on first boot). See §6 of the build doc.
 build-recovery:
 build-recovery:

+ 12 - 0
OpenPuck/OpenPuck.ino

@@ -41,6 +41,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "fault_diag.h"
 #include "fault_diag.h"
 #include "fw_update.h"
 #include "fw_update.h"
 #include "usb_tx.h"
 #include "usb_tx.h"
+#include "ble_host.h"
 #include <stdio.h>
 #include <stdio.h>
 
 
 #if CFG_TUD_HID < 4
 #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
 	// 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.
 	// to a reserved flash page -- the post-mortem channel that survives even the boards that wipe .noinit.
 	faultDiagBlackBoxArm();
 	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
 // 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);
 	acc[6] += (uint32_t)(micros() - t);
 	faultDiagSetStage(7);
 	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	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);
 	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
 	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)
 	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
@@ -397,6 +408,7 @@ void loop()
 	ledTask();
 	ledTask();
 	faultDiagSetStage(7);
 	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	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);
 	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
 	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)
 	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
 // rumble strength % (200 = double); adjustable from the WebUSB panel
 uint8_t g_rumbleScale = 200;
 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
 // 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).
 // persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 const uint32_t g_pollUs = POLL_US_DEFAULT;
@@ -76,8 +79,9 @@ const uint32_t g_pollUs = POLL_US_DEFAULT;
 struct Cfg {
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;
 		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;
 	uint8_t rxWin10, lizKeep, landAll87;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
@@ -94,7 +98,7 @@ void saveCfg()
 		  g_bootMode,
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
 		  g_rumbleScale,
 		  g_rumbleScale,
-		  (uint8_t)(g_rxWin / 10),
+		  (uint8_t)(g_bleEn ? 1 : 0),
 		  g_lizKeep,
 		  g_lizKeep,
 		  g_landAll87,
 		  g_landAll87,
 		  {} };
 		  {} };
@@ -158,7 +162,9 @@ void loadCfg()
 			// verbatim-0x87-relay experiment toggle (0/1; default off)
 			// verbatim-0x87-relay experiment toggle (0/1; default off)
 			if (c.landAll87 <= 1)
 			if (c.landAll87 <= 1)
 				g_landAll87 = c.landAll87;
 				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();
 		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
 // persist g_swProRate + g_swGyroScale10 to their flash file
 void swProSaveCfg();
 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)
 // 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
 #define POLL_US_DEFAULT 4000u
 #define POLL_US_DEFAULT 4000u
 // host-side HID stream cadence for translated modes (~250 Hz)
 // 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.
 // corruption-safety invariant every step here preserves.
 #include "fw_update.h"
 #include "fw_update.h"
 #include "fault_diag.h"
 #include "fault_diag.h"
+#include "ble_host.h" // bleShutdownForUpdate() -- direct NVMC is illegal under an enabled SoftDevice
 #include <Arduino.h>
 #include <Arduino.h>
 #include <string.h>
 #include <string.h>
 
 
@@ -125,6 +126,11 @@ uint32_t fwupNextOff(void)
 uint8_t fwupBegin(uint32_t size, uint32_t crc32)
 uint8_t fwupBegin(uint32_t size, uint32_t crc32)
 {
 {
 	s_active = false;
 	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
 	// 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).
 	// commit behind (the staged bytes it points at are about to be overwritten).
 	nvmcErasePage(FWUP_META);
 	nvmcErasePage(FWUP_META);

+ 14 - 0
OpenPuck/haptics.cpp

@@ -1,6 +1,8 @@
 #include "haptics.h"
 #include "haptics.h"
 #include "bonds.h"
 #include "bonds.h"
 #include "config.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 "rf_link.h"
 #include "usb_tx.h" // usbTxBoost/Unboost -- flood-rate CDC prints share the dcd DMA claim window
 #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
 #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 s0 = (slot == 0xFF) ? 0 : slot;
 	uint8_t s1 = (slot == 0xFF) ? NSLOT : slot + 1;
 	uint8_t s1 = (slot == 0xFF) ? NSLOT : slot + 1;
 	for (uint8_t s = s0; s < s1; s++) {
 	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);
 		uint8_t h = g_rqHead[s], nx = rqNext(h);
 		if (nx == g_rqTail[s])
 		if (nx == g_rqTail[s])
 			g_rqTail[s] = rqNext(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;
 		lowFreq = (l > 0xFFFF) ? 0xFFFF : (uint16_t)l;
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 		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;
 	bool on = lowFreq || highFreq;
 	// Per-slot settle gate (the per-slot reconnect block + link-up check). 0x82 haptics in Steam mode use the
 	// 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
 	// 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)
 	if (slot < 0 || slot >= NSLOT)
 		return;
 		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
 	// post-connect haptic block is permanently disabled -- relay haptics immediately on (re)connect
 	g_hapticBlockUntil[slot] = 0;
 	g_hapticBlockUntil[slot] = 0;
 	g_haptic82On = false;
 	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 "rf_diag.h"
 #include "radio.h"
 #include "radio.h"
+#include "rf_timeslot.h" // rfRadioOwned() -- BLE (SoftDevice) radio arbitration
 #include "bonds.h"
 #include "bonds.h"
 #include <Arduino.h>
 #include <Arduino.h>
 #include <string.h>
 #include <string.h>
@@ -491,6 +492,11 @@ static void rfSniffPoll()
 
 
 void rfDiagTask()
 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();
 	rfPoll();
 	rfRespondPoll();
 	rfRespondPoll();
 	rfCapPoll();
 	rfCapPoll();

+ 16 - 0
OpenPuck/rf_link.cpp

@@ -1,5 +1,6 @@
 #include "rf_link.h"
 #include "rf_link.h"
 #include "radio.h"
 #include "radio.h"
+#include "rf_timeslot.h" // rfRadioOwned() -- BLE (SoftDevice) radio arbitration
 #include "bonds.h"
 #include "bonds.h"
 #include "config.h"
 #include "config.h"
 #include "triton.h"
 #include "triton.h"
@@ -161,6 +162,9 @@ static void rfHostFrameOnce(int slot, bool discovery)
 {
 {
 	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
 	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
 		return;
 		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]
 	// [proteus_uuid 4][ibex_uuid 4][serial 16]
 	uint8_t *rec = g_slot[slot].rec;
 	uint8_t *rec = g_slot[slot].rec;
 	// CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)]. payload:
 	// 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
 	// relays pass a tiny window (no reply expected); polls use g_rxWin
 	uint16_t win = rxWinUs ? rxWinUs : 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);
 	memset(rftx, 0, sizeof rftx);
 	rftx[0] = plen; // LENGTH = payload byte count
 	rftx[0] = plen; // LENGTH = payload byte count
 	rftx[1] = s1; // S1 (type-specific)
 	rftx[1] = s1; // S1 (type-specific)
@@ -907,6 +914,13 @@ static void rfConnStep()
 
 
 void rfLinkTask()
 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
 	// 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
 	// 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
 	// 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_RECOVER_MS :
 					    RF_STALL_BACKOFF_MS;
 					    RF_STALL_BACKOFF_MS;
 		if (anyEverConnected && allStalled &&
 		if (anyEverConnected && allStalled &&
+		    rfRadioOwned(
+			    500) && // BLE coex: peripheral reset only while the radio is ours
 		    (uint32_t)(nowMs2 - lastRecoverMs) > interval) {
 		    (uint32_t)(nowMs2 - lastRecoverMs) > interval) {
 			lastRecoverMs = nowMs2;
 			lastRecoverMs = nowMs2;
 			g_rfStallRecover++;
 			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 "radio.h"
 #include "rf_diag.h"
 #include "rf_diag.h"
 #include "rf_link.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 "haptics.h"
 #include "bonds.h"
 #include "bonds.h"
 #include "config.h"
 #include "config.h"
@@ -70,6 +73,26 @@ void serialConsolePoll()
 				g_vitals = !g_vitals;
 				g_vitals = !g_vitals;
 				Serial.printf("# live vitals line %s\n",
 				Serial.printf("# live vitals line %s\n",
 					      g_vitals ? "ON" : "off");
 					      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')
 			} else if (line[0] == 'l')
 				rfListenStart();
 				rfListenStart();
 			else if (line[0] == 'B') {
 			else if (line[0] == 'B') {
@@ -125,7 +148,9 @@ void serialConsolePoll()
 				g_rfSweep = false;
 				g_rfSweep = false;
 				g_rfHost = false;
 				g_rfHost = false;
 				g_rfRespond = 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");
 				Serial.println("# RF off");
 			} else if (line[0] == 'u') {
 			} else if (line[0] == 'u') {
 				// id9=0 hold (MODE_STEAM only) A/B toggle (persisted; see haptics.h LIZKEEP_MS)
 				// 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);
 	*gy = s16off(r, 40);
 	*gz = s16off(r, 42);
 	*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
 #include "bonds.h" // NSLOT
 extern PuckInput g_in[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 "usb_mount.h"
 #include "bonds.h"
 #include "bonds.h"
+#include "input_driver.h" // slotIsBle() -- BLE-driven slots mount like RF ones
 #include "fault_diag.h" // faultDiagTrace() -- flight recorder
 #include "fault_diag.h" // faultDiagTrace() -- flight recorder
 #include <Arduino.h>
 #include <Arduino.h>
 
 
@@ -27,8 +28,10 @@ static uint8_t connectedMask()
 {
 {
 	unsigned long now = millis();
 	unsigned long now = millis();
 	uint8_t m = 0;
 	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++)
 	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)
 		    (now - g_connReplyMs[s]) < CONN_UP_MS)
 			m |= (uint8_t)(1u << s);
 			m |= (uint8_t)(1u << s);
 	return m;
 	return m;
@@ -127,7 +130,8 @@ void usbMountTask()
 	// New controllers grow the set right away (after the usual debounce) regardless of idle state.
 	// New controllers grow the set right away (after the usual debounce) regardless of idle state.
 	unsigned long lastAny = 0;
 	unsigned long lastAny = 0;
 	for (int s = 0; s < NSLOT; s++)
 	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];
 			lastAny = g_connReplyMs[s];
 	bool idle = (lastAny == 0) || (now - lastAny) >= IDLE_CLEANUP_MS;
 	bool idle = (lastAny == 0) || (now - lastAny) >= IDLE_CLEANUP_MS;
 	uint8_t want = idle ? connectedMask() :
 	uint8_t want = idle ? connectedMask() :

+ 66 - 9
OpenPuck/webusb_config.cpp

@@ -10,6 +10,7 @@
 #include "fault_diag.h"
 #include "fault_diag.h"
 #include "fw_update.h"
 #include "fw_update.h"
 #include "usb_tx.h"
 #include "usb_tx.h"
+#include "ble_host.h" // BLE controllers: ops 0x16..0x19, 0xAC status frame, blob state byte
 #include <Arduino.h>
 #include <Arduino.h>
 #include <string.h>
 #include <string.h>
 
 
@@ -31,6 +32,8 @@ static volatile bool g_bondExportRequest = false;
 static volatile bool g_fwupAckPend = false;
 static volatile bool g_fwupAckPend = false;
 static volatile uint8_t g_fwupAckStatus = 0;
 static volatile uint8_t g_fwupAckStatus = 0;
 static volatile uint32_t g_fwupAckOff = 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)
 static void fwupAckPost(uint8_t status)
 {
 {
 	g_fwupAckStatus = 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,
 //                [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)]
 //                 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)]
 //                [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
 // 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
 // -- 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
 // 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[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 	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[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
 	p[5] = (uint8_t)g_mFric;
@@ -250,6 +255,7 @@ static void webusbSendBlob()
 	}
 	}
 	// v14: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
 	// v14: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
 	p[177] = g_landAll87;
 	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
 	// 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
 	// 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
 	// 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;
 		g_bondExportRequest = false;
 		webusbSendBondExport();
 		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) --------------------------------------------------------------------------
 // ---- live wedge reporter (0xA9) --------------------------------------------------------------------------
 // THE one channel that survives a loop() wedge on boards that wipe .noinit/GPREGRET across the watchdog reset
 // 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)
 			if (n == 0)
 				break;
 				break;
 			uint8_t op = buf[0];
 			uint8_t op = buf[0];
-			if ((op < 0x01 || op > 0x15) &&
+			if ((op < 0x01 || op > 0x19) &&
 			    (op < 0x20 || op > 0x24)) { // resync: drop one byte
 			    (op < 0x20 || op > 0x24)) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				memmove(buf, buf + 1, --n);
 				continue;
 				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
 			// 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 +
 			// 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.)
 			// 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 =
 			uint8_t need =
 				(op == 0x0D) ? 27 :
 				(op == 0x0D) ? 27 :
 				(op == 0x12) ? 18 :
 				(op == 0x12) ? 18 :
 				(op == 0x02) ? 3 :
 				(op == 0x02) ? 3 :
 				(op == 0x03 || op == 0x05 || op == 0x0E ||
 				(op == 0x03 || op == 0x05 || op == 0x0E ||
-				 op == 0x0F || op == 0x10 || op == 0x13) ?
+				 op == 0x0F || op == 0x10 || op == 0x13 ||
+				 op == 0x16) ?
 					       2 :
 					       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] :
 				(op == 0x21) ? (uint8_t)(6 + (n >= 6 ? buf[5] :
 								       0)) :
 								       0)) :
 					       1;
 					       1;
@@ -641,6 +663,41 @@ void webusbPoll()
 			else if (op == 0x10) {
 			else if (op == 0x10) {
 				webusbDrainFlight(buf[1] != 0);
 				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
 #if OPK_LOG
 
 
 			// rewind drain: buf[1]=1 from boot (whole ring), 0 from now
 			// 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>
       <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>
 
 
+    <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">
     <div class="card wide debugonly">
       <h2>Hang log</h2>
       <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>
       <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
 // 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).
 // 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{
   try{
     // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
     // 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;
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
     const d=new Uint8Array(r.data.buffer);
     // Live wedge reporter (0xA9): emitted from the firmware's SOF callback (usbd task) while loop() is stalled --
     // 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 swProRate=(p.length>52?p[52]:2);
   const swGyro10=(p.length>53?p[53]:10);
   const swGyro10=(p.length>53?p[53]:10);
   const rssi=(p.length>37?p[37]:0);
   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)
   // per-slot link status (protocol v8, p.length >= 73)
   const hasSlotsData = 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;
   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 --
   // 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".
   // 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); }
   catch(e){ log("refresh err: "+e.message); }
   finally{ inflight=false; }
   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 ----
 // ---- Backup / clone ----
 // A backup file is { magic, version, bonds[4], config{} }. bonds come from the 0x09 export (0xA7 frame); the
 // 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
 // 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
     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;
 $("#backupExport").onclick=exportBackup;
 $("#backupImport").onclick=()=>$("#backupFile").click();
 $("#backupImport").onclick=()=>$("#backupFile").click();
 $("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await importBackup(f); e.target.value=""; };
 $("#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