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.
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.
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.
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.
Same goal, two shapes. MQTT: announce through a middleman. OPC UA: every machine answers for itself.
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 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.)
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.
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.
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.
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.