Skip to content

Free tool

mqtt-tap — print a building broker's topic tree, read-only

mqtt-tap is a free, read-only MQTT client for the command line: point it at a broker and it prints what arrived while it watched — the topic tree, how many messages came in on each branch and how often, what shape the payloads are, the last value seen, and which topics were replayed from the broker's store and then never updated. It subscribes. There is no publish code path in the file.

  • MQTT 3.1.1
  • Topic tree
  • Retained
  • Payload types
  • Read-only
  • Python 3

Install and run

One file, standard library only. No pip, no paho-mqtt, no broker client library, nothing to install on a machine you do not own. It was written and run on Python 3.12.

# download it
curl -O https://plantroomlabs.com/tools/mqtt-tap.py

# watch a broker for the default twenty seconds and print what turned up
python3 mqtt-tap.py tap 10.0.4.12

# one branch only, for five minutes, with the staleness table
python3 mqtt-tap.py tap 10.0.4.12 --topic "site/plantroom/#" --duration 300 --stale

# the same survey as JSON or CSV, to keep
python3 mqtt-tap.py tap 10.0.4.12 --duration 300 --json > survey.json

--port is 1883 by default and 8883 with --tls. --user takes a username and --pass-env takes the name of an environment variable to read the password out of, so the password never appears in ps output or in your shell history. TLS is --tls, with --ca for a private site certificate authority, --cert and --cert-key for mutual TLS, and --insecure to skip verification, which warns you loudly about what you just gave up. --topic is repeatable and sets what is subscribed to; --filter is a different thing, a client-side narrowing that only applies to --watch.

Ask the person who owns the broker before you connect to it — not because the tool is dangerous, but because an unannounced new client on a building broker is the sort of thing that gets a firewall rule written about you.

What you get

The tree. Every topic that published while the tap was running, collapsed into its levels, so a broker somebody else designed becomes legible in a minute rather than a morning. With a count and a rate per topic, which is what tells you the difference between a sensor, a heartbeat and a gateway republishing its whole state every second. The rate is messages divided by the window, and the output says so: it is not an instantaneous rate.

The payload shape, guessed from the bytes, because MQTT 3.1.1 carries no type information at all: a JSON object, a JSON array, a bare number, a boolean, a string, an empty payload, or binary with a byte count and the first bytes in hex. This is the question that decides how much work an integration is, and it is nearly always answered wrongly from the documentation.

The last value on each topic, so you can see whether the units are what the schedule claims and whether the number is plausible.

Which topics were retained. The broker hands a new subscriber the last retained message on every matching topic immediately, then nothing more until someone publishes. A topic that arrives in that first instant and never updates again is the shape of a dead sensor, a decommissioned gateway or a device somebody unplugged in March — and it is the finding people are most surprised by, because every dashboard upstream is still showing that value as current. What the tool reports is "nothing updated this while I watched", which is evidence; it does not tell you a sensor is dead, and it says so in the output.

Real output, from the fixture broker that ships with the tool's test suite, bound to 127.0.0.1. No real broker was contacted at any point; the survey below is a twelve-second window, and the per-topic table and the Sparkplug summary that followed it are trimmed here for width:

mqtt-tap: connected to 127.0.0.1:41859 as 'mqtt-tap-44c7ca86' (CONNACK accepted)
mqtt-tap: subscribed '#' at QoS 0

# MQTT tap: 127.0.0.1:41859

Listened 12.0s on '#' as 'mqtt-tap-44c7ca86'. 239 messages on 15 topics, 36.7 kB of payload.
Keepalive 30s: 0 PINGREQ sent, 0 PINGRESP back.  Read-only: 1 CONNECT, 1 SUBSCRIBE, 1 DISCONNECT.

## topic tree

building/
  ahu1/
    clearedPoint                                  1 msg     0.08/s  empty                            
    commissionedBy                                1 msg     0.08/s  string                           [retained] Plantroom Labs 2024-03-11
    fanRunning                                   16 msg     1.33/s  boolean                          true
    setpointFrozen                                1 msg     0.08/s  number                           [retained] 21.5
    status                                       24 msg     2.00/s  string                           OK
    supplyTemp                                   48 msg     4.00/s  number                           14.00
    telemetry                                    48 msg     4.00/s  JSON object                      {"unit": "ahu1", "temperature": 22.5, "…
    trendlog                                      1 msg     0.08/s  JSON array                       [{"t": 1759000000, "v": 20.0}, {"t": 17…
  ahu2/
    telemetry                                    24 msg     2.00/s  JSON object                      {"unit": "ahu2", "temperature": 22.0, "…
  ahü1/
    température                                   1 msg     0.08/s  JSON object                      {"temperature": 19.5, "unit": "ahü1"}
  alarm/
    latest                                       12 msg     1.00/s  JSON object                      {"point": "ahu1/supplyTemp", "state": "…
  meter1/
    profile                                       1 msg     0.08/s  binary                           [retained] 00 01 02 03 04 05 06 07 08 09 0a 0b 0c …
    pulse                                        12 msg     1.00/s  binary                           00 01 02 03 04 05 06 07 08 09 0a 0b 0c …
spBv1.0/
  Plant1/
    NBIRTH/
      edge1                                       1 msg     0.08/s  binary (Sparkplug B, not decode… 08 c0 eb e0 c6 06 12 0c 0a 05 62 64 53 …
    NDATA/
      edge1                                      48 msg     4.00/s  binary (Sparkplug B, not decode… 08 ef eb e0 c6 06 12 12 0a 0b 49 6e 70 …

And --stale on a ten-second window over the same fixture. Note what the last line does, which is refuse to draw the conclusion the table is inviting:

## staleness (no update for 4.0s counts as stale)

| Topic | Last seen | Msgs | Retained | Reading |
|---|---|---|---|---|
| building/ahu1/setpointFrozen | 9.9s ago | 1 | 1 | retained only - the broker replayed a stored value and nothing updated it in 10s |
| building/ahu1/commissionedBy | 9.9s ago | 1 | 1 | retained only - the broker replayed a stored value and nothing updated it in 10s |
| building/meter1/profile | 9.9s ago | 1 | 1 | retained only - the broker replayed a stored value and nothing updated it in 10s |
| spBv1.0/Plant1/NBIRTH/edge1 | 9.9s ago | 1 | 0 | Sparkplug NBIRTH - an event topic, published once per session by design; its age is not a staleness signal |
| building/ahu1/trendlog | 9.6s ago | 1 | 0 | stale - last message 9.6s ago |
| building/ahu1/clearedPoint | 9.5s ago | 1 | 0 | stale - last message 9.5s ago |
| building/ahü1/température | 9.4s ago | 1 | 0 | stale - last message 9.4s ago |
| building/meter1/pulse | 0.7s ago | 10 | 0 | live |
| building/alarm/latest | 0.6s ago | 10 | 0 | live |
| building/ahu2/telemetry | 0.3s ago | 20 | 0 | live |
| building/ahu1/status | 0.2s ago | 20 | 0 | live |
| building/ahu1/telemetry | 0.1s ago | 40 | 0 | live |
| building/ahu1/supplyTemp | 0.1s ago | 40 | 0 | live |
| spBv1.0/Plant1/NDATA/edge1 | 0.0s ago | 40 | 0 | live |
| building/ahu1/fanRunning | 0.0s ago | 14 | 0 | live |

6 of 15 topics look stale. A 10s window cannot tell a dead sensor from a slow one - a value that updates every five minutes is stale in every window shorter than that.

How far it has actually been tested

It has never been run against a real broker. Not Mosquitto, not EMQX, not HiveMQ, not a cloud IoT hub, and not Niagara's own MQTT driver — none of them, ever. Everything on this page was proved against a fixture broker that ships with the tool and binds 127.0.0.1: 130 assertions, in under a minute. The fixture has its own independent encoder and decoder written from the specification rather than imported from the tool, which is what stops the suite from merely proving that the tool agrees with itself — but tool and fixture were written by the same person in the same sitting, so a misreading of the specification could sit in both. Running it against a real broker product is the one thing that would close that, and it has not been done.

What the fixture did cover, deliberately awkwardly: a PUBLISH written one byte at a time and several PUBLISHes in one read; a 27 kB payload reassembled byte for byte; a multi-byte remaining-length varint, cross-checked against a second implementation over 5,010 lengths; QoS 1 delivery with one PUBACK each; every MQTT 3.1.1 connect-refusal code turned into a sentence; a broker that refuses a topic filter, and one that accepts # and then sends nothing; a keepalive that goes unanswered; a broker that hangs up mid-window; non-ASCII topics and payloads; and the $SYS tree correctly withheld from a wildcard subscription.

TLS was verified against a certificate authority and a certificate pair generated locally for the test, on loopback: verification passing, verification correctly failing on an untrusted certificate, --insecure warning and proceeding, and mutual TLS with a client certificate. It has not been tested against a public certificate chain, an intermediate, a commercial broker's TLS, or a TLS 1.2-only server.

Not tested, and therefore not claimed: any real broker product; MQTT 5 beyond accepting a connect acknowledgement; QoS 2; session resumption; IPv6; Windows; macOS; and scale — the largest survey run had fifteen topics over a few seconds of fixture traffic. There is no performance number on this page, because none was measured anywhere that would make one meaningful.

Limits

LimitWhat it means on site
It cannot list a broker's topics MQTT has no directory. A subscriber sees what is published while it is connected, plus whatever was retained. A topic that exists but is idle and not retained is invisible, and no client of any kind can do better. What you get is an observation over a window, not an inventory.
The window decides the answer Rates and counts are for however long you ran it, twenty seconds by default. A sensor that publishes hourly looks dead in a five-minute tap, and a chatty gateway looks like the whole system. Run it for longer than the slowest thing you care about.
A wildcard misses the dollar topics A subscription to # does not match topics that begin with $, so the broker's own statistics tree is absent unless you ask for it by name — --topic '$SYS/#' — and on many brokers it is turned off or restricted anyway.
Counts are what arrived here They are this client's view, not the broker's. Anything published at QoS 0 and lost on the way is invisible, and a broker bridged to another broker may not forward retained messages, so the tree you see can be a subset of the tree that exists.
"Stale" is an inference MQTT puts no timestamp in a message. Stale here means "arrived, and did not update while I watched", which is evidence and not proof — a value that updates every five minutes is stale in every window shorter than that. Confirm against the device before anyone gets told their sensor is dead.
It speaks MQTT 3.1.1 Protocol level 4 on a raw socket. It subscribes at QoS 0 or 1 and never above; there is no QoS 2, no session resumption, no unsubscribe, no will, no topic aliases and no WebSocket transport. It decoded a 5.0-style connect acknowledgement and its reason code from the fixture, and that is the whole of its MQTT 5: no v5 properties are sent, parsed or reported, and no real MQTT 5 broker has been tried.
Sparkplug B is recognised, not decoded It spots spBv1.0 topics, reports the payload as Sparkplug protobuf with a byte count, and does not parse it — decoding protobuf without a dependency is its own job. It does know not to call a birth certificate a dead topic, because those are published once per session by design.
It is narrower than the tools you have MQTT Explorer and mosquitto_sub are both excellent and both do things this does not — publishing, editing a retained value, a GUI, plugins, a history. This one prints a tree and some counts, and it fits on a laptop where you cannot install either of them.

What it will never do. There is no PUBLISH encoder in the file. It cannot set a value, clear a retained message, trigger a command topic or leave a will behind. Every outbound byte goes through one function that refuses any packet type outside five — CONNECT, SUBSCRIBE, PUBACK, PINGREQ, DISCONNECT — and mqtt-tap.py --self-check makes that refusal happen in front of you without touching a socket. It connects with a clean session under a random mqtt-tap- client identifier, which is what stops a diagnostic client from evicting a gateway by stealing its identity or leaving the broker queueing messages for a subscriber that has gone home. The note below explains both failures.

If it found something you now have to fix

The usual findings are a topic tree nobody documented, payloads that do not match the integration spec, a gateway republishing far more often than anything needs, and retained values that stopped being true a long time ago.

Getting that into a station properly is a module or driver job more often than people expect, because the stock MQTT driver has real limits and a payload decoder is usually where the work is: what the Niagara MQTT driver will and will not do is the honest version, and bringing LoRaWAN and MQTT data into a station covers decoding, topic design and buffering. If the traffic is going the other way, getting data out of a station is the one to read.

Send the tree and the payload sample. Whether it is a driver, a decoder or a topic design somebody needs to be talked out of is usually clear in half an hour.

Reading further

Reading an unfamiliar MQTT broker in a building is the protocol behind this page: how the retain flag lets you tell a stored value from a live one, why an empty payload is not a bug, and the two ways a read-only client can still break something. The other program is bacnet-sweep, and both of them are listed here.

The file you are downloading

Published here so the download is checkable rather than trusted. Both figures are read off the file served at /tools/mqtt-tap.py when this page is built, so they cannot disagree with it.

PropertyValue
Filemqtt-tap.py
Size63,496 bytes
SHA-256 aec64dc5849016c010b2569ffc76c15eff3a295be62bc2ccf24b51be720f6c22

To check it, on Linux sha256sum mqtt-tap.py, on macOS shasum -a 256 mqtt-tap.py, on Windows certutil -hashfile mqtt-tap.py SHA256. A different digest means a different file — not necessarily a hostile one, but not this one.

Also free

The other one

Same idea, different protocol. Both tools.

Next step

Send the topic tree and one payload.

Those two things decide almost everything about what an MQTT integration costs. You will get a written scope and a fixed price, or a sentence saying it is simpler than you think.