diff --git a/examples/win-adapter-select.js b/examples/win-adapter-select.js new file mode 100644 index 00000000..3968dcd8 --- /dev/null +++ b/examples/win-adapter-select.js @@ -0,0 +1,158 @@ +const readline = require('readline'); +const { withBindings } = require('../'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +function ask (question) { + return new Promise(resolve => rl.question(question, resolve)); +} + +function printTable (rows, headers) { + const widths = headers.map((h, i) => + Math.max(h.length, ...rows.map(r => String(r[i]).length)) + ); + const sep = widths.map(w => '-'.repeat(w + 2)).join('+'); + const fmt = row => row.map((c, i) => ` ${String(c).padEnd(widths[i])} `).join('|'); + + console.log(fmt(headers)); + console.log(sep); + rows.forEach(r => console.log(fmt(r))); +} + +async function main () { + const noble = withBindings('win'); + await noble.waitForPoweredOnAsync(); + + // --- Step 1: List adapters --- + const adapters = await noble.getAdaptersAsync(); + + if (adapters.length === 0) { + console.log('No Bluetooth adapters found.'); + cleanup(noble); + return; + } + + console.log('\nAvailable Bluetooth adapters:\n'); + printTable( + adapters.map((a, i) => [i, a.name, a.address, a.default ? '*' : '']), + ['#', 'Name', 'Address', 'Default'] + ); + + // --- Step 2: Select adapter --- + let selected = 0; + if (adapters.length > 1) { + const input = await ask(`\nSelect adapter [0-${adapters.length - 1}]: `); + selected = parseInt(input, 10); + if (isNaN(selected) || selected < 0 || selected >= adapters.length) { + console.log('Invalid selection.'); + cleanup(noble); + return; + } + + console.log(`\nSwitching to "${adapters[selected].name}"...`); + await noble.setAdapterAsync(adapters[selected].id); + } + + console.log(`\nUsing adapter: ${adapters[selected].name} (${adapters[selected].address})`); + + // --- Step 3: Discover peripherals --- + console.log('Scanning for peripherals... (press Ctrl+C to stop)\n'); + + const discovered = new Map(); + + noble.on('discover', peripheral => { + const name = peripheral.advertisement.localName || '(unknown)'; + const isNew = !discovered.has(peripheral.id); + discovered.set(peripheral.id, { name, peripheral }); + + if (isNew) { + console.log( + ` [${discovered.size}] ${name.padEnd(30)} ` + + `${peripheral.address.padEnd(20)} RSSI: ${peripheral.rssi}` + ); + } + }); + + await noble.startScanningAsync([], false); + + // Wait for user to press enter to stop scanning + await ask('\nPress Enter to stop scanning...'); + await noble.stopScanningAsync(); + + if (discovered.size === 0) { + console.log('No peripherals found.'); + cleanup(noble); + return; + } + + // --- Step 4: Pick a peripheral to explore --- + const input = await ask(`\nSelect peripheral to explore [1-${discovered.size}] (or Enter to exit): `); + if (!input) { + cleanup(noble); + return; + } + + const idx = parseInt(input, 10); + const entries = [...discovered.values()]; + if (isNaN(idx) || idx < 1 || idx > entries.length) { + console.log('Invalid selection.'); + cleanup(noble); + return; + } + + const { peripheral } = entries[idx - 1]; + console.log(`\nConnecting to ${peripheral.advertisement.localName || peripheral.id}...`); + + peripheral.on('disconnect', reason => { + console.log(`\nDisconnected (${reason})`); + }); + + try { + await peripheral.connectAsync(); + console.log('Connected!\n'); + + const services = await peripheral.discoverServicesAsync([]); + for (const service of services) { + console.log(`Service: ${service.uuid}${service.name ? ` (${service.name})` : ''}`); + + const chars = await service.discoverCharacteristicsAsync([]); + for (const c of chars) { + let info = ` ${c.uuid}`; + if (c.name) info += ` (${c.name})`; + info += ` [${c.properties.join(', ')}]`; + + if (c.properties.includes('read')) { + try { + const data = await c.readAsync(); + if (data) info += ` = ${data.toString('hex')}`; + } catch (_) {} + } + console.log(info); + } + } + + await peripheral.disconnectAsync(); + } catch (err) { + console.error('Error:', err.message); + } + + cleanup(noble); +} + +function cleanup (noble) { + rl.close(); + noble.stop(); +} + +process.on('SIGINT', () => { + console.log('\nInterrupted.'); + process.exit(); +}); + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/index.d.ts b/index.d.ts index acd49447..aba4a6e4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -35,6 +35,8 @@ declare module '@stoprocent/noble' { readonly address: string; waitForPoweredOnAsync(timeout?: number): Promise; + getAdaptersAsync(): Promise; + setAdapterAsync(deviceId: string): Promise; startScanningAsync(serviceUUIDs?: string[], allowDuplicates?: boolean): Promise; stopScanningAsync(): Promise; discoverAsync(): AsyncGenerator; @@ -247,8 +249,22 @@ declare module '@stoprocent/noble' { userChannel?: boolean; } + export interface Adapter { + /** WinRT device ID used for selecting this adapter */ + id: string; + /** Bluetooth MAC address (e.g. "AA:BB:CC:DD:EE:FF") */ + address: string; + /** Friendly adapter name */ + name: string; + /** Whether this is the system default adapter */ + default: boolean; + } + export interface MacBindingsOptions extends BaseBindingsOptions {} - export interface WinBindingsOptions extends BaseBindingsOptions {} + export interface WinBindingsOptions extends BaseBindingsOptions { + /** WinRT device ID of the Bluetooth adapter to use (from getAdaptersAsync()) */ + deviceId?: string; + } export type WithBindingsOptions = HciBindingsOptions | MacBindingsOptions | WinBindingsOptions; diff --git a/lib/common/include/Emit.h b/lib/common/include/Emit.h index 1d25bb10..b5e905e5 100644 --- a/lib/common/include/Emit.h +++ b/lib/common/include/Emit.h @@ -7,11 +7,19 @@ #include "Peripheral.h" #include "ThreadSafeCallback.h" +struct AdapterInfo { + std::string id; + std::string address; + std::string name; + bool isDefault; +}; + class Emit { public: // clang-format off void Wrap(const Napi::Value& receiver, const Napi::Function& callback); + void Adapters(const std::vector& adapters); void RadioState(const std::string& status); void Address(const std::string& address); void ScanState(bool start); diff --git a/lib/common/src/Emit.cc b/lib/common/src/Emit.cc index a1caca68..e2a7d729 100644 --- a/lib/common/src/Emit.cc +++ b/lib/common/src/Emit.cc @@ -70,6 +70,24 @@ void Emit::Wrap(const Napi::Value& receiver, const Napi::Function& callback) mCallback = std::make_shared(receiver, callback); } +void Emit::Adapters(const std::vector& adapters) +{ + auto adaptersCopy = adapters; + mCallback->call([adaptersCopy](Napi::Env env, std::vector& args) { + auto arr = Napi::Array::New(env, adaptersCopy.size()); + for (size_t i = 0; i < adaptersCopy.size(); i++) { + auto obj = Napi::Object::New(env); + obj.Set(_s("id"), _s(adaptersCopy[i].id)); + obj.Set(_s("address"), _s(adaptersCopy[i].address)); + obj.Set(_s("name"), _s(adaptersCopy[i].name)); + obj.Set(_s("default"), _b(adaptersCopy[i].isDefault)); + arr.Set(i, obj); + } + // emit('adapters', [...]) + args = { _s("adapters"), arr }; + }); +} + void Emit::RadioState(const std::string& state) { mCallback->call([state](Napi::Env env, std::vector& args) { diff --git a/lib/noble.js b/lib/noble.js index 94b4dc7b..776f5667 100644 --- a/lib/noble.js +++ b/lib/noble.js @@ -79,6 +79,7 @@ class Noble extends NobleEventEmitter { this._bindings.on('handleWrite', this._onHandleWrite.bind(this)); this._bindings.on('handleNotify', this._onHandleNotify.bind(this)); this._bindings.on('onMtu', this._onMtu.bind(this)); + this._bindings.on('adapters', this._onAdapters.bind(this)); } _createPeripheral (uuid, address, addressType, connectable, advertisement, rssi, scannable) { @@ -184,6 +185,37 @@ class Noble extends NobleEventEmitter { }); } + async getAdaptersAsync () { + if (this._initialized === false) { + this._initializeBindings(); + } + if (typeof this._bindings.getAdapters !== 'function') { + throw new Error('getAdapters is not supported by the current binding'); + } + return new Promise((resolve) => { + this.onceExclusive('adapters', resolve); + this._bindings.getAdapters(); + }); + } + + async setAdapterAsync (deviceId) { + if (this._initialized === false) { + this._initializeBindings(); + } + if (typeof this._bindings.setAdapter !== 'function') { + throw new Error('setAdapter is not supported by the current binding'); + } + return new Promise((resolve) => { + this.once('stateChange', (state) => resolve(state)); + this._bindings.setAdapter(deviceId); + }); + } + + _onAdapters (adapters) { + debug('adapters', adapters); + this.emit('adapters', adapters); + } + startScanning (serviceUuids, allowDuplicates, callback) { const self = this; const scan = (state) => { diff --git a/lib/win/src/ble_manager.cc b/lib/win/src/ble_manager.cc index 19d09915..ae437d2e 100644 --- a/lib/win/src/ble_manager.cc +++ b/lib/win/src/ble_manager.cc @@ -130,10 +130,13 @@ const std::vector serviceDataTypes = { } }; -BLEManager::BLEManager(const Napi::Value& receiver, const Napi::Function& callback) +BLEManager::BLEManager(const Napi::Value& receiver, const Napi::Function& callback, const std::string& deviceId) { mRadioState = AdapterState::Initial; mEmit.Wrap(receiver, callback); + if (!deviceId.empty()) { + mWatcher.SetDeviceId(deviceId); + } auto onRadio = std::bind(&BLEManager::OnRadio, this, std::placeholders::_1, std::placeholders::_2); mWatcher.Start(onRadio); mAdvertismentWatcher.ScanningMode(BluetoothLEScanningMode::Active); @@ -143,6 +146,20 @@ BLEManager::BLEManager(const Napi::Value& receiver, const Napi::Function& callba mStoppedRevoker = mAdvertismentWatcher.Stopped(winrt::auto_revoke, onStopped); } +void BLEManager::GetAdapters() +{ + mWatcher.EnumerateAdapters([this](const std::vector& adapters) { + mEmit.Adapters(adapters); + }); +} + +void BLEManager::SetAdapter(const std::string& deviceId) +{ + mRadioState = AdapterState::Initial; + mWatcher.SetDeviceId(deviceId); + mWatcher.OnRadioChanged(); +} + void BLEManager::OnRadio(Radio& radio, const AdapterCapabilities& capabilities) { auto state = AdapterState::Unsupported; diff --git a/lib/win/src/ble_manager.h b/lib/win/src/ble_manager.h index 646c6d75..3c091e9b 100644 --- a/lib/win/src/ble_manager.h +++ b/lib/win/src/ble_manager.h @@ -17,7 +17,9 @@ using winrt::Windows::Foundation::IInspectable; class BLEManager { public: // clang-format off - BLEManager(const Napi::Value& receiver, const Napi::Function& callback); + BLEManager(const Napi::Value& receiver, const Napi::Function& callback, const std::string& deviceId = ""); + void GetAdapters(); + void SetAdapter(const std::string& deviceId); void Scan(const std::vector& serviceUUIDs, bool allowDuplicates); void StopScan(); bool Connect(const std::string& uuid); diff --git a/lib/win/src/noble_winrt.cc b/lib/win/src/noble_winrt.cc index 245df7a4..0a5a0760 100644 --- a/lib/win/src/noble_winrt.cc +++ b/lib/win/src/noble_winrt.cc @@ -47,12 +47,37 @@ NobleWinrt::NobleWinrt(const Napi::CallbackInfo& info) : ObjectWrap(info) { + if (info.Length() > 0 && info[0].IsObject()) { + auto options = info[0].As(); + if (options.Has("deviceId") && options.Get("deviceId").IsString()) { + mDeviceId = options.Get("deviceId").As().Utf8Value(); + } + } } Napi::Value NobleWinrt::Start(const Napi::CallbackInfo& info) { Napi::Function emit = info.This().As().Get("emit").As(); - manager = new BLEManager(info.This(), emit); + manager = new BLEManager(info.This(), emit, mDeviceId); + return info.Env().Undefined(); +} + +// getAdapters() +Napi::Value NobleWinrt::GetAdapters(const Napi::CallbackInfo& info) +{ + CHECK_MANAGER() + manager->GetAdapters(); + return info.Env().Undefined(); +} + +// setAdapter(deviceId) +Napi::Value NobleWinrt::SetAdapter(const Napi::CallbackInfo& info) +{ + CHECK_MANAGER() + ARG1(String) + auto deviceId = info[0].As().Utf8Value(); + mDeviceId = deviceId; + manager->SetAdapter(deviceId); return info.Env().Undefined(); } @@ -304,6 +329,8 @@ Napi::Object NobleWinrt::Init(Napi::Env env, Napi::Object exports) { Napi::Function func = DefineClass(env, "NobleWinrt", { NobleWinrt::InstanceMethod("start", &NobleWinrt::Start), NobleWinrt::InstanceMethod("stop", &NobleWinrt::Stop), + NobleWinrt::InstanceMethod("getAdapters", &NobleWinrt::GetAdapters), + NobleWinrt::InstanceMethod("setAdapter", &NobleWinrt::SetAdapter), NobleWinrt::InstanceMethod("startScanning", &NobleWinrt::Scan), NobleWinrt::InstanceMethod("stopScanning", &NobleWinrt::StopScan), NobleWinrt::InstanceMethod("connect", &NobleWinrt::Connect), diff --git a/lib/win/src/noble_winrt.h b/lib/win/src/noble_winrt.h index ec39ecca..ae3ed057 100644 --- a/lib/win/src/noble_winrt.h +++ b/lib/win/src/noble_winrt.h @@ -10,6 +10,8 @@ class NobleWinrt : public Napi::ObjectWrap NobleWinrt(const Napi::CallbackInfo&); Napi::Value Start(const Napi::CallbackInfo&); Napi::Value Stop(const Napi::CallbackInfo&); + Napi::Value GetAdapters(const Napi::CallbackInfo&); + Napi::Value SetAdapter(const Napi::CallbackInfo&); Napi::Value Scan(const Napi::CallbackInfo&); Napi::Value StopScan(const Napi::CallbackInfo&); Napi::Value Connect(const Napi::CallbackInfo&); @@ -33,4 +35,5 @@ class NobleWinrt : public Napi::ObjectWrap private: BLEManager* manager; + std::string mDeviceId; }; diff --git a/lib/win/src/radio_watcher.cc b/lib/win/src/radio_watcher.cc index ad0ada4b..ccbe52da 100644 --- a/lib/win/src/radio_watcher.cc +++ b/lib/win/src/radio_watcher.cc @@ -14,6 +14,7 @@ // Project includes #include "radio_watcher.h" #include "winrt_cpp.h" +#include "Emit.h" using namespace winrt::Windows::Devices::Enumeration; using namespace winrt::Windows::Devices::Bluetooth; @@ -62,9 +63,19 @@ void RadioWatcher::Start(std::function&)> callback) +{ + try { + auto selector = BluetoothAdapter::GetDeviceSelector(); + auto devices = co_await DeviceInformation::FindAllAsync(selector); + + BluetoothAdapter defaultAdapter = co_await BluetoothAdapter::GetDefaultAsync(); + uint64_t defaultAddress = defaultAdapter ? defaultAdapter.BluetoothAddress() : 0; + + std::vector adapters; + for (const auto& devInfo : devices) { + auto adapter = co_await BluetoothAdapter::FromIdAsync(devInfo.Id()); + if (adapter) { + AdapterInfo info; + info.id = winrt::to_string(devInfo.Id()); + info.name = winrt::to_string(devInfo.Name()); + info.address = formatBluetoothAddress(adapter.BluetoothAddress()); + info.isDefault = (adapter.BluetoothAddress() == defaultAddress); + adapters.push_back(info); + } + } + callback(adapters); + } catch (const winrt::hresult_error&) { + callback({}); + } +} diff --git a/lib/win/src/radio_watcher.h b/lib/win/src/radio_watcher.h index e27e7661..7cd1899c 100644 --- a/lib/win/src/radio_watcher.h +++ b/lib/win/src/radio_watcher.h @@ -50,6 +50,9 @@ struct AdapterCapabilities { // Convert AdapterState to std:string const char* adapterStateToString(AdapterState state); +// Forward declaration +struct AdapterInfo; + // RadioWatcher class class RadioWatcher { @@ -57,8 +60,10 @@ class RadioWatcher RadioWatcher(); void Start(std::function on); + void SetDeviceId(const std::string& deviceId); winrt::fire_and_forget OnRadioChanged(); + winrt::fire_and_forget EnumerateAdapters(std::function&)> callback); void OnAdded(DeviceWatcher watcher, DeviceInformation info); void OnUpdated(DeviceWatcher watcher, DeviceInformationUpdate info); @@ -69,9 +74,10 @@ class RadioWatcher Radio mRadio; DeviceWatcher watcher; bool inEnumeration; - + winrt::hstring mDeviceId; + std::function radioStateChanged; - + winrt::event_revoker mAddedRevoker; winrt::event_revoker mUpdatedRevoker; winrt::event_revoker mRemovedRevoker;