The server-side view of the MQTT interface — how a backend subscribes to device data, routes it, and configures devices remotely.
Integration guidance comes first; the complete topic and payload reference follows in Wire reference, repeated here in full so this page can be used on its own.
All traffic sits under a deployment prefix, so one broker can host several sites. Wildcarding the locator segment gives a backend one kind of message from the whole estate:
<prefix>/Locators/+/RTLS all positions
<prefix>/Locators/+/GNSS all satellite positions
<prefix>/Locators/+/Sensors all telemetry
<prefix>/Locators/+/Alarm all alarms
<prefix>/Locators/+/KeepAlive liveness for the whole estate
<prefix>/Locators/+/Diag diagnostics
<prefix>/Locators/+/BufferedData records delivered after an outage
<prefix>/Locators/+/# everything
The single-level wildcard + occupies the locator-ID segment, so a new locator appears in an existing subscription without a configuration change.
Because each content type has its own suffix, a service that only needs alarms never receives position traffic. Subscribe per concern rather than taking # and filtering in code.
Underground mining deployments add four further suffixes — OutSos, OutAck, InSms and InAck — carrying gas alarms, SOS and two-way messaging. They are specific to the Smart Lamp Locator; a site without cap lamps never sees them. Their payloads are in the reference below.
One topic carries all three positioning methods, and DeviceType is the only thing that separates them:
/RTLS always means a metric distance will read a timestamp or a beacon ID as centimetres.A bler position has no metric coordinate — it names the beacon pair a locator is between. Handle it as a topological position, not as a degraded distance.
Every message names who reported it and, when something else was observed, what it is about: LocatorID is the reporting locator's serial number, and TagID or BeaconID is the observed device.
A locator publishes under its own ID whether it ranged a remote tag or measured its own position against fixed beacons. A BufferedData record has no observed-device identity at all, so a backend should not expect a tag to exist for it.
Normalise identity before keying on it.TagIDis printed in MAC order andBeaconIDis printed reversed. The rule follows the key name rather than the topic, so the same physical device produces two different strings depending on which message it arrived in.
Several timestamps arrive together and answer different questions:
| Field | Unit | Meaning |
|---|---|---|
GnssUtc | seconds | when the GNSS data was taken |
TimePos | seconds | when a buffered position was observed |
BeaconSeq + OffsetMs | — | sensor measurement time, on the ranging clock |
TimestampMs | milliseconds | message time at the publishing locator |
Timestamp | ms or s | message time — the unit depends on the publishing firmware |
ArrivalMs | milliseconds | arrival at the anchor — includes latency |
Fuse on measurement time, order on arrival. Using arrival time to correlate a position with telemetry introduces queue and broker latency into the result; using it to order a stream is exactly right.
For sensor telemetry the measurement time is carried inside the record itself (BeaconSeq + OffsetMs, see Sensor TLV). A frame marked unsynced must not be fused with a ranging measurement.
Timestampis not one unit. Some firmware publishes seconds and some milliseconds, so normalise on ingest by magnitude rather than by topic — a current epoch value is 10 digits in seconds and 13 in milliseconds.TimestampMsis always milliseconds and needs no such test. A value of0means the locator had no network time when it published; treat it as absent rather than as 1970.
A locator that was offline delivers its stored records on reconnect, so a backend receives observations far older than their arrival time and not necessarily in order. Insert on measurement time rather than appending to the head of a track, and expect duplicates when a handover is interrupted and retried — records are released on the device only after a response has been read to its end.
ArrSize and CurrIndex place each record within its batch, so a consumer can tell a partial drain from a complete one.
Device settings are readable and writable from the server side. Requests reach a locator through its chain master, and replies return on the ParamResponse topic:
<prefix>/Locators/<LocatorID>/ParamResponse
Parameters use the same ID space as GATT and CAN — one ID, one meaning, one validation path regardless of transport. The table is on the CAN-FD page, and the GATT view is on BLE GATT. Request and response bodies are in the reference below.
Runtime TDMA and sensor parameters live in RAM and revert on reboot, by design — a bad commissioning value cannot strand a remote device. Radio parameters persist but apply only after restart. A backend that writes a parameter should therefore verify it rather than assume it took effect immediately.
Keep-alive messages carry a fault bitmask and a cumulative error counter, so an operations dashboard can distinguish currently faulted from has been unreliable. The bit meanings are listed on the CAN-FD page; the uplink-related bits are meaningful only on a chain master.
Everything below is the device-side interface, reproduced from the MQTT protocol page.
A topic is the locator's identity followed by a content suffix. The suffix says what kind of message it carries — position always goes to RTLS:
LocatorID is the locator's serial number. A subscriber wildcards that segment to receive one kind of message from the whole estate:
{prefix}/Locators/+/RTLS
| Suffix | Direction | Content |
|---|---|---|
RTLS | out | position — UWB or BLE, see Device type |
GNSS | out | satellite position |
Sensors | out | IMU, barometer, temperature, battery |
Alarm | out | tag alarm |
KeepAlive | out | liveness and fault status |
Diag | out | uplink and MCU diagnostics |
BufferedData | out | records stored while offline |
ParamRead | in | read a runtime parameter |
ParamWrite | in | write a runtime parameter |
ParamResponse | out | reply to a read or write |
Position, satellite position and telemetry are separate topics, not variants of one message. A consumer subscribes to what it needs and never has to parse a payload to find out whether it wanted it.
A locator publishes under its own ID whether it observed another device or measured its own position. In a CAN chain the master publishes on behalf of every slave, using that slave's own LocatorID — so each anchor stays a distinct reporter regardless of its position on the bus.
Only the master anchor has a network uplink. Slaves have no route to the broker of their own — they put their measurements on the CAN bus, and the master relays them. That relay is the CAN-to-MQTT gateway.
The master polls the bus and forwards three frame types; the rest are bus-local and produce no MQTT traffic:
| CAN frame | Becomes |
|---|---|
| RTLS Data — type 1 | {prefix}/Locators/{LocatorID}/RTLS |
| Keep Alive — type 2 | {prefix}/Locators/{LocatorID}/KeepAlive |
| Param Response — type 5 | {prefix}/Locators/{LocatorID}/ParamResponse |
| Sync, Time Config — types 0, 6 | nothing — bus-local |
Parameter requests travel the other way: the master subscribes to ParamRead and ParamWrite, and relays each onto the bus as a type 3 or type 4 frame addressed to the target anchor.
The master's own UWB measurements go through the same path as a slave's, published under its own anchor ID rather than through a separate code path. A backend therefore sees one schema for every anchor in the chain, and nothing in the message reveals which anchor happened to hold the uplink. Moving the uplink to a different anchor changes no payload a consumer sees.
A CAN frame carries only a 16-bit anchor ID, which is the last four decimal digits of the anchor's LocatorID. The gateway reverses that to rebuild the serial-style identity for the topic:
anchor_id 1528 → "ZP1528" → {prefix}/Locators/ZP1528/RTLS
Without this step every slave would be published under the master's identity and the chain would collapse into one apparent device. The ZP prefix is fixed by convention.
Each frame carries the measuring anchor's own utc_ms. The gateway does not overwrite it — a measurement keeps the time of the anchor that took it. It is validated rather than trusted:
Frame utc_ms | Published |
|---|---|
| plausible | the frame's own value |
| implausible, gateway has network time | the gateway's time |
| implausible, gateway has none either | 0 |
The sentinel matters: an unsynced anchor would otherwise publish milliseconds-since-boot, which is a small number that parses as 1970 and silently lands decades in the past. 0 is unambiguous — a consumer treats it as no time rather than as a date.
Slave anchor ZP1528 ranges a tag at 4.37 m and puts one type 1 frame on the bus:
CAN ID 0x020BF000 type 1 (RTLS Data), anchor 1528, 29-bit extended
payload DE AD BE EF 00 17 tag_mac de:ad:be:ef:00:17
B5 01 00 00 distance_cm 437
8B D0 D0 AD 98 01 00 00
utc_ms 1755262800011
00 00 00 00 00 00 padding
The master receives it, rebuilds ZP1528 from anchor ID 1528, and publishes:
topic {prefix}/Locators/ZP1528/RTLS
{
"LocatorID": "ZP1528",
"TagID": "de:ad:be:ef:00:17",
"DeviceType": "uwb",
"Distance_cm": 437,
"TimestampMs": 1755262800011
}
Note the endianness change across the boundary: on the bus tag_mac is big-endian while distance_cm and utc_ms are little-endian. In JSON all three are already decoded, so a consumer never deals with byte order — that is confined to the CAN side.
Every RTLS message carries DeviceType, naming the method that produced the position:
| Value | Method | Position expressed as |
|---|---|---|
uwb | UWB ranging | one distance, anchor to tag |
uwb2 | UWB TDoA | arrival timestamps, multilaterated server-side |
bler | BLE — Offline-online RMA | which beacon pair the locator is between |
This is the single branch point for position handling: one topic, three payload shapes, one field that tells them apart.
/RTLSuwb — rangingOne measured distance between an anchor and a tag:
{
"LocatorID": "ZP1528",
"TagID": "de:ad:be:ef:00:17",
"DeviceType": "uwb",
"Distance_cm": 437,
"TimestampMs": 1755262800011
}
uwb2 — TDoAArrival timestamps for one tag blink, for server-side multilateration — see UWB TDoA:
{
"MessageID": 1234567890,
"MessageType": 0,
"LocatorID": "ZP1528",
"BeaconID": "17:00:ef:be:ad:de",
"DeviceType": "uwb2",
"timestamp_ref_1": 10924418560,
"timestampTDoA": 10924471296,
"timestamp_ref_2": 10924524032,
"counter": 4471,
"Timestamp": 1755262800011
}
The three timestamps are what make anchors with independent clocks comparable: timestamp_ref_1 and timestamp_ref_2 are arrivals of the master anchor's reference frames, and timestampTDoA is the tag blink — all measured on the same local counter. Reports are grouped server-side by LocatorID and counter.
bler — BLEA fixed beacon observed by a locator that reports its own position — see Offline-online RMA:
{
"MessageID": 8814,
"LocatorID": "SL0042",
"BeaconMinor": 1041,
"BeaconID": "17:00:ef:be:ad:de",
"DeviceType": "bler",
"Distance": 340,
"RawDistance": 402,
"FilterType": "EmptyFilter",
"BeaconRSSI": -71,
"Boarded": 3, "B1": 12, "B2": 7,
"Timestamp": 1755262800
}
Distance is filtered and RawDistance is not, with FilterType naming the filter applied — EmptyFilter means none. Boarded is the number of tags currently counted on board. The beacons named here are fixed infrastructure, not tracked tags.
TagIDis printed in MAC order;BeaconIDis printed reversed. The rule follows the key name, not the topic. A consumer that keys devices on the string must normalise, or the same physical device will appear twice.
/SensorsOne aggregation window. Sub-objects are present only when that sensor produced data:
{
"LocatorID": "ZP1528",
"TagID": "de:ad:be:ef:00:17",
"Seq": 1834,
"BeaconSeq": 90512,
"OffsetMs": 214,
"Imu": {
"Gravity": [12, -35, 1004],
"AccelRms": 42, "AccelPeak": 310,
"GyroRms": 15, "DomFreqMHz": 0,
"MotionState": 2, "Events": 0
},
"Baro": {
"PressurePa": 97981, "AltitudeCm": -320,
"VertVelCmS": 0, "FloorIndex": 255, "Flags": 0
},
"TempDeciC": 214,
"TempSrc": 1,
"Battery": { "Present": true, "Percent": 72, "Mv": 3910,
"Charging": false, "External": false },
"ArrivalMs": 1755262800123
}
BeaconSeq + OffsetMs is the measurement time, on the same clock the ranging measurement uses — see Sensor TLV. A FloorIndex of 255 means unknown. TempDeciC is deci-degrees Celsius.
null and false mean different things, and the distinction is deliberate:
| Shape | Meaning |
|---|---|
"Present": false, "Percent": null, "External": true | No cell fitted; running on external supply |
"Percent": null with a numeric Mv | Voltage read, but the percentage is implausible — a scaling or sense fault, not a flat cell |
"Charging": null, "External": null | Charge sensing is not wired on this device |
An absent Battery object means firmware too old to report one — different again from any of the above.
/Alarm{
"LocatorID": "ZP1528",
"TagID": "de:ad:be:ef:00:17",
"Seq": 1834,
"Alarm": 1,
"Type": "Fall Detection",
"Timestamp": 1755262800123
}
Alarm is 1 while pending and 0 when cleared. It is reported by level, not edge — repeated while raised, so a lost message cannot lose the alarm.
/GNSS{
"LocatorID": "ZP1528",
"TagID": "de:ad:be:ef:00:17",
"Seq": 1834,
"Source": "GNSS",
"Lat": 42.6977000,
"Lon": 23.3219000,
"AltM": 562,
"SpeedCmS": 138,
"CourseCdeg": 8710,
"Sats": 9,
"HdopX10": 12,
"GnssUtc": 1755262798,
"Timestamp": 1755262800123
}
Latitude and longitude are decimal degrees, with the sign carried explicitly so a position between 0 and −1 degree is not reported in the wrong hemisphere. CourseCdeg is centidegrees (8710 = 87.10°) and HdopX10 is HDOP × 10. See GNSS.
/KeepAlive{
"LocatorID": "ZP1528",
"StatusCode": 16,
"ErrorCount": 7,
"RebootCount": 3,
"TimestampMs": 1755262800011
}
StatusCode is the current fault bitmask (0 = nominal); ErrorCount counts error onsets since boot. Bit meanings are on the CAN-FD page — so a dashboard can distinguish currently faulted from has been unreliable.
/Diag{
"EspFreeHeap": 24192, "EspWifiRssi": -67,
"EspWifiDisconnects": 2, "EspMqttFails": 0,
"EspUptimeS": 84213, "EspFwVer": "0.5.16",
"NrfUptimeS": 84250, "NrfResetReason": 4,
"Dw1000Ok": 1,
"LastReconnectFails": 0, "LastReconnectRc": 0, "LastReconnectS": 0
}
Covers both processors: the uplink module's heap, WiFi and MQTT health, and the MCU's uptime, reset reason and UWB module state.
Runtime parameters are read and written over MQTT using the same ID space as GATT and CAN. ParamId accepts decimal or 0x-prefixed hex.
Publish to {prefix}/Locators/{LocatorID}/ParamRead:
{"ParamId": 31} value as hex (default)
{"ParamId": 31, "Format": "text"} adds a "Text" field
The Format key is case-insensitive. hex returns "Value" as a hex string; text keeps Value and adds "Text" — the raw bytes trimmed at the first NUL and JSON-escaped. Text format suits string parameters such as device name, WiFi SSID and server address; binary parameters just look like noise.
Publish to {prefix}/Locators/{LocatorID}/ParamWrite:
{"ParamId": 31, "Value": "01"} binary or scalar, hex bytes
{"ParamId": 5, "Text": "Anchor North-2"} string parameters
Both forms reply on {prefix}/Locators/{LocatorID}/ParamResponse, echoing the parameter and the value now in effect.
TDMA and sensor runtime parameters live in RAM and revert to their defaults on reboot; radio parameters persist but apply only after restart. A client that writes a parameter should read it back rather than assume it took effect immediately.
/BufferedDataRecords accumulated while the locator had no connectivity. It stores which beacon pair it was between, so no observed-device identity appears:
{
"MessageID": 42,
"LocatorID": "SL0042",
"DeviceType": "bler",
"ArrSize": 12, "CurrIndex": 3,
"WB0": 1041, "WB1": 1042,
"TimePos": 1755262798,
"Timestamp": 1755262800
}
WB0 and WB1 are the beacon pair the device was between; ArrSize and CurrIndex place the record within the stored batch, so a consumer can tell a partial drain from a complete one. TimePos dates the observation and may be far earlier than Timestamp.
The Smart Lamp Locator extends these payloads with battery, mode, lamp status, gas readings and SOS — its full field set is on that page.
The messages in this section are specific to the Smart Lamp Locator and to underground mining use cases. No other device model publishes or accepts them, and a backend serving other deployments never sees these suffixes.
They cover gas measurement, the SOS button and two-way text messaging to the cap lamp display, on top of the position topics already described. Four additional suffixes are involved:
| Suffix | Direction | Content |
|---|---|---|
OutSos | out | SOS or gas alarm, with the readings that accompany it |
OutAck | out | delivery and read receipts |
InSms | in | text or alarm message for the lamp display |
InAck | in | operator acknowledgement of an SOS |
One broadcast topic omits the locator segment and reaches every lamp on the site:
{prefix}/Locators/InSmsAll
An OutSos message is a position report with the alarm state and the readings that were current when it fired — the operator sees where, what and how bad in one message:
{
"MessageID": 8815,
"LocatorID": "SL0042",
"DeviceType": "bler",
"BatteryCharge": 72,
"Lamp_Status": "ONLINE",
"WB0": 1041,
"WB1": 1042,
"Methane": 1.35,
"MethaneALM": 1,
"CO": 24,
"COALM": 0,
"SOS": 1,
"APRSSI": -71,
"Timestamp": 1755262800123
}
SOS is 1 while the wearer has raised an SOS from the button or the control board reports an active alarm, and 0 otherwise. The wearer clears their own SOS with three presses of the button.
The gas keys present depend on the fitted sensor option recorded in SL_CONFIG (0x8308) at manufacture. Pressure and temperature travel with the gas reading rather than as separate telemetry:
SL_CONFIG | Keys in the message |
|---|---|
0 none | — |
1 methane | Methane, MethaneALM |
2 CO | CO, COALM |
3 methane + CO | all four |
4 methane + pressure + temperature | Methane, MethaneALM, Pressure, LocatorTemp |
5 CO + pressure + temperature | CO, COALM, Pressure, LocatorTemp |
A consumer reads SL_CONFIG rather than inferring capability from a message: an absent key means no sensor fitted, which is not the same as a zero reading.
A gas reading is not always a number. When the sensor does not answer, the device emits the JSON string"-.--"in place of the value:A parser that assumes a numeric type will fail on exactly the messages that matter most. Check the type before converting, and treat the string form as no reading rather than as zero."Methane": 1.35 sensor answering "Methane": "-.--" sensor not answering
The two gases are scaled differently, and neither carries its unit in the message:
| Key | Format | Unit |
|---|---|---|
Methane | decimal, two places | percent by volume |
CO | integer, no decimals | ppm |
Pressure | integer | pascals |
LocatorTemp | decimal, one place | degrees Celsius |
MethaneALM and COALM are the sensor's raw status word, not booleans. Bit 0 is the alarm: test value & 1. Other bits report sensor state, and the value 0x1100 is the fault code that accompanies a "-.--" reading. Comparing the whole word to 1 works today but breaks as soon as another status bit is set.
A message published to InSms carries one of two keys, and the key chooses how the lamp presents it:
{"ALM": "Evacuate section 4"} alarm banner
{"SMS": "Report to the shift lead"} text message
The text is capped at 140 characters. If a message is already on the display, the new one is queued rather than dropped or overwritten.
Every delivery is confirmed twice — once when the lamp receives it, once when the wearer has seen it. All four flags are always present and exactly one is set:
{
"MessageID": 8816,
"LocatorID": "SL0042",
"DeviceType": "bler",
"BatteryCharge": 72,
"Lamp_Status": "ONLINE",
"AckSMS": 1,
"AckReadSMS": 0,
"AckALM": 0,
"AckReadALM": 0,
"APRSSI": -71,
"Timestamp": 1755262800456
}
| Flag | Set when |
|---|---|
AckALM | an alarm reached the lamp |
AckReadALM | the wearer acknowledged that alarm |
AckSMS | a text message reached the lamp |
AckReadSMS | the wearer read that message |
Because exactly one flag is set per receipt, a backend tracks message state by the flag rather than by counting messages. Receipts are queued and re-sent like any other message, so a lamp that was out of coverage confirms on reconnect rather than losing the receipt.
| Field | Unit | Meaning |
|---|---|---|
TimestampMs | ms | message time at the publishing locator |
Timestamp | ms or s | message time — the unit depends on the publishing firmware |
ArrivalMs | ms | arrival at the anchor — includes queue and link latency |
GnssUtc | s | from the satellites; dates the GNSS data |
TimePos | s | when a buffered position was observed |
BeaconSeq + OffsetMs | — | sensor measurement time, on the ranging clock |
Timestampis not one unit. Some firmware publishes seconds and some milliseconds, so normalise on ingest by magnitude rather than by topic: a current epoch value is 10 digits in seconds and 13 in milliseconds.TimestampMsis always milliseconds and needs no such test. A value of0means the device had no network time — treat it as absent, not as 1970.
Fuse on measurement time, order on arrival. Correlating a position with telemetry using arrival time folds queue and link latency into the result; using it to order a stream is exactly right.