MQTT
Simple IoT can serve MQTT itself and turn published messages into points. See the PLC page for how MQTT compares with the other ways to bring plant data in, and ADR-8 for how common MQTT payload formats compare with the Simple IoT point model. The design is open for discussion on the community forum.
Support comes in four pieces, each usable on its own:
- A built-in broker. Gateways and sensors publish directly to Simple IoT, with no separate broker to deploy, secure, and update.
- Subscriptions. An
mqttnode and itsmqttSubchildren map named topics into points. - A topic schema. Declare what your topic levels mean, and nodes are created automatically as data arrives.
- Sparkplug B. Birth certificates describe the data, so the node structure builds itself. Covered in its own section below.
The built-in broker
Simple IoT embeds a NATS server, and NATS includes an MQTT server. It needs JetStream, which Simple IoT already runs, so serving MQTT is a port setting rather than a new dependency:
SIOT_NATS_MQTT_PORT=1883
The port is disabled by default. Points worth knowing:
- The broker implements MQTT 3.1.1. Clients that require MQTT 5 are refused, which matters mostly for newer gateways that default to version 5.
- QoS 0, 1, and 2 are supported. Sessions and retained messages are stored in JetStream, so they survive a restart.
- When an auth token is configured (
SIOT_AUTH_TOKEN), MQTT clients supply it in the password field of the connect packet, with any non-empty user name alongside it, which MQTT 3.1.1 requires whenever a password is present. Use TLS (SIOT_NATS_TLS_CERT/SIOT_NATS_TLS_KEY, which also serve the MQTT listener) whenever a connection leaves a trusted network. - Published messages become NATS subjects, so anything connected to Simple IoT
over NATS sees them. Topic levels convert as
/to., and a literal.in a topic converts to//. A Sparkplug topic ofspBv1.0/plant/DDATA/line3/tankarrives on the NATS subjectspBv1//0.plant.DDATA.line3.tank.
Add -u siot -P $SIOT_AUTH_TOKEN to both when a token is configured. The
command line walkthrough below
takes this further and creates nodes from published messages.
An external broker still makes sense when the plant already runs one, when you bridge several sites, or when you need broker features such as clustering or fine-grained access control. Connecting to an external broker as a client is planned as well, so the choice stays open.
Subscriptions
An mqtt node holds the connection, and each mqttSub child maps one topic
into points:
nodes:
- mqtt:
description: Plant data
uri: "" # blank uses the built-in broker
children:
- mqttSub:
description: Tank level
topic: plant/line3/tank/level
path: $.value
units: cm
A blank uri uses the broker built into this instance, which is the only mode
available today – setting a uri reports an error on the node until external
brokers are supported. mqttSub settings:
| Setting | Purpose |
|---|---|
topic | The topic to subscribe to |
path | Where in a JSON payload the value lives, such as $.value |
units | Engineering units, carried on the emitted points |
scale | Multiplier applied to numeric values, 1 when unset |
offset | Added after scaling: value = raw * scale + offset |
disabled | Stops the subscription without deleting the configuration |
Each subscription also carries a tag point named topic holding the full topic,
so a series can be traced back to the message that produced it. See the
graphing section of the PLC page.
A payload that does not parse, or a path that is not in it, sets an error
point on the subscription node and leaves the rest running.
Payloads
Payloads are JSON, which covers the AWS IoT, Azure IoT, and gateway-defined
formats most installations use. How a payload maps depends on path:
pathset: the value at that location becomes a single point. Numbers becomevaluepoints, strings become text points, andtrueandfalsebecome 1 and 0 so a rule compares them the way it compares any other on/off value.pathblank, payload is a bare number or string: the payload itself becomes the point.pathblank, payload is an object: each top-level field becomes a point, with the field name as the point key. A payload with twenty fields becomes one node and twenty points rather than twenty nodes, and the field name is queryable in the database as thekeylabel.
A path is written in the dot notation JSON documentation generally uses:
$.value, $.a.b, and $.a[0] all work, and the leading $ is optional.
Topics you have not named are ignored. A wildcard topic on one mqttSub
subscribes fine, but every match lands on that one node, so name topics
individually when they represent different things. The topic schema below is the
better tool when you want one rule to cover many topics.
Automatic nodes with a topic schema
Plain MQTT carries no information about which topic level is a site and which is
a device, which is why nothing is created automatically by default. A topic
schema supplies that missing information. Declare what the levels mean on the
mqtt node, and matching topics create nodes as data arrives:
nodes:
- mqtt:
description: Plant data
uri: ""
topicSchema: "{site}/{gateway}/{device}"
The first message on plant-07/kepware-l3/press/tank_level carrying
{"value": 42.1} creates:
Plant data (mqtt)
└── plant-07 (group, tag: site=plant-07)
└── kepware-l3 (group, tag: gateway=kepware-l3)
└── press (mqttDevice, tag: device=press)
point: value, key tank_level
Expanding the mqttDevice node in the web UI lists every point it holds
alongside its current value, so you can see what a device is publishing without
querying the store.
The rules:
- Each named level becomes a node, carrying a tag named by its schema label.
Intermediate levels are group nodes; the last named level is an
mqttDevicenode that receives the points. Levels written without braces are literals a topic has to match, soplant/{site}/{device}covers one prefix only. - Everything beyond the named levels becomes the point key. Remaining topic
levels and JSON field names join into the key with
/, so a deeper topic extends the key rather than the node tree. A payload carrying a single field namedvalueis treated as a scalar, since that is the shape a gateway publishing one measurement per topic uses. - Nodes are matched by topic identity, not by name. Renaming a description or adding tags to an auto-created node survives later messages and restarts, and nothing is duplicated.
- Nodes are never deleted automatically. A quiet sensor and a removed sensor look the same from outside, so removal stays a human decision.
- A
maxNodeslimit (default 1000) guards against topics that carry unbounded values such as message IDs. When the limit is reached, an error point is set on themqttnode and new topics are dropped. - Explicit
mqttSubchildren win. A topic named by a subscription is handled by that subscription alone, so hand-tuned mappings with units and scaling override the schema where precision matters.
The schema and explicit subscriptions compose well: start with a schema to see
what a site publishes, then add mqttSub entries for the values that need
units, scaling, or careful naming.
Trying a topic schema from the command line
A topic schema is the quickest way to watch MQTT data turn into nodes, and the
Mosquitto command line tools are enough to exercise the whole path. Install them
with apt install mosquitto-clients, pacman -S mosquitto, or
brew install mosquitto, then start an instance with the broker enabled:
SIOT_NATS_MQTT_PORT=1883 siot serve
Add an mqtt node with a schema. Setting debug: 1 logs every message the node
handles, which is worth having while testing:
cat <<EOF | siot import
apiVersion: 1
nodes:
- mqtt:
description: Plant data
uri: ""
topicSchema: "{site}/{gateway}/{device}"
debug: 1
EOF
One measurement per topic
Publishing a single value per topic is what most gateways do. The schema names three levels, so the first message creates the site, gateway, and device nodes, and the levels past the third become the point key:
mosquitto_pub -h localhost -p 1883 \
-t plant-07/kepware-l3/press/tank_level -m '{"value":42.1}'
mosquitto_pub -h localhost -p 1883 \
-t plant-07/kepware-l3/press/pump_rpm -m '{"value":1800}'
A payload that is a bare number or string works the same way, so a gateway that
publishes 1800 with no JSON around it needs nothing extra:
mosquitto_pub -h localhost -p 1883 -t plant-07/kepware-l3/pump/rpm -m 1800
Several measurements in one payload
A gateway that publishes an object at the device level, the last level the schema names, gets one point per field, with the field name as the point key:
mosquitto_pub -h localhost -p 1883 -t plant-07/kepware-l3/hmi \
-m '{"line_speed":12.5,"state":"running","running":true}'
Numbers become value points, strings become text points, and true and false
become 1 and 0, so the hmi device ends up with line_speed, state, and
running.
Topic levels past the schema and field names inside the payload join into the
key with /, which means a deeper topic extends the key rather than the tree:
mosquitto_pub -h localhost -p 1883 \
-t plant-07/kepware-l3/pump/motor/temp -m '{"value":38.5,"units":"C"}'
That message lands on the pump device as motor/temp/value and
motor/temp/units. An object holding one field named value is the scalar
case, so the same topic carrying {"value":38.5} produces the single key
motor/temp.
Seeing what arrived
siot export prints the tree the messages built, tags included:
$ siot export
apiVersion: 1
nodes:
- mqtt:
description: Plant data
topicSchema: "{site}/{gateway}/{device}"
children:
- group:
description: plant-07
id: plant-07
tag:
site: plant-07
children:
- group:
description: kepware-l3
id: kepware-l3
tag:
gateway: kepware-l3
children:
- mqttDevice:
description: press
id: press
tag:
device: press
value:
pump_rpm: 1800
tank_level: 42.1
siot log prints points as they arrive, which answers whether a value is
updating without reloading a page:
$ siot log
2026/08/20 14:32:09 NODE: hmi (mqttDevice) (799dd5f3-aa51-4245-a31d-5f3139cca804)
- POINT: T:value V:12.500 K:line_speed O:d931bb99-e924-4fc2-86e7-d949ec942f2c 2026-08-20T14:32:09-04:00
mosquitto_sub shows the messages themselves, which separates a gateway that is
not publishing from a schema that is not matching:
mosquitto_sub -h localhost -p 1883 -t 'plant-07/#' -v
The same nodes appear in the web UI at http://localhost:8118, where you can
rename a device or add tags to it. Those edits survive later messages and
restarts, since auto-created nodes are matched by their id point rather than
by description.
If nothing appears
- Match the depth. A schema of
{site}/{gateway}/{device}needs three levels, so a message onplant-07/kepware-l3is ignored and logs nothing, even withdebug: 1set. - Supply the token. When
SIOT_AUTH_TOKENis set, a client that connects without it is refused with return code 5. Add-u siot -P $SIOT_AUTH_TOKENto themosquitto_pubandmosquitto_subcommands; the user name can be anything non-empty, which MQTT 3.1.1 requires alongside a password. - Stay on MQTT 3.1.1. The Mosquitto clients use it by default, so no flag is
needed. Passing
-V mqttv5is refused by the broker. - Check the
mqttnode for an error point. A topic level carrying an unbounded value can reachmaxNodes(1000 by default), after which new topics are dropped and the error point says so. - Watch for typos becoming nodes. Nodes are never deleted automatically, so a mistyped topic leaves a node behind. Delete it in the UI once you are done.
Sparkplug B
Note, Sparkplug B support is preliminary, testing feedback is welcome.
Sparkplug B adds a defined topic namespace, a
protobuf payload, and birth and death certificates on top of MQTT. Because an
edge node announces every metric it will report, with names and types, Simple
IoT builds the node structure from the data itself and no schema or subscription
list is required. Enable it on the mqtt node:
nodes:
- mqtt:
description: Plant 03 Sparkplug
uri: ""
sparkplug: true
The topic namespace is spBv1.0/{group}/{message type}/{edge node}/{device},
and it maps onto the graph directly:
Plant 03 Sparkplug (mqtt)
└── plant-03 (sparkplugGroup)
└── ignition-edge (sparkplugNode)
├── press-1 (sparkplugDevice)
│ points: tank_level, pump_rpm, ...
└── press-2 (sparkplugDevice)
- NBIRTH and DBIRTH create or refresh the group, edge node, and device nodes and write one point per metric. A birth after a gateway restart refreshes the existing nodes rather than duplicating them, and tags or descriptions you have set on them survive.
- NDATA and DDATA arrive as point updates carrying the payload timestamp. Metrics are referenced by numeric alias after birth, and the alias assignments are kept on the edge node, so data that arrives after a restart resolves straight away. When there is no mapping for an alias – data from a gateway that was already running when Simple IoT started, for instance – Simple IoT requests a rebirth and the structure builds itself from the answer.
- NDEATH and DDEATH mark the node offline rather than deleting it. An edge node death takes its devices offline with it.
Each auto-created node carries a tag naming its Sparkplug identity –
sparkplugGroup, sparkplugNode, sparkplugDevice – so queries select on the
structure the same way they select on a hand-set tag, and
tag inheritance carries a site tag on the mqtt node down
through all of it.
Metric names become point keys, with any character a subject cannot carry replaced by an underscore. Sparkplug types map to point types the same way other PLC values do: see the data types table. Metrics carrying a dataset, a template, or a file are skipped for now, and the rest of the message is used. Acting as a Sparkplug primary host application (the STATE topic) and publishing Simple IoT data outbound as Sparkplug are not part of this support.
A multi-site deployment
Fifteen sites, each with one or more gateways publishing JSON to the broker built into a central instance. Put identity in the topic and configure the gateways to match:
{site}/{gateway}/{device}/{measurement}
With one mqtt node and a topic schema, the whole fleet needs no per-site
configuration; sites, gateways, and devices appear as they publish, each
carrying its tags:
apiVersion: 1
nodes:
- mqtt:
description: Plant data
uri: ""
topicSchema: "{site}/{gateway}/{device}"
When a site needs curated metadata, give it a provisioning file instead, with a
group node per site carrying a site tag and explicit mqttSub entries below
it:
apiVersion: 1
nodes:
- group:
description: Plant 07
tag:
site: plant-07
children:
- mqtt:
description: Kepware line 3
uri: ""
tag:
gateway: kepware-l3
children:
- mqttSub:
description: Tank level
topic: plant-07/kepware-l3/press/tank_level
path: $.value
units: cm
tag:
machine: press-3
The two compose: start with the schema to see what fifteen sites are publishing,
then add mqttSub entries, which take precedence, for the values that need
units, scaling, or careful naming.
With tag listed in the Database node’s Tag Point Types,
every point arrives in the time series database labeled by site, gateway, and
machine:
points_value{key="tank_level",
"node.tag.site"="plant-07",
"node.tag.gateway"="kepware-l3",
"node.tag.machine"="press-3"}
A Sparkplug site is one more node with sparkplug: true under its site group,
and its auto-created structure inherits the same site tag. The
graphing section of the PLC page covers how topic
hierarchies, tags, and point keys become queryable series, and the cautions that
come with them: keep unbounded values out of tags, and settle names before
collecting history you intend to keep.
Not yet planned in detail
- External brokers. The
urisetting is reserved for connecting to an existing broker as a client. - Schema-less discovery, for browsing what an unfamiliar broker publishes under a prefix when no topic convention exists.
- Per-client credentials, so each gateway authenticates individually and can be restricted to its own topics. The device credential authorizer already covers the MQTT listener; what remains is a credential type scoped to topics rather than to a device’s sync subjects.
- MQTT 5, which depends on the NATS server gaining support for it.