|
| 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. |
0 commit comments