v0.2.0
This repository is a Raspberry Pi Pico W example that uses
NS-LIB-HID to present
Switch-style controller behavior over either USB or Bluetooth Classic HID.
The example is intentionally small: it focuses on the protocol glue, the transport loops, and pairing-data persistence rather than on a full input stack. In other words, this project is meant to show how to wire the library into real firmware, not to be a finished controller product.
- Initializing
NS-LIB-HIDwith a concrete device configuration. - Selecting USB or Bluetooth mode at boot using GPIO straps.
- Forwarding host output reports into the library with
ns_api_output_tunnel(). - Generating 64-byte input reports at the expected cadence with
ns_api_generate_inputreport(). - Saving pairing credentials to flash so Bluetooth reconnect can work across resets.
- Providing the application callbacks that
NS-LIB-HIDexpects firmware to own.
This example is intentionally minimal. Out of the box it currently does the following:
- Exposes two example buttons:
AonGP14BonGP15
- Keeps both analog sticks centered.
- Leaves IMU reporting unimplemented.
- Leaves player LEDs and shutdown handling as stubs.
- Decodes host haptics into raw lookup-index packets, but does not drive a real actuator.
- Stores host MAC + link key when pairing data becomes available.
- Uses a fixed example device MAC address in
main.c. - Configures the library with
NS_DEVTYPE_PROCONinmain.c.
That makes the project a good transport and protocol example, but not yet a full-featured controller implementation.
The example is written for a Raspberry Pi Pico W and assumes simple active-low inputs with internal pull-ups enabled in firmware.
Boot-mode pins:
GP0: hold low during boot to enter Bluetooth reconnect mode.GP1: hold low during boot to enter Bluetooth pairing mode.- If neither pin is held low at boot, the example enters USB mode.
Example input pins:
GP14:Abutton, active low.GP15:Bbutton, active low.
Debug UART:
GP12: UART TXGP13: UART RX
Those UART pins were chosen so GP0 and GP1 stay available as boot straps.
The entire example revolves around a simple boot-time transport choice:
-
USB modeDefault mode when both boot pins are left high. The firmware starts TinyUSB, enumerates using descriptors supplied byNS-LIB-HID, and sends reports on the USB HID path. -
Bluetooth reconnect modeEntered by holdingGP0low during boot. The firmware starts the Pico W wireless stack, configures BTstack as a HID device, restores saved pairing credentials, and attempts to reconnect using the stored host information. -
Bluetooth pairing modeEntered by holdingGP1low during boot. This is the discovery/bonding path intended for first-time wireless pairing or re-pairing.
This split keeps the runtime simple: transport is chosen once at startup, and the rest of the firmware can run a single transport loop without mode switching in the background.
For a first pass, the easiest way to use the example is:
- Build and flash the firmware onto a Pico W.
- Wire momentary switches so
GP14andGP15can be pulled to ground. - Boot normally to enter USB mode.
- Connect the board to the host or Switch over USB and confirm the device enumerates.
- If you want wireless operation, pair once so the host MAC and link key can be captured and written to flash.
- Reboot while holding
GP0low to test Bluetooth reconnect behavior. - Reboot while holding
GP1low whenever you want to force wireless pairing mode instead.
One of the most important things this example shows is that transport handling alone is not enough for a usable wireless controller. The firmware also has to remember pairing material.
The stored structure is defined in main.h:
- a magic value used to detect initialized storage
- the last known host MAC address
- the 16-byte link key
The example stores that data in flash through ns_flash.c. Writes are deferred
through ns_flash_task() instead of writing immediately from a callback. That
design matters because flash erase/program operations are sensitive on RP2040
systems and are safer when funneled through one controlled path.
ns_api_hook_set_usbpair() is the application hook that copies the pairing data into
device_storage and schedules the write. In this example that callback is used
as the central place to preserve credentials, regardless of whether they were
learned from USB-assisted pairing or the Bluetooth stack.
main.c is the top-level orchestrator.
It:
- reads the boot pins to choose transport
- initializes stdio and flash support
- loads previously saved pairing data
- fills
ns_device_config_s - calls
ns_api_init() - hands execution to either
ns_usb_enter()orns_btc_enter()
It also implements the callback surface that NS-LIB-HID expects platform
firmware to own, such as:
ns_api_hook_get_input()ns_api_hook_get_powerstatus()ns_api_hook_set_usbpair()ns_api_hook_set_led()ns_api_hook_set_power()ns_api_hook_set_imu_mode()ns_api_hook_get_imu()ns_api_hook_get_quaternion()ns_api_hook_set_haptic_packet_raw()ns_api_hook_get_time_ms()ns_api_hook_get_random_u8()
That division of responsibility is deliberate: NS-LIB-HID handles the Switch
protocol and descriptor content, while the application remains responsible for
real hardware state, timing, persistence, and side effects.
For input specifically, ns_api_hook_get_input() supplies logical button bits
and unpacked stick values in ns_input_s; the library packs those values into
the Switch report layout internally.
For haptics, ns_api_hook_set_haptic_packet_raw() receives decoded lookup-table
indices. The example stops there, but a real product could either consume those
indices directly with precomputed fixed-point tables or call
ns_api_convert_haptic_packet() to obtain float reference values.
ns_usb.c is the wired transport path.
Its job is simple:
- initialize TinyUSB
- feed received host output reports into
ns_api_output_tunnel() - call
ns_api_generate_inputreport()at the right cadence - submit the resulting HID report back over USB
The cadence is synchronized to USB start-of-frame events so the example lands
close to the Switch's expected 8 ms report interval. That is why the file uses
tud_sof_cb() and a _frame_ready flag instead of blindly transmitting as fast
as the main loop can run.
ns_btc.c is the wireless transport path.
It:
- brings up the CYW43 + BTstack stack
- configures GAP/HID/SDP state
- publishes the HID descriptor supplied by
NS-LIB-HID - forwards host HID output data into
ns_api_output_tunnel() - requests "can send now" events and emits input reports at roughly 8 ms
- stores or restores link-key material as needed
The Bluetooth loop mirrors the same core library contract as USB: host output
goes in through ns_api_output_tunnel(), and outgoing reports come from
ns_api_generate_inputreport(). That symmetry is a major reason this example is
useful; once the application callbacks are implemented, the transport-specific
code stays relatively thin.
ns_flash.c is a tiny persistence helper.
It provides:
ns_flash_read()to load a saved structure from flashns_flash_write()to queue a write requestns_flash_task()to perform the queued write safely
Keeping flash behavior isolated makes the rest of the example easier to reason about and gives you one obvious place to replace if your product later moves to EEPROM, FRAM, a file system, or a different flash layout.
The example has a few design choices that are worth calling out explicitly:
-
Boot-time mode selection instead of runtime switchingThis keeps the demo predictable and easy to debug. USB and Bluetooth each get a dedicated loop, and there is no need to hot-switch stacks after startup. -
Library-owned protocol, application-owned hardware hooksNS-LIB-HIDis responsible for protocol details, descriptor data, and report formatting. The application is responsible for the parts only it can know: buttons, storage, LEDs, haptics, IMU data, and timing. -
Persist pairing data in flashWireless reconnect only becomes practical when the controller can remember the console identity and key material across resets. -
Maintain an 8 ms report cadenceBoth transport files are written around the idea that stable report timing is part of making host communication behave like a real controller.
You will need:
- Raspberry Pi Pico SDK
2.2.0or a compatible setup - CMake
3.13+ - An ARM GCC toolchain suitable for Pico development
- Git submodule support for
external/NS-LIB-HID
Because NS-LIB-HID is included as a git submodule, make sure it is present
before building:
git submodule update --init --recursiveFrom the repository root:
mkdir build
cd build
cmake .. -DPICO_SDK_PATH=/path/to/pico-sdk
cmake --build .Expected outputs are generated into the build directory by
pico_add_extra_outputs(...), including the UF2 file used for drag-and-drop
flashing.
This project was generated from the Pico VS Code extension template and already
includes the standard pico_sdk_import.cmake plumbing.
If your Pico SDK environment is already set up in the IDE, you can generally:
- Open the repository folder.
- Configure CMake.
- Build the
Pico-W-NS-Exampletarget. - Flash the produced UF2 to a Pico W in BOOTSEL mode.
The usual RP2040 flow applies:
- Hold
BOOTSELwhile connecting the Pico W to USB. - Copy the generated
.uf2file onto the mounted mass-storage device. - The board will reboot into the example firmware.
- Leave
GP0andGP1unasserted. - Power or reset the board.
- The firmware enters
ns_usb_enter(). - Host output reports are tunneled into
NS-LIB-HID. - Input reports are generated on the USB HID path.
- Hold
GP0low while powering or resetting the board. - The firmware enters
ns_btc_enter(..., false). - Saved pairing data is used to restore link-key context and reconnect.
- Hold
GP1low while powering or resetting the board. - The firmware enters
ns_btc_enter(..., true). - The Bluetooth HID device becomes pairable/discoverable for a fresh bond.
The example enables Pico SDK stdio on UART and disables USB stdio:
- UART enabled
- USB stdio disabled
That means boot logs and pairing/debug prints are expected on the configured
UART pins (GP12/GP13), not over the USB CDC console.
Useful things the firmware prints today include:
- the stored host MAC address
- the stored link key
- whether the board entered USB or Bluetooth mode
- Bluetooth pairing and connection events
If you use this example as a base, these are the first things you will normally replace:
device_macinmain.cwith a unique per-device address policy- the placeholder button/stick mapping in
ns_api_hook_get_input() ns_api_hook_get_powerstatus()with real battery and charge reportingns_api_hook_set_led()with actual LED behaviorns_api_hook_set_power()with product-specific power management behaviorns_api_hook_set_haptic_packet_raw()with motor/actuator handlingns_api_hook_get_imu()/ns_api_hook_get_quaternion()with sensor data- the selected
NS_DEVTYPE_*identity inmain.c
Unless otherwise noted, the example application code and original documentation in this repository are Copyright (c) 2026 Hand Held Legend, LLC, authored by Mitchell Cairns, and licensed under CC BY-NC 4.0.
That means the example content may be shared and adapted for non-commercial use with proper attribution and indication of changes.
For commercial licensing inquiries, contact support@handheldlegend.com.
Important scope note:
- The bundled
external/NS-LIB-HIDlibrary is not covered by the top-level example license; it carries its own licensing terms and notices. - Some template-derived or third-party files may retain their original notices where required.
See the repository LICENSE file for the project-level license notice and
scope.
main.c/main.h: boot logic, configuration, and application callbacksns_usb.c: TinyUSB transport pathns_btc.c: Pico W Bluetooth HID transport pathns_flash.c: flash-backed pairing-data persistenceexternal/NS-LIB-HID: bundled protocol/descriptor librarybtstack_config.h: BTstack feature sizing and configurationtusb_config.h: TinyUSB device configuration
For release v0.2.0, the value of this repository is mainly educational:
- it shows the smallest complete path from
NS-LIB-HIDinit to live transport - it demonstrates where pairing data is captured and why it must be persisted
- it makes the transport/library boundary clear enough to extend into real hardware
If you are trying to understand the code quickly, start with main.c, then
read ns_usb.c and ns_btc.c as two parallel transport adapters wrapped
around the same library API.