Skip to content

Commit 5b4866c

Browse files
committed
docs(ble): add REST API and plugin development documentation
REST API reference for all BLE endpoints, consumer and provider plugin development guide with examples, ESP32 gateway POST body specification, and consumer API reference table.
1 parent 8df0d58 commit 5b4866c

4 files changed

Lines changed: 471 additions & 0 deletions

File tree

docs/develop/plugins/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ children:
1010
- course_calculations.md
1111
- resource_provider_plugins.md
1212
- weather_provider_plugins.md
13+
- ble_provider_plugins.md
1314
- custom_renderers.md
1415
- publishing.md
1516
---
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
---
2+
title: BLE Provider & Consumer Plugins
3+
---
4+
5+
# BLE Provider and Consumer Plugins
6+
7+
The Signal K [BLE API](../rest-api/ble_api.md) decouples BLE hardware access from BLE data consumers. **Provider plugins** supply hardware (local BlueZ adapter, remote gateway, MQTT bridge, etc.). **Consumer plugins** subscribe to the merged advertisement stream and request GATT connections through the server.
8+
9+
## Consumer Plugin
10+
11+
Most BLE plugins are consumers — they process advertisements and optionally connect via GATT to read sensor data. The server handles provider selection, GATT slot management, and failover.
12+
13+
### Subscribing to Advertisements
14+
15+
```javascript
16+
module.exports = function (app) {
17+
const plugin = { id: 'my-ble-plugin', name: 'My BLE Plugin' }
18+
let unsubscribe = null
19+
20+
plugin.start = function () {
21+
unsubscribe = app.bleApi.onAdvertisement(plugin.id, (adv) => {
22+
// adv.mac, adv.rssi, adv.manufacturerData, adv.serviceData, …
23+
if (adv.mac === 'AA:BB:CC:DD:EE:FF') {
24+
processAdvertisement(adv)
25+
}
26+
})
27+
}
28+
29+
plugin.stop = function () {
30+
if (unsubscribe) unsubscribe()
31+
}
32+
33+
return plugin
34+
}
35+
```
36+
37+
`onAdvertisement` returns an unsubscribe function. Call it in `plugin.stop()`.
38+
39+
### GATT Subscriptions
40+
41+
For sensors that require a persistent GATT connection, use `subscribeGATT`. Provide a declarative descriptor — the server selects the best provider (strongest RSSI, available slots) and manages connect/reconnect autonomously.
42+
43+
```javascript
44+
plugin.start = async function () {
45+
const descriptor = {
46+
mac: 'AA:BB:CC:DD:EE:FF',
47+
service: '0000180f-0000-1000-8000-00805f9b34fb', // Battery Service
48+
notify: ['00002a19-0000-1000-8000-00805f9b34fb'] // Battery Level
49+
}
50+
51+
gattHandle = await app.bleApi.subscribeGATT(
52+
descriptor,
53+
plugin.id,
54+
(charUuid, data) => {
55+
const level = data.readUInt8(0)
56+
app.handleMessage(plugin.id, {
57+
updates: [
58+
{
59+
values: [
60+
{
61+
path: 'electrical.batteries.0.capacity.stateOfCharge',
62+
value: level / 100
63+
}
64+
]
65+
}
66+
]
67+
})
68+
}
69+
)
70+
71+
gattHandle.onDisconnect(() => {
72+
app.debug('GATT disconnected — server will reconnect automatically')
73+
})
74+
}
75+
76+
plugin.stop = async function () {
77+
if (gattHandle) await gattHandle.close()
78+
}
79+
```
80+
81+
### GATTSubscriptionDescriptor
82+
83+
| Field | Type | Description |
84+
| ----------------------------- | --------- | ----------------------------------------------------------------------- |
85+
| `mac` | string | Target device MAC address |
86+
| `service` | string | Primary service UUID |
87+
| `notify` | string[]? | Characteristic UUIDs to subscribe for notifications |
88+
| `poll` | object[]? | Characteristics to poll: `{ uuid, intervalMs, writeBeforeRead? }` |
89+
| `init` | object[]? | One-time writes after connection: `{ uuid, data, withResponse? }` (hex) |
90+
| `periodicWrite` | object[]? | Repeated writes: `{ uuid, data, intervalMs, withResponse? }` (hex) |
91+
| `failover.enabled` | boolean? | Enable provider failover on disconnect (default: true) |
92+
| `failover.migrationThreshold` | number? | dBm advantage needed for proactive migration |
93+
| `failover.migrationHoldTime` | number? | Seconds advantage must hold before migrating (default: 60) |
94+
95+
`withResponse` on `init` and `periodicWrite` items controls whether the write uses Write With Response (default) or Write Without Response. Set to `false` for sensors that require `writeValueWithoutResponse`.
96+
97+
### GATTSubscriptionHandle
98+
99+
| Member | Type | Description |
100+
| -------------------------------------- | ----------------- | ----------------------------------------------------------- |
101+
| `read(charUuid)` | `Promise<Buffer>` | Read a characteristic value |
102+
| `write(charUuid, data, withResponse?)` | `Promise<void>` | Write to a characteristic (`withResponse` defaults to true) |
103+
| `close()` | `Promise<void>` | Release the GATT claim |
104+
| `connected` | boolean | Whether the GATT connection is currently active |
105+
| `onDisconnect(cb)` | void | Called when the connection drops |
106+
| `onConnect(cb)` | void | Called when the connection (re-)establishes |
107+
108+
### Raw GATT Connection
109+
110+
For sensors with truly dynamic GATT sequences, use `connectGATT` to get a raw connection handle. Prefer `subscribeGATT` with a descriptor when possible — it handles reconnection automatically.
111+
112+
```javascript
113+
const conn = await app.bleApi.connectGATT('AA:BB:CC:DD:EE:FF', plugin.id)
114+
const services = await conn.discoverServices()
115+
const data = await conn.read(serviceUuid, charUuid)
116+
await conn.disconnect()
117+
```
118+
119+
### BLE API Mode Detection
120+
121+
Consumer plugins that can also operate with a direct BlueZ connection should auto-detect which mode to use:
122+
123+
```javascript
124+
plugin.start = async function () {
125+
if (app.bleApi) {
126+
// Server manages BLE — use the BLE API
127+
await startBleApiMode()
128+
} else {
129+
// Fall back to direct BlueZ access
130+
await startDirectBlueZMode()
131+
}
132+
}
133+
```
134+
135+
---
136+
137+
## Provider Plugin
138+
139+
A provider plugin gives the server access to a BLE radio. Register a provider by calling `app.bleApi.register()`. The server will call your `onAdvertisement` callback to receive all advertisements, merge them into the device table, and route GATT requests to your `subscribeGATT` method.
140+
141+
```javascript
142+
const plugin = { id: 'my-ble-gateway', name: 'My BLE Gateway' }
143+
144+
plugin.start = function () {
145+
const provider = {
146+
name: 'My Gateway',
147+
methods: {
148+
startDiscovery: async () => {
149+
/* start scanning */
150+
},
151+
stopDiscovery: async () => {
152+
/* stop scanning */
153+
},
154+
getDevices: async () => [], // return visible MACs
155+
156+
onAdvertisement(callback) {
157+
// Store callback and call it whenever an advertisement arrives:
158+
// callback({ mac, rssi, name, manufacturerData, serviceData, providerId: plugin.id, timestamp: Date.now() })
159+
return () => {
160+
/* unsubscribe */
161+
}
162+
},
163+
164+
supportsGATT: () => true,
165+
availableGATTSlots: () => 3,
166+
167+
async subscribeGATT(descriptor, callback) {
168+
// Connect to descriptor.mac, subscribe to descriptor.notify, etc.
169+
// Call callback(charUuid, buffer) on notifications.
170+
return {
171+
read: async (charUuid) => {
172+
/* read characteristic and return Buffer */
173+
},
174+
write: async (charUuid, data) => {
175+
/* write to characteristic */
176+
},
177+
close: async () => {
178+
/* disconnect and clean up */
179+
},
180+
connected: true,
181+
onDisconnect: (cb) => {
182+
/* register callback */
183+
},
184+
onConnect: (cb) => {
185+
/* register callback */
186+
}
187+
}
188+
}
189+
}
190+
}
191+
192+
app.bleApi.register(plugin.id, provider)
193+
}
194+
195+
plugin.stop = function () {
196+
app.bleApi.unRegister(plugin.id)
197+
}
198+
```
199+
200+
### BLEProviderMethods interface
201+
202+
| Method | Returns | Description |
203+
| ------------------------------- | --------------------------------- | --------------------------------------------------- |
204+
| `startDiscovery()` | `Promise<void>` | Begin scanning for advertisements |
205+
| `stopDiscovery()` | `Promise<void>` | Stop scanning |
206+
| `getDevices()` | `Promise<string[]>` | MACs of currently visible devices |
207+
| `onAdvertisement(cb)` | `() => void` | Subscribe to advertisements; returns unsubscribe fn |
208+
| `supportsGATT()` | `boolean` | Whether this provider can make GATT connections |
209+
| `totalGATTSlots?()` | `number` | Total GATT connection slots (optional) |
210+
| `availableGATTSlots()` | `number` | How many concurrent GATT connections are free |
211+
| `subscribeGATT(descriptor, cb)` | `Promise<GATTSubscriptionHandle>` | Establish a GATT subscription |
212+
| `connectGATT?(mac)` | `Promise<BLEGattConnection>` | Raw GATT connection (optional) |
213+
214+
### Advertisement format
215+
216+
Each advertisement fired into the server must conform to `BLEAdvertisement`:
217+
218+
```typescript
219+
{
220+
mac: 'AA:BB:CC:DD:EE:FF', // uppercase colon-separated
221+
name?: 'SensorName',
222+
rssi: -72,
223+
manufacturerData?: { 1177: 'ff0102...' }, // key = decimal company ID
224+
serviceData?: { '0000feaa-...': 'deadbeef' },
225+
serviceUuids?: ['0000180f-...'],
226+
providerId: 'my-ble-gateway', // must match the registered plugin ID
227+
timestamp: Date.now(),
228+
connectable?: true,
229+
txPower?: -59,
230+
}
231+
```
232+
233+
`manufacturerData` keys are **decimal** company IDs (matching the Bluetooth SIG assigned numbers list). The values are hex-encoded payloads **without** the 2-byte company ID prefix.
234+
235+
---
236+
237+
## Consumer API Reference
238+
239+
All methods are available on `app.bleApi`:
240+
241+
| Method | Returns | Description |
242+
| ----------------------------------------- | --------------------------------- | ----------------------------------------------------- |
243+
| `onAdvertisement(pluginId, cb)` | `() => void` | Subscribe to merged advertisement stream |
244+
| `subscribeGATT(descriptor, pluginId, cb)` | `Promise<GATTSubscriptionHandle>` | Declarative GATT subscription |
245+
| `connectGATT(mac, pluginId)` | `Promise<BLEGattConnection>` | Raw GATT connection (escape hatch) |
246+
| `releaseGATTDevice(mac, pluginId)` | `Promise<void>` | Release a GATT claim without going through the handle |
247+
| `getDevices()` | `Promise<BLEDeviceInfo[]>` | All visible devices, deduplicated by MAC |
248+
| `getDevice(mac)` | `Promise<BLEDeviceInfo\|null>` | Single device lookup |
249+
| `getGATTClaims()` | `Map<string, string>` | Current GATT claims: MAC → pluginId |
250+
251+
---
252+
253+
## ESP32 Gateway POST Body
254+
255+
ESP32 gateways that cannot maintain a WebSocket connection (e.g. due to RAM constraints) send advertisements via HTTP POST to `/signalk/v2/api/ble/gateway/advertisements`. The POST body is JSON:
256+
257+
```json
258+
{
259+
"gateway_id": "my-ble-gateway",
260+
"firmware": "1.2.0",
261+
"mac": "AA:BB:CC:DD:EE:FF",
262+
"hostname": "my-ble-gateway",
263+
"uptime": 3600,
264+
"free_heap": 131072,
265+
"devices": [
266+
{
267+
"mac": "11:22:33:44:55:66",
268+
"rssi": -65,
269+
"name": "SensorName",
270+
"adv_data": "0201061bff..."
271+
}
272+
]
273+
}
274+
```
275+
276+
| Field | Required | Description |
277+
| ------------ | -------- | -------------------------------------------------- |
278+
| `gateway_id` | yes | Unique gateway identifier (typically the hostname) |
279+
| `devices` | yes | Array of discovered BLE devices |
280+
| `firmware` | no | Firmware version string |
281+
| `mac` | no | Gateway's own MAC address |
282+
| `hostname` | no | Gateway hostname (defaults to `gateway_id`) |
283+
| `uptime` | no | Gateway uptime in seconds |
284+
| `free_heap` | no | Free heap memory in bytes |
285+
286+
Each device in the `devices` array:
287+
288+
| Field | Required | Description |
289+
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
290+
| `mac` | yes | Device MAC address |
291+
| `rssi` | yes | Signal strength (dBm) |
292+
| `name` | no | Advertised local name |
293+
| `adv_data` | no | Raw advertisement payload as hex (AD structures). Server parses this into `manufacturerData`, `serviceData`, `serviceUuids`, and `txPower`. |
294+
| `manufacturer_data` | no | Pre-parsed manufacturer data: company ID (decimal string) → hex payload |
295+
| `service_data` | no | Pre-parsed service data: service UUID → hex payload |
296+
| `service_uuids` | no | Advertised service UUIDs |
297+
| `connectable` | no | Whether the device accepts GATT connections |
298+
| `tx_power` | no | TX power level (dBm) |
299+
300+
The simplest approach is to send `adv_data` with the raw AD bytes — the server will parse all fields automatically. Pre-parsed fields can supplement or override the parsed values.

docs/develop/rest-api/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ children:
99
- radar_api.md
1010
- resources_api.md
1111
- weather_api.md
12+
- ble_api.md
1213
- plugin_api.md
1314
- ./proposed/README.md
1415
---
@@ -31,5 +32,6 @@ APIs are available via `/signalk/v2/api/<endpoint>`
3132
| [Radar](./radar_api.md) | View and control marine radar equipment via a provider plugin. _(In development)_ | `vessels/self/radars` |
3233
| [Resources](./resources_api.md) | Create, view, update and delete waypoints, routes, etc. | `resources` |
3334
| _[`Notifications`](notifications_api.md)_ | Provide the ability to raise, update and clear notifications from multiple sources. _[View PR](https://github.com/SignalK/signalk-server/pull/1560)_ | `notifications` |
35+
| [BLE](./ble_api.md) | Unified BLE device discovery and GATT connection management via provider plugins and ESP32 gateways. | `vessels/self/ble` |
3436

3537
---

0 commit comments

Comments
 (0)