peckworks / plantfloor View on GitHub
INDUSTRIAL IOT · ON REAL HARDWARE

How factory data moves: MQTT and OPC UA.

A small but complete industrial-telemetry pipeline built on hardware you can hold. An ESP32-S3 board publishes its own sensor readings over MQTT, leaves a last-will liveness signal with the broker, and every message shape is enforced as JSON Schema. A C#/.NET service stores the readings, and the same values are re-exposed through an OPC UA server that any standard industrial client can browse. It is the shape of a real manufacturing data platform, in miniature.

device ESP32-S3, self-publishing MQTT Mosquitto OPC UA Node-OPCUA historian C# / .NET + SQLite contract JSON Schema fallback bridge TypeScript

01 The pipeline

One device publishes. A broker routes. Two independent consumers store and re-expose the data. None of them knows the others exist; the broker decouples everything.

ESP32-S3 BOARD real sensors: chip temperature + accelerometer self-publishing + last will MOSQUITTO MQTT broker Docker · port 1883 C#/.NET HISTORIAN MQTTnet subscriber: store every reading in SQLite OPC UA SERVER Node-OPCUA, port 4840: readings as browsable nodes UaExpert browses it publish route by topic

Add a third consumer tomorrow and the device's code does not change at all. That decoupling is the point.

Why a broker instead of calling the device directly? Direct calls couple every consumer to the device: each one has to know its address, poll it on its own schedule, and handle its outages. With a broker in the middle, the device publishes once and any number of consumers subscribe, none of them aware of the others. That is why publish/subscribe owns the IoT world.

The board publishes for itself. The MQTT publisher lives in the board's own firmware (mqtt_publisher.ino in peckworks-esp32s3matrix): off by default, switched on from the board's web-UI settings, publishing every few seconds with a birth/last-will liveness pair (section 04). This pipeline started life with a TypeScript bridge that polled the board's HTTP API and republished; that bridge stays in the repo as a fallback for a board without the firmware. Both sources publish identical topics and payloads, so the consumers cannot tell them apart. Run exactly one at a time: two publishers means every reading is stored twice.

02 Two protocols, two shapes

MQTT and OPC UA are unrelated standards that solve two halves of the same problem. The clearest way to hold them apart is their shape.

THE MQTT SHAPE THE OPC UA SHAPE MACHINE A shouts, walks away MACHINE B shouts, walks away BROKER the middleman LISTENER 1 gets what it subscribed to LISTENER 2 gets what it subscribed to everything flows THROUGH the middleman ONE CLIENT UaExpert, a dashboard, anything MACHINE A runs its own server (its front desk) MACHINE B runs its own server (its front desk) visits each desk directly: browse, read, subscribe no middleman exists in this picture

Same goal, two shapes. MQTT: announce through a middleman. OPC UA: every machine answers for itself.

MQTT moves data
A featherweight publish/subscribe protocol: many devices stream tiny messages through a central broker to many listeners, over cheap chips and unreliable networks. It does not care what the bytes mean.
OPC UA describes data
A vendor-neutral standard where every machine runs a server that presents a browsable, typed tree of its values. One generic client can discover and read any vendor's server with no custom code.
The gateway is a choice
The two standards are strangers; neither needs the other. This project's OPC UA server just happens to listen to the MQTT side and answer OPC UA visitors. That wiring lives in the code, not in either standard.

03 The proof: live in a real client

The payoff is watching the board's own readings appear in UaExpert, the industry's free reference OPC UA client, with zero custom client code.

UaExpert browsing the MatrixBoard node tree: ChipTemperatureC, AccelX, AccelY, AccelZ, LastReadingAt, and Online, all six reading Good status with live values, Online reading true. The server hostname is redacted.

UaExpert connected to opc.tcp://localhost:4840, browsing Objects > MatrixBoard. All six nodes read Good, including Online: true, the liveness flag from section 04, straight off the board's retained birth message. The accelerometer values update as the physical board is moved. (The server's machine hostname is blurred.)

Browsable by design
A client that knew nothing about this server discovered every value by browsing. That discovery-without-documentation property is the whole argument for OPC UA over a pile of custom REST APIs.
Timestamps that lag on purpose
A subscription reports only when a value changes. A still sensor keeps its older timestamp honestly, rather than pretending to refresh.
A live negotiation
The client asks for a sampling interval; the server enforces its own floor and revises the client up to it. Client requests, server disposes, all visible in the log.

An honest label: ChipTemperatureC is the temperature of the ESP32's own silicon, not the room. The board has no ambient temperature sensor, so the value is named for exactly what it is.

04 The dead-man's switch

A subscriber cannot tell "no news" from "the sender died"; silence looks the same either way. MQTT's answer is the last will: a message the board hands the broker in advance, which the broker publishes on the board's behalf if it ever goes silent.

plantfloor/status/matrix   {"online":true}    <- retained birth, published on connect
plantfloor/matrix/...      data every 3 s     <- normal operation

   (power pulled: the board vanishes with no goodbye)
   ~45 s of keepalive silence...

plantfloor/status/matrix   {"online":false}   <- the BROKER publishes the will
                                                 for the dead board

Demonstrated live: with the board publishing for itself, pulling its power froze the sensor nodes at their last values and, about 45 seconds later, the OPC UA Online node flipped to false. Plugging it back in published the retained birth and flipped it back. The delay is not a flaw: the broker only has silence to go on, and it waits out the keepalive window before declaring the will.

Reachability, not health
The will means "the broker stopped hearing from this board", never "the hardware failed". The broker cannot tell dead from slow; a lapsed keepalive is all the evidence it has.
Why absence is not enough
Retained sensor values keep looking alive after the sender dies, and a dead board cannot retract them. A positive "I'm gone" signal is the only thing that tells consumers to stop trusting the numbers on the wall.
Off the data topics, on purpose
Status lives at plantfloor/status/matrix, outside the plantfloor/matrix/# wildcard the consumers subscribe to for data, so a liveness note is never stored as a fake sensor reading.

05 The message contract

MQTT is a dumb pipe: the broker ships whatever bytes it is handed and never checks their shape. The firmware and this pipeline live in different repositories and agree only on the payload shapes, so those shapes are written down as JSON Schema and enforced at the door.

// schemas/messages.schema.json: the shape both sides rely on
"plantfloor/matrix/temperature": {
  "type": "object",
  "properties": {
    "celsius": { "type": "number" },
    "ts":      { "type": "string", "format": "date-time" }
  },
  "required": ["celsius", "ts"],
  "additionalProperties": false
}

# a wrong-shaped message is refused loudly, and the last good value holds:
REJECTED plantfloor/matrix/temperature (bad shape): missing required
property 'celsius'; missing required property 'ts'; unexpected property 'temp_f'

Why it matters: without the check, a payload like {"temp_f":170} parses fine, .celsius comes back undefined, and the OPC UA server serves garbage with no error anywhere. The guard turns a silent wrong-shape into a logged rejection. node validate.js runs the self-check.

The other half of the contract lives in another repository. The publisher side is mqtt_publisher.ino in the peckworks-esp32s3matrix firmware (an open ESP32-S3 LED-matrix project with its own showcase site): same topics, same keys, same retained flags, plus the birth/will pair. That repo holds the publisher, this one holds the consumers, and the schema file is the handshake between them. It is why swapping the data source from the polling bridge to the board itself was a configuration change, not a rewrite.

06 Run it yourself

Four small pieces, each started on its own. Node 22.6+ runs the TypeScript directly, no build step.

# 1. broker (Docker Desktop must be running)
docker compose up -d

# 2. data source: pick exactly ONE (both publish identical topics;
#    running both stores every reading twice)
#    default: the board publishes for itself. Enable MQTT in the
#    board's web-UI settings (broker host = this PC's LAN IP, port 1883).
#    fallback, for a board without the firmware:
node bridge/bridge.ts

# 3. historian: subscribe and store every reading in SQLite
dotnet run --project historian

# 4. OPC UA server: re-expose the readings as browsable nodes
node opcua/server.ts

Then verify at the destination, never the source's logs: node scripts/dbcheck.js reads row counts and timestamps straight from the database, node scripts/opcuacheck.ts connects the way a real client would and reads live values back (including Online), and node validate.js self-checks the message-shape guard.