How to Use Particle Devices as BLE Gateways with Particle.publish

A Boron sitting on a shelf can hear a BLE sensor 20 meters away and forward its data to your backend in under a second. Move that same sensor to a truck heading across three states, and the Boron is useless unless you’ve also bolted gateways to every loading dock, warehouse, and rest stop along the route.
Most BLE backhaul tutorials skip the gap between what a single gateway does well and what a fleet actually needs. We’ll build a production-grade Particle BLE gateway end to end (scan, filter, parse, publish, route), then talk honestly about where the architecture stops working.
The Data Path
Here’s what we’re building:
┌────────────┐ BLE adv ┌─────────────┐ Particle.publish ┌──────────────┐ webhook ┌────────────┐
│ BLE Sensor │ ──────────▶ │ Boron (LTE) │ ─────────────────▶ │ Particle Cloud│ ────────▶ │ Your Backend│
└────────────┘ └─────────────┘ └──────────────┘ └────────────┘Boron backhauls over LTE-M/NB-IoT. Argon does the same job over Wi-Fi. The firmware is essentially identical. The deployment trade-offs aren’t, and we’ll get to those.
Up front: any self-hosted gateway, Particle or otherwise, only sees BLE peripherals within roughly 10 to 30 meters. We’ll come back to that.
Code samples below target Device OS 5.x. The BLE API shifted meaningfully from 2.x, so if you’re on an older release, verify signatures against the current reference before pasting.
Setting Up BLE Scanning on Boron
The Boron acts as a BLE central. Turn the radio on, set scan parameters, and register a callback that fires for every advertisement received.
#include "Particle.h"
SYSTEM_THREAD(ENABLED);
SYSTEM_MODE(AUTOMATIC);
void onScanResult(const BleScanResult *result, void *context);
void setup() {
BLE.on();
// Scan timeout in 10ms units. 50 = 500ms per scan cycle.
BLE.setScanTimeout(50);
// Active scanning requests scan responses (more data, more power).
BleScanParams params = {};
params.size = sizeof(BleScanParams);
params.interval = 80; // 50ms (units of 0.625ms)
params.window = 48; // 30ms
params.timeout = 50; // 500ms
params.active = true;
params.filter_policy = BLE_SCAN_FP_ACCEPT_ALL;
BLE.setScanParameters(¶ms);
}
void loop() {
// Synchronous scan; callback fires for each advertisement seen.
BLE.scan(onScanResult, nullptr);
delay(2000); // duty cycle: 500ms scan, ~2s idle
}A 25% duty cycle (500ms scan, 2s idle) is a reasonable starting point for battery-powered deployments. Mains-powered gateways can run continuous scans, but watch the cellular bill: every advertisement you process is a candidate for Particle.publish().
Filtering Advertisements
In a dense RF environment you’ll see hundreds of advertisements per second. Most are noise. Filter aggressively in the callback before you do anything expensive.
Three filters worth combining:
- Service UUID: the cleanest filter for sensors that advertise a known GATT service.
- Manufacturer-specific data: standard for proprietary beacons (iBeacon, Eddystone, custom).
- MAC allowlist: useful for fixed deployments where peripheral MACs are pre-registered.
const BleUuid TARGET_SERVICE("181A"); // Environmental Sensing
const uint16_t TARGET_MFR_ID = 0x05DA; // example: your company ID
void onScanResult(const BleScanResult *result, void *context) {
BleAdvertisingData adv = result->advertisingData();
// Filter 1: service UUID match
BleUuid services[4];
size_t count = adv.serviceUUID(services, 4);
bool serviceMatch = false;
for (size_t i = 0; i < count; i++) {
if (services[i] == TARGET_SERVICE) { serviceMatch = true; break; }
}
if (!serviceMatch) return;
// Filter 2: manufacturer ID match
uint8_t mfrBuf[31];
size_t mfrLen = adv.customData(mfrBuf, sizeof(mfrBuf));
if (mfrLen < 2) return;
uint16_t mfrId = mfrBuf[0] | (mfrBuf[1] << 8);
if (mfrId != TARGET_MFR_ID) return;
// Passed filters. Hand off to a queue for loop() to process.
enqueueAdvertisement(result);
}Don’t do real work inside the scan callback. It runs in the BLE thread context, and blocking it drops packets. Push to a queue, process in loop().
Parsing Advertising Data
BLE advertising payloads are a sequence of length-type-value triplets, defined by the Bluetooth Core Spec’s GAP section:
| Len | Type | Data ......................... |
| 1B | 1B | (Len - 1) bytes |
ex: 0x09 0xFF [Mfr ID LSB][Mfr ID MSB][payload...]For a sensor advertising temperature and humidity inside manufacturer-specific data (AD type 0xFF), parsing looks like this:
struct SensorReading {
uint8_t mac[6];
int8_t rssi;
int16_t temperatureCx100; // 22.45°C as 2245
uint8_t humidityPct;
uint32_t seq;
};
bool parseReading(const BleScanResult *r, SensorReading &out) {
BleAdvertisingData adv = r->advertisingData();
uint8_t buf[31];
size_t len = adv.customData(buf, sizeof(buf));
if (len < 9) return false; // 2B mfr + 2B temp + 1B humidity + 4B seq
// Skip the 2-byte manufacturer ID we already validated.
out.temperatureCx100 = (int16_t)(buf[2] | (buf[3] << 8));
out.humidityPct = buf[4];
out.seq = buf[5] | (buf[6] << 8) | (buf[7] << 16) | (buf[8] << 24);
out.rssi = r->rssi();
BleAddress addr = r->address();
addr.toArray(out.mac);
return true;
}Two gotchas: BLE advertising data is little-endian, and temperature is almost always signed. Cast accordingly. Including RSSI is cheap and useful downstream (signal-strength-based dedup, proximity heuristics).
Publishing Structured JSON via Particle.publish
Particle.publish() has hard limits worth memorizing:
- 1024-byte event data payload
- 1 event/second averaged, with bursts up to 4
PRIVATEevents for fleet data (don’t usePUBLIC)
Build JSON with snprintf. Skip JSON libraries; they eat flash you don’t have to spare.
void publishReading(const SensorReading &r) {
char payload[256];
snprintf(payload, sizeof(payload),
"{\"mac\":\"%02X%02X%02X%02X%02X%02X\","
"\"rssi\":%d,\"t\":%d,\"h\":%u,\"seq\":%lu}",
r.mac[0], r.mac[1], r.mac[2], r.mac[3], r.mac[4], r.mac[5],
r.rssi, r.temperatureCx100, r.humidityPct, (unsigned long)r.seq);
Particle.publish("ble/reading", payload, PRIVATE | WITH_ACK);
}If you’re scanning more than a handful of peripherals, single-reading events will blow through your quota fast. Batch:
// Aggregate up to 8 readings per publish.
char batch[1000];
size_t offset = snprintf(batch, sizeof(batch), "{\"r\":[");
for (size_t i = 0; i < readingCount; i++) {
offset += snprintf(batch + offset, sizeof(batch) - offset,
"%s{\"m\":\"%02X%02X%02X%02X%02X%02X\",\"t\":%d,\"h\":%u,\"r\":%d}",
i == 0 ? "" : ",",
readings[i].mac[0], readings[i].mac[1], readings[i].mac[2],
readings[i].mac[3], readings[i].mac[4], readings[i].mac[5],
readings[i].temperatureCx100, readings[i].humidityPct, readings[i].rssi);
}
snprintf(batch + offset, sizeof(batch) - offset, "]}");
Particle.publish("ble/batch", batch, PRIVATE | WITH_ACK);A batch of 8 readings every 4 seconds stays well inside the rate limit and uses far less cellular data than one publish per reading.
Webhook: From Particle Cloud to Your Backend
In the Particle Console, create an Integration → Webhook:
- Event Name:
ble/batch(matches your publish event prefix) - URL: your HTTPS ingestion endpoint
- Request Type:
POST - Request Format:
JSON - Custom Body (optional, if you want full control):
{
"device_id": "{{{PARTICLE_DEVICE_ID}}}",
"published_at": "{{{PARTICLE_PUBLISHED_AT}}}",
"data": {{{PARTICLE_EVENT_VALUE}}}
}The triple-brace {{{...}}} is critical. Double-brace HTML-escapes the value, which mangles JSON.
Add an Authorization header if your endpoint requires it. Particle retries failed webhook deliveries on 5xx and timeouts, so make your endpoint idempotent. The seq field from the advertising payload combined with the device MAC is usually enough for a dedup key.
Production Considerations
A few things that bite people running this in the field:
Threading. Scan callbacks run in BLE thread context. Memory allocation, Particle.publish(), and anything that blocks belongs in loop(), not the callback. Use a lock-free ring buffer or a Vector guarded by a mutex.
Deduplication is non-optional. A single peripheral might advertise 10 times per second. Track recently-seen (MAC, seq) pairs in a small ring buffer (32 entries is plenty) and drop repeats. Skip this and your event quota disappears in minutes.
Power budgets fight each other. Continuous scanning on a battery-powered Boron is a non-starter. The cellular modem also competes for power, so coordinate scan windows with publish bursts to avoid lighting up both radios at once.
Argon vs. Boron: same firmware, different math. Argon needs Wi-Fi credentials and mains power, which usually means fixed indoor sites. Boron goes anywhere LTE-M reaches, with a battery if needed.
The Range Problem: Where Self-Hosted Gateways Stop Working
For a warehouse, a factory floor, a retail store: ship it.
Now imagine your BLE assets are on pallets, in vehicles, in luggage, in shipping containers. A Boron covers maybe 30 meters of usable range. To hear those assets across a city, a region, or a supply chain, you’d be deploying and maintaining gateway hardware everywhere they go.
Self-hosted Particle gateway: Managed BLE network:
[Site A] ● (deployed) [Asset] ~~ detected anywhere
[Site B] ● (deployed) gateways exist
[Site C] ○ (no coverage)
[Mobile] ✕ (impossible)Hubble’s global BLE gateway network takes a different approach: a crowd-sourced fleet of existing gateways (in phones, vehicles, fixed infrastructure) listens for BLE advertisements from your devices and delivers the packets to you. No gateway hardware to deploy, no site surveys, no maintenance. The peripheral firmware side uses the Hubble Device SDK to format advertisements the network understands.
The two approaches solve different problems. Particle gateways suit fixed-site deployments where you control the environment and want full ownership of the data path. A managed network fits distributed and mobile assets where deploying your own gateways doesn’t pencil out.
Matching Architecture to Deployment
The pattern’s simple: scan with filters, parse the AD structure, batch into JSON under 1024 bytes, publish, route via webhook to an idempotent endpoint. That gets you a working Particle BLE gateway you can ship.
Before you do, sanity-check the deployment. Fixed sites and known geographies: a Particle gateway is a good fit. Assets that move across places you don’t own: the gateway problem is the actual problem to solve, and self-hosting probably isn’t the answer.
Hubble Network provides global BLE coverage for distributed and mobile assets without deploying or maintaining gateways. See how it works →