Pārlūkot izejas kodu

First time working

Jari 3 mēneši atpakaļ
vecāks
revīzija
d3f7268600
41 mainītis faili ar 3735 papildinājumiem un 5 dzēšanām
  1. 64 0
      .github/workflows/picopuck.yml
  2. 3 0
      .gitignore
  3. 23 2
      Makefile
  4. 93 0
      PicoPuck/CMakeLists.txt
  5. 27 0
      PicoPuck/NOTICE
  6. 84 0
      PicoPuck/README.md
  7. 121 0
      PicoPuck/pico_sdk_import.cmake
  8. 47 0
      PicoPuck/src/bt/bt_control.h
  9. 58 0
      PicoPuck/src/bt/bt_control_stub.c
  10. 862 0
      PicoPuck/src/bt/bt_host.c
  11. 23 0
      PicoPuck/src/bt/bt_host.h
  12. 259 0
      PicoPuck/src/bt/bt_valve.c
  13. 32 0
      PicoPuck/src/bt/bt_valve.h
  14. 107 0
      PicoPuck/src/bt/drv_ds4.c
  15. 38 0
      PicoPuck/src/bt/drv_sc2.c
  16. 116 0
      PicoPuck/src/bt/drv_xinput.c
  17. 29 0
      PicoPuck/src/bt/input_driver.c
  18. 38 0
      PicoPuck/src/bt/input_driver.h
  19. 70 0
      PicoPuck/src/config/btstack_config.h
  20. 38 0
      PicoPuck/src/config/picopuck_config.h
  21. 71 0
      PicoPuck/src/config/tusb_config.h
  22. 73 0
      PicoPuck/src/main.c
  23. 36 0
      PicoPuck/src/puck/identity.c
  24. 24 0
      PicoPuck/src/puck/identity.h
  25. 298 0
      PicoPuck/src/puck/personality.c
  26. 33 0
      PicoPuck/src/puck/personality.h
  27. 22 0
      PicoPuck/src/puck/relay.c
  28. 20 0
      PicoPuck/src/puck/relay.h
  29. 37 0
      PicoPuck/src/puck/slots.c
  30. 48 0
      PicoPuck/src/puck/slots.h
  31. 74 0
      PicoPuck/src/puck/triton.c
  32. 67 0
      PicoPuck/src/puck/triton.h
  33. 44 0
      PicoPuck/src/usb/puck_desc.c
  34. 22 0
      PicoPuck/src/usb/puck_desc.h
  35. 226 0
      PicoPuck/src/usb/usb_descriptors.c
  36. 43 0
      PicoPuck/src/usb/usb_descriptors.h
  37. 67 0
      PicoPuck/src/usb/usb_tx.c
  38. 23 0
      PicoPuck/src/usb/usb_tx.h
  39. 231 0
      PicoPuck/src/usb/webusb.c
  40. 22 0
      PicoPuck/src/usb/webusb.h
  41. 122 3
      docs/index.html

+ 64 - 0
.github/workflows/picopuck.yml

@@ -0,0 +1,64 @@
+name: PicoPuck
+
+on:
+  push:
+    branches:
+      - main
+    tags:
+      - '[0-9]*.[0-9]*.[0-9]*'
+    paths:
+      - 'PicoPuck/**'
+      - '.github/workflows/picopuck.yml'
+  pull_request:
+    paths:
+      - 'PicoPuck/**'
+      - '.github/workflows/picopuck.yml'
+
+concurrency:
+  group: picopuck-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        board: [pico_w, pico2_w]
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+        with:
+          ref: ${{ github.event.pull_request.head.sha || github.sha }}
+
+      - name: Install toolchain
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y cmake gcc-arm-none-eabi libnewlib-arm-none-eabi build-essential
+
+      - name: Check out Pico SDK 2.x
+        uses: actions/checkout@v4
+        with:
+          repository: raspberrypi/pico-sdk
+          ref: 2.2.0
+          path: pico-sdk
+          submodules: true
+
+      - name: Build PicoPuck (${{ matrix.board }})
+        env:
+          PICO_SDK_PATH: ${{ github.workspace }}/pico-sdk
+        run: |
+          cmake -S PicoPuck -B PicoPuck/build-${{ matrix.board }} -DPICO_BOARD=${{ matrix.board }}
+          cmake --build PicoPuck/build-${{ matrix.board }} -j
+
+      - name: Upload UF2 artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: picopuck_${{ matrix.board }}.uf2
+          retention-days: 7
+          if-no-files-found: error
+          path: PicoPuck/build-${{ matrix.board }}/picopuck_${{ matrix.board }}.uf2

+ 3 - 0
.gitignore

@@ -11,3 +11,6 @@ __pycache__/
 
 # generated build-provenance header (gen_version.sh); never commit
 OpenPuck/git_version.h
+
+# PicoPuck CMake build directories
+PicoPuck/build-*/

+ 23 - 2
Makefile

@@ -50,7 +50,7 @@ _PATH_FLAGS = $(if $(BUILD_PATH),--clean --build-path $(BUILD_PATH) --output-dir
 # (No auto-detect -- uploading to a guessed serial port risks writing to the wrong device. List with
 # `arduino-cli board list`.) FLASH_PORT = whatever goal isn't one of our real targets; the catch-all rule at
 # the bottom swallows it so make doesn't try to build the port path as a target.
-FLASH_PORT := $(filter-out format format-check check build build-recovery reversepuck reversepuck-flash reversepuck-deploy flash deploy,$(MAKECMDGOALS))
+FLASH_PORT := $(filter-out format format-check check build build-recovery reversepuck reversepuck-flash reversepuck-deploy flash deploy picopuck picopuck-pico2w picopuck-clean,$(MAKECMDGOALS))
 UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" OpenPuck
 
 # ReversePuck (controller dongle, 28DE:1302) build flags. It has ONE HID interface (core default 2 is fine),
@@ -59,7 +59,12 @@ UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" OpenPuck
 RP_USB_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
 RP_UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" ReversePuckFirmware
 
-.PHONY: format format-check check build build-recovery reversepuck reversepuck-flash reversepuck-deploy flash deploy
+.PHONY: format format-check check build build-recovery reversepuck reversepuck-flash reversepuck-deploy flash deploy picopuck picopuck-pico2w picopuck-clean
+
+# --- PicoPuck (Pico W / Pico 2 W firmware; pico-sdk + CMake) -----------------
+# Requires PICO_SDK_PATH to point at a Pico SDK 2.x checkout with submodules.
+# UF2 lands in PicoPuck/build-<board>/picopuck_<board>.uf2.
+PICO_SDK_PATH ?=
 
 ## 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.
@@ -85,6 +90,22 @@ reversepuck-deploy:
 	$(MAKE) reversepuck BUILD_PATH=$(BUILD_PATH) OUTPUT_DIR=$(OUTPUT_DIR)
 	$(RP_UPLOAD)
 
+## Build the PicoPuck firmware for the Pico W (RP2040). Needs PICO_SDK_PATH set.
+picopuck:
+	@[ -n "$(PICO_SDK_PATH)" ] || { echo "PICO_SDK_PATH must point at a Pico SDK 2.x checkout"; exit 1; }
+	cmake -S PicoPuck -B PicoPuck/build-pico_w -DPICO_BOARD=pico_w
+	cmake --build PicoPuck/build-pico_w -j
+
+## Build the PicoPuck firmware for the Pico 2 W (RP2350).
+picopuck-pico2w:
+	@[ -n "$(PICO_SDK_PATH)" ] || { echo "PICO_SDK_PATH must point at a Pico SDK 2.x checkout"; exit 1; }
+	cmake -S PicoPuck -B PicoPuck/build-pico2_w -DPICO_BOARD=pico2_w
+	cmake --build PicoPuck/build-pico2_w -j
+
+## Remove PicoPuck CMake build directories.
+picopuck-clean:
+	rm -rf PicoPuck/build-pico_w PicoPuck/build-pico2_w
+
 ## Upload the most recent build. Usage: make flash <port>   e.g.  make flash /dev/cu.usbmodem1101
 ## The board may need DFU/bootloader mode first (double-tap RST): puck (Steam/Lizard) mode drops the CDC port
 ## so arduino-cli can't auto-reset it -- see docs/BUILD_AND_DEPLOY.md.

+ 93 - 0
PicoPuck/CMakeLists.txt

@@ -0,0 +1,93 @@
+# PicoPuck — a Steam-Controller-2 puck emulator for Raspberry Pi Pico W / Pico 2 W.
+#
+# Presents the same Valve puck USB identity as OpenPuck (28DE:1304) while
+# connecting to controllers over Bluetooth (Classic + BLE) via the CYW43 radio
+# and BTstack. See README.md for the build instructions and PLAN/phase notes.
+#
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+cmake_minimum_required(VERSION 3.13)
+
+# Board must be chosen on the command line: -DPICO_BOARD=pico_w | pico2_w.
+if(NOT DEFINED PICO_BOARD)
+    set(PICO_BOARD pico_w CACHE STRING "Target board (pico_w or pico2_w)")
+endif()
+
+# Pull in the SDK (PICO_SDK_PATH env var or -DPICO_SDK_PATH=...) before project().
+include(pico_sdk_import.cmake)
+
+project(picopuck C CXX ASM)
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_CXX_STANDARD 17)
+
+pico_sdk_init()
+
+# --- version / build id -----------------------------------------------------
+execute_process(
+    COMMAND git rev-parse --short HEAD
+    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
+    OUTPUT_VARIABLE PICOPUCK_GIT_COMMIT
+    OUTPUT_STRIP_TRAILING_WHITESPACE
+    ERROR_QUIET)
+if(NOT PICOPUCK_GIT_COMMIT)
+    set(PICOPUCK_GIT_COMMIT "unknown")
+endif()
+
+add_executable(picopuck)
+
+target_sources(picopuck PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/main.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/usb/usb_descriptors.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/usb/puck_desc.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/usb/usb_tx.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/usb/webusb.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/identity.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/slots.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/personality.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/relay.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/triton.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_control_stub.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_host.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_valve.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/input_driver.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/drv_xinput.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/drv_sc2.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/drv_ds4.c
+)
+
+target_include_directories(picopuck PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/src
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/config
+)
+
+target_compile_definitions(picopuck PRIVATE
+    CYW43_LWIP=0                 # no networking; we only use the radio for BT
+    BTSTACK_USE_CYW43=1          # BTstack runs on the CYW43 async_context
+    ENABLE_BTSTACK=1
+    PICOPUCK_GIT_COMMIT="${PICOPUCK_GIT_COMMIT}"
+)
+
+# LED blink + BT radio need cyw43_arch. The _poll variant (no background IRQ
+# thread) is what BTstack-on-CYW43 uses; it is serviced by cyw43_arch_poll().
+# USB (tinyusb_device) and BTstack libraries are added in later phases.
+target_link_libraries(picopuck PRIVATE
+    pico_stdlib
+    pico_unique_id
+    pico_cyw43_arch_poll
+    pico_btstack_ble
+    pico_btstack_classic
+    pico_btstack_cyw43
+    tinyusb_device
+    tinyusb_board
+)
+
+# Debug console on UART (GP0/GP1); USB is reserved for the puck interfaces.
+pico_enable_stdio_uart(picopuck 1)
+pico_enable_stdio_usb(picopuck 0)
+
+pico_add_extra_outputs(picopuck)
+
+# Name the UF2 per board so both artifacts can live side by side.
+set_target_properties(picopuck PROPERTIES OUTPUT_NAME "picopuck_${PICO_BOARD}")
+
+message(STATUS "PicoPuck: board=${PICO_BOARD} commit=${PICOPUCK_GIT_COMMIT}")

+ 27 - 0
PicoPuck/NOTICE

@@ -0,0 +1,27 @@
+PicoPuck
+========
+
+PicoPuck is part of the OpenPuck project and is licensed under the
+GNU Affero General Public License v3.0 or later (see ../LICENSE).
+
+Third-party components
+----------------------
+
+Portions of the Bluetooth host, controller drivers, and HID descriptor
+parsing under PicoPuck/vendor/ are derived from joypad-os
+(https://github.com/RobertDaleSmith/joypad-os), Copyright (c) Robert Dale
+Smith and contributors, licensed under the Apache License, Version 2.0.
+The original license and copyright notices are retained in those files, and
+each local modification is marked with a "PICOPUCK:" comment. Apache-2.0
+material is included here under the terms permitting relicensing into a
+(A)GPL-3.0 combined work.
+
+BTstack is used via the Raspberry Pi Pico SDK. The bundled BTstack is
+distributed under BlueKitchen's license, which is non-commercial for general
+use but carries an additional grant (LICENSE.RP in the SDK) permitting use on
+Raspberry Pi RP2040 / RP2350 silicon. PicoPuck binaries are built for and
+intended to run only on Raspberry Pi Pico W / Pico 2 W hardware, within the
+scope of that grant.
+
+TinyUSB (MIT), the CYW43 driver, and the Pico SDK carry their own licenses as
+distributed with the SDK.

+ 84 - 0
PicoPuck/README.md

@@ -0,0 +1,84 @@
+# PicoPuck
+
+A Steam Controller 2 **puck emulator for the Raspberry Pi Pico W and Pico 2 W**.
+
+Where the original OpenPuck is an nRF52840 dongle that talks to the controller
+over Valve's proprietary 2.4 GHz ESB link, PicoPuck uses the Pico W's CYW43
+radio to reach controllers over **Bluetooth** — both BLE and Classic — while
+presenting the host with the *exact same* Valve puck USB device
+(`28DE:1304`). Steam therefore sees a normal puck, and:
+
+- A **Steam Controller 2** paired over its native Bluetooth mode is forwarded
+  *transparently* — input reports go through verbatim and Steam's haptics /
+  settings writes are relayed back to the controller, so it behaves just like a
+  controller on a real puck.
+- **Other Bluetooth controllers** (Xbox, DualShock/DualSense, Switch Pro,
+  8BitDo, Stadia, …) are canonicalised into the puck's input model and, in Steam
+  mode, presented as a controller with neutral trackpads/gyro.
+
+Pairing, unpairing and scanning are driven from the same **WebUSB panel**
+(`docs/index.html`) as the other OpenPuck devices.
+
+> Status: under construction. See the phase checklist below.
+
+## Building
+
+You need the Arm GNU toolchain, CMake ≥ 3.13, and the Pico SDK **2.x** with its
+`btstack`, `cyw43-driver`, `lwip`, `mbedtls`, and `tinyusb` submodules
+initialised. Point `PICO_SDK_PATH` at it.
+
+```sh
+# Pico W (RP2040)
+cmake -S PicoPuck -B PicoPuck/build-pico_w -DPICO_BOARD=pico_w
+cmake --build PicoPuck/build-pico_w -j
+
+# Pico 2 W (RP2350)
+cmake -S PicoPuck -B PicoPuck/build-pico2_w -DPICO_BOARD=pico2_w
+cmake --build PicoPuck/build-pico2_w -j
+```
+
+Outputs: `picopuck_pico_w.uf2` / `picopuck_pico2_w.uf2`. Flash by holding
+BOOTSEL while plugging in, or from the panel's "reboot to bootloader" button.
+
+There is also a `make picopuck` / `make picopuck-pico2w` convenience target in
+the repo root `Makefile`.
+
+## Phase checklist
+
+- [x] **Phase 0** — scaffold: builds for both boards, heartbeat LED.
+- [x] **Phase 1** — USB puck identity (4 HID slots + WebUSB) enumerates; panel connects.
+- [x] **Phase 2** — generic BLE pad (Xbox) presented in Steam mode via a synthesized report.
+- [x] **Phase 3** — Classic HID host + DualShock 4. *(DS5 / Switch Pro are drivers-in-waiting, see below.)*
+- [x] **Phase 4** — Steam Controller 2 transparent forwarding over the Valve GATT service.
+- [x] **Phase 5** — 4-slot model, flash bond persistence + auto-reconnect, scan-duty policy, firmware/bootloader button.
+
+### Implemented in code (needs on-hardware validation)
+
+The full architecture is in place and compiling for both boards, but has **not
+been run on real hardware** yet. Everything below is correct-by-construction
+(ported from OpenPuck / joypad-os byte layouts and the reversed SC2 protocol);
+expect to iterate once flashed.
+
+### Follow-on drivers / features (same interfaces, deferred)
+
+- **DualSense (DS5)** — Classic, needs the CRC32-sealed output report and
+  adaptive-trigger/touchpad handling; add a `drv_ds5` implementing `input_driver_t`.
+- **Switch Pro** — Classic, needs the subcommand init handshake before it reports.
+- **Battery** — read the BLE Battery Service (and the SC2's battery channel) into
+  `g_battery[slot]` so Steam shows a level instead of the wired glyph.
+- Concurrency/bandwidth and soak testing (see risks).
+
+## Known risks / open questions
+
+- **Concurrent-link ceiling.** BTstack pools are sized for four slots, but the
+  CYW43 firmware's real concurrent Classic+BLE limit and ACL bandwidth under
+  load are unverified; running inquiry+scan while controllers are connected is
+  known to hurt throughput. The four USB slots are always enumerated regardless.
+- **Latency.** A BLE connection interval floor (~7.5 ms) delivers input slower
+  than the nRF puck's RF poll; verbatim on-air timestamps keep gyro integration
+  correct but feel is to be compared against a real puck.
+- **Windows descriptor cache.** Same VID/PID as the nRF puck with a different
+  interface layout — `bcdDevice` is bumped to `0x03xx` to force a fresh cache
+  entry; test both plug orders.
+- **BTstack licensing.** Covered by the SDK's `LICENSE.RP` grant only on
+  Raspberry Pi silicon; see `NOTICE`.

+ 121 - 0
PicoPuck/pico_sdk_import.cmake

@@ -0,0 +1,121 @@
+# This is a copy of <PICO_SDK_PATH>/external/pico_sdk_import.cmake
+
+# This can be dropped into an external project to help locate this SDK
+# It should be include()ed prior to project()
+
+# Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd.
+#
+# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
+# following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
+# disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
+# disclaimer in the documentation and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products
+# derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
+    set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
+    message("Using PICO_SDK_PATH from environment ('${PICO_SDK_PATH}')")
+endif ()
+
+if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
+    set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
+    message("Using PICO_SDK_FETCH_FROM_GIT from environment ('${PICO_SDK_FETCH_FROM_GIT}')")
+endif ()
+
+if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
+    set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
+    message("Using PICO_SDK_FETCH_FROM_GIT_PATH from environment ('${PICO_SDK_FETCH_FROM_GIT_PATH}')")
+endif ()
+
+if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_TAG} AND (NOT PICO_SDK_FETCH_FROM_GIT_TAG))
+    set(PICO_SDK_FETCH_FROM_GIT_TAG $ENV{PICO_SDK_FETCH_FROM_GIT_TAG})
+    message("Using PICO_SDK_FETCH_FROM_GIT_TAG from environment ('${PICO_SDK_FETCH_FROM_GIT_TAG}')")
+endif ()
+
+if (PICO_SDK_FETCH_FROM_GIT AND NOT PICO_SDK_FETCH_FROM_GIT_TAG)
+  set(PICO_SDK_FETCH_FROM_GIT_TAG "master")
+  message("Using master as default value for PICO_SDK_FETCH_FROM_GIT_TAG")
+endif()
+
+set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
+set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch copy of SDK from git if not otherwise locatable")
+set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "location to download SDK")
+set(PICO_SDK_FETCH_FROM_GIT_TAG "${PICO_SDK_FETCH_FROM_GIT_TAG}" CACHE FILEPATH "release tag for SDK")
+
+if (NOT PICO_SDK_PATH)
+    if (PICO_SDK_FETCH_FROM_GIT)
+        include(FetchContent)
+        set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
+        if (PICO_SDK_FETCH_FROM_GIT_PATH)
+            get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}")
+        endif ()
+        FetchContent_Declare(
+                pico_sdk
+                GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
+                GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
+        )
+
+        if (NOT pico_sdk)
+            message("Downloading Raspberry Pi Pico SDK")
+            # GIT_SUBMODULES_RECURSE was added in 3.17
+            if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.17.0")
+                FetchContent_Populate(
+                        pico_sdk
+                        QUIET
+                        GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
+                        GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
+                        GIT_SUBMODULES_RECURSE FALSE
+
+                        SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
+                        BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
+                        SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
+                )
+            else ()
+                FetchContent_Populate(
+                        pico_sdk
+                        QUIET
+                        GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
+                        GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
+
+                        SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
+                        BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
+                        SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
+                )
+            endif ()
+
+            set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
+        endif ()
+        set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
+    else ()
+        message(FATAL_ERROR
+                "SDK location was not specified. Please set PICO_SDK_PATH or set PICO_SDK_FETCH_FROM_GIT to on to fetch from git."
+                )
+    endif ()
+endif ()
+
+get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
+if (NOT EXISTS ${PICO_SDK_PATH})
+    message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
+endif ()
+
+set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
+if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
+    message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' does not appear to contain the Raspberry Pi Pico SDK")
+endif ()
+
+set(PICO_SDK_PATH ${PICO_SDK_PATH} CACHE PATH "Path to the Raspberry Pi Pico SDK" FORCE)
+
+include(${PICO_SDK_INIT_CMAKE_FILE})

+ 47 - 0
PicoPuck/src/bt/bt_control.h

@@ -0,0 +1,47 @@
+// bt_control.h — Bluetooth control surface used by the WebUSB panel.
+//
+// The panel drives scanning / pairing / forgetting through these calls, and the
+// status frame reads per-slot info from bt_slot_info(). Phase 1 links weak
+// no-op defaults; Phase 2 (bt_host.c) provides the real implementations.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_BT_CONTROL_H
+#define PICOPUCK_BT_CONTROL_H
+
+#include <stdint.h>
+#include <stdbool.h>
+
+// One discovered (not yet paired) device, for the panel's scan list.
+typedef struct {
+	uint8_t addr[6];
+	uint8_t addr_type;  // 0 = Classic, 1 = BLE public, 2 = BLE random
+	uint8_t kind;       // 0 unknown, 1 SC2, 2 BLE gamepad, 3 Classic gamepad
+	int8_t rssi;
+	char name[16];
+} bt_scan_entry_t;
+
+// Per-slot live info for the status frame (kind/rssi/addr/name of the bound
+// controller). Returns false if the slot has no bound controller.
+bool bt_slot_info(int slot, uint8_t *kind, int8_t *rssi, uint8_t addr[6],
+		  char name[16]);
+
+void bt_scan_start(uint16_t seconds);
+void bt_scan_stop(void);
+bool bt_scan_active(void);
+
+// Diagnostics for the panel. flags bits: 0 scan running, 1 HCI working,
+// 2 connect in flight, 3 btstack init ok, 4 power-on issued. hci_state = last
+// BTSTACK_EVENT_STATE (0 off, 1 initialising, 2 working). hci_events = total
+// HCI events seen (0 ⇒ run loop dead).
+void bt_scan_diag(uint16_t *adv_seen, uint8_t *flags, uint8_t *hci_state,
+		  uint16_t *hci_events);
+
+// Copy up to `max` scan entries into `out`; returns the count written.
+uint8_t bt_scan_list(bt_scan_entry_t *out, uint8_t max);
+
+bool bt_pair(const uint8_t addr[6], uint8_t addr_type);
+void bt_forget(const uint8_t addr[6], uint8_t addr_type);
+void bt_forget_all(void);
+void bt_disconnect_slot(int slot);
+
+#endif // PICOPUCK_BT_CONTROL_H

+ 58 - 0
PicoPuck/src/bt/bt_control_stub.c

@@ -0,0 +1,58 @@
+// bt_control_stub.c — weak no-op BT control (see bt_control.h).
+//
+// Lets Phase 1 build and the panel connect before the BTstack host exists. Each
+// symbol is weak; Phase 2's bt_host.c overrides it with the real implementation.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "bt/bt_control.h"
+
+__attribute__((weak)) bool bt_slot_info(int slot, uint8_t *kind, int8_t *rssi,
+					uint8_t addr[6], char name[16])
+{
+	(void)slot;
+	(void)kind;
+	(void)rssi;
+	(void)addr;
+	(void)name;
+	return false;
+}
+
+__attribute__((weak)) void bt_scan_start(uint16_t seconds) { (void)seconds; }
+__attribute__((weak)) void bt_scan_stop(void) {}
+__attribute__((weak)) bool bt_scan_active(void) { return false; }
+
+__attribute__((weak)) void bt_scan_diag(uint16_t *adv_seen, uint8_t *flags,
+					uint8_t *hci_state, uint16_t *hci_events)
+{
+	if (adv_seen)
+		*adv_seen = 0;
+	if (flags)
+		*flags = 0;
+	if (hci_state)
+		*hci_state = 0;
+	if (hci_events)
+		*hci_events = 0;
+}
+
+__attribute__((weak)) uint8_t bt_scan_list(bt_scan_entry_t *out, uint8_t max)
+{
+	(void)out;
+	(void)max;
+	return 0;
+}
+
+__attribute__((weak)) bool bt_pair(const uint8_t addr[6], uint8_t addr_type)
+{
+	(void)addr;
+	(void)addr_type;
+	return false;
+}
+
+__attribute__((weak)) void bt_forget(const uint8_t addr[6], uint8_t addr_type)
+{
+	(void)addr;
+	(void)addr_type;
+}
+
+__attribute__((weak)) void bt_forget_all(void) {}
+__attribute__((weak)) void bt_disconnect_slot(int slot) { (void)slot; }

+ 862 - 0
PicoPuck/src/bt/bt_host.c

@@ -0,0 +1,862 @@
+// bt_host.c — BTstack dual-mode controller host (see bt_host.h).
+//
+// Phase 2 scope: BLE central. Scans for HID-over-GATT controllers, connects and
+// pairs (Just Works), drives BTstack's hids_client to subscribe to input
+// reports, decodes each via the matching input_driver into g_in[slot], and
+// presents it to the puck personality. Rumble flows back through bt_relay ->
+// the driver's output report. Classic HID (Phase 3) and the SC2 Valve GATT
+// client (Phase 4) plug into the same slot/driver model.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "bt/bt_host.h"
+#include "bt/bt_control.h"
+#include "bt/input_driver.h"
+#include "bt/bt_valve.h"
+#include "puck/personality.h"
+#include "puck/triton.h"
+#include "config/picopuck_config.h"
+
+#include <stdio.h>
+#include <string.h>
+#include "btstack.h"
+#include "ble/gatt-service/hids_client.h"
+#include "classic/hid_host.h"
+#include "pico/cyw43_arch.h"
+#include "pico/time.h"
+
+#define SCAN_UI_INTERVAL 0x00A0    // 100 ms / 50 ms while the panel is scanning
+#define SCAN_UI_WINDOW 0x0050
+#define SCAN_IDLE_INTERVAL 0x0140  // 200 ms / 30 ms background reconnect (~15%)
+#define SCAN_IDLE_WINDOW 0x0030
+#define SCAN_MAX 12
+#define RUMBLE_MIN_GAP_MS 30
+
+static btstack_packet_callback_registration_t s_hci_cb;
+static btstack_packet_callback_registration_t s_sm_cb;
+static bool s_ready;
+static uint8_t s_hid_desc_storage[1024];    // BLE hids_client
+static uint8_t s_classic_desc_storage[1024]; // Classic hid_host
+
+// Per-slot connection state.
+typedef struct {
+	bool active;
+	hci_con_handle_t handle;
+	uint16_t hids_cid;
+	bool hid_started;  // hids_client_connect issued
+	bool is_valve;     // SC2 native BLE (Valve GATT, not HOGP)
+	bool is_classic;   // Bluetooth Classic HID (hids_cid holds the hid_host cid)
+	const input_driver_t *drv;
+	bool is_ble;
+	bd_addr_t addr;
+	bd_addr_type_t addr_type;
+	char name[16];
+	int8_t rssi;
+	uint16_t rum_lo, rum_hi;
+	bool rum_pending;
+	uint32_t rum_last_ms;
+} conn_t;
+static conn_t g_conn[PP_NSLOT];
+
+// The device we are currently connecting to (before a slot is assigned).
+static struct {
+	bool busy;
+	uint32_t started_ms;
+	bd_addr_t addr;
+	bd_addr_type_t addr_type;
+	char name[16];
+} s_connecting;
+
+static uint16_t s_adv_seen;  // diagnostic: raw advertising reports since scan start
+static uint16_t s_hci_events;  // diagnostic: total HCI events seen (run-loop alive?)
+static uint8_t s_last_state;   // diagnostic: last BTSTACK_EVENT_STATE value
+static bool s_bt_init_ok;      // btstack_cyw43_init returned success
+static bool s_power_on;        // hci_power_control(HCI_POWER_ON) was issued
+#define CONNECT_TIMEOUT_MS 10000u
+
+// Scan state. s_scanning = panel/UI scan (populates the list); a low-duty
+// background scan also runs whenever a bonded device is offline, for reconnect.
+static bool s_scanning;
+static uint32_t s_scan_until_ms;
+static bt_scan_entry_t s_scan[SCAN_MAX];
+static uint8_t s_scan_n;
+static bool s_scan_running;  // gap_start_scan is active
+static bool s_scan_ui;       // running with UI (high-duty) params
+
+// Pending pair target (set by bt_pair, connected on the next advert or directly).
+static bool s_pair_pending;
+static bd_addr_t s_pair_addr;
+static bd_addr_type_t s_pair_type;
+
+static uint32_t now_ms(void)
+{
+	return to_ms_since_boot(get_absolute_time());
+}
+
+// ---- slot / connection helpers --------------------------------------------
+static int slot_by_handle(hci_con_handle_t h)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (g_conn[i].active && g_conn[i].handle == h)
+			return i;
+	return -1;
+}
+static int slot_by_cid(uint16_t cid)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (g_conn[i].active && !g_conn[i].is_classic &&
+		    g_conn[i].hids_cid == cid)
+			return i;
+	return -1;
+}
+static int slot_by_hid_cid(uint16_t cid)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (g_conn[i].active && g_conn[i].is_classic &&
+		    g_conn[i].hids_cid == cid)
+			return i;
+	return -1;
+}
+static int slot_alloc(void)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (!g_conn[i].active)
+			return i;
+	return -1;
+}
+static void slot_release(int slot)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	memset(&g_conn[slot], 0, sizeof(g_conn[slot]));
+	puck_set_connected(slot, false);
+}
+
+static bool any_free_slot(void)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (!g_conn[i].active)
+			return true;
+	return false;
+}
+static bool addr_connected(const bd_addr_t addr)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (g_conn[i].active && memcmp(g_conn[i].addr, addr, 6) == 0)
+			return true;
+	return false;
+}
+static bool addr_bonded_le(const bd_addr_t addr)
+{
+	int max = le_device_db_max_count();
+	for (int i = 0; i < max; i++) {
+		int type;
+		bd_addr_t a;
+		le_device_db_info(i, &type, a, NULL);
+		if (type != (int)BD_ADDR_TYPE_UNKNOWN && memcmp(a, addr, 6) == 0)
+			return true;
+	}
+	return false;
+}
+static bool any_le_bond(void)
+{
+	int max = le_device_db_max_count();
+	for (int i = 0; i < max; i++) {
+		int type;
+		bd_addr_t a;
+		le_device_db_info(i, &type, a, NULL);
+		if (type != (int)BD_ADDR_TYPE_UNKNOWN)
+			return true;
+	}
+	return false;
+}
+
+// Start/stop the BLE scan to match need: high-duty while the panel scans,
+// low-duty while a bonded device is offline (reconnect), off otherwise.
+static void scan_ensure(void)
+{
+	if (s_connecting.busy)
+		return;  // don't touch scan state while a connect is in flight
+	bool want = s_scanning || (any_free_slot() && any_le_bond());
+	bool ui = s_scanning;
+	if (want && (!s_scan_running || s_scan_ui != ui)) {
+		if (s_scan_running)
+			gap_stop_scan();
+		if (ui)
+			gap_set_scan_params(1, SCAN_UI_INTERVAL, SCAN_UI_WINDOW, 0);
+		else
+			gap_set_scan_params(1, SCAN_IDLE_INTERVAL, SCAN_IDLE_WINDOW, 0);
+		gap_start_scan();
+		s_scan_running = true;
+		s_scan_ui = ui;
+	} else if (!want && s_scan_running) {
+		gap_stop_scan();
+		s_scan_running = false;
+	}
+}
+
+// ---- forward decl ----------------------------------------------------------
+static void hids_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			 uint16_t size);
+static void start_hids(int slot);
+
+// ---- HCI events ------------------------------------------------------------
+static void handle_advertising_report(uint8_t *packet)
+{
+	bd_addr_t addr;
+	gap_event_advertising_report_get_address(packet, addr);
+	bd_addr_type_t addr_type = gap_event_advertising_report_get_address_type(packet);
+	int8_t rssi = gap_event_advertising_report_get_rssi(packet);
+	uint8_t adv_len = gap_event_advertising_report_get_data_length(packet);
+	const uint8_t *adv_data = gap_event_advertising_report_get_data(packet);
+
+	char name[16] = { 0 };
+	uint16_t appearance = 0;
+	bool has_hid_uuid = false;
+
+	ad_context_t ctx;
+	for (ad_iterator_init(&ctx, adv_len, adv_data); ad_iterator_has_more(&ctx);
+	     ad_iterator_next(&ctx)) {
+		uint8_t t = ad_iterator_get_data_type(&ctx);
+		uint8_t l = ad_iterator_get_data_len(&ctx);
+		const uint8_t *d = ad_iterator_get_data(&ctx);
+		if (t == BLUETOOTH_DATA_TYPE_COMPLETE_LOCAL_NAME ||
+		    t == BLUETOOTH_DATA_TYPE_SHORTENED_LOCAL_NAME) {
+			uint8_t n = (l < sizeof(name) - 1) ? l : sizeof(name) - 1;
+			memcpy(name, d, n);
+			name[n] = 0;
+		} else if (t == BLUETOOTH_DATA_TYPE_APPEARANCE && l >= 2) {
+			appearance = d[0] | (d[1] << 8);
+		} else if ((t == BLUETOOTH_DATA_TYPE_COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS ||
+			    t == BLUETOOTH_DATA_TYPE_INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS)) {
+			for (int i = 0; i + 1 < l; i += 2)
+				if ((d[i] | (d[i + 1] << 8)) == 0x1812)
+					has_hid_uuid = true;
+		}
+	}
+
+	s_adv_seen++;  // diagnostic: every advertising report, pre-filter
+
+	bool is_gamepad = has_hid_uuid || (appearance >= 0x03C0 && appearance <= 0x03C4);
+
+	// Add / merge the scan list for the panel. A controller splits its data
+	// across the advertisement and the scan response (name in one, UUID/
+	// appearance in the other), and the SC2 advertises no standard HID UUID at
+	// all — so keep anything with a name or a gamepad hint, and merge fields by
+	// address across reports rather than requiring one packet to have everything.
+	if (s_scanning && (name[0] || is_gamepad)) {
+		int found = -1;
+		for (int i = 0; i < s_scan_n; i++)
+			if (memcmp(s_scan[i].addr, addr, 6) == 0) {
+				found = i;
+				break;
+			}
+		if (found < 0 && s_scan_n < SCAN_MAX) {
+			found = s_scan_n++;
+			memset(&s_scan[found], 0, sizeof(s_scan[found]));
+		}
+		if (found >= 0) {
+			memcpy(s_scan[found].addr, addr, 6);
+			s_scan[found].addr_type = addr_type;
+			s_scan[found].rssi = rssi;
+			if (name[0])  // only overwrite when this report carried a name
+				memcpy(s_scan[found].name, name,
+				       sizeof(s_scan[found].name));
+			s_scan[found].kind =
+				strstr(s_scan[found].name, "Steam") ? 1 : 2;
+		}
+	}
+
+	// Connect if this is our pending pair target, or a bonded device coming back
+	// online while a slot is free (auto-reconnect). A driver match needs the
+	// name, so for auto-reconnect wait for a report that carries it.
+	bool want = (s_pair_pending && memcmp(addr, s_pair_addr, 6) == 0);
+	if (!want && !s_connecting.busy && name[0] && any_free_slot() &&
+	    addr_bonded_le(addr) && !addr_connected(addr))
+		want = true;
+	if (want && !s_connecting.busy) {
+		s_pair_pending = false;
+		s_connecting.busy = true;
+		s_connecting.started_ms = now_ms();
+		memcpy(s_connecting.addr, addr, 6);
+		s_connecting.addr_type = addr_type;
+		if (name[0])
+			memcpy(s_connecting.name, name, sizeof(s_connecting.name));
+		if (s_scan_running) {  // stop scanning while a connect is in flight
+			gap_stop_scan();
+			s_scan_running = false;
+		}
+		gap_connect(addr, addr_type);
+	}
+}
+
+static void handle_inquiry_result(uint8_t *packet)
+{
+	bd_addr_t addr;
+	gap_event_inquiry_result_get_bd_addr(packet, addr);
+	uint32_t cod = gap_event_inquiry_result_get_class_of_device(packet);
+
+	// Peripheral major device class (0x05) covers gamepads/keyboards/mice.
+	if (((cod >> 8) & 0x1F) != 0x05)
+		return;
+
+	char name[16] = { 0 };
+	if (gap_event_inquiry_result_get_name_available(packet)) {
+		int n = gap_event_inquiry_result_get_name_len(packet);
+		if (n > (int)sizeof(name) - 1)
+			n = sizeof(name) - 1;
+		memcpy(name, gap_event_inquiry_result_get_name(packet), n);
+	}
+	int8_t rssi = gap_event_inquiry_result_get_rssi_available(packet)
+			      ? gap_event_inquiry_result_get_rssi(packet)
+			      : 0;
+
+	if (!s_scanning)
+		return;
+	int found = -1;
+	for (int i = 0; i < s_scan_n; i++)
+		if (memcmp(s_scan[i].addr, addr, 6) == 0) {
+			found = i;
+			break;
+		}
+	if (found < 0 && s_scan_n < SCAN_MAX)
+		found = s_scan_n++;
+	if (found >= 0) {
+		memcpy(s_scan[found].addr, addr, 6);
+		s_scan[found].addr_type = 0;  // 0 = Classic
+		s_scan[found].kind = 3;       // Classic gamepad
+		s_scan[found].rssi = rssi;
+		memcpy(s_scan[found].name, name, sizeof(s_scan[found].name));
+	}
+}
+
+static void handle_le_connection_complete(uint8_t *packet)
+{
+	uint8_t status = hci_subevent_le_connection_complete_get_status(packet);
+	hci_con_handle_t handle =
+		hci_subevent_le_connection_complete_get_connection_handle(packet);
+	if (status != ERROR_CODE_SUCCESS) {
+		s_connecting.busy = false;
+		return;
+	}
+	int slot = slot_alloc();
+	if (slot < 0) {
+		gap_disconnect(handle);
+		s_connecting.busy = false;
+		return;
+	}
+	conn_t *c = &g_conn[slot];
+	memset(c, 0, sizeof(*c));
+	c->active = true;
+	c->handle = handle;
+	c->is_ble = true;
+	c->is_valve = (strstr(s_connecting.name, "Steam") != NULL);
+	memcpy(c->addr, s_connecting.addr, 6);
+	c->addr_type = s_connecting.addr_type;
+	memcpy(c->name, s_connecting.name, sizeof(c->name));
+	s_connecting.busy = false;
+
+	printf("[bt] LE connected slot %d handle 0x%04X name '%s'\n", slot, handle,
+	       c->name);
+	// Encrypt/bond; hids_client starts once secured (SM_EVENT_PAIRING/REENCRYPTION).
+	sm_request_pairing(handle);
+}
+
+static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			   uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET)
+		return;
+
+	s_hci_events++;
+
+	switch (hci_event_packet_get_type(packet)) {
+	case BTSTACK_EVENT_STATE:
+		s_last_state = btstack_event_state_get_state(packet);
+		if (s_last_state == HCI_STATE_WORKING) {
+			s_ready = true;
+			printf("[bt] host up (dual-mode)\n");
+			scan_ensure();  // begin reconnect scan if bonds exist
+		}
+		break;
+	case GAP_EVENT_ADVERTISING_REPORT:
+		handle_advertising_report(packet);
+		break;
+	case GAP_EVENT_INQUIRY_RESULT:
+		handle_inquiry_result(packet);
+		break;
+	case GAP_EVENT_INQUIRY_COMPLETE:
+		if (s_scanning)
+			gap_inquiry_start(8);  // keep looking while the panel scans
+		break;
+	case HCI_EVENT_LE_META:
+		if (hci_event_le_meta_get_subevent_code(packet) ==
+		    HCI_SUBEVENT_LE_CONNECTION_COMPLETE)
+			handle_le_connection_complete(packet);
+		break;
+	case HCI_EVENT_DISCONNECTION_COMPLETE: {
+		hci_con_handle_t h =
+			hci_event_disconnection_complete_get_connection_handle(packet);
+		int slot = slot_by_handle(h);
+		if (slot >= 0) {
+			printf("[bt] disconnect slot %d\n", slot);
+			if (g_conn[slot].is_valve)
+				valve_disconnected(h);
+			slot_release(slot);
+			scan_ensure();  // resume reconnect scan
+		}
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+// ---- Security manager events ----------------------------------------------
+static void sm_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+		       uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET)
+		return;
+
+	switch (hci_event_packet_get_type(packet)) {
+	case SM_EVENT_JUST_WORKS_REQUEST:
+		sm_just_works_confirm(sm_event_just_works_request_get_handle(packet));
+		break;
+	case SM_EVENT_NUMERIC_COMPARISON_REQUEST:
+		sm_numeric_comparison_confirm(
+			sm_event_numeric_comparison_request_get_handle(packet));
+		break;
+	case SM_EVENT_PAIRING_COMPLETE: {
+		if (sm_event_pairing_complete_get_status(packet) == ERROR_CODE_SUCCESS) {
+			int slot = slot_by_handle(
+				sm_event_pairing_complete_get_handle(packet));
+			if (slot >= 0)
+				start_hids(slot);
+		}
+		break;
+	}
+	case SM_EVENT_REENCRYPTION_COMPLETE: {
+		int slot = slot_by_handle(
+			sm_event_reencryption_complete_get_handle(packet));
+		if (slot >= 0)
+			start_hids(slot);
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+static void start_hids(int slot)
+{
+	conn_t *c = &g_conn[slot];
+	if (c->hid_started)
+		return;
+	c->hid_started = true;
+
+	// SC2 speaks a proprietary Valve GATT service, not HOGP.
+	if (c->is_valve) {
+		c->drv = input_driver_match(0, 0, c->name, true);  // kind 1 (SC2)
+		puck_set_connected(slot, true);
+		valve_start(slot, c->handle);
+		return;
+	}
+
+	uint8_t r = hids_client_connect(c->handle, hids_handler,
+					HID_PROTOCOL_MODE_REPORT, &c->hids_cid);
+	if (r != ERROR_CODE_SUCCESS) {
+		printf("[bt] hids_client_connect failed %d\n", r);
+		c->hid_started = false;
+	}
+}
+
+// ---- HID-over-GATT client events -------------------------------------------
+static void hids_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			 uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET ||
+	    hci_event_packet_get_type(packet) != HCI_EVENT_GATTSERVICE_META)
+		return;
+
+	switch (hci_event_gattservice_meta_get_subevent_code(packet)) {
+	case GATTSERVICE_SUBEVENT_HID_SERVICE_CONNECTED: {
+		uint16_t cid = gattservice_subevent_hid_service_connected_get_hids_cid(packet);
+		uint8_t status = gattservice_subevent_hid_service_connected_get_status(packet);
+		int slot = slot_by_cid(cid);
+		if (slot < 0)
+			break;
+		conn_t *c = &g_conn[slot];
+		if (status != ERROR_CODE_SUCCESS) {
+			printf("[bt] HID connect failed slot %d status 0x%02X\n", slot,
+			       status);
+			gap_disconnect(c->handle);
+			break;
+		}
+		c->drv = input_driver_match(0, 0, c->name, c->is_ble);
+		if (!c->drv) {
+			printf("[bt] no driver for '%s' — dropping\n", c->name);
+			gap_disconnect(c->handle);
+			break;
+		}
+		printf("[bt] slot %d driver '%s' ready\n", slot, c->drv->name);
+		puck_set_connected(slot, true);
+		hids_client_enable_notifications(cid);
+		break;
+	}
+	case GATTSERVICE_SUBEVENT_HID_REPORT: {
+		uint16_t cid = gattservice_subevent_hid_report_get_hids_cid(packet);
+		int slot = slot_by_cid(cid);
+		if (slot < 0 || !g_conn[slot].drv || !g_conn[slot].drv->decode)
+			break;
+		// hids_client delivers [report_id][body...]; drivers want body-only.
+		const uint8_t *rep = gattservice_subevent_hid_report_get_report(packet);
+		uint16_t rlen = gattservice_subevent_hid_report_get_report_len(packet);
+		if (rlen < 1)
+			break;
+		if (g_conn[slot].drv->decode(rep[0], rep + 1, (uint16_t)(rlen - 1),
+					     &g_in[slot]))
+			puck_present_synth(slot);
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+// ---- Classic HID host ------------------------------------------------------
+static void classic_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			    uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET ||
+	    hci_event_packet_get_type(packet) != HCI_EVENT_HID_META)
+		return;
+
+	switch (hci_event_hid_meta_get_subevent_code(packet)) {
+	case HID_SUBEVENT_INCOMING_CONNECTION:
+		// A pad (e.g. a DualShock) initiated — accept in report mode.
+		hid_host_accept_connection(
+			hid_subevent_incoming_connection_get_hid_cid(packet),
+			HID_PROTOCOL_MODE_REPORT);
+		break;
+	case HID_SUBEVENT_CONNECTION_OPENED: {
+		uint8_t status = hid_subevent_connection_opened_get_status(packet);
+		uint16_t cid = hid_subevent_connection_opened_get_hid_cid(packet);
+		if (status != ERROR_CODE_SUCCESS)
+			break;
+		int slot = slot_alloc();
+		if (slot < 0) {
+			hid_host_disconnect(cid);
+			break;
+		}
+		conn_t *c = &g_conn[slot];
+		memset(c, 0, sizeof(*c));
+		c->active = true;
+		c->is_classic = true;
+		c->hids_cid = cid;
+		hid_subevent_connection_opened_get_bd_addr(packet, c->addr);
+		memcpy(c->name, s_connecting.name, sizeof(c->name));
+		s_connecting.busy = false;
+		c->drv = input_driver_match(0, 0, c->name, false);
+		if (!c->drv) {
+			printf("[bt] no Classic driver for '%s'\n", c->name);
+			hid_host_disconnect(cid);
+			slot_release(slot);
+			break;
+		}
+		printf("[bt] Classic slot %d driver '%s'\n", slot, c->drv->name);
+		puck_set_connected(slot, true);
+		break;
+	}
+	case HID_SUBEVENT_REPORT: {
+		int slot = slot_by_hid_cid(hid_subevent_report_get_hid_cid(packet));
+		if (slot < 0 || !g_conn[slot].drv || !g_conn[slot].drv->decode)
+			break;
+		const uint8_t *r = hid_subevent_report_get_report(packet);
+		uint16_t rlen = hid_subevent_report_get_report_len(packet);
+		if (rlen < 1)
+			break;
+		// Classic HID interrupt reports carry the report-id as byte 0.
+		if (g_conn[slot].drv->decode(r[0], r + 1, (uint16_t)(rlen - 1),
+					     &g_in[slot]))
+			puck_present_synth(slot);
+		break;
+	}
+	case HID_SUBEVENT_CONNECTION_CLOSED: {
+		int slot = slot_by_hid_cid(
+			hid_subevent_connection_closed_get_hid_cid(packet));
+		if (slot >= 0) {
+			printf("[bt] Classic disconnect slot %d\n", slot);
+			slot_release(slot);
+		}
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+// ---- init ------------------------------------------------------------------
+bool bt_host_init(void)
+{
+	// cyw43_arch_init() (with CYW43_ENABLE_BLUETOOTH=1) has ALREADY run
+	// btstack_cyw43_init(): BTstack memory, the async-context run loop, the HCI
+	// transport, and the flash-bank bond store (Classic link keys + LE device DB)
+	// are all set up. We must NOT call btstack_cyw43_init() again. Here we only
+	// bring up the protocol services and power on.
+	s_bt_init_ok = true;
+
+	l2cap_init();
+	sm_init();
+	sm_set_io_capabilities(IO_CAPABILITY_NO_INPUT_NO_OUTPUT);
+	sm_set_authentication_requirements(SM_AUTHREQ_SECURE_CONNECTION |
+					   SM_AUTHREQ_BONDING);
+	gatt_client_init();
+	hids_client_init(s_hid_desc_storage, sizeof(s_hid_desc_storage));
+
+	// Classic HID host (DS4/DS5/etc). Discoverable + connectable so Sony pads
+	// can initiate the connection inbound.
+	hid_host_init(s_classic_desc_storage, sizeof(s_classic_desc_storage));
+	hid_host_register_packet_handler(&classic_handler);
+	gap_set_class_of_device(0x000104);  // Computer / Desktop
+	gap_set_bondable_mode(1);
+	gap_ssp_set_io_capability(SSP_IO_CAPABILITY_NO_INPUT_NO_OUTPUT);
+	gap_ssp_set_auto_accept(1);
+	gap_ssp_set_authentication_requirement(
+		SSP_IO_AUTHREQ_MITM_PROTECTION_NOT_REQUIRED_DEDICATED_BONDING);
+	gap_discoverable_control(1);
+	gap_connectable_control(1);
+
+	gap_set_local_name("PicoPuck");
+
+	s_hci_cb.callback = &packet_handler;
+	hci_add_event_handler(&s_hci_cb);
+	s_sm_cb.callback = &sm_handler;
+	sm_add_event_handler(&s_sm_cb);
+
+	hci_power_control(HCI_POWER_ON);
+	s_power_on = true;
+	return true;
+}
+
+bool bt_host_ready(void)
+{
+	return s_ready;
+}
+
+void bt_host_task(void)
+{
+	if (s_scanning && s_scan_until_ms && now_ms() > s_scan_until_ms)
+		bt_scan_stop();
+
+	// A connect with no completion event would otherwise wedge scanning forever.
+	if (s_connecting.busy && now_ms() - s_connecting.started_ms > CONNECT_TIMEOUT_MS) {
+		gap_connect_cancel();
+		s_connecting.busy = false;
+	}
+
+	scan_ensure();     // keep reconnect scan matched to need
+	valve_periodic();  // SC2 lizard-off keepalive + IMU enable
+
+	// Flush latched rumble, rate-limited.
+	uint32_t t = now_ms();
+	for (int i = 0; i < PP_NSLOT; i++) {
+		conn_t *c = &g_conn[i];
+		if (!c->active || !c->rum_pending || !c->drv || !c->drv->build_rumble)
+			continue;
+		if (t - c->rum_last_ms < RUMBLE_MIN_GAP_MS)
+			continue;
+		uint8_t buf[96];
+		uint8_t n = c->drv->build_rumble(c->rum_lo, c->rum_hi, buf, sizeof(buf));
+		if (n) {
+			if (c->is_classic)
+				hid_host_send_set_report(c->hids_cid,
+							 HID_REPORT_TYPE_OUTPUT,
+							 c->drv->rumble_report_id,
+							 buf, n);
+			else
+				hids_client_send_write_report(
+					c->hids_cid, c->drv->rumble_report_id,
+					HID_REPORT_TYPE_OUTPUT, buf, n);
+			c->rum_last_ms = t;
+			c->rum_pending = false;
+		}
+	}
+}
+
+// ---- relay (host → controller): rumble for generic pads --------------------
+void bt_relay(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len)
+{
+	if (slot < 0 || slot >= PP_NSLOT || !g_conn[slot].active)
+		return;
+
+	// SC2: forward the whole command verbatim to the Valve report characteristic
+	// (this is the transparent haptics/settings/power path).
+	if (g_conn[slot].is_valve) {
+		valve_feature_write(slot, cmd, payload, len);
+		return;
+	}
+
+	// Generic pad: map Steam's 0x80 rumble to the pad's rumble output.
+	// 0x80 layout: [type][..][lo u16 @3][.. ][hi u16 @6].
+	if (cmd == 0x80 && len >= 8) {
+		g_conn[slot].rum_lo = (uint16_t)(payload[3] | (payload[4] << 8));
+		g_conn[slot].rum_hi = (uint16_t)(payload[6] | (payload[7] << 8));
+		g_conn[slot].rum_pending = true;
+	}
+}
+
+// ---- bt_control (panel surface) --------------------------------------------
+bool bt_slot_info(int slot, uint8_t *kind, int8_t *rssi, uint8_t addr[6],
+		  char name[16])
+{
+	if (slot < 0 || slot >= PP_NSLOT || !g_conn[slot].active)
+		return false;
+	conn_t *c = &g_conn[slot];
+	*kind = c->drv ? c->drv->kind : 0;
+	*rssi = c->rssi;
+	memcpy(addr, c->addr, 6);
+	memcpy(name, c->name, 16);
+	return true;
+}
+
+void bt_scan_start(uint16_t seconds)
+{
+	s_scan_n = 0;
+	s_adv_seen = 0;
+	s_scanning = true;
+	scan_ensure();          // BLE scan at UI duty
+	gap_inquiry_start(8);   // Classic (~10 s rounds, restarted on complete)
+	s_scan_until_ms = seconds ? (now_ms() + (uint32_t)seconds * 1000) : 0;
+}
+
+void bt_scan_stop(void)
+{
+	if (s_scanning)
+		gap_inquiry_stop();
+	s_scanning = false;
+	s_scan_until_ms = 0;
+	scan_ensure();  // drop to background reconnect duty (or off)
+}
+
+bool bt_scan_active(void)
+{
+	return s_scanning;
+}
+
+void bt_scan_diag(uint16_t *adv_seen, uint8_t *flags, uint8_t *hci_state,
+		  uint16_t *hci_events)
+{
+	if (adv_seen)
+		*adv_seen = s_adv_seen;
+	if (flags)
+		*flags = (uint8_t)((s_scan_running ? 1 : 0) |
+				   (s_ready ? 2 : 0) |
+				   (s_connecting.busy ? 4 : 0) |
+				   (s_bt_init_ok ? 8 : 0) |
+				   (s_power_on ? 16 : 0));
+	if (hci_state)
+		*hci_state = s_last_state;
+	if (hci_events)
+		*hci_events = s_hci_events;
+}
+
+uint8_t bt_scan_list(bt_scan_entry_t *out, uint8_t max)
+{
+	uint8_t n = s_scan_n < max ? s_scan_n : max;
+	memcpy(out, s_scan, n * sizeof(bt_scan_entry_t));
+	return n;
+}
+
+bool bt_pair(const uint8_t addr[6], uint8_t addr_type)
+{
+	// Look up the scanned entry to carry its name/kind into the connection.
+	uint8_t kind = 2;
+	memset(&s_connecting, 0, sizeof(s_connecting));
+	for (int i = 0; i < s_scan_n; i++)
+		if (memcmp(s_scan[i].addr, addr, 6) == 0) {
+			memcpy(s_connecting.name, s_scan[i].name,
+			       sizeof(s_connecting.name));
+			kind = s_scan[i].kind;
+		}
+	memcpy(s_connecting.addr, addr, 6);
+	s_connecting.addr_type = addr_type;
+	s_connecting.busy = true;
+	s_connecting.started_ms = now_ms();
+
+	if (kind == 3) {
+		// Classic: connect the HID host directly (slot bound on OPENED).
+		uint16_t cid;
+		uint8_t r = hid_host_connect((uint8_t *)addr,
+					     HID_PROTOCOL_MODE_REPORT, &cid);
+		if (r != ERROR_CODE_SUCCESS) {
+			s_connecting.busy = false;
+			return false;
+		}
+		return true;
+	}
+
+	// BLE: connect directly (slot bound on connection complete).
+	memcpy(s_pair_addr, addr, 6);
+	s_pair_type = addr_type;
+	if (s_scan_running) {
+		gap_stop_scan();
+		s_scan_running = false;
+	}
+	uint8_t r = gap_connect((uint8_t *)addr, (bd_addr_type_t)addr_type);
+	if (r != ERROR_CODE_SUCCESS) {
+		s_connecting.busy = false;
+		s_pair_pending = true;  // fall back to connecting on next advert
+		return false;
+	}
+	return true;
+}
+
+static void conn_disconnect(conn_t *c)
+{
+	if (c->is_classic)
+		hid_host_disconnect(c->hids_cid);
+	else
+		gap_disconnect(c->handle);
+}
+
+void bt_forget(const uint8_t addr[6], uint8_t addr_type)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (g_conn[i].active && memcmp(g_conn[i].addr, addr, 6) == 0)
+			conn_disconnect(&g_conn[i]);
+	// Drop both bond stores (whichever holds it).
+	gap_delete_bonding((bd_addr_type_t)addr_type, (uint8_t *)addr);
+	gap_drop_link_key_for_bd_addr((uint8_t *)addr);
+}
+
+void bt_forget_all(void)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (g_conn[i].active)
+			conn_disconnect(&g_conn[i]);
+	// Wipe the LE bond database.
+	int max = le_device_db_max_count();
+	for (int i = 0; i < max; i++) {
+		int addr_type;
+		bd_addr_t a;
+		le_device_db_info(i, &addr_type, a, NULL);
+		if (addr_type != (int)BD_ADDR_TYPE_UNKNOWN)
+			le_device_db_remove(i);
+	}
+	// Classic link keys.
+	gap_delete_all_link_keys();
+}
+
+void bt_disconnect_slot(int slot)
+{
+	if (slot >= 0 && slot < PP_NSLOT && g_conn[slot].active)
+		conn_disconnect(&g_conn[slot]);
+}

+ 23 - 0
PicoPuck/src/bt/bt_host.h

@@ -0,0 +1,23 @@
+// bt_host.h — BTstack dual-mode (Classic + BLE) controller host.
+//
+// Owns the CYW43 radio via BTstack, discovers game controllers, and (in later
+// increments) routes their input into the puck slots. Runs on the CYW43
+// async_context, which is serviced by cyw43_arch_poll() in the main loop.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_BT_HOST_H
+#define PICOPUCK_BT_HOST_H
+
+#include <stdbool.h>
+
+// Bring up BTstack on the (already-initialised) CYW43 radio and power on the
+// controller. Returns false if BTstack init failed.
+bool bt_host_init(void);
+
+// True once the HCI stack has reached the working state.
+bool bt_host_ready(void);
+
+// Periodic housekeeping: scan timeout + rumble flush. Call each loop.
+void bt_host_task(void);
+
+#endif // PICOPUCK_BT_HOST_H

+ 259 - 0
PicoPuck/src/bt/bt_valve.c

@@ -0,0 +1,259 @@
+// bt_valve.c — Steam Controller 2 native-BLE client (see bt_valve.h).
+//
+// Ported from joypad-os btstack_host.c's Valve GATT client. One instance per
+// slot; events are demuxed by con_handle.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "bt/bt_valve.h"
+#include "puck/personality.h"
+#include "puck/triton.h"
+#include "config/picopuck_config.h"
+
+#include <stdio.h>
+#include <string.h>
+#include "pico/time.h"
+
+// 128-bit UUIDs in textual/big-endian order (as BTstack's uuid128 API expects).
+static const uint8_t VALVE_SERVICE[16] = { 0x10, 0x0F, 0x6C, 0x32, 0x17, 0x35,
+					   0x43, 0x13, 0xB4, 0x02, 0x38, 0x56,
+					   0x71, 0x31, 0xE5, 0xF3 };
+static const uint8_t VALVE_INPUT_45[16] = { 0x10, 0x0F, 0x6C, 0x7A, 0x17, 0x35,
+					    0x43, 0x13, 0xB4, 0x02, 0x38, 0x56,
+					    0x71, 0x31, 0xE5, 0xF3 };
+static const uint8_t VALVE_INPUT_47[16] = { 0x10, 0x0F, 0x6C, 0x7C, 0x17, 0x35,
+					    0x43, 0x13, 0xB4, 0x02, 0x38, 0x56,
+					    0x71, 0x31, 0xE5, 0xF3 };
+static const uint8_t VALVE_REPORT[16] = { 0x10, 0x0F, 0x6C, 0x34, 0x17, 0x35,
+					  0x43, 0x13, 0xB4, 0x02, 0x38, 0x56,
+					  0x71, 0x31, 0xE5, 0xF3 };
+
+// The SC2 re-enables lizard (keyboard/mouse) mode on a ~3 s watchdog, so resend
+// lizard-off well inside that. {0x87 ID_SET_SETTINGS, 0x03 len, 0x09 id, u16 0}.
+#define VALVE_KEEPALIVE_MS 2000u
+static uint8_t s_lizard_off[5] = { 0x87, 0x03, 0x09, 0x00, 0x00 };
+// IMU on: SEND_RAW_ACCEL(0x08) | SEND_RAW_GYRO(0x10) = 0x18 on setting id 0x30.
+static uint8_t s_imu_on[5] = { 0x87, 0x03, 0x30, 0x18, 0x00 };
+
+typedef enum {
+	V_IDLE = 0,
+	V_DISC_SERVICE,
+	V_DISC_CHARS,
+	V_ENABLE_CCC,
+	V_READY,
+} valve_state_t;
+
+typedef struct {
+	valve_state_t state;
+	int slot;
+	hci_con_handle_t handle;
+	gatt_client_service_t service;
+	gatt_client_characteristic_t input_char;
+	uint8_t report_id;              // 0x45 or 0x47
+	uint16_t report_value_handle;   // 100F6C34 write target
+	gatt_client_notification_t notify;
+	uint32_t last_keepalive_ms;
+	bool imu_enabled;
+} valve_t;
+
+static valve_t s_valve[PP_NSLOT];
+
+static uint32_t now_ms(void)
+{
+	return to_ms_since_boot(get_absolute_time());
+}
+
+static valve_t *by_handle(hci_con_handle_t h)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (s_valve[i].state != V_IDLE && s_valve[i].handle == h)
+			return &s_valve[i];
+	return NULL;
+}
+
+static void valve_reset(valve_t *v)
+{
+	if (v->state == V_READY)
+		gatt_client_stop_listening_for_characteristic_value_updates(&v->notify);
+	memset(v, 0, sizeof(*v));
+	v->handle = HCI_CON_HANDLE_INVALID;
+}
+
+void valve_disconnected(hci_con_handle_t handle)
+{
+	valve_t *v = by_handle(handle);
+	if (v)
+		valve_reset(v);
+}
+
+// Input notifications: prepend the report id and forward verbatim to the slot.
+static void notify_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			   uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET ||
+	    hci_event_packet_get_type(packet) != GATT_EVENT_NOTIFICATION)
+		return;
+	hci_con_handle_t h = gatt_event_notification_get_handle(packet);
+	valve_t *v = by_handle(h);
+	if (!v || v->state != V_READY)
+		return;
+	if (gatt_event_notification_get_value_handle(packet) !=
+	    v->input_char.value_handle)
+		return;
+
+	uint16_t len = gatt_event_notification_get_value_length(packet);
+	const uint8_t *val = gatt_event_notification_get_value(packet);
+	if (len < 18)
+		return;
+
+	uint8_t rep[PUCK45_LEN + 8];
+	if ((size_t)len + 1 > sizeof(rep))
+		len = sizeof(rep) - 1;
+	rep[0] = v->report_id;
+	memcpy(rep + 1, val, len);
+	puck_present_raw(v->slot, rep, (uint8_t)(len + 1));  // transparent forward
+}
+
+static void write_cb(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size)
+{
+	(void)type;
+	(void)channel;
+	(void)packet;
+	(void)size;  // completion only; ignore
+}
+
+static void gatt_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			 uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET)
+		return;
+	hci_con_handle_t h = gatt_event_query_complete_get_handle(packet);
+	valve_t *v = by_handle(h);
+	if (!v)
+		return;
+
+	switch (hci_event_packet_get_type(packet)) {
+	case GATT_EVENT_SERVICE_QUERY_RESULT:
+		gatt_event_service_query_result_get_service(packet, &v->service);
+		break;
+	case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: {
+		gatt_client_characteristic_t ch;
+		gatt_event_characteristic_query_result_get_characteristic(packet, &ch);
+		if (memcmp(ch.uuid128, VALVE_INPUT_45, 16) == 0) {
+			v->input_char = ch;
+			v->report_id = 0x45;
+		} else if (memcmp(ch.uuid128, VALVE_INPUT_47, 16) == 0 &&
+			   v->report_id == 0) {
+			v->input_char = ch;
+			v->report_id = 0x47;
+		} else if (memcmp(ch.uuid128, VALVE_REPORT, 16) == 0) {
+			v->report_value_handle = ch.value_handle;
+		}
+		break;
+	}
+	case GATT_EVENT_QUERY_COMPLETE: {
+		uint8_t status = gatt_event_query_complete_get_att_status(packet);
+		if (status != ATT_ERROR_SUCCESS) {
+			printf("[sc2] GATT query failed state=%d status=0x%02X\n",
+			       v->state, status);
+			valve_reset(v);
+			break;
+		}
+		if (v->state == V_DISC_SERVICE) {
+			if (v->service.start_group_handle == 0) {
+				printf("[sc2] no Valve service\n");
+				valve_reset(v);
+				break;
+			}
+			v->state = V_DISC_CHARS;
+			gatt_client_discover_characteristics_for_service(
+				gatt_handler, v->handle, &v->service);
+		} else if (v->state == V_DISC_CHARS) {
+			if (v->report_id == 0 || v->input_char.value_handle == 0) {
+				printf("[sc2] no Valve input characteristic\n");
+				valve_reset(v);
+				break;
+			}
+			v->state = V_ENABLE_CCC;
+			gatt_client_write_client_characteristic_configuration(
+				gatt_handler, v->handle, &v->input_char,
+				GATT_CLIENT_CHARACTERISTICS_CONFIGURATION_NOTIFICATION);
+		} else if (v->state == V_ENABLE_CCC) {
+			gatt_client_listen_for_characteristic_value_updates(
+				&v->notify, notify_handler, v->handle,
+				&v->input_char);
+			v->state = V_READY;
+			v->last_keepalive_ms = now_ms();
+			printf("[sc2] ready (report id 0x%02X)\n", v->report_id);
+		}
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+void valve_start(int slot, hci_con_handle_t handle)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	valve_t *v = &s_valve[slot];
+	memset(v, 0, sizeof(*v));
+	v->slot = slot;
+	v->handle = handle;
+	v->state = V_DISC_SERVICE;
+	printf("[sc2] discovering Valve service (slot %d)\n", slot);
+	gatt_client_discover_primary_services_by_uuid128(gatt_handler, handle,
+							 VALVE_SERVICE);
+}
+
+void valve_periodic(void)
+{
+	for (int i = 0; i < PP_NSLOT; i++) {
+		valve_t *v = &s_valve[i];
+		if (v->state != V_READY || v->report_value_handle == 0)
+			continue;
+		if (!gatt_client_is_ready(v->handle))
+			continue;  // one GATT op at a time
+
+		// Enable IMU once — but only when Steam isn't configuring it itself
+		// (its own 0x87 writes are relayed through; don't fight them).
+		if (!v->imu_enabled && !puck_steam_active()) {
+			v->imu_enabled = true;
+			gatt_client_write_value_of_characteristic(
+				write_cb, v->handle, v->report_value_handle,
+				sizeof(s_imu_on), s_imu_on);
+			continue;
+		}
+		uint32_t t = now_ms();
+		if (t - v->last_keepalive_ms >= VALVE_KEEPALIVE_MS) {
+			v->last_keepalive_ms = t;
+			gatt_client_write_value_of_characteristic(
+				write_cb, v->handle, v->report_value_handle,
+				sizeof(s_lizard_off), s_lizard_off);
+		}
+	}
+}
+
+void valve_feature_write(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	valve_t *v = &s_valve[slot];
+	if (v->state != V_READY || v->report_value_handle == 0)
+		return;
+	if (!gatt_client_is_ready(v->handle))
+		return;  // busy → drop (Steam re-sends); keeps it simple and non-blocking
+	if (len > 60)
+		len = 60;
+	uint8_t buf[64];
+	buf[0] = cmd;
+	buf[1] = (uint8_t)len;
+	memcpy(buf + 2, payload, len);
+	gatt_client_write_value_of_characteristic(write_cb, v->handle,
+						  v->report_value_handle,
+						  (uint16_t)(len + 2), buf);
+}

+ 32 - 0
PicoPuck/src/bt/bt_valve.h

@@ -0,0 +1,32 @@
+// bt_valve.h — Steam Controller 2 native-BLE client (Valve proprietary GATT).
+//
+// The SC2 in Bluetooth mode does NOT use HID-over-GATT. It exposes Valve's
+// custom service 100F6C32-…: an input-notify characteristic carrying report
+// 0x45 (or the 0x47 variant) and a writable "report" characteristic for feature
+// reports. This client discovers the service, subscribes to input (forwarded
+// verbatim to the puck slot for transparent Steam operation), keeps the pad in
+// gamepad mode (lizard-off keepalive), and writes Steam's relayed commands
+// straight through. Reference: SDL SD_hidapi_steam_triton.c and joypad-os.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_BT_VALVE_H
+#define PICOPUCK_BT_VALVE_H
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "btstack.h"
+
+// Begin Valve GATT discovery for a freshly-secured SC2 on `slot`.
+void valve_start(int slot, hci_con_handle_t handle);
+
+// Tear down the client for a disconnected handle.
+void valve_disconnected(hci_con_handle_t handle);
+
+// Periodic: resend the lizard-off keepalive (~2 s) and enable IMU once. Call each loop.
+void valve_periodic(void);
+
+// Relay one host command (cmd + payload) to the SC2's report characteristic as
+// the on-air feature form [cmd][len][payload].
+void valve_feature_write(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len);
+
+#endif // PICOPUCK_BT_VALVE_H

+ 107 - 0
PicoPuck/src/bt/drv_ds4.c

@@ -0,0 +1,107 @@
+// drv_ds4.c — Sony DualShock 4 over Bluetooth Classic (HID).
+//
+// Report layout ported from joypad-os ds4_bt.c. Reports arrive body-only (the
+// report-id byte is stripped by the host before decode): BT report 0x11 carries
+// two header bytes before the standard DS4 input block; the basic 0x01 report
+// starts the block immediately. Rumble/LED go out as report 0x11 via SET_REPORT
+// on the control channel (no CRC needed on that path).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "bt/input_driver.h"
+#include <string.h>
+
+static bool ds4_match(uint16_t vid, uint16_t pid, const char *devname, bool is_ble)
+{
+	(void)pid;
+	if (is_ble)
+		return false;  // DS4 pairs over Classic
+	if (vid == 0x054C)
+		return true;
+	return devname && (strstr(devname, "Wireless Controller") ||
+			   strstr(devname, "DUALSHOCK") || strstr(devname, "DualShock"));
+}
+
+static bool ds4_decode(uint8_t report_id, const uint8_t *d, uint16_t len,
+		       puck_input_t *io)
+{
+	const uint8_t *b;  // start of the DS4 input block
+	if (report_id == 0x11 && len >= 2 + 11)
+		b = d + 2;  // BT full report: 2 header bytes precede the block
+	else if (report_id == 0x01 && len >= 11)
+		b = d;
+	else
+		return false;
+
+	uint8_t x = b[0], y = b[1], z = b[2], rz = b[3];
+	uint8_t b0 = b[4], b1 = b[5], b2 = b[6];
+	uint8_t dpad = b0 & 0x0F;
+	bool up = (dpad == 0 || dpad == 1 || dpad == 7);
+	bool right = (dpad >= 1 && dpad <= 3);
+	bool down = (dpad >= 3 && dpad <= 5);
+	bool left = (dpad >= 5 && dpad <= 7);
+
+	uint32_t bt = 0;
+	if (up) bt |= TB_DUP;
+	if (down) bt |= TB_DDN;
+	if (left) bt |= TB_DLF;
+	if (right) bt |= TB_DRT;
+	if (b0 & 0x20) bt |= TB_A;    // cross → south
+	if (b0 & 0x40) bt |= TB_B;    // circle → east
+	if (b0 & 0x10) bt |= TB_X;    // square → west
+	if (b0 & 0x80) bt |= TB_Y;    // triangle → north
+	if (b1 & 0x01) bt |= TB_LB;   // L1
+	if (b1 & 0x02) bt |= TB_RB;   // R1
+	if (b1 & 0x04) bt |= TB_L2;   // L2 click
+	if (b1 & 0x08) bt |= TB_R2;   // R2 click
+	if (b1 & 0x10) bt |= TB_VIEW; // share → View
+	if (b1 & 0x20) bt |= TB_MENU; // options → Menu
+	if (b1 & 0x40) bt |= TB_L3;
+	if (b1 & 0x80) bt |= TB_R3;
+	if (b2 & 0x01) bt |= TB_STEAM; // PS → guide
+	if (b2 & 0x02) bt |= TB_QAM;   // touchpad click → QAM
+
+	io->buttons = bt;
+	io->lx = (int16_t)(((int)x - 128) << 8);
+	io->ly = (int16_t)((128 - (int)y) << 8);
+	io->rx = (int16_t)(((int)z - 128) << 8);
+	io->ry = (int16_t)((128 - (int)rz) << 8);
+	io->lt = b[7];
+	io->rt = b[8];
+
+	// IMU (full report only): gyro then accel, int16 LE, after timestamp+temp.
+	if (report_id == 0x11 && len >= 2 + 24) {
+		io->gx = (int16_t)(b[12] | (b[13] << 8));
+		io->gy = (int16_t)(b[14] | (b[15] << 8));
+		io->gz = (int16_t)(b[16] | (b[17] << 8));
+		io->ax = (int16_t)(b[18] | (b[19] << 8));
+		io->ay = (int16_t)(b[20] | (b[21] << 8));
+		io->az = (int16_t)(b[22] | (b[23] << 8));
+	}
+	return true;
+}
+
+static uint8_t ds4_rumble(uint16_t lo, uint16_t hi, uint8_t *out, uint8_t max)
+{
+	// Report 0x11 output body (report-id supplied separately to SET_REPORT).
+	const uint8_t BODY = 77;
+	if (max < BODY)
+		return 0;
+	memset(out, 0, BODY);
+	out[0] = 0x80;  // BT flags
+	out[2] = 0xFF;  // enable rumble + LED
+	out[5] = (uint8_t)(hi >> 8);  // right / high-freq motor
+	out[6] = (uint8_t)(lo >> 8);  // left / low-freq motor
+	out[7] = 0x00;  // LED R
+	out[8] = 0x00;  // LED G
+	out[9] = 0x40;  // LED B (blue idle)
+	return BODY;
+}
+
+const input_driver_t drv_ds4 = {
+	.name = "DualShock 4 (Classic)",
+	.kind = 3,
+	.match = ds4_match,
+	.decode = ds4_decode,
+	.build_rumble = ds4_rumble,
+	.rumble_report_id = 0x11,
+};

+ 38 - 0
PicoPuck/src/bt/drv_sc2.c

@@ -0,0 +1,38 @@
+// drv_sc2.c — Steam Controller 2 identity (BLE, Valve GATT).
+//
+// The SC2's input is forwarded verbatim by the Valve GATT client (bt_valve.c),
+// not decoded here — so this driver exists only to (a) claim the device so the
+// host routes it to the Valve path and (b) label it "Steam Controller 2" (kind 1)
+// in the panel. A future emulated-mode path could add a real decode().
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "bt/input_driver.h"
+#include <string.h>
+
+static bool sc2_match(uint16_t vid, uint16_t pid, const char *devname, bool is_ble)
+{
+	(void)pid;
+	if (!is_ble)
+		return false;
+	if (vid == 0x28DE)
+		return true;
+	return devname && strstr(devname, "Steam") != NULL;
+}
+
+static bool sc2_decode(uint8_t rid, const uint8_t *d, uint16_t len, puck_input_t *io)
+{
+	(void)rid;
+	(void)d;
+	(void)len;
+	(void)io;
+	return false;  // handled verbatim by bt_valve.c
+}
+
+const input_driver_t drv_sc2 = {
+	.name = "Steam Controller 2 (BLE)",
+	.kind = 1,
+	.match = sc2_match,
+	.decode = sc2_decode,
+	.build_rumble = NULL,
+	.rumble_report_id = 0,
+};

+ 116 - 0
PicoPuck/src/bt/drv_xinput.c

@@ -0,0 +1,116 @@
+// drv_xinput.c — Xbox One S / Series controller over Bluetooth (HOGP).
+//
+// Ported from OpenPuck's ble-test drv_xinput_ble.cpp. Also covers 8BitDo pads in
+// Xbox mode (same layout). Input report 0x01 = gamepad state, 0x02 = legacy
+// separate guide button (pre-fw5). Rumble = output report 0x03.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "bt/input_driver.h"
+#include <string.h>
+#include <ctype.h>
+
+static bool name_has(const char *hay, const char *needle)
+{
+	if (!hay)
+		return false;
+	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;
+}
+
+static bool xi_match(uint16_t vid, uint16_t pid, const char *devname, bool is_ble)
+{
+	(void)pid;
+	if (!is_ble)
+		return false;  // Classic Xbox pads are proprietary; BLE only here
+	if (vid == 0x045E || vid == 0x2DC8)
+		return true;
+	return name_has(devname, "xbox") || name_has(devname, "8bitdo");
+}
+
+static bool xi_decode(uint8_t rid, const uint8_t *d, uint16_t len,
+		      puck_input_t *io)
+{
+	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));
+	uint16_t ly = (uint16_t)(d[2] | (d[3] << 8));
+	uint16_t rx = (uint16_t)(d[4] | (d[5] << 8));
+	uint16_t ry = (uint16_t)(d[6] | (d[7] << 8));
+	uint16_t lt = (uint16_t)(d[8] | (d[9] << 8));
+	uint16_t 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 = triton_hat_bits(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 in-band
+		if (b2 & 0x02) b |= TB_L3;
+		if (b2 & 0x04) b |= TB_R3;
+	} else {
+		b |= io->buttons & TB_STEAM;  // 14-byte: guide latched from report 0x02
+	}
+	if (b3 & 0x01) b |= TB_QAM;  // Series share → QAM
+	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));
+	return true;
+}
+
+static uint8_t xi_rumble(uint16_t lo, uint16_t hi, uint8_t *out, uint8_t max)
+{
+	if (max < 8)
+		return 0;
+	out[0] = 0x0F;  // enable all four motors
+	out[1] = 0;     // trigger-left magnitude (impulse triggers)
+	out[2] = 0;     // trigger-right magnitude
+	out[3] = (uint8_t)((uint32_t)lo * 100 / 0xFFFF);  // strong (low-freq)
+	out[4] = (uint8_t)((uint32_t)hi * 100 / 0xFFFF);  // weak (high-freq)
+	out[5] = 0xFF;  // sustain ~2.5 s (the stream refreshes long rumbles)
+	out[6] = 0x00;  // release
+	out[7] = 0x00;  // no loop — a lost "off" must not buzz forever
+	return 8;
+}
+
+const input_driver_t drv_xinput = {
+	.name = "Xbox (BLE)",
+	.kind = 2,
+	.match = xi_match,
+	.decode = xi_decode,
+	.build_rumble = xi_rumble,
+	.rumble_report_id = 0x03,
+};

+ 29 - 0
PicoPuck/src/bt/input_driver.c

@@ -0,0 +1,29 @@
+// input_driver.c — driver registry (see input_driver.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "bt/input_driver.h"
+#include <stddef.h>
+
+extern const input_driver_t drv_sc2;
+extern const input_driver_t drv_xinput;
+extern const input_driver_t drv_ds4;
+// Future Classic pads (same interface): drv_ds5 (needs CRC32 output + adaptive
+// triggers) and drv_switch_pro (needs a subcommand init handshake).
+
+// Most specific first: SC2 (by name/VID) before the generic BLE/Classic matchers.
+static const input_driver_t *const REGISTRY[] = {
+	&drv_sc2,
+	&drv_xinput,
+	&drv_ds4,
+};
+
+const input_driver_t *input_driver_match(uint16_t vid, uint16_t pid,
+					 const char *devname, bool is_ble)
+{
+	for (size_t i = 0; i < sizeof(REGISTRY) / sizeof(REGISTRY[0]); i++) {
+		const input_driver_t *d = REGISTRY[i];
+		if (d->match && d->match(vid, pid, devname, is_ble))
+			return d;
+	}
+	return NULL;
+}

+ 38 - 0
PicoPuck/src/bt/input_driver.h

@@ -0,0 +1,38 @@
+// input_driver.h — per-controller decode/rumble interface.
+//
+// A driver claims a connected controller (by VID/PID/name/transport), decodes
+// its HID input reports into a puck_input_t, and builds a rumble output report.
+// hids_client (BLE) and the Classic HID host both deliver reports as
+// (report_id, data, len), so drivers key off report_id directly.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_INPUT_DRIVER_H
+#define PICOPUCK_INPUT_DRIVER_H
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "puck/triton.h"
+
+typedef struct input_driver {
+	const char *name;
+	uint8_t kind;  // matches bt_control kind: 1 SC2, 2 BLE gamepad, 3 Classic gamepad
+
+	// Claim this device? `is_ble` distinguishes BLE from Classic transports.
+	bool (*match)(uint16_t vid, uint16_t pid, const char *devname, bool is_ble);
+
+	// Decode one input report into `io` (accumulating). Returns true if `io`
+	// changed and should be presented.
+	bool (*decode)(uint8_t report_id, const uint8_t *d, uint16_t len,
+		       puck_input_t *io);
+
+	// Build a rumble output report from 16-bit magnitudes. Returns its length
+	// (0 = no rumble); the report id to send it on is `rumble_report_id`.
+	uint8_t (*build_rumble)(uint16_t lo, uint16_t hi, uint8_t *out, uint8_t max);
+	uint8_t rumble_report_id;
+} input_driver_t;
+
+// First driver whose match() accepts the device, or NULL.
+const input_driver_t *input_driver_match(uint16_t vid, uint16_t pid,
+					 const char *devname, bool is_ble);
+
+#endif // PICOPUCK_INPUT_DRIVER_H

+ 70 - 0
PicoPuck/src/config/btstack_config.h

@@ -0,0 +1,70 @@
+// btstack_config.h — BTstack configuration for PicoPuck (CYW43, dual-mode).
+//
+// Derived from joypad-os src/bt/btstack/btstack_config.h with PicoPuck deltas:
+// four controller slots instead of two, and no BLE-peripheral / HIDS-device
+// role (we are a central/host only). Not compiled until the BTstack libraries
+// are linked (Phase 2).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef BTSTACK_CONFIG_H
+#define BTSTACK_CONFIG_H
+
+// ---- port features ---------------------------------------------------------
+#define HAVE_EMBEDDED_TIME_MS
+#define HAVE_BTSTACK_STDIN
+#define HAVE_MALLOC              // CYW43 path provides malloc
+#define HAVE_PRINTF
+#define ENABLE_PRINTF_HEXDUMP
+
+// ---- BTstack features ------------------------------------------------------
+#ifndef ENABLE_BLE
+#define ENABLE_BLE
+#endif
+#define ENABLE_LE_CENTRAL
+// LE peripheral is enabled even though we never advertise: parts of BTstack's
+// hci.c reference advertisement state that only exists under this flag, so the
+// central-only build fails to compile without it. Matches joypad-os's config.
+#define ENABLE_LE_PERIPHERAL
+#define ENABLE_LE_SECURE_CONNECTIONS
+
+#ifndef ENABLE_CLASSIC
+#define ENABLE_CLASSIC
+#endif
+
+#define ENABLE_LOG_ERROR
+#define ENABLE_LOG_INFO
+// #define ENABLE_LOG_DEBUG      // verbose; enable when debugging BT bring-up
+
+#define ENABLE_SOFTWARE_AES128
+#define ENABLE_MICRO_ECC_FOR_LE_SECURE_CONNECTIONS
+
+// ---- buffer sizes ----------------------------------------------------------
+#define HCI_ACL_PAYLOAD_SIZE 256
+#define HCI_INCOMING_PRE_BUFFER_SIZE 14
+
+#ifdef BTSTACK_USE_CYW43
+#define HCI_OUTGOING_PRE_BUFFER_SIZE 4
+#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4
+#endif
+
+// ---- memory pools (sized for four controller slots) ------------------------
+// Whether the CYW43 firmware actually schedules four concurrent links well is
+// an open hardware question (see README risks); the pools allow it so the
+// ceiling is measured, not baked in below the target.
+#define MAX_NR_HCI_CONNECTIONS 4
+#define MAX_NR_L2CAP_CHANNELS 16
+#define MAX_NR_L2CAP_SERVICES 3
+#define MAX_NR_GATT_CLIENTS 5
+#define MAX_NR_WHITELIST_ENTRIES 4
+#define MAX_NR_LE_DEVICE_DB_ENTRIES 4
+#define NVM_NUM_LINK_KEYS 4
+#define MAX_NR_BTSTACK_LINK_KEY_DB_MEMORY_ENTRIES 8
+#define NVM_NUM_DEVICE_DB_ENTRIES 8
+
+// ---- HID host --------------------------------------------------------------
+#define ENABLE_HID_HOST
+#define MAX_NR_HID_HOST_CONNECTIONS 4
+#define MAX_NR_HIDS_CLIENTS 4
+#define MAX_NR_BATTERY_SERVICE_CLIENTS 4
+
+#endif // BTSTACK_CONFIG_H

+ 38 - 0
PicoPuck/src/config/picopuck_config.h

@@ -0,0 +1,38 @@
+// picopuck_config.h — compile-time constants shared across PicoPuck.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_CONFIG_H
+#define PICOPUCK_CONFIG_H
+
+// ---- puck presentation -----------------------------------------------------
+// Number of controller "slots" the puck exposes. The real Valve puck enumerates
+// four HID interfaces (one per bond slot); we always present all four so Steam
+// sees a normal puck regardless of how many controllers are actually connected.
+#define PP_NSLOT 4
+
+// USB identity — identical to a real Valve puck so Steam treats us as one.
+#define PP_USB_VID 0x28DE
+#define PP_USB_PID 0x1304
+
+// bcdDevice discriminates PicoPuck from the nRF OpenPuck (0x02xx) and the
+// ReversePuck dongle (PID 0x1302) for the WebUSB panel and for Windows'
+// per-(VID,PID,bcdDevice) descriptor cache (our interface layout differs).
+//   0x03x0 = release, 0x03x1 = debug (CDC console) build.
+#define PP_BCD_DEVICE 0x0310
+#define PP_BCD_DEVICE_CDC 0x0311
+
+// Board type reported in the WebUSB status frame.
+#define PP_BOARD_PICO_W 1
+#define PP_BOARD_PICO2_W 2
+
+#if defined(PICO_RP2350) || defined(PICO_PLATFORM_RP2350)
+#define PP_BOARD PP_BOARD_PICO2_W
+#else
+#define PP_BOARD PP_BOARD_PICO_W
+#endif
+
+// ---- cadences (ms) ---------------------------------------------------------
+#define PP_STATUS_LED_MS 500      // heartbeat blink period while idle
+#define PP_WATCHDOG_MS 4000       // hardware watchdog timeout
+
+#endif // PICOPUCK_CONFIG_H

+ 71 - 0
PicoPuck/src/config/tusb_config.h

@@ -0,0 +1,71 @@
+// tusb_config.h — TinyUSB device configuration for PicoPuck.
+//
+// Device-only. The puck presents four HID interfaces (one per bond slot) plus a
+// WebUSB vendor interface for the config panel, and an optional CDC console on
+// debug builds. Not compiled until tinyusb_device is linked (Phase 1).
+//
+// SPDX-License-Identifier: MIT (TinyUSB config template) / AGPL-3.0-or-later
+#ifndef _TUSB_CONFIG_H_
+#define _TUSB_CONFIG_H_
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifndef CFG_TUSB_MCU
+#error CFG_TUSB_MCU must be defined
+#endif
+
+#include "config/picopuck_config.h"  // PP_NSLOT
+
+#define CFG_TUSB_RHPORT0_MODE OPT_MODE_DEVICE
+
+// The Pico SDK sets CFG_TUSB_OS (OPT_OS_PICO) on the command line; only default
+// it if building outside that environment.
+#ifndef CFG_TUSB_OS
+#define CFG_TUSB_OS OPT_OS_NONE
+#endif
+
+#ifndef CFG_TUSB_DEBUG
+#define CFG_TUSB_DEBUG 0
+#endif
+
+#ifndef CFG_TUD_MEM_SECTION
+#define CFG_TUD_MEM_SECTION
+#endif
+#ifndef CFG_TUD_MEM_ALIGN
+#define CFG_TUD_MEM_ALIGN __attribute__((aligned(4)))
+#endif
+
+#define CFG_TUD_ENDPOINT0_SIZE 64
+
+// ---- class driver counts ---------------------------------------------------
+// Four puck slot interfaces, each a HID instance sharing the cloned puck report
+// descriptor (input 0x40-0x45/0x79/0x7B, output 0x80-0x89, feature 0x01/0x02).
+#define CFG_TUD_HID PP_NSLOT
+#define CFG_TUD_VENDOR 1  // WebUSB config channel
+
+// Optional CDC debug console (debug builds). Kept off by default; the endpoint
+// budget (4 HID IN + 2 vendor + 3 CDC) stays within RP2040/RP2350 limits.
+#ifdef PICOPUCK_DEBUG_CDC
+#define CFG_TUD_CDC 1
+#else
+#define CFG_TUD_CDC 0
+#endif
+#define CFG_TUD_MSC 0
+#define CFG_TUD_MIDI 0
+
+// ---- buffer sizes ----------------------------------------------------------
+#define CFG_TUD_HID_EP_BUFSIZE 64        // 64-byte feature/output reports
+#define CFG_TUD_VENDOR_RX_BUFSIZE 64
+#define CFG_TUD_VENDOR_TX_BUFSIZE 512    // status/scan frames fit in one FIFO
+#ifndef CFG_TUD_CDC_RX_BUFSIZE
+#define CFG_TUD_CDC_RX_BUFSIZE 256
+#define CFG_TUD_CDC_TX_BUFSIZE 256
+#endif
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // _TUSB_CONFIG_H_

+ 73 - 0
PicoPuck/src/main.c

@@ -0,0 +1,73 @@
+// PicoPuck main — core0 cooperative loop.
+//
+// Phase 1: bring up the puck USB identity (four HID slots + WebUSB) and answer
+// Steam's command channel with no controllers connected. Bluetooth (BTstack
+// host) is layered on in later phases; the loop already reserves its slot.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+#include "config/picopuck_config.h"
+#include "puck/identity.h"
+#include "puck/slots.h"
+#include "puck/personality.h"
+#include "usb/usb_tx.h"
+#include "usb/webusb.h"
+#include "bt/bt_host.h"
+
+#include "pico/stdlib.h"
+#include "pico/cyw43_arch.h"
+#include "hardware/watchdog.h"
+#include "tusb.h"
+
+int main(void)
+{
+	stdio_init_all();
+
+	identity_init();
+	slots_init();
+	puck_personality_init();
+	webusb_init();
+
+	// USB device stack (puck HID slots + WebUSB vendor interface).
+	tud_init(0);
+
+	// The user LED hangs off the CYW43 chip; this init also hands the radio to
+	// BTstack in a later phase (btstack_cyw43_init on the async_context).
+	bool have_radio = (cyw43_arch_init() == 0);
+
+	// Bring up the Bluetooth host on the radio (dual-mode Classic + BLE).
+	bool have_bt = have_radio && bt_host_init();
+
+	printf("\n[picopuck] boot commit=%s board=%d radio=%d bt=%d\n",
+	       PICOPUCK_GIT_COMMIT, PP_BOARD, have_radio, have_bt);
+
+	watchdog_enable(PP_WATCHDOG_MS, /*pause_on_debug=*/true);
+
+	absolute_time_t next_led = make_timeout_time_ms(PP_STATUS_LED_MS);
+	bool led_on = false;
+
+	while (1) {
+		tud_task();               // USB device events
+		usb_tx_pump();            // drain queued HID reports
+		puck_personality_task();  // 0x79 / 0x7B / 0x43 status
+		webusb_task();            // panel commands
+
+		if (have_bt) {
+			cyw43_arch_poll();  // services BTstack on its async_context
+			bt_host_task();     // scan timeout + rumble flush
+		} else if (have_radio) {
+			cyw43_arch_poll();
+		}
+
+		if (absolute_time_diff_us(get_absolute_time(), next_led) <= 0) {
+			led_on = !led_on;
+			if (have_radio)
+				cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, led_on);
+			next_led = make_timeout_time_ms(PP_STATUS_LED_MS);
+		}
+
+		watchdog_update();
+	}
+
+	return 0;
+}

+ 36 - 0
PicoPuck/src/puck/identity.c

@@ -0,0 +1,36 @@
+// identity.c — puck identity (see identity.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/identity.h"
+
+#include <stdio.h>
+#include <string.h>
+#include "pico/unique_id.h"
+
+// 0x83 attributes (product 0x1304, stored as u32 LE at offset 1). Verbatim from
+// OpenPuck/identity.cpp — Steam matches the puck by this blob.
+const uint8_t ATTR83[25] = {
+	0x01, 0x04, 0x13, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
+	0x00, 0x0A, 0xF2, 0xF9, 0xD2, 0x68, 0x04, 0x53, 0xD0,
+	0x18, 0x6A, 0x09, 0x47, 0x00, 0x00, 0x00
+};
+
+char g_board[16];
+char g_unit[16];
+char g_usb_serial[18];
+
+void identity_init(void)
+{
+	// Fold the 64-bit unique flash id into 20 bits, matching the shape of the
+	// nRF puck's FICR-derived serials ("FXB.../MXB..." + 5 hex digits).
+	pico_unique_board_id_t id;
+	pico_get_unique_board_id(&id);
+	uint32_t x = 0;
+	for (int i = 0; i < PICO_UNIQUE_BOARD_ID_SIZE_BYTES; i++)
+		x = (x * 31u) + id.id[i];
+	unsigned tail = (unsigned)(x & 0xFFFFFu);
+
+	snprintf(g_unit, sizeof g_unit, "FXB99602%05X", tail);
+	snprintf(g_board, sizeof g_board, "MXB99602%05X", tail);
+	snprintf(g_usb_serial, sizeof g_usb_serial, "PPK%08X", (unsigned)x);
+}

+ 24 - 0
PicoPuck/src/puck/identity.h

@@ -0,0 +1,24 @@
+// identity.h — puck identity: 0x83 attribute blob and derived serials.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_IDENTITY_H
+#define PICOPUCK_IDENTITY_H
+
+#include <stdint.h>
+
+// The 25-byte 0x83 GET_ATTRIBUTES payload (product 0x1304 = Valve puck), copied
+// from OpenPuck/identity.cpp. Steam reads this to identify the dongle.
+extern const uint8_t ATTR83[25];
+
+// Board / unit serial strings (Valve "MXB..." / "FXB..." shape), derived from
+// the RP2040/RP2350 unique flash id at boot. NUL-terminated, <=15 chars.
+extern char g_board[16];
+extern char g_unit[16];
+
+// USB string-descriptor serial (also derived from the unique id).
+extern char g_usb_serial[18];
+
+// Populate the serials. Call once at boot before USB init.
+void identity_init(void);
+
+#endif // PICOPUCK_IDENTITY_H

+ 298 - 0
PicoPuck/src/puck/personality.c

@@ -0,0 +1,298 @@
+// personality.c — the puck HID personality (see personality.h).
+//
+// Ported from OpenPuck/puck_hid.cpp (SteamPuckController). TinyUSB routes HID
+// control transfers to us per instance, where instance == slot. We answer the
+// feature command channel locally (identity/bond/settings) and hand actuator /
+// config writes to relay_enqueue for whatever controller is bound to the slot.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/personality.h"
+#include "puck/slots.h"
+#include "puck/identity.h"
+#include "puck/relay.h"
+#include "puck/triton.h"
+#include "usb/usb_tx.h"
+#include "config/picopuck_config.h"
+
+#include <string.h>
+#include "tusb.h"
+#include "pico/time.h"
+
+static uint32_t s_steam_alive_ms;
+static bool s_pairing;
+
+static uint8_t s_conn_state[PP_NSLOT];  // last emitted 0x79 state (0 none/1 disc/2 conn)
+static uint32_t s_last_7b_ms[PP_NSLOT];
+static uint32_t s_last_43_ms[PP_NSLOT];
+
+static inline uint32_t now_ms(void)
+{
+	return to_ms_since_boot(get_absolute_time());
+}
+
+static bool rec_empty(const uint8_t *rec)
+{
+	for (int i = 0; i < PP_BOND_REC_LEN; i++)
+		if (rec[i])
+			return false;
+	return true;
+}
+
+void puck_personality_init(void)
+{
+	s_steam_alive_ms = 0;
+	s_pairing = false;
+	memset(s_conn_state, 0, sizeof(s_conn_state));
+	memset(s_last_7b_ms, 0, sizeof(s_last_7b_ms));
+	memset(s_last_43_ms, 0, sizeof(s_last_43_ms));
+}
+
+bool puck_steam_active(void)
+{
+	// Steam is "driving" if it has written the command channel in the last 5 s.
+	return s_steam_alive_ms && (now_ms() - s_steam_alive_ms < 5000u);
+}
+
+static uint8_t s_seq[PP_NSLOT];
+
+void puck_present_synth(int slot)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	uint8_t rep[PUCK45_LEN];
+	puck_synth45(&g_in[slot], s_seq[slot]++, time_us_32(), rep);
+	usb_tx_hid((uint8_t)slot, rep[0], rep + 1, PUCK45_LEN - 1);
+	slot_note_input(slot, now_ms());
+}
+
+void puck_present_raw(int slot, const uint8_t *rep, uint8_t len)
+{
+	if (slot < 0 || slot >= PP_NSLOT || len < 1)
+		return;
+	usb_tx_hid((uint8_t)slot, rep[0], rep + 1, (uint16_t)(len - 1));
+	slot_note_input(slot, now_ms());
+}
+
+void puck_set_connected(int slot, bool connected)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	g_slot[slot].connected = connected;
+	if (!connected)
+		g_slot[slot].conn_reply_ms = 0;
+}
+
+// ---- feature / output writes (host → puck/controller) ----------------------
+static void handle_set(int slot, uint8_t rid, hid_report_type_t type,
+		       const uint8_t *b, uint16_t n)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	puck_slot_t *S = &g_slot[slot];
+
+	// OUTPUT reports 0x80-0x89: haptics/actuators. Relay the 0x80-0x86 range to
+	// the bound controller; the rest are config that rides the feature path.
+	if (type == HID_REPORT_TYPE_OUTPUT) {
+		if (rid >= 0x80 && rid <= 0x89)
+			s_steam_alive_ms = now_ms();
+		if (rid >= 0x80 && rid <= 0x86 && n >= 1)
+			relay_enqueue(slot, rid, b, n);
+		return;
+	}
+
+	if (type != HID_REPORT_TYPE_FEATURE || n < 1)
+		return;
+
+	uint8_t cmd = b[0];
+	uint8_t len = (n > 1) ? b[1] : 0;
+	const uint8_t *pl = b + 2;
+	uint16_t pln = (n >= 2) ? (uint16_t)(n - 2) : 0;
+
+	if (cmd >= 0x80 && cmd <= 0x89)
+		s_steam_alive_ms = now_ms();
+
+	// report id 1 = commands relayed to the bonded controller. Do NOT relay the
+	// identity/bond/settings reads we answer locally (their reply can't come
+	// back over the link, and sub-0x87 commands would execute on the controller).
+	if (rid == 1 && n >= 2) {
+		bool local_answer =
+			(cmd == 0x83 || cmd == 0x89 || cmd == 0xAE ||
+			 cmd == 0xA2 || cmd == 0xA3 || cmd == 0xAD ||
+			 cmd == 0xB4 || cmd == 0xED || cmd == 0xA4);
+		if (!local_answer) {
+			uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
+			relay_enqueue(slot, cmd, pl, rl);
+		}
+	}
+
+	memset(S->resp, 0, sizeof(S->resp));
+	S->resp_len = 0;
+
+	switch (cmd) {
+	case 0x83:  // GET_ATTRIBUTES
+		S->resp[0] = 0x83;
+		S->resp[1] = sizeof(ATTR83);
+		memcpy(S->resp + 2, ATTR83, sizeof(ATTR83));
+		// rid 1 = the bonded controller's attributes → product 0x1302, so Steam
+		// takes the modern quiet config path instead of the legacy 0x81 storm.
+		if (rid == 1)
+			S->resp[2 + 1] = 0x02;
+		S->resp_len = 63;
+		break;
+
+	case 0xAE: {  // string attributes
+		uint8_t idx = pln > 0 ? pl[0] : 1;
+		S->resp[0] = 0xAE;
+		S->resp[1] = 0x14;
+		S->resp[2] = idx;
+		if (rid == 1 && S->used && (idx == 0 || idx == 1 || idx == 4)) {
+			// The bonded controller's serial lives in its bond record
+			// (rec[8..24]); Steam matches the controller to its bond by it.
+			memcpy(S->resp + 3, S->rec + 8, 16);
+		} else {
+			const char *s = (rid == 1) ? "NA" :
+					(idx == 0 || idx == 4) ? g_board :
+					(idx == 1) ? g_unit : "NA";
+			memcpy(S->resp + 3, s, strlen(s));
+		}
+		S->resp_len = 63;
+		break;
+	}
+
+	case 0xB4:  // connection / version state
+		s_steam_alive_ms = now_ms();
+		S->resp[0] = 0xB4;
+		S->resp[1] = 0x01;
+		S->resp[2] = slot_is_live(slot, now_ms()) ? 0x02 : 0x01;
+		S->resp_len = 63;
+		break;
+
+	case 0xAD:  // enter/exit pairing
+		s_pairing = (pln > 0 && pl[0] != 0);
+		S->resp[0] = 0xAD;
+		S->resp_len = 63;
+		break;
+
+	case 0xA2:  // write/clear this slot's bond record
+		if (len >= PP_BOND_REC_LEN && pln >= PP_BOND_REC_LEN) {
+			if (rec_empty(pl)) {
+				S->used = false;
+				memset(S->rec, 0, PP_BOND_REC_LEN);
+			} else {
+				memcpy(S->rec, pl, PP_BOND_REC_LEN);
+				S->used = true;
+			}
+		}
+		S->resp[0] = 0xA2;
+		S->resp_len = 63;
+		break;
+
+	case 0xA3:  // read this slot's bond record
+		S->resp[0] = 0xA3;
+		S->resp[1] = 0x18;
+		if (S->used)
+			memcpy(S->resp + 2, S->rec, PP_BOND_REC_LEN);
+		S->resp_len = 63;
+		break;
+
+	case 0x87:  // SET_SETTINGS_VALUES → shadow for 0x89 read-back
+		for (uint16_t i = 0; i + 2 < pln; i += 3) {
+			uint8_t id = pl[i];
+			if (id < PP_SETTINGS_MAX)
+				S->set_shadow[id] =
+					(uint16_t)(pl[i + 1] | (pl[i + 2] << 8));
+		}
+		S->resp[0] = 0x87;
+		S->resp_len = 63;
+		break;
+
+	case 0x89: {  // GET_SETTINGS_VALUES
+		uint8_t id = (pln > 0) ? pl[0] : 0;
+		uint16_t v = (id < PP_SETTINGS_MAX) ? S->set_shadow[id] : 0;
+		S->resp[0] = 0x89;
+		S->resp[1] = 0x03;
+		S->resp[2] = id;
+		S->resp[3] = (uint8_t)(v & 0xFF);
+		S->resp[4] = (uint8_t)(v >> 8);
+		S->resp_len = 63;
+		break;
+	}
+
+	default:
+		break;
+	}
+}
+
+// ---- feature reads (host ← puck) -------------------------------------------
+static uint16_t handle_get(int slot, uint8_t rid, hid_report_type_t type,
+			   uint8_t *buf, uint16_t reqlen)
+{
+	(void)rid;
+	if (type != HID_REPORT_TYPE_FEATURE || slot < 0 || slot >= PP_NSLOT)
+		return 0;
+	puck_slot_t *S = &g_slot[slot];
+	uint16_t n = S->resp_len ? S->resp_len : 63;
+	if (n > reqlen)
+		n = reqlen;
+	memcpy(buf, S->resp, n);
+	return n;
+}
+
+// ---- TinyUSB HID callbacks (instance == slot) ------------------------------
+uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id,
+			       hid_report_type_t report_type, uint8_t *buffer,
+			       uint16_t reqlen)
+{
+	return handle_get(instance, report_id, report_type, buffer, reqlen);
+}
+
+void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id,
+			   hid_report_type_t report_type, uint8_t const *buffer,
+			   uint16_t bufsize)
+{
+	handle_set(instance, report_id, report_type, buffer, bufsize);
+}
+
+// ---- periodic status (0x79 / 0x7B / 0x43) ----------------------------------
+void puck_personality_task(void)
+{
+	if (tud_suspended())
+		return;  // don't emit while the host sleeps (could wake it)
+
+	uint32_t t = now_ms();
+
+	for (int s = 0; s < PP_NSLOT; s++) {
+		bool live = slot_is_live(s, t);
+		uint8_t state = live ? 0x02 : 0x01;
+
+		// 0x79 connection-state edge: emit on change.
+		if (state != s_conn_state[s]) {
+			s_conn_state[s] = state;
+			uint8_t body = state;
+			usb_tx_hid((uint8_t)s, 0x79, &body, 1);
+		}
+
+		if (!live)
+			continue;
+
+		// 0x7B status (RSSI template) every 750 ms.
+		if (t - s_last_7b_ms[s] >= 750) {
+			uint8_t s7b[12] = { 0xF7, 0x01, 0x89, 0x00, 0x00, 0x00,
+					    0x03, 0x00, 0xDD, 0x00, 0x3A, 0x02 };
+			usb_tx_hid((uint8_t)s, 0x7B, s7b, sizeof(s7b));
+			s_last_7b_ms[s] = t;
+		}
+
+		// Synthesized 0x43 battery every 2 s (SDL needs the full 14-byte form).
+		if (g_battery[s] && t - s_last_43_ms[s] >= 2000) {
+			uint8_t st = g_battery_state[s];
+			if (st != 1 && st != 2 && st != 4)
+				st = 1;  // discharging
+			uint8_t b43[14] = { 0 };
+			b43[0] = st;
+			b43[1] = g_battery[s];
+			usb_tx_hid((uint8_t)s, 0x43, b43, sizeof(b43));
+			s_last_43_ms[s] = t;
+		}
+	}
+}

+ 33 - 0
PicoPuck/src/puck/personality.h

@@ -0,0 +1,33 @@
+// personality.h — the puck HID personality (feature command channel + status).
+//
+// Implements the TinyUSB HID get/set report callbacks (one instance per slot)
+// and a periodic task that emits the 0x79 connection-state edges and the
+// synthesized 0x43 battery report Steam reads. This is the port of OpenPuck's
+// SteamPuckController.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_PERSONALITY_H
+#define PICOPUCK_PERSONALITY_H
+
+#include <stdbool.h>
+#include <stdint.h>
+
+void puck_personality_init(void);
+
+// Periodic: connection-state (0x79) edges + battery (0x43). Call each loop.
+void puck_personality_task(void);
+
+// True while Steam is actively driving (has written to the command channel
+// recently); used to decide whether we configure a controller ourselves.
+bool puck_steam_active(void);
+
+// Present a controller's input on `slot`. _synth serialises g_in[slot] into a
+// limited report 0x45 (generic pads); _raw forwards an SC2's on-air report
+// verbatim (rep[0] = report id). Both mark the slot live.
+void puck_present_synth(int slot);
+void puck_present_raw(int slot, const uint8_t *rep, uint8_t len);
+
+// Mark a slot connected/disconnected (BT link up/down).
+void puck_set_connected(int slot, bool connected);
+
+#endif // PICOPUCK_PERSONALITY_H

+ 22 - 0
PicoPuck/src/puck/relay.c

@@ -0,0 +1,22 @@
+// relay.c — host→controller relay dispatch (see relay.h).
+//
+// The actual routing lives in the BT host (bt_relay), which knows what is bound
+// to each slot: a generic pad's rumble output, or an SC2's Valve report
+// characteristic. bt_relay is weak so a BT-less build still links.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/relay.h"
+
+__attribute__((weak)) void bt_relay(int slot, uint8_t cmd, const uint8_t *payload,
+				    uint16_t len)
+{
+	(void)slot;
+	(void)cmd;
+	(void)payload;
+	(void)len;
+}
+
+void relay_enqueue(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len)
+{
+	bt_relay(slot, cmd, payload, len);
+}

+ 20 - 0
PicoPuck/src/puck/relay.h

@@ -0,0 +1,20 @@
+// relay.h — host→controller relay seam.
+//
+// The personality hands Steam's actuator/config writes (haptics 0x80-0x86,
+// settings 0x87, power-off 0x9F, and feature-id-1 passthrough) to relay_enqueue.
+// Where they go depends on what is bound to the slot:
+//   - Phase 2: a generic BLE/Classic pad → mapped to the pad's rumble output.
+//   - Phase 4: a Steam Controller 2 → GATT-written verbatim to the Valve report
+//     characteristic (transparent forwarding).
+// In Phase 1 there is nothing bound, so this is a no-op.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_RELAY_H
+#define PICOPUCK_RELAY_H
+
+#include <stdint.h>
+
+// Queue one command (cmd byte + payload) for the controller bound to `slot`.
+void relay_enqueue(int slot, uint8_t cmd, const uint8_t *payload, uint16_t len);
+
+#endif // PICOPUCK_RELAY_H

+ 37 - 0
PicoPuck/src/puck/slots.c

@@ -0,0 +1,37 @@
+// slots.c — puck bond slots and live state (see slots.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/slots.h"
+#include <string.h>
+
+puck_slot_t g_slot[PP_NSLOT];
+uint8_t g_battery[PP_NSLOT];
+uint8_t g_battery_state[PP_NSLOT];
+
+// A slot counts as "live" for a short window after its last input report, so a
+// brief BT hiccup doesn't bounce the connection state Steam sees. Mirrors the
+// 500 ms window OpenPuck uses for 0xB4.
+#define SLOT_LIVE_WINDOW_MS 500u
+
+void slots_init(void)
+{
+	memset(g_slot, 0, sizeof(g_slot));
+	memset(g_battery, 0, sizeof(g_battery));
+	memset(g_battery_state, 0, sizeof(g_battery_state));
+}
+
+bool slot_is_live(int slot, uint32_t now_ms)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return false;
+	const puck_slot_t *s = &g_slot[slot];
+	return s->connected && s->conn_reply_ms &&
+	       (now_ms - s->conn_reply_ms < SLOT_LIVE_WINDOW_MS);
+}
+
+void slot_note_input(int slot, uint32_t now_ms)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	g_slot[slot].conn_reply_ms = now_ms ? now_ms : 1;
+}

+ 48 - 0
PicoPuck/src/puck/slots.h

@@ -0,0 +1,48 @@
+// slots.h — the four puck bond slots and their live state.
+//
+// A slot models one of the puck's four HID interfaces. It carries the bond
+// record Steam reads/writes (0xA2/0xA3), a settings shadow for 0x87/0x89
+// read-back, and the live-connection bookkeeping the personality uses to drive
+// the 0x79 / 0xB4 / 0x43 connection reporting. Bluetooth controllers are bound
+// to slots in later phases; a real BT link sets `connected`.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_SLOTS_H
+#define PICOPUCK_SLOTS_H
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "config/picopuck_config.h"
+
+#define PP_BOND_REC_LEN 24
+#define PP_SETTINGS_MAX 0x53  // 0x87 setting-id space (id < 0x53 shadowed)
+
+typedef struct {
+	bool used;                      // a bond record is present (paired)
+	bool connected;                 // a BT controller is live on this slot
+	uint8_t rec[PP_BOND_REC_LEN];   // Steam's 0xA2/0xA3 bond view
+	uint16_t set_shadow[PP_SETTINGS_MAX];  // 0x87 values, read back by 0x89
+	uint32_t conn_reply_ms;         // ms of last input report (0 = none)
+
+	// Staged feature-GET reply (built in a feature SET, read by the GET).
+	uint8_t resp[63];
+	uint8_t resp_len;
+} puck_slot_t;
+
+extern puck_slot_t g_slot[PP_NSLOT];
+
+// Per-slot battery, set by the bound controller (BAS / report). Percent 0-100;
+// state: 0 = discharging, 1 = charging (mirrors OpenPuck g_battery/g_batteryState).
+extern uint8_t g_battery[PP_NSLOT];
+extern uint8_t g_battery_state[PP_NSLOT];
+
+// True while a controller is currently presented on this slot (connected and
+// producing input recently). Drives 0xB4 / 0x79.
+bool slot_is_live(int slot, uint32_t now_ms);
+
+// Mark that a fresh input report arrived for `slot` (updates conn_reply_ms).
+void slot_note_input(int slot, uint32_t now_ms);
+
+void slots_init(void);
+
+#endif // PICOPUCK_SLOTS_H

+ 74 - 0
PicoPuck/src/puck/triton.c

@@ -0,0 +1,74 @@
+// triton.c — SC2 input model + report 0x45 encoder (see triton.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/triton.h"
+#include <string.h>
+
+puck_input_t g_in[PP_NSLOT];
+
+// Write a 16-bit LE value at out[2 + off] (the offset convention triton's field
+// decoders use: report byte = 2 + field-offset-from-buttons-low-byte).
+static inline void put16(uint8_t *out, int off, int v)
+{
+	out[2 + off] = (uint8_t)(v & 0xFF);
+	out[2 + off + 1] = (uint8_t)((v >> 8) & 0xFF);
+}
+
+static inline void put32(uint8_t *out, int idx, uint32_t v)
+{
+	out[idx] = (uint8_t)(v & 0xFF);
+	out[idx + 1] = (uint8_t)((v >> 8) & 0xFF);
+	out[idx + 2] = (uint8_t)((v >> 16) & 0xFF);
+	out[idx + 3] = (uint8_t)((v >> 24) & 0xFF);
+}
+
+uint8_t puck_synth45(const puck_input_t *in, uint8_t seq, uint32_t usec,
+		     uint8_t out[PUCK45_LEN])
+{
+	memset(out, 0, PUCK45_LEN);
+	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);
+	// Triggers: the SC2's analog trigger is a u16 that trigU8 maps via >>7, so
+	// the inverse is lt << 7 (full 0xFF pull → 0x7F80).
+	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);
+	// IMU timestamp (report bytes 30..33): Steam integrates gyro as gyro*dt with
+	// dt from the delta of this µs counter. Zero gyro × any dt = 0, so it's
+	// harmless for generic pads and correct once a gyro-capable pad lands.
+	put32(out, 30, usec);
+	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;
+}
+
+uint32_t triton_hat_bits(uint8_t hat)
+{
+	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 hat <= 8 ? T[hat] : 0;
+}

+ 67 - 0
PicoPuck/src/puck/triton.h

@@ -0,0 +1,67 @@
+// triton.h — the Steam Controller 2's native input model, shared by every path.
+//
+// Bluetooth drivers decode a controller into one PuckInput per slot; the puck
+// personality serialises it back into an SC2 report 0x45 (puck_synth45) for the
+// host, or — for a real SC2 — forwards the on-air 0x45 verbatim. Ported from
+// OpenPuck/triton.h (button masks and field layout are the SC2's).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_TRITON_H
+#define PICOPUCK_TRITON_H
+
+#include <stdint.h>
+#include "config/picopuck_config.h"
+
+// Triton button masks (the 32-bit field at report 0x45 bytes [2..5]).
+#define TB_A 0x1u
+#define TB_B 0x2u
+#define TB_X 0x4u
+#define TB_Y 0x8u
+#define TB_QAM 0x10u
+#define TB_R3 0x20u
+#define TB_VIEW 0x40u
+#define TB_R4 0x80u
+#define TB_R5 0x100u
+#define TB_RB 0x200u
+#define TB_DDN 0x400u
+#define TB_DRT 0x800u
+#define TB_DLF 0x1000u
+#define TB_DUP 0x2000u
+#define TB_MENU 0x4000u
+#define TB_L3 0x8000u
+#define TB_STEAM 0x10000u
+#define TB_L4 0x20000u
+#define TB_L5 0x40000u
+#define TB_LB 0x80000u
+#define TB_RPADT 0x200000u
+#define TB_RPADC 0x400000u
+#define TB_LPADT 0x2000000u
+#define TB_LPADC 0x4000000u
+#define TB_R2 0x800000u
+#define TB_L2 0x8000000u
+
+// Decoded controller input, one per slot.
+typedef struct {
+	uint32_t buttons;             // TB_* bits
+	int16_t lx, ly, rx, ry;       // sticks, center 0
+	uint8_t lt, rt;               // triggers 0..255
+	int16_t lpx, lpy, rpx, rpy;   // trackpads (SC2 only; 0 for generic pads)
+	int16_t ax, ay, az;           // accelerometer (0 if none)
+	int16_t gx, gy, gz;           // gyroscope (0 if none)
+} puck_input_t;
+
+extern puck_input_t g_in[PP_NSLOT];
+
+// Length of a full SC2 report 0x45 (report id + 45 body bytes).
+#define PUCK45_LEN 46
+
+// Serialise g_in-style input into a report 0x45 buffer (out[0]=0x45). Trackpads
+// and IMU are written from the struct (zero for generic pads → neutral). The µs
+// timestamp Steam uses for gyro dt is stamped from `usec`. Returns PUCK45_LEN.
+uint8_t puck_synth45(const puck_input_t *in, uint8_t seq, uint32_t usec,
+		     uint8_t out[PUCK45_LEN]);
+
+// 8-way HID hat (0 idle, 1..8 clockwise from N) → TB_ dpad bits.
+uint32_t triton_hat_bits(uint8_t hat);
+
+#endif // PICOPUCK_TRITON_H

+ 44 - 0
PicoPuck/src/usb/puck_desc.c

@@ -0,0 +1,44 @@
+// puck_desc.c — the cloned Valve puck HID report descriptor (see puck_desc.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "usb/puck_desc.h"
+#include "tusb.h"
+
+const uint8_t PUCK_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
+	0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
+	0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
+	0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
+	0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
+	0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
+	0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
+	0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
+	0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
+	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
+	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
+	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
+	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
+	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0
+};
+
+const size_t PUCK_HID_DESC_LEN = sizeof(PUCK_HID_DESC);
+
+TU_VERIFY_STATIC(sizeof(PUCK_HID_DESC) == PUCK_HID_DESC_SIZE,
+		 "PUCK_HID_DESC_SIZE mismatch");

+ 22 - 0
PicoPuck/src/usb/puck_desc.h

@@ -0,0 +1,22 @@
+// puck_desc.h — the cloned Valve puck HID report descriptor.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_PUCK_DESC_H
+#define PICOPUCK_PUCK_DESC_H
+
+#include <stdint.h>
+#include <stddef.h>
+
+// Byte-for-byte the descriptor a real Valve puck presents on each of its four
+// control interfaces: mouse(0x40) + keyboard(0x41) + vendor(FF00) with input
+// reports 0x42/0x43/0x44/0x45/0x79/0x7B, output reports 0x80-0x89, and 63-byte
+// FEATURE command reports on report ids 0x01/0x02. Copied verbatim from
+// OpenPuck/puck_hid.cpp so Steam's SDL driver sees an identical device.
+// Compile-time size, needed by the config-descriptor macros. Verified against
+// the actual array with a static assert in puck_desc.c.
+#define PUCK_HID_DESC_SIZE 372
+
+extern const uint8_t PUCK_HID_DESC[];
+extern const size_t PUCK_HID_DESC_LEN;
+
+#endif // PICOPUCK_PUCK_DESC_H

+ 226 - 0
PicoPuck/src/usb/usb_descriptors.c

@@ -0,0 +1,226 @@
+// usb_descriptors.c — TinyUSB descriptor callbacks for the puck identity.
+//
+// Presents VID/PID 28DE:1304 with four HID slot interfaces (each the cloned
+// puck report descriptor) plus a WebUSB vendor interface (WinUSB-bound via an
+// MS OS 2.0 descriptor so Chrome can open it without a manual driver). bcdDevice
+// is 0x03xx to distinguish PicoPuck from the nRF puck in the panel and in
+// Windows' per-(VID,PID,bcdDevice) descriptor cache.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include <string.h>
+#include "tusb.h"
+#include "usb/usb_descriptors.h"
+#include "usb/puck_desc.h"
+#include "puck/identity.h"
+
+// Set by the panel's class request (0x22) on the vendor interface.
+void webusb_set_connected(bool connected);
+
+// ---- device descriptor -----------------------------------------------------
+#if CFG_TUD_CDC
+#define PP_BCD PP_BCD_DEVICE_CDC
+#else
+#define PP_BCD PP_BCD_DEVICE
+#endif
+
+static const tusb_desc_device_t desc_device = {
+	.bLength = sizeof(tusb_desc_device_t),
+	.bDescriptorType = TUSB_DESC_DEVICE,
+	.bcdUSB = 0x0210,  // 2.1 → device advertises a BOS descriptor
+#if CFG_TUD_CDC
+	.bDeviceClass = TUSB_CLASS_MISC,
+	.bDeviceSubClass = MISC_SUBCLASS_COMMON,
+	.bDeviceProtocol = MISC_PROTOCOL_IAD,
+#else
+	.bDeviceClass = 0x00,
+	.bDeviceSubClass = 0x00,
+	.bDeviceProtocol = 0x00,
+#endif
+	.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
+	.idVendor = PP_USB_VID,
+	.idProduct = PP_USB_PID,
+	.bcdDevice = PP_BCD,
+	.iManufacturer = 0x01,
+	.iProduct = 0x02,
+	.iSerialNumber = 0x03,
+	.bNumConfigurations = 0x01,
+};
+
+const uint8_t *tud_descriptor_device_cb(void)
+{
+	return (const uint8_t *)&desc_device;
+}
+
+// ---- HID report descriptor (all four instances share the puck descriptor) --
+const uint8_t *tud_hid_descriptor_report_cb(uint8_t instance)
+{
+	(void)instance;
+	return PUCK_HID_DESC;
+}
+
+// ---- configuration descriptor ----------------------------------------------
+#define CONFIG_TOTAL_LEN                                                       \
+	(TUD_CONFIG_DESC_LEN + 4 * TUD_HID_DESC_LEN + TUD_VENDOR_DESC_LEN +     \
+	 CFG_TUD_CDC * TUD_CDC_DESC_LEN)
+
+static const uint8_t desc_configuration[] = {
+	TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 250),
+
+	// Four puck slot interfaces: IN-only, 1 ms poll, sharing PUCK_HID_DESC.
+	TUD_HID_DESCRIPTOR(ITF_NUM_HID0, 0, HID_ITF_PROTOCOL_NONE,
+			   PUCK_HID_DESC_SIZE, EPNUM_HID0, CFG_TUD_HID_EP_BUFSIZE, 1),
+	TUD_HID_DESCRIPTOR(ITF_NUM_HID1, 0, HID_ITF_PROTOCOL_NONE,
+			   PUCK_HID_DESC_SIZE, EPNUM_HID1, CFG_TUD_HID_EP_BUFSIZE, 1),
+	TUD_HID_DESCRIPTOR(ITF_NUM_HID2, 0, HID_ITF_PROTOCOL_NONE,
+			   PUCK_HID_DESC_SIZE, EPNUM_HID2, CFG_TUD_HID_EP_BUFSIZE, 1),
+	TUD_HID_DESCRIPTOR(ITF_NUM_HID3, 0, HID_ITF_PROTOCOL_NONE,
+			   PUCK_HID_DESC_SIZE, EPNUM_HID3, CFG_TUD_HID_EP_BUFSIZE, 1),
+
+	// WebUSB config channel (bulk IN/OUT). String index 4 names it.
+	TUD_VENDOR_DESCRIPTOR(ITF_NUM_VENDOR, 4, EPNUM_VENDOR_OUT, EPNUM_VENDOR_IN, 64),
+
+#if CFG_TUD_CDC
+	TUD_CDC_DESCRIPTOR(ITF_NUM_CDC, 5, EPNUM_CDC_NOTIF, 8, EPNUM_CDC_OUT,
+			   EPNUM_CDC_IN, 64),
+#endif
+};
+
+const uint8_t *tud_descriptor_configuration_cb(uint8_t index)
+{
+	(void)index;
+	return desc_configuration;
+}
+
+// ---- BOS + MS OS 2.0 (WinUSB binding for the vendor interface) -------------
+#define MS_OS_20_DESC_LEN 0xB2
+
+#define BOS_TOTAL_LEN                                                          \
+	(TUD_BOS_DESC_LEN + TUD_BOS_WEBUSB_DESC_LEN +                          \
+	 TUD_BOS_MICROSOFT_OS_DESC_LEN)
+
+static const uint8_t desc_bos[] = {
+	TUD_BOS_DESCRIPTOR(BOS_TOTAL_LEN, 2),
+	// WebUSB: vendor request code, landing-page index 0 = no landing page
+	// (the panel opens the device directly; avoids a Chrome popup).
+	TUD_BOS_WEBUSB_DESCRIPTOR(VENDOR_REQUEST_WEBUSB, 0),
+	TUD_BOS_MS_OS_20_DESCRIPTOR(MS_OS_20_DESC_LEN, VENDOR_REQUEST_MICROSOFT),
+};
+
+const uint8_t *tud_descriptor_bos_cb(void)
+{
+	return desc_bos;
+}
+
+static const uint8_t desc_ms_os_20[] = {
+	// Set header
+	U16_TO_U8S_LE(0x000A), U16_TO_U8S_LE(MS_OS_20_SET_HEADER_DESCRIPTOR),
+	U32_TO_U8S_LE(0x06030000), U16_TO_U8S_LE(MS_OS_20_DESC_LEN),
+	// Configuration subset header
+	U16_TO_U8S_LE(0x0008), U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_CONFIGURATION),
+	0, 0, U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A),
+	// Function subset header (binds the vendor interface)
+	U16_TO_U8S_LE(0x0008), U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_FUNCTION),
+	ITF_NUM_VENDOR, 0, U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A - 0x08),
+	// Compatible ID: WINUSB
+	U16_TO_U8S_LE(0x0014), U16_TO_U8S_LE(MS_OS_20_FEATURE_COMPATBLE_ID),
+	'W', 'I', 'N', 'U', 'S', 'B', 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	// Registry property: DeviceInterfaceGUIDs
+	U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A - 0x08 - 0x08 - 0x14),
+	U16_TO_U8S_LE(MS_OS_20_FEATURE_REG_PROPERTY),
+	U16_TO_U8S_LE(0x0007), U16_TO_U8S_LE(0x002A),
+	'D', 0x00, 'e', 0x00, 'v', 0x00, 'i', 0x00, 'c', 0x00, 'e', 0x00, 'I', 0x00,
+	'n', 0x00, 't', 0x00, 'e', 0x00, 'r', 0x00, 'f', 0x00, 'a', 0x00, 'c', 0x00,
+	'e', 0x00, 'G', 0x00, 'U', 0x00, 'I', 0x00, 'D', 0x00, 's', 0x00, 0x00, 0x00,
+	U16_TO_U8S_LE(0x0050),
+	'{', 0x00, '9', 0x00, '7', 0x00, '5', 0x00, 'F', 0x00, '4', 0x00, '4', 0x00,
+	'D', 0x00, '9', 0x00, '-', 0x00, '0', 0x00, 'D', 0x00, '0', 0x00, '8', 0x00,
+	'-', 0x00, '4', 0x00, '3', 0x00, 'F', 0x00, 'D', 0x00, '-', 0x00, '8', 0x00,
+	'B', 0x00, '3', 0x00, 'E', 0x00, '-', 0x00, '1', 0x00, '2', 0x00, '7', 0x00,
+	'C', 0x00, 'A', 0x00, '8', 0x00, 'A', 0x00, 'F', 0x00, 'F', 0x00, 'F', 0x00,
+	'9', 0x00, 'D', 0x00, '}', 0x00, 0x00, 0x00, 0x00, 0x00,
+};
+
+TU_VERIFY_STATIC(sizeof(desc_ms_os_20) == MS_OS_20_DESC_LEN, "MS OS 2.0 size");
+
+// Single vendor control-transfer callback: WebUSB/MS-OS descriptor requests and
+// the panel's class "connect" request (0x22, Adafruit convention).
+bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
+				tusb_control_request_t const *request)
+{
+	if (stage != CONTROL_STAGE_SETUP)
+		return true;
+
+	switch (request->bmRequestType_bit.type) {
+	case TUSB_REQ_TYPE_VENDOR:
+		switch (request->bRequest) {
+		case VENDOR_REQUEST_MICROSOFT:
+			if (request->wIndex == 7) {
+				uint16_t total_len;
+				memcpy(&total_len, desc_ms_os_20 + 8, 2);
+				return tud_control_xfer(
+					rhport, request,
+					(void *)(uintptr_t)desc_ms_os_20,
+					total_len);
+			}
+			return false;
+		default:
+			break;
+		}
+		break;
+
+	case TUSB_REQ_TYPE_CLASS:
+		if (request->bRequest == 0x22) {
+			webusb_set_connected(request->wValue != 0);
+			return tud_control_status(rhport, request);
+		}
+		break;
+
+	default:
+		break;
+	}
+	return false;
+}
+
+// ---- string descriptors ----------------------------------------------------
+enum { STRID_LANGID = 0, STRID_MANUFACTURER, STRID_PRODUCT, STRID_SERIAL, STRID_VENDOR };
+
+static const char *const string_desc_arr[] = {
+	[STRID_LANGID] = (const char[]){ 0x09, 0x04 },  // English (0x0409)
+	[STRID_MANUFACTURER] = "Valve Software",
+	[STRID_PRODUCT] = "Steam Controller Puck",
+	[STRID_SERIAL] = NULL,  // filled from g_usb_serial at runtime
+	[STRID_VENDOR] = "PicoPuck WebUSB",
+};
+
+static uint16_t desc_str[32];
+
+const uint16_t *tud_descriptor_string_cb(uint8_t index, uint16_t langid)
+{
+	(void)langid;
+	size_t chr_count;
+
+	if (index == STRID_LANGID) {
+		memcpy(&desc_str[1], string_desc_arr[0], 2);
+		chr_count = 1;
+	} else {
+		const char *str;
+		if (index == STRID_SERIAL)
+			str = g_usb_serial;
+		else if (index < TU_ARRAY_SIZE(string_desc_arr))
+			str = string_desc_arr[index];
+		else
+			return NULL;
+		if (!str)
+			return NULL;
+
+		chr_count = strlen(str);
+		if (chr_count > 31)
+			chr_count = 31;
+		for (size_t i = 0; i < chr_count; i++)
+			desc_str[1 + i] = str[i];
+	}
+
+	desc_str[0] = (uint16_t)((TUSB_DESC_STRING << 8) | (2 * chr_count + 2));
+	return desc_str;
+}

+ 43 - 0
PicoPuck/src/usb/usb_descriptors.h

@@ -0,0 +1,43 @@
+// usb_descriptors.h — interface / endpoint layout and vendor request codes.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_USB_DESCRIPTORS_H
+#define PICOPUCK_USB_DESCRIPTORS_H
+
+#include "config/picopuck_config.h"
+
+// Interface numbers. The four HID slot interfaces come first so slot N == HID
+// instance N == interface N (Steam addresses a slot by interface index / wIndex).
+// The WebUSB vendor interface follows; an optional CDC console is last.
+enum {
+	ITF_NUM_HID0 = 0,
+	ITF_NUM_HID1,
+	ITF_NUM_HID2,
+	ITF_NUM_HID3,
+	ITF_NUM_VENDOR,
+#if CFG_TUD_CDC
+	ITF_NUM_CDC,
+	ITF_NUM_CDC_DATA,
+#endif
+	ITF_NUM_TOTAL
+};
+
+// Endpoint addresses. HID slots are IN-only (output/feature reports ride EP0
+// SET_REPORT, as on the real puck). The vendor interface is a bulk IN/OUT pair.
+#define EPNUM_HID0 0x81
+#define EPNUM_HID1 0x82
+#define EPNUM_HID2 0x83
+#define EPNUM_HID3 0x84
+#define EPNUM_VENDOR_OUT 0x05
+#define EPNUM_VENDOR_IN 0x85
+#if CFG_TUD_CDC
+#define EPNUM_CDC_NOTIF 0x86
+#define EPNUM_CDC_OUT 0x06
+#define EPNUM_CDC_IN 0x87
+#endif
+
+// Vendor control-transfer request codes referenced by the BOS descriptor.
+#define VENDOR_REQUEST_WEBUSB 1
+#define VENDOR_REQUEST_MICROSOFT 2
+
+#endif // PICOPUCK_USB_DESCRIPTORS_H

+ 67 - 0
PicoPuck/src/usb/usb_tx.c

@@ -0,0 +1,67 @@
+// usb_tx.c — per-HID-instance report queue (see usb_tx.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "usb/usb_tx.h"
+#include "config/picopuck_config.h"
+#include "tusb.h"
+
+#include <string.h>
+
+#define TX_RING_DEPTH 8
+#define TX_MAX_BODY 63  // largest puck report body (0x45 is 45 bytes)
+
+typedef struct {
+	uint8_t report_id;
+	uint8_t len;
+	uint8_t body[TX_MAX_BODY];
+} tx_msg_t;
+
+typedef struct {
+	tx_msg_t msg[TX_RING_DEPTH];
+	uint8_t head;  // next slot to write
+	uint8_t tail;  // next slot to read
+	uint8_t count;
+} tx_ring_t;
+
+static tx_ring_t s_ring[PP_NSLOT];
+
+bool usb_tx_hid(uint8_t inst, uint8_t report_id, const uint8_t *body, uint16_t len)
+{
+	if (inst >= PP_NSLOT || len > TX_MAX_BODY)
+		return false;
+
+	tx_ring_t *r = &s_ring[inst];
+	if (r->count == TX_RING_DEPTH) {
+		// Drop oldest so a stalled host can't wedge fresh input.
+		r->tail = (uint8_t)((r->tail + 1) % TX_RING_DEPTH);
+		r->count--;
+	}
+
+	tx_msg_t *m = &r->msg[r->head];
+	m->report_id = report_id;
+	m->len = (uint8_t)len;
+	if (len)
+		memcpy(m->body, body, len);
+	r->head = (uint8_t)((r->head + 1) % TX_RING_DEPTH);
+	r->count++;
+	return true;
+}
+
+void usb_tx_pump(void)
+{
+	if (!tud_mounted())
+		return;
+
+	for (uint8_t inst = 0; inst < PP_NSLOT; inst++) {
+		tx_ring_t *r = &s_ring[inst];
+		if (r->count == 0 || !tud_hid_n_ready(inst))
+			continue;
+
+		tx_msg_t *m = &r->msg[r->tail];
+		if (tud_hid_n_report(inst, m->report_id, m->body, m->len)) {
+			r->tail = (uint8_t)((r->tail + 1) % TX_RING_DEPTH);
+			r->count--;
+		}
+		// If the send failed, leave it queued and retry next pump.
+	}
+}

+ 23 - 0
PicoPuck/src/usb/usb_tx.h

@@ -0,0 +1,23 @@
+// usb_tx.h — per-HID-instance report queue, drained from the main loop.
+//
+// TinyUSB's tud_hid_n_report() can only have one report in flight per instance;
+// callers (personality, BLE input path) enqueue here and usb_tx_pump() sends as
+// each instance reports ready. Oldest report is dropped when a ring is full, so
+// a stalled host never blocks input. Mirrors OpenPuck's usbTxHid.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_USB_TX_H
+#define PICOPUCK_USB_TX_H
+
+#include <stdint.h>
+#include <stdbool.h>
+
+// Queue a report (report_id + body) for HID instance `inst`. Returns false if
+// the report was dropped (ring full → oldest evicted, still returns true; false
+// only for invalid args). Safe to call from the main loop.
+bool usb_tx_hid(uint8_t inst, uint8_t report_id, const uint8_t *body, uint16_t len);
+
+// Send queued reports for every instance that is ready. Call each loop.
+void usb_tx_pump(void);
+
+#endif // PICOPUCK_USB_TX_H

+ 231 - 0
PicoPuck/src/usb/webusb.c

@@ -0,0 +1,231 @@
+// webusb.c — WebUSB config channel (see webusb.h).
+//
+// Host→device opcodes (each a single bulk-OUT packet):
+//   0x01                       get status          → 0xA5 frame
+//   0x02 [secs]                scan start
+//   0x03                       scan stop
+//   0x04                       get scan list       → 0xA6 frame
+//   0x05 [addr6][type]         pair / connect
+//   0x06 [addr6][type]         forget one bond
+//   0x07                       forget all bonds
+//   0x08 [slot]                disconnect a slot
+//   0x0A                       reboot
+//   0x0B [0x42 0x4C]           reboot to UF2 bootloader (BOOTSEL)
+//   0x0C [0xDE 0xAD]           factory reset (bonds + settings)
+// Device→host frames: 0xA5 status, 0xA6 scan list.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "usb/webusb.h"
+#include "config/picopuck_config.h"
+#include "puck/slots.h"
+#include "bt/bt_control.h"
+
+#include <string.h>
+#include "tusb.h"
+#include "pico/bootrom.h"
+#include "hardware/watchdog.h"
+
+static bool s_connected;
+
+// Slot record: state, kind, battery, rssi, bd_addr[6], name[16] = 26 bytes.
+#define SLOT_REC_LEN 26
+// Scan record: kind, addr_type, rssi, bd_addr[6], name[16] = 25 bytes.
+#define SCAN_REC_LEN 25
+#define SCAN_MAX 12
+
+void webusb_init(void)
+{
+	s_connected = false;
+}
+
+void webusb_set_connected(bool connected)
+{
+	s_connected = connected;
+}
+
+// ---- frame senders ---------------------------------------------------------
+static void send_frame(const uint8_t *frame, uint16_t len)
+{
+	// Drop rather than block if the panel isn't draining (avoids wedging loop).
+	if (tud_vendor_write_available() < len)
+		return;
+	tud_vendor_write(frame, len);
+	tud_vendor_write_flush();
+}
+
+static void send_status(void)
+{
+	uint8_t f[2 + 17 + PP_NSLOT * SLOT_REC_LEN + 6];
+	uint8_t *p = f;
+	*p++ = 0xA5;
+	uint8_t *plen = p++;  // payload length, filled below
+	uint8_t *start = p;
+
+	*p++ = PP_WEBUSB_VER;
+	*p++ = PP_BOARD;
+	*p++ = 0;  // reserved (git-dirty flag)
+
+	char build[12] = { 0 };
+	strncpy(build, PICOPUCK_GIT_COMMIT, sizeof(build));
+	memcpy(p, build, 12);
+	p += 12;
+
+	*p++ = bt_scan_active() ? 1 : 0;
+	*p++ = PP_NSLOT;
+
+	uint32_t now = 0;  // (state doesn't need a timestamp here)
+	(void)now;
+	for (int s = 0; s < PP_NSLOT; s++) {
+		uint8_t kind = 0;
+		int8_t rssi = 0;
+		uint8_t addr[6] = { 0 };
+		char name[16] = { 0 };
+		bool bound = bt_slot_info(s, &kind, &rssi, addr, name);
+		uint8_t state = g_slot[s].connected ? 2 :
+				((bound || g_slot[s].used) ? 1 : 0);
+
+		*p++ = state;
+		*p++ = kind;
+		*p++ = g_battery[s];
+		*p++ = (uint8_t)rssi;
+		memcpy(p, addr, 6);
+		p += 6;
+		memcpy(p, name, 16);
+		p += 16;
+	}
+
+	// Diagnostics tail: [advSeen 2][flags 1][hciState 1][hciEvents 2].
+	uint16_t adv = 0, hev = 0;
+	uint8_t flags = 0, hci_state = 0;
+	bt_scan_diag(&adv, &flags, &hci_state, &hev);
+	*p++ = (uint8_t)(adv & 0xFF);
+	*p++ = (uint8_t)(adv >> 8);
+	*p++ = flags;
+	*p++ = hci_state;
+	*p++ = (uint8_t)(hev & 0xFF);
+	*p++ = (uint8_t)(hev >> 8);
+
+	*plen = (uint8_t)(p - start);
+	send_frame(f, (uint16_t)(p - f));
+}
+
+static void send_scan_list(void)
+{
+	bt_scan_entry_t ents[SCAN_MAX];
+	uint8_t n = bt_scan_list(ents, SCAN_MAX);
+
+	uint8_t f[2 + 1 + SCAN_MAX * SCAN_REC_LEN];
+	uint8_t *p = f;
+	*p++ = 0xA6;
+	uint8_t *plen = p++;
+	uint8_t *start = p;
+	*p++ = n;
+	for (uint8_t i = 0; i < n; i++) {
+		*p++ = ents[i].kind;
+		*p++ = ents[i].addr_type;
+		*p++ = (uint8_t)ents[i].rssi;
+		memcpy(p, ents[i].addr, 6);
+		p += 6;
+		memcpy(p, ents[i].name, 16);
+		p += 16;
+	}
+	*plen = (uint8_t)(p - start);
+	send_frame(f, (uint16_t)(p - f));
+}
+
+// ---- opcode dispatch -------------------------------------------------------
+// Expected total length (incl. opcode) for each known opcode, or 0 if unknown.
+static uint8_t opcode_len(uint8_t op)
+{
+	switch (op) {
+	case 0x01: return 1;
+	case 0x02: return 2;
+	case 0x03: return 1;
+	case 0x04: return 1;
+	case 0x05: return 8;
+	case 0x06: return 8;
+	case 0x07: return 1;
+	case 0x08: return 2;
+	case 0x0A: return 1;
+	case 0x0B: return 3;
+	case 0x0C: return 3;
+	default: return 0;
+	}
+}
+
+static void dispatch(const uint8_t *c, uint8_t n)
+{
+	switch (c[0]) {
+	case 0x01:
+		send_status();
+		break;
+	case 0x02:
+		bt_scan_start(c[1]);
+		break;
+	case 0x03:
+		bt_scan_stop();
+		break;
+	case 0x04:
+		send_scan_list();
+		break;
+	case 0x05:
+		bt_pair(&c[1], c[7]);
+		break;
+	case 0x06:
+		bt_forget(&c[1], c[7]);
+		break;
+	case 0x07:
+		bt_forget_all();
+		break;
+	case 0x08:
+		bt_disconnect_slot(c[1]);
+		break;
+	case 0x0A:
+		watchdog_reboot(0, 0, 10);
+		break;
+	case 0x0B:
+		if (c[1] == 0x42 && c[2] == 0x4C)
+			reset_usb_boot(0, 0);
+		break;
+	case 0x0C:
+		if (c[1] == 0xDE && c[2] == 0xAD)
+			bt_forget_all();  // settings wipe added with the settings module
+		break;
+	default:
+		break;
+	}
+	(void)n;
+}
+
+void webusb_task(void)
+{
+	static uint8_t rx[64];
+	static uint8_t rxlen;
+
+	while (tud_vendor_available()) {
+		uint32_t got = tud_vendor_read(rx + rxlen,
+					       (uint32_t)(sizeof(rx) - rxlen));
+		if (!got)
+			break;
+		rxlen += (uint8_t)got;
+	}
+
+	uint8_t off = 0;
+	while (off < rxlen) {
+		uint8_t need = opcode_len(rx[off]);
+		if (need == 0) {
+			// Unknown opcode: resync by dropping one byte.
+			off++;
+			continue;
+		}
+		if ((uint8_t)(rxlen - off) < need)
+			break;  // wait for the rest of this command
+		dispatch(&rx[off], need);
+		off += need;
+	}
+
+	// Keep any partial trailing command for the next poll.
+	if (off > 0 && off < rxlen)
+		memmove(rx, rx + off, (size_t)(rxlen - off));
+	rxlen -= off;
+}

+ 22 - 0
PicoPuck/src/usb/webusb.h

@@ -0,0 +1,22 @@
+// webusb.h — WebUSB config channel for the OpenPuck panel.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_WEBUSB_H
+#define PICOPUCK_WEBUSB_H
+
+#include <stdbool.h>
+
+// Protocol version reported in the status frame; bump when the frame layout or
+// opcode set changes so the panel can gate features.
+#define PP_WEBUSB_VER 1
+
+void webusb_init(void);
+
+// Drain the vendor OUT endpoint, dispatch opcodes, and send any queued frames.
+// Call each loop.
+void webusb_task(void);
+
+// Set from the vendor class request (0x22) when the panel attaches/detaches.
+void webusb_set_connected(bool connected);
+
+#endif // PICOPUCK_WEBUSB_H

+ 122 - 3
docs/index.html

@@ -23,6 +23,8 @@
   /* ReversePuck dongle: hide every puck-only Controller card, keep just the paired-pucks card (this leaves
      the debugonly/lizard inline visibility rules untouched, so switching back to a puck restores them). */
   body.dongle #tabMain > .cards > .card:not(#pairedCard){display:none !important}
+  /* PicoPuck (Pico W / Pico 2 W): a Bluetooth puck. Show only its controllers card. */
+  body.picopuck #tabMain > .cards > .card:not(#picopuckCard){display:none !important}
   .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
   .row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
   .row label{flex:0 0 150px;color:var(--fg)}
@@ -136,6 +138,19 @@
       <div id="pairedList"><p class="note">Reading paired pucks…</p></div>
       <p class="note" style="margin-top:8px">This is a <strong>ReversePuck</strong> controller dongle (28DE:1302). It emulates a Steam Controller to whichever puck it's paired with. Pair new pucks with <code>scpair.py</code>; use the <em>Firmware update</em> tab to flash a new dongle build.</p>
     </div>
+    <div class="card wide" id="picopuckCard" style="display:none">
+      <h2>Bluetooth controllers</h2>
+      <div class="row" style="gap:10px;align-items:center;margin-bottom:4px">
+        <span class="pill" id="ppBoard">PicoPuck</span>
+        <span class="note" id="ppState" style="flex:1">—</span>
+        <button id="ppScanBtn">Scan</button>
+        <button id="ppBootBtn" title="Reboot into the UF2 bootloader to flash new firmware">Update firmware</button>
+      </div>
+      <div id="ppSlots"></div>
+      <h3 style="font-size:12px;color:var(--mut);margin:14px 0 6px">Discovered</h3>
+      <div id="ppScanList"><p class="note">Press <em>Scan</em> to look for controllers in pairing mode.</p></div>
+      <p class="note" style="margin-top:8px">This is a <strong>PicoPuck</strong> (Pico W / Pico 2 W). It connects to controllers over Bluetooth and presents them to Steam as a normal puck. Put a controller in pairing mode, press <em>Scan</em>, then pair it into one of the four slots. Use the <em>Firmware update</em> tab (reboot to bootloader) to flash a new build.</p>
+    </div>
     <div class="card">
       <h2>Version</h2>
       <div class="statgrid">
@@ -376,6 +391,10 @@ let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false, backupBu
 // lizard / config, just a paired-pucks list (0xAC) plus the identical firmware-update + DFU ops. When true,
 // the panel shows the controller UI (manage paired pucks + flash) and skips every puck-only poll.
 let isDongle=false;
+// A PicoPuck (Pico W / Pico 2 W, 28DE:1304 with bcdDevice 0x03xx) is a Bluetooth
+// puck. It speaks its own small protocol (0xA5 status ver 1 + a scan/pair set),
+// distinct from the nRF puck's blob, so the panel routes it to its own branch.
+let isPicoPuck=false;
 let lastP=null; // most recent status-blob payload (config source for Export)
 // Stability test: armed = active across reconnects; stabStart = ms of the current run; stabLastRun = last
 // completed run {secs} (ended by a reset). Kept in-page so it survives the device reset (not a page refresh).
@@ -715,7 +734,9 @@ async function openDevice(d){
     await dev.claimInterface(ifNum);
     await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
     isDongle = (dev.productId === 0x1302); // ReversePuck controller dongle vs a puck (0x1304 / real)
-    const kind = isDongle ? "ReversePuck" : "puck";
+    // PicoPuck shares the puck PID but bumps bcdDevice to 0x03xx (nRF puck = 0x02xx).
+    isPicoPuck = (dev.productId === 0x1304 && dev.deviceVersionMajor === 3);
+    const kind = isDongle ? "ReversePuck" : isPicoPuck ? "PicoPuck" : "puck";
     $("#connState").textContent="connected"; $("#connState").className="pill up";
     $("#connState").title = dev.serialNumber ? (kind+" serial "+dev.serialNumber) : "";
     $("#connectBtn").textContent="Reconnect";
@@ -724,7 +745,7 @@ async function openDevice(d){
     log(`connected — ${kind} ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} PID ${dev.productId.toString(16)} iface ${ifNum})`);
     // resume a stability test across the reset: re-arm the firmware buzz and start timing the next run
     // (puck-only: the dongle has no stability-test / haptic path)
-    if(stabArmed && !isDongle){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
+    if(stabArmed && !isDongle && !isPicoPuck){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
     updateStabUI();
     // The lizard-map dump (0x11 -> 0xAA) is loaded LAZILY, gated on the firmware advertising v16+ (see
     // applyBlob). Older firmware (every pre-lizard build, including the v15 updater builds) silently drops
@@ -733,7 +754,7 @@ async function openDevice(d){
     lizardLoaded=false;
     startPolling();
     // GitHub releases are PUCK firmware; skip the fetch for a dongle (it flashes via the local-file card).
-    if(!isDongle) loadReleases(false); // background, cached: feeds the update-available notice + pre-warms the update tab
+    if(!isDongle && !isPicoPuck) loadReleases(false); // background, cached: feeds the update-available notice + pre-warms the update tab
     return true;
   }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
 }
@@ -1064,6 +1085,7 @@ async function refresh(){
   // that would silently kill all future polls and read as a fake "heartbeat lost".
   try{
     if(isDongle){ await send([0x01]); const p=await readFrame(0xAC, 3, 256); if(p) applyDongleStatus(p); }
+    else if(isPicoPuck){ await send([0x01]); const p=await readFrame(0xA5, 3, 256); if(p) applyPicoPuckStatus(p); }
     else { await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
   }
   catch(e){ log("refresh err: "+e.message); }
@@ -1114,6 +1136,99 @@ function renderPaired(bonds, flags){
   }
 }
 
+// ---- PicoPuck: Bluetooth controllers (0xA5 status ver 1, 0xA6 scan list) ----
+// Status payload (header stripped): [ver][board][rsvd][build12][scanActive][nSlot]
+//   then nSlot * 26B slot recs: [state][kind][battery][rssi][addr6][name16].
+// state: 0 empty, 1 bonded, 2 connected. kind: 0 none,1 SC2,2 BLE pad,3 Classic pad.
+const PP_KIND=["—","Steam Controller 2","Bluetooth gamepad","Classic gamepad"];
+const PP_ADDRT=["Classic","BLE","BLE"];
+function ppHex(a){ return [...a].map(x=>x.toString(16).padStart(2,"0")).join(":"); }
+function ppName(a){ const s=new TextDecoder("latin1").decode(new Uint8Array(a)); return s.replace(/[^\x20-\x7e]/g,"").trim(); }
+function applyPicoPuckStatus(p){
+  window._lastBlobTs=Date.now(); // heartbeat (mirrors applyBlob/applyDongleStatus)
+  if(pendingHang) pendingHang=null;
+  const board=p[1], scanActive=p[15], nSlot=p[16]||4;
+  $("#ppBoard").textContent = board===2 ? "Pico 2 W" : "Pico W";
+  // Diagnostics tail after the slot records: [advSeen lo][hi][flags].
+  const dg=17+nSlot*26;
+  let diag="";
+  if(p.length>=dg+6){
+    const adv=p[dg]|(p[dg+1]<<8), fl=p[dg+2], hs=p[dg+3], hev=p[dg+4]|(p[dg+5]<<8);
+    const HS=["off","initialising","working","halting","sleeping"];
+    diag = ` · ${adv} adv · ${(fl&1)?"scan on":"scan off"}`
+      + ` · hci=${HS[hs]||hs}(evts ${hev})`
+      + `${(fl&8)?"":" · INIT FAIL"}${(fl&16)?"":" · no power-on"}${(fl&2)?"":" · NOT READY"}${(fl&4)?" · connecting":""}`;
+  } else if(p.length>=dg+3){
+    const adv=p[dg]|(p[dg+1]<<8), fl=p[dg+2];
+    diag = ` · ${adv} adv · ${(fl&1)?"scan on":"scan off"}${(fl&2)?"":" · NOT READY"}`;
+  }
+  $("#ppState").textContent = (scanActive ? "scanning for controllers…" : "idle") + diag;
+  $("#ppScanBtn").textContent = scanActive ? "Stop" : "Scan";
+  const slots=[];
+  for(let i=0;i<nSlot;i++){
+    const q=17+i*26; if(q+26>p.length) break;
+    slots.push({state:p[q],kind:p[q+1],battery:p[q+2],rssi:(p[q+3]<<24>>24),
+      addr:p.slice(q+4,q+10),name:ppName(p.slice(q+10,q+26))});
+  }
+  renderPicoPuckSlots(slots);
+  if(scanActive) ppRequestScanList();
+}
+function renderPicoPuckSlots(slots){
+  const el=$("#ppSlots"); if(!el) return;
+  el.innerHTML="";
+  slots.forEach((s,i)=>{
+    const row=document.createElement("div"); row.className="row"; row.style.cssText="align-items:center;gap:10px;margin:6px 0";
+    const live=s.state===2;
+    const badge=live?'<span class="pill up" style="flex:0 0 auto">live</span>'
+      :s.state===1?'<span class="pill" style="flex:0 0 auto">bonded</span>'
+      :'<span class="pill" style="flex:0 0 auto">empty</span>';
+    const title=s.state? (s.name||PP_KIND[s.kind]||"controller") : "(empty)";
+    const meta=s.state? `${PP_KIND[s.kind]||"?"} · ${ppHex(s.addr)}`+(live?` · batt ${s.battery}% · ${s.rssi} dBm`:"") : `slot ${i}`;
+    row.innerHTML=`${badge}<div style="flex:1"><div style="font-weight:600">${title}</div>`
+      +`<div class="note" style="font-family:ui-monospace,Menlo,monospace">${meta}</div></div>`;
+    if(s.state){
+      const rm=document.createElement("button"); rm.textContent="Forget"; rm.className="danger"; rm.style.flex="0 0 auto";
+      rm.onclick=async()=>{ if(!confirm(`Forget "${title}"?`)) return; await send([0x06, ...s.addr, 1]); log("forgot slot "+i); };
+      row.appendChild(rm);
+    }
+    el.appendChild(row);
+  });
+}
+async function ppRequestScanList(){
+  try{ await send([0x04]); const p=await readFrame(0xA6, 1, 256); if(p) renderPicoPuckScan(p); }catch(e){}
+}
+function renderPicoPuckScan(p){
+  const el=$("#ppScanList"); if(!el) return;
+  const count=p[0], ents=[];
+  for(let i=0;i<count;i++){
+    const q=1+i*25; if(q+25>p.length) break;
+    ents.push({kind:p[q],addrType:p[q+1],rssi:(p[q+2]<<24>>24),
+      addr:p.slice(q+3,q+9),name:ppName(p.slice(q+9,q+25))});
+  }
+  if(!ents.length){ el.innerHTML='<p class="note">No controllers found yet — make sure the controller is in Bluetooth pairing mode.</p>'; return; }
+  el.innerHTML="";
+  for(const e of ents){
+    const row=document.createElement("div"); row.className="row"; row.style.cssText="align-items:center;gap:10px;margin:6px 0";
+    row.innerHTML=`<div style="flex:1"><div style="font-weight:600">${e.name||PP_KIND[e.kind]||"controller"}</div>`
+      +`<div class="note" style="font-family:ui-monospace,Menlo,monospace">${PP_KIND[e.kind]||"?"} · ${PP_ADDRT[e.addrType]||"?"} · ${ppHex(e.addr)} · ${e.rssi} dBm</div></div>`;
+    const pair=document.createElement("button"); pair.textContent="Pair"; pair.style.flex="0 0 auto";
+    pair.onclick=async()=>{ await send([0x05, ...e.addr, e.addrType]); log("pairing "+ppHex(e.addr)); };
+    row.appendChild(pair); el.appendChild(row);
+  }
+}
+function ppInitCard(){
+  const btn=$("#ppScanBtn"); if(!btn||btn._wired) return; btn._wired=true;
+  btn.onclick=async()=>{
+    if(btn.textContent==="Stop"){ await send([0x03]); }
+    else { await send([0x02, 30]); await ppRequestScanList(); }
+  };
+  const boot=$("#ppBootBtn");
+  if(boot) boot.onclick=async()=>{
+    if(!confirm("Reboot the PicoPuck into its UF2 bootloader?\n\nIt will appear as a USB drive; drag the new picopuck_*.uf2 onto it.")) return;
+    await send([0x0B, 0x42, 0x4C]); log("rebooting to bootloader…");
+  };
+}
+
 // Toggle the UI between the full puck panel and the reduced ReversePuck (controller dongle) panel. Called on
 // every (re)connect so switching a puck<->dongle in one session lands on the right layout.
 function applyDeviceProfile(){
@@ -1122,6 +1237,10 @@ function applyDeviceProfile(){
   // reconnect restores them without clobbering the debugonly/lizard inline visibility rules.
   document.body.classList.toggle("dongle", d);
   const paired=$("#pairedCard"); if(paired) paired.style.display = d ? "" : "none";
+  // PicoPuck: same idea — hide the nRF puck cards, show only the Bluetooth-controllers card.
+  document.body.classList.toggle("picopuck", isPicoPuck);
+  const ppc=$("#picopuckCard"); if(ppc) ppc.style.display = isPicoPuck ? "" : "none";
+  if(isPicoPuck){ ppInitCard(); const ut=$('#mainTabs .slot-tab[data-tab="tabUpdate"]'); if(ut) ut.style.display=""; }
   // top-bar buttons: factory-erase is a puck-only op (0x0A); the dongle keeps DFU + (debug) wipe-board
   const fe=$("#factoryErase"); if(fe) fe.style.display = d ? "none" : "";
   // the GitHub "Update from a release" list is puck firmware; a dongle flashes via the local-file drop card