This repository has no description
README.md

rtnbleserver #

A Bluetooth LE GATT server for React Native.

The server authenticates a peer with a one-time password, exchanges DIDs, records the handshake time, and reports the result back to JavaScript.

Installation #

npm install rtnbleserver

Usage #

import {
  getRecord,
  handleDID,
  setDID,
  setOTP,
  startGatt,
  stopGatt,
} from 'rtnbleserver';

setOTP('123456');
setDID('did:plc:yourdid');

handleDID((record) => {
  console.log(record.did, record.createdAt);
});

startGatt();

// Later, when the server is no longer needed:
stopGatt();
  • setOTP(otp) sets the OTP a peer has to write before the server will send its DID.
  • setDID(did) sets the DID this device sends: to the peer when acting as a server, to the server when acting as a client.
  • handleDID(callback) registers the callback invoked when a handshake completes. The callback receives { did, createdAt }. Acting as a server, did is the peer's DID; acting as a client, did is the server's DID. createdAt is always read from the clock of the device running the GATT server, as an ISO 8601 (RFC 3339) UTC timestamp. Registering a callback replaces the previous one.
  • getRecord() returns the last completed handshake as { did, createdAt }, or null when no handshake has completed. Acting as a server, did is the peer's DID; acting as a client, did is the server's DID.
  • startGatt() starts the GATT server and advertising.
  • stopGatt() stops advertising, closes the server, and clears the session state. The last record is kept.

Client #

The same module can act as the GATT client. It never starts a GATT server: it scans for the service, authenticates with the OTP, sends the DID from setDID, and reports the DID and timestamp it received from the server.

setDID('did:plc:yourdid');

handleDID((record) => {
  console.log(record.did, record.createdAt);
});
handleClientError((message) => {
  console.warn(message);
});

startClient('123456');
  • startClient(otp) scans for the first device advertising the service, connects, enables notifications, writes otp, sends the DID from setDID, and then calls the handleDID callback with the server's DID and the timestamp recorded by the server. It throws when the DID is not set, Bluetooth is off, permissions are missing, or a client is already running.
  • stopClient() aborts the scan or connection.
  • handleClientError(callback) registers the callback for asynchronous failures: timeouts, disconnects, and scan failures. Registering a callback replaces the previous one.

A client handshake gives up after 15 seconds.

GATT protocol #

The server advertises one primary service. All UUIDs are fixed:

Purpose UUID
Service 5f1d0000-9b3a-4c7e-8d2f-6a4b1c8e0f93
OTP 5f1d0001-9b3a-4c7e-8d2f-6a4b1c8e0f93
DID 5f1d0002-9b3a-4c7e-8d2f-6a4b1c8e0f93
Record 5f1d0003-9b3a-4c7e-8d2f-6a4b1c8e0f93
Characteristic Properties Value
OTP write UTF-8 OTP digits. The write succeeds only when it matches setOTP.
DID read, write, notify This device's DID (read/notify) and the peer's DID (write).
Record read, notify {"did":"<client did>","createdAt":"<ISO 8601>"} after the handshake.

Flow:

  1. The peer connects and writes the OTP to the OTP characteristic.
  2. On a match, the server marks the peer as authorized and notifies its own DID on the DID characteristic. A mismatch is rejected: Android cancels the connection, and iOS responds with an authorization error (CoreBluetooth cannot disconnect a central).
  3. The peer writes its DID to the DID characteristic.
  4. The server records the time from its own clock (the device running the GATT server, not the peer), notifies the record on the Record characteristic, and calls the handleDID callback.

DID and record notifications are chunked so they work with the default 23-byte MTU. Each notification carries a two-byte header followed by up to 18 payload bytes:

[sequence:1][total:1][payload...]

Concatenate the payloads in sequence order. The DID and Record characteristics can also be read; reads return the complete value, so use a long read (read blob) when the value is longer than the negotiated MTU.

Notifications require the peer to enable them (write 0x0001 to the standard CCCD, which the server exposes for the DID and Record characteristics).

The client runs the complementary side: it subscribes to the DID and Record characteristics, writes the OTP, writes its own DID in frames, then reassembles the server's DID notification and the Record notification. Its handleDID callback combines the server's DID with the createdAt field of the record.

Permissions #

Android: the library declares BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE, BLUETOOTH_SCAN (with neverForLocation), and ACCESS_FINE_LOCATION for Android 11 and below. The app has to request the runtime permissions it uses before calling startGatt() or startClient(), for example with PermissionsAndroid. On Android 11 and below, scanning also requires location services to be enabled.

iOS: add NSBluetoothAlwaysUsageDescription to the app's Info.plist. The permission prompt appears when startGatt() creates the CBPeripheralManager or startClient() creates the CBCentralManager.

Two synchronous checks report whether the runtime permissions are currently granted:

import { hasClientPermissions, hasServerPermissions } from 'rtnbleserver';

hasServerPermissions(); // BLUETOOTH_CONNECT + BLUETOOTH_ADVERTISE
hasClientPermissions(); // BLUETOOTH_SCAN + BLUETOOTH_CONNECT

The checks never prompt. On iOS, authorization is notDetermined until CoreBluetooth has been used, so both return false before the first startGatt() or startClient() call triggers the prompt.

Using a local checkout in a host app #

A published install is a normal directory in node_modules. When a host app depends on this repository through a file: path, the package is symlinked and Metro resolves the sources to their real path. Point Metro at the checkout and keep peer dependencies in the host app so only one copy of React Native is bundled:

const path = require('node:path');
const config = getDefaultConfig(__dirname);

config.watchFolders = [path.resolve(__dirname, '../RTNbleServer')];
config.resolver.nodeModulesPaths = [path.resolve(__dirname, 'node_modules')];
config.resolver.disableHierarchicalLookup = true;

With Bun, file: dependencies are installed as a tree of symlinks, which Expo's autolinking does not traverse. Add the checkout to the host app's autolinking search paths so the Android and iOS sources are discovered:

{
  "expo": {
    "autolinking": {
      "searchPaths": ["../RTNbleServer"]
    }
  }
}

Development #

The library has no example app. See CONTRIBUTING.md for the standalone Android build and the JavaScript build.

Contributing #

License #

MIT