Skip to content

feat(windows): add native GATT support - #20

Merged
stoprocent merged 5 commits into
mainfrom
agent/windows-native-gatt
Jul 29, 2026
Merged

feat(windows): add native GATT support#20
stoprocent merged 5 commits into
mainfrom
agent/windows-native-gatt

Conversation

@stoprocent

@stoprocent stoprocent commented Jul 28, 2026

Copy link
Copy Markdown
Owner

What changed

  • Added a native C++/WinRT GATT server binding for Windows.
  • Added radio-state tracking, advertising lifecycle handling, dynamic services, characteristics and descriptors, read/write requests, notifications, indications, subscriptions, MTU updates, and connection lifecycle events.
  • Made the WinRT binding the default on win32; the raw HCI backend remains available with withBindings('hci').
  • Added Windows build targets, binding resolution, TypeScript declarations, documentation, and service-setup error regression coverage.
  • Documented manual withBindings('win') selection, handle-specific disconnects, platform requirements, supported features, and native API limitations.
  • Hardened the shared thread-safe callback lifecycle used by native bindings.

Why

Windows previously defaulted to raw HCI access, which requires a dedicated adapter and WinUSB driver. The native binding uses the normal Windows Bluetooth stack and exposes bleno's peripheral APIs through WinRT.

Impact

Windows users with an adapter that supports the Bluetooth LE peripheral role can use the system Bluetooth driver. Raw EIR and iBeacon advertising, application-controlled local names, and peer RSSI reads are not available through this binding; these limitations are documented.

Validation

  • GitHub Build matrix passed, including native Windows x86 and x64 compilation, prebuild generation, and artifact upload.
  • GitHub Test / Lint matrix passed on Node 18 and 20 across Linux, macOS, and Windows.
  • Commit-message validation passed.
  • Local clean install, lint, native macOS rebuild, and 25 tests passed.
  • npm pack --dry-run includes the Windows sources.

@stoprocent
stoprocent force-pushed the agent/windows-native-gatt branch from 02fb37c to 9901010 Compare July 28, 2026 09:34
@stoprocent
stoprocent marked this pull request as ready for review July 28, 2026 09:47
@stoprocent
stoprocent force-pushed the agent/windows-native-gatt branch from 9901010 to c67338f Compare July 29, 2026 06:38
Marek Serafin and others added 3 commits July 29, 2026 16:42
Three defects in the native Windows GATT binding, found while testing a
peripheral on Windows 11:

* Advertising reported a spurious failure on every start. The
  AdvertisementStatusChanged handler treated any Aborted status as fatal,
  but Windows transiently reports Aborted with BluetoothError::Success
  while the provider re-tunes its advertisement during startup (the
  observed transition is Created -> Aborted -> Started). The success code
  0 was then stringified into "Advertising GATT service failed with
  Bluetooth error 0", so consumers saw advertisingStart fire twice, the
  second time with a phantom error. Only surface a genuine failure.

* Reserved descriptors broke setServices. Only 2901/2902 were recognised,
  and only by raw string compare, so the long-form UUID was missed
  entirely and 2904 fell through to CreateDescriptorAsync - which Windows
  rejects with "The provided descriptor uuid is reserved and will be
  automatically published by the system", failing the whole call. This
  broke the battery-service example. Resolve the 16-bit SIG alias from the
  GUID so short and long forms both match, skip the system-managed
  descriptors (2900/2902/2903), and forward 2904 through
  GattLocalCharacteristicParameters.PresentationFormats so its declared
  value is preserved rather than dropped.

* The advertised local name was silently discarded. mName was assigned and
  never read: GattServiceProviderAdvertisingParameters exposes no local
  name, and Windows always advertises the system Bluetooth name. Emit a
  warning once per session so callers know to discover the peripheral by
  service UUID instead of by the name they passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
setAddressAsync resolved unconditionally: the addressChange callback
parameter shadowed the requested address, so `address === address` was
always true and a mismatch could never be detected. The else branch also
left the rejection timer running and could reject after an earlier
settle. Name the callback parameter, clear the timer before branching,
and compare case-insensitively since address formatting varies between
bindings.

Also relay the binding's new warning event, so notices such as the
Windows local-name limitation reach consumers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GattServiceProviderAdvertisingParameters selects between legacy and
extended advertising by whether a secondary PHY is requested. The binding
never set one, so Windows was always asked for legacy advertising with no
way to change that.

Expose it as an opt-in: withBindings('win', { extendedAdvertising: true }),
or BLENO_WIN_EXTENDED_ADV=1 for use with the stock examples. Setting it
requests the uncoded 1M PHY as secondary PHY, which switches Windows to
extended advertising.

The default stays legacy, matching current behaviour: extended
advertisements are invisible to Bluetooth 4.x-only scanners, so this is
not safe to enable for everyone. The call is guarded with try_as on
IGattServiceProviderAdvertisingParameters3 and degrades to legacy with a
warning on Windows releases that predate the secondary PHY parameters.

Note this was written to test whether a Bluetooth 5 controller that never
put a legacy advertisement on air would advertise in extended mode. It did
not: on a Realtek dongle (USB\VID_0BDA&PID_A729) running the Microsoft
inbox driver, advertising is accepted and AdvertisementStatus reports
Started in both modes while nothing is transmitted. A CSR8510 (BT 4.0) on
the same machine works in legacy mode. The option is kept because the
capability is legitimate, not because it fixes that controller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@stoprocent

Copy link
Copy Markdown
Owner Author

Tested this branch on Windows 11 (build 26200, Node 22.22) against two USB Bluetooth adapters. The native binding builds cleanly and works: a peripheral powers on, advertises, publishes services and serves GATT. Pushed four fixes found along the way, and one hardware finding that is worth documenting but is not a bleno bug.

Fixes

1. Every advertising start reported a phantom failure (9b13b92)

advertisingStart fired twice on every run, the second time with:

Error: Advertising GATT service failed with Bluetooth error 0

BluetoothError::Success is 0, so this literally said "failed with success". The AdvertisementStatusChanged handler treated any Aborted status as fatal, but Windows transiently reports Aborted with Error() == Success while the provider re-tunes during startup. Traced status transitions confirm the normal sequence is:

Created -> Aborted (error=0) -> Started

Fixed by requiring a genuine error before surfacing one. Verified by A/B: without the guard 3/3 advertise cycles emit the error, with it 0/3, final status Started either way.

2. Reserved descriptors broke setServices (9b13b92)

The battery-service example failed outright:

setServices: error Error: The provided descriptor uuid is reserved and will be automatically published by the system.

Only 2901/2902 were recognised, and only by raw string compare, so 2904 fell through to CreateDescriptorAsync and Windows rejected the whole call. The string compare also missed the long form entirely — 00002902-0000-1000-8000-00805f9b34fb was not treated as a CCCD.

Now the 16-bit SIG alias is resolved from the GUID so short and long forms both match, system-managed descriptors (2900/2902/2903) are skipped, and 2904 is parsed and forwarded through GattLocalCharacteristicParameters.PresentationFormats so its value is preserved rather than dropped. battery-service now reports setServices: success.

3. The advertised local name was silently discarded (9b13b92)

mName was assigned in StartAdvertising and never read again. GattServiceProviderAdvertisingParameters exposes no local name — Windows always advertises the system Bluetooth name — so startAdvertising('my-name', [...]) puts nothing named my-name on the air.

This is genuinely confusing in practice: scanning for the name you passed will never find the device, and there was no indication of why. The binding now emits a warning once per session telling callers to discover by service UUID instead.

4. setAddressAsync could never detect a mismatch (1b04fa5)

this._bindings.once('addressChange', (address) => {
  if (address === address) {   // parameter shadows the requested address

Always true. The else branch was unreachable, and it also left the rejection timer running so it could reject after an earlier settle. Renamed the parameter, clear the timer before branching, compare case-insensitively. Also relays the new warning event to consumers.

Not fixed, worth a maintainer decision

startAdvertising() before setServices() is a no-op that still reports success. At that point mProviders is empty, so StartProviders() iterates nothing, yet advertisingStart emits success. Advertising only actually begins later, as a side effect of setServices() via if (mAdvertising) StartProviders(). Since every example uses exactly that order it works in practice, but the success event fires before anything is transmitted. Fixing the semantics would change the event contract, so I left it alone.

Extended advertising (573be6d) — added, but it did not fix the case it was written for

GattServiceProviderAdvertisingParameters chooses between legacy and extended advertising by whether a secondary PHY is requested. The binding never set one, so Windows was always asked for legacy advertising with no way to change it. Now opt-in via withBindings('win', { extendedAdvertising: true }) or BLENO_WIN_EXTENDED_ADV=1, guarded with try_as on IGattServiceProviderAdvertisingParameters3, default unchanged (extended advertisements are invisible to BLE 4.x-only scanners, so it is not safe to enable for everyone).

Hardware finding: Realtek dongles accept advertising and transmit nothing

On one adapter the peripheral was never visible to any scanner, while every software layer reported success:

Check Result
BluetoothAdapter.IsPeripheralRoleSupported True
Raw WinRT GattServiceProvider (no bleno) AdvertisementStatus: Started, sustained
bleno advertisingStart: success, setServices: success
Legacy advertising not visible
Extended advertising (1M secondary PHY) not visible

Adapters involved:

  • USB\VID_0BDA&PID_A729 — Realtek, Microsoft generic inbox driver, sold as "Bluetooth 5.3". Never transmits, in either advertising mode.
  • USB\VID_0A12&PID_0001 — CSR8510 (Bluetooth 4.0). Works.

Reproduced with raw WinRT and no bleno in the process, so this is below the binding: Windows accepts the peripheral role, reports Started, and nothing reaches the air. Nothing bleno can do about it, but it is a nasty failure mode because every available API says success. If anyone has this working on a Realtek BT5 dongle under Windows, I would be glad to hear which driver.

Practical note for anyone testing on Windows: scan by service UUID, not by name — the local name is never advertised (see fix 3).

@stoprocent
stoprocent merged commit 8a19196 into main Jul 29, 2026
28 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant