SimpleBLE

Usage

Install and use the SimpleJSBLE Node.js and Electron binding.

SimpleJSBLE exposes SimpleBLE to Node.js and Electron with promises, EventEmitter events, and TypeScript types.

Requirements

  • Node.js 22 or newer, or an Electron release built on it.
  • Windows 10 or newer, macOS 15.0 or newer, or Linux with glibc 2.28 or newer, on x64 or arm64.
  • On Linux, BlueZ and the D-Bus library (libdbus-1-3 on Debian and Ubuntu).

Install from npm

npm install simplejsble

The package carries the native addon for every supported platform, so installation compiles and downloads nothing. Development builds of the main branch are published under the dev tag:

npm install simplejsble@dev

First scan

import { Adapter } from 'simplejsble';

const [adapter] = await Adapter.getAdapters();

adapter.on('scanFound', peripheral => {
  console.log(peripheral.identifier(), peripheral.address(), peripheral.rssi());
});
await adapter.scanFor(5000);

The package works with both import and require. For more, see Quickstart, Read, Write, Notify, and the SimpleJSBLE Examples.

Calls, events, and lifetime

Calls that reach the Bluetooth stack return promises. Calls on one object run in the order they were made: a read() issued after connect() waits for the connection. Data the library already holds, such as identifier(), rssi(), manufacturerData(), or services(), is returned directly.

Adapter, Peripheral, LocalPeripheral, and LocalCharacteristic are EventEmitters. Events are delivered on the JavaScript thread in the order they happened, and a listener that throws is an uncaught exception, as with any other emitter.

Every enumeration returns the same Adapter object for an adapter, and an adapter gives you one Peripheral object per device, whether it came from scanFound, scanGetResults(), or getConnectedPeripherals(). Compare them with === and keep state on them.

Objects with listeners or subscriptions stay alive without other references. An active scan, an open connection, and a started local peripheral keep Node.js running until they end, so a script exits once it stops scanning, disconnects, and stops hosting.

Failures reject with a SimpleBLEError. Its code is SimpleBLE's error code, such as 'PERIPHERAL_NOT_CONNECTED' or 'GATT_CHARACTERISTIC_NOT_FOUND'; WinRT failures also carry the original HRESULT in nativeCode. Invalid arguments are TypeErrors.

Connect and use GATT

const SERVICE = '0000180f-0000-1000-8000-00805f9b34fb';
const BATTERY_LEVEL = '00002a19-0000-1000-8000-00805f9b34fb';

peripheral.on('disconnected', () => console.log('Disconnected'));
await peripheral.connect();

for (const service of peripheral.services()) {
  console.log(service.uuid, service.characteristics.map(c => c.uuid));
}

const level = await peripheral.read(SERVICE, BATTERY_LEVEL);
await peripheral.notify(SERVICE, BATTERY_LEVEL, value => console.log(`Battery: ${value[0]}%`));

UUIDs are full 128-bit strings. Values are read as Buffers, and writes take any Uint8Array. writeRequest() waits for the peripheral to confirm, while writeCommand() does not.

notify() and indicate() call your function with each value until unsubscribe() or a disconnection; after a disconnection, subscribe again once reconnected. Calling either again for the same characteristic replaces the function.

Host a local peripheral

adapter.createLocalPeripheral() returns a LocalPeripheral that hosts GATT services on that adapter. Configure the advertisement and services, then call start(); call stop() before changing them.

const SERVICE = '12345678-1234-5678-1234-56789abcdef0';
const ECHO = '12345678-1234-5678-1234-56789abcdef1';

const host = adapter.createLocalPeripheral();
host.addAdvertisedService(SERVICE);

const echo = host.addService(SERVICE).addCharacteristic(ECHO, ['read', 'writeRequest', 'notify']);
await echo.setValue(Buffer.from('ready'));

// Echo each write to subscribed clients.
echo.on('write', value => echo.setValue(value));
host.on('clientConnected', address => console.log(`${address} connected`));

await host.start();

setValue() stores a value and publishes it to subscribed clients when the characteristic can notify or indicate. Characteristics emit write with the written value, which value() already returns, and subscribed and unsubscribed when the first client subscribes and the last one leaves. The local peripheral emits clientConnected and clientDisconnected with the client's address, or a platform-specific identifier where the platform hides it.

setAdvertisedManufacturerData(companyId, data) adds manufacturer-specific data to the advertisement, and setConnectable(false) makes it non-connectable, for example to advertise a beacon.

To compute values on demand, setReadHandler() takes a function that answers each read instead of the stored value, until you pass null. It can return a promise, and the client's read waits for it.

Handlers

Read handlers and the Dongl pairing handlers below answer questions SimpleBLE waits on. A handler can return a promise and take as long as it needs, for example to ask the user; the operation waits for the answer.

If a handler throws or rejects, the error is an unhandled rejection in Node.js, and SimpleBLE gets a default answer: an empty value for a read, and a rejection for pairing.

Setting a handler returns a promise that resolves once the native object uses it. Await it before the operation that needs it.

Configure backends

Config holds process-wide settings grouped by backend: Config.simpleBluez (Linux), Config.winRT (Windows), and Config.dongl. Set them at startup, before the first call that reaches a backend.

import { Config } from 'simplejsble';

Config.simpleBluez.connectionTimeout = 5000;

Timeouts are in milliseconds. Each group has a reset() that restores its defaults, and Config.resetAll() restores them all.

Use a Dongl

A Dongl is a USB Bluetooth adapter made for SimpleBLE. Its backend is on by default, so Adapter.getAdapters() includes Dongl adapters; to use only them, take the adapters of the Dongl backend:

import { Backend } from 'simplejsble';

const dongl = await Backend.getDongl();
const [adapter] = await dongl.adapters();

A Dongl adapter works like any other adapter. Only one program can use a Dongl at a time: await adapter.close() stops the adapter's scan and local peripherals, disconnects its peripherals, and frees the Dongl for other programs right away. The closed objects then reject calls with OBJECT_NOT_INITIALIZED. In TypeScript and runtimes with explicit resource management, await using adapter = ... closes it at the end of the scope.

Pairing through a Dongl happens without the operating system, so your application answers pairing requests. Set the handlers your device needs before connect(), since pairing can start as soon as the link is up:

  • setPasskeyRequestHandler(handler): return the six digits the peer shows, or undefined to reject.
  • setPasskeyDisplayHandler(handler): show the passkey so the user can type it on the peer.
  • setNumericComparisonHandler(handler): return true only if the peer shows the same six digits.
import { createInterface } from 'node:readline/promises';

const input = createInterface({ input: process.stdin, output: process.stdout });

await peripheral.setNumericComparisonHandler(async passkey => {
  return (await input.question(`Does the device show ${passkey}? [y/N] `)) === 'y';
});
await peripheral.connect();

Passing null removes a handler. A request without a handler is rejected, and peripherals of other adapters reject the setters with OPERATION_NOT_SUPPORTED.

Logging

SimpleBLE writes its log to standard output, up to the info level. Logging changes that for the whole process:

import { Logging } from 'simplejsble';

Logging.level = 'warn';
Logging.setCallback(entry => console.error(`[${entry.level}] ${entry.module}: ${entry.message}`));

setCallback() delivers each entry, with its level, module, file, line, function, and message, on the JavaScript thread in the order SimpleBLE logged them, and null discards entries. logToFile(path) appends them to a file instead, and logToStdout() restores the default. Levels go from 'none', which turns logging off, to 'verbose'.

Platform-specific features

  • On macOS and Linux, host.setAdvertisementLocalName('Name') sets the local name in a local peripheral's advertisement before start(), and null removes it. This changes only the advertisement, not the system Bluetooth name.
  • On macOS, await adapter.retrieveCachedPeripherals(identifiers) returns the peripherals CoreBluetooth already knows, by the identifiers address() returned earlier, without scanning. Unknown identifiers are skipped, and each peripheral is the same object a scan returns. connect() then reaches it if it is in range.

Electron

Use SimpleJSBLE from the main process, and send plain data to renderers over IPC rather than SimpleJSBLE objects. When packaging, unpack *.node files from the ASAR archive, for example with asarUnpack: ['**/*.node'] in electron-builder.

Platform notes

  • macOS: Bluetooth permission belongs to the app that runs Node.js. From a terminal, macOS asks for permission for the terminal app; packaged Electron apps need an NSBluetoothAlwaysUsageDescription entry in their Info.plist. See Bluetooth Permissions.
  • Linux: SimpleJSBLE talks to BlueZ over the D-Bus system bus, so the user needs access to it, as described in Bluetooth Permissions.

On this page