Wiki

Technical reference for system integrators and engineers.

MQTT API

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.

Subscribing

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.

Routing a position message

One topic carries all three positioning methods, and DeviceType is the only thing that separates them:

message on /RTLS read "DeviceType" "uwb" Distance_cm — one range anchor to tag "uwb2" three arrival timestamps multilaterate server-side "bler" beacon pair — no metres topological position satellite positions arrive on /GNSS — a separate topic, not a DeviceType
A subscriber that assumes /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.

Identity

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. TagID is printed in MAC order and BeaconID is 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.

Time handling

Several timestamps arrive together and answer different questions:

FieldUnitMeaning
GnssUtcsecondswhen the GNSS data was taken
TimePossecondswhen a buffered position was observed
BeaconSeq + OffsetMssensor measurement time, on the ranging clock
TimestampMsmillisecondsmessage time at the publishing locator
Timestampms or smessage time — the unit depends on the publishing firmware
ArrivalMsmillisecondsarrival 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.

Timestamp is 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. TimestampMs is always milliseconds and needs no such test. A value of 0 means the locator had no network time when it published; treat it as absent rather than as 1970.

Buffered history

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.

Remote parameter access

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.

Liveness and diagnostics

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.

Wire reference

Everything below is the device-side interface, reproduced from the MQTT protocol page.

Topic structure

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:

{prefix} Locators {LocatorID} RTLS site fixed which locator what kind of message GATT 0x8108 serial number the only part that varies per message
The prefix lets one broker host several sites. Everything else is fixed per locator except the suffix.

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

Topics

SuffixDirectionContent
RTLSoutposition — UWB or BLE, see Device type
GNSSoutsatellite position
SensorsoutIMU, barometer, temperature, battery
Alarmouttag alarm
KeepAliveoutliveness and fault status
Diagoutuplink and MCU diagnostics
BufferedDataoutrecords stored while offline
ParamReadinread a runtime parameter
ParamWriteinwrite a runtime parameter
ParamResponseoutreply 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.

CAN-to-MQTT gateway

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 frameBecomes
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, 6nothing — 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 is not a special case

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.

Identity

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.

Timestamps

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_msPublished
plausiblethe frame's own value
implausible, gateway has network timethe gateway's time
implausible, gateway has none either0

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.

Worked example

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.

Device type

Every RTLS message carries DeviceType, naming the method that produced the position:

ValueMethodPosition expressed as
uwbUWB rangingone distance, anchor to tag
uwb2UWB TDoAarrival timestamps, multilaterated server-side
blerBLE — Offline-online RMAwhich 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.

Position — /RTLS

uwb — ranging

One 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 — TDoA

Arrival 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 — BLE

A 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.

Identity keys

TagID is printed in MAC order; BeaconID is 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.

Sensors — /Sensors

One 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.

Battery

null and false mean different things, and the distinction is deliberate:

ShapeMeaning
"Present": false, "Percent": null, "External": trueNo cell fitted; running on external supply
"Percent": null with a numeric MvVoltage read, but the percentage is implausible — a scaling or sense fault, not a flat cell
"Charging": null, "External": nullCharge 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 — /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 — /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.

Keep alive — /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.

Diagnostics — /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.

Remote parameter access

Runtime parameters are read and written over MQTT using the same ID space as GATT and CAN. ParamId accepts decimal or 0x-prefixed hex.

Read

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.

Write

Publish to {prefix}/Locators/{LocatorID}/ParamWrite:

{"ParamId": 31, "Value": "01"}                binary or scalar, hex bytes
{"ParamId": 5,  "Text": "Anchor North-2"}     string parameters

Response

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.

Buffered records — /BufferedData

Records 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.

Gas, SOS and messaging — Smart Lamp Locator

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:

SuffixDirectionContent
OutSosoutSOS or gas alarm, with the readings that accompany it
OutAckoutdelivery and read receipts
InSmsintext or alarm message for the lamp display
InAckinoperator acknowledgement of an SOS

One broadcast topic omits the locator segment and reaches every lamp on the site:

{prefix}/Locators/InSmsAll

SOS and gas alarms

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.

Which gas keys appear

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_CONFIGKeys in the message
0 none
1 methaneMethane, MethaneALM
2 COCO, COALM
3 methane + COall four
4 methane + pressure + temperatureMethane, MethaneALM, Pressure, LocatorTemp
5 CO + pressure + temperatureCO, 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.

Reading the gas values

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:
"Methane": 1.35     sensor answering
"Methane": "-.--"   sensor not answering
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.

The two gases are scaled differently, and neither carries its unit in the message:

KeyFormatUnit
Methanedecimal, two placespercent by volume
COinteger, no decimalsppm
Pressureintegerpascals
LocatorTempdecimal, one placedegrees 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.

Messages to the lamp display

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.

Receipts

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
}
FlagSet when
AckALMan alarm reached the lamp
AckReadALMthe wearer acknowledged that alarm
AckSMSa text message reached the lamp
AckReadSMSthe 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.

Timestamps

FieldUnitMeaning
TimestampMsmsmessage time at the publishing locator
Timestampms or smessage time — the unit depends on the publishing firmware
ArrivalMsmsarrival at the anchor — includes queue and link latency
GnssUtcsfrom the satellites; dates the GNSS data
TimePosswhen a buffered position was observed
BeaconSeq + OffsetMssensor measurement time, on the ranging clock
Timestamp is 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. TimestampMs is always milliseconds and needs no such test. A value of 0 means 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.