Devices publish position, telemetry, alarms and diagnostics to an MQTT broker as JSON, and accept remote parameter reads and writes on the same connection.
Every topic begins with a configurable prefix (GATT 0x8108), so one broker can host several sites.
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.