Skip to main content

Speaking OT: OPC UA, MQTT, and Sparkplug B

📍 Where we are: Part II · Capturing the Process — we built a deterministic bioreactor and named its tags (its individual named signals); now we make that data speak over the two protocols every modern plant floor runs on, with real security and an honest look at where field deployments get it wrong.

The simple version

Imagine the bioreactor is a person who only knows how to mumble numbers. Two helpers translate for the rest of the plant. The first, OPC UA, is a meticulous librarian: ask it anything and it hands you not just the value but a labelled card — "this is temperature, in degrees Celsius, measured at 14:32:07, and I'm confident it's good." The second, MQTT with Sparkplug B, is a town crier on a party line: every device announces itself when it wakes up ("BR101 is online, here are my metrics"), shouts changes as they happen, and — cleverly — leaves a sealed death certificate with the switchboard so that if it drops dead mid-shift, everyone is told instantly. This chapter stands up both, wires them to our simulated CHO (Chinese Hamster Ovary cell-line) bioreactor — the stirred vessel where the cells grow and secrete the drug — and is blunt about the part most people skip: actually turning on the security.

What this chapter covers

In Chapter 5 we gave every signal a disciplined name like BR101.Titer.PV — read asset.measurement.role, where Titer is the antibody (product) concentration in the broth (the liquid cell culture inside the bioreactor), in g/L, and .PV is its live Process Value (the present reading, as opposed to a setpoint .SP — the target value you want the control loop to hold). A name with no transport is just a label on an empty box. This chapter fills the box and ships it. We cover:

  • The OPC UA information model — a self-describing address space where each tag carries its value plus type, engineering unit, timestamp, and a quality flag — node by node, attribute by attribute, and how to stand up a server and client with open-source stacks (a stack here is a software library that implements the protocol — open62541, node-opcua, asyncua).
  • The client–server handshake itself — the ordered service set of discover, open a secure channel, create and activate a session, browse, read, and subscribe — and why a subscription beats polling (re-reading on a timer) for live process data.
  • MQTT publish/subscribe with Eclipse Mosquitto — QoS levels, the Will message, and keep-alive — and the full Sparkplug B session lifecycle (birth, data, command, death, with seq/bdSeq sequencing, metric aliases, and the primary-host STATE) that turns a dumb message bus into a stateful, self-discovering one, then how to lock the bus down with TLS and per-client ACLs.
  • Real OPC UA security: the Basic256Sha256 policy, application certificates, and strict trust lists — and the uncomfortable evidence that most reachable servers misconfigure exactly this.
  • The difference between syntactic transport (moving bytes safely) and semantic transport (moving meaning), and why quality flags and timestamps are not optional decoration.

The OPC UA story here runs end-to-end on your laptop; the Sparkplug B half is taught from the spec and decoded payloads — cited, not run — with the runnable Sparkplug topic helper living in Chapter 5. Let's open the box.

OPC UA: the self-describing librarian

The reason OPC UA (formally IEC 62541) dominates the modern plant floor is that it refuses to ship a number naked. Its core idea is an address space: a browsable tree of nodes, where a node is not just a value but an object carrying metadata — its data type, its engineering unit, its access rights, and references to other nodes [1]. A client can connect to a server it has never seen, browse the tree, and discover what is there without a pre-shared spec sheet. That self-description is the whole point: a temperature node tells you it is a temperature, in °C, right now, and that the reading is trustworthy.

Our companion repo models exactly this. The file examples/chapters/05-connectivity-opcua-mqtt/opcua_server.py stands up an OPC UA server using asyncua, the pure-Python asyncio implementation from the FreeOpcUa project [8]. It exposes our BR101 bioreactor as an address space — one node per live process variable — and replays the deterministic fed-batch trace into those nodes. Here is the server's heart:

# examples/chapters/05-connectivity-opcua-mqtt/opcua_server.py
ENDPOINT = "opc.tcp://0.0.0.0:4841/bioproc/"
NAMESPACE = "https://example.org/bioproc"

# tag -> (engineering unit, low, high). Each Variable also carries an EngineeringUnits
# and an EURange Property, so a browsing client discovers "g/L, range 0..10" by itself.
TAGS = {
"BR101.Temp.PV": ("degC", 0.0, 50.0),
"BR101.pH.PV": ("pH", 0.0, 14.0),
"BR101.DO.PV": ("%sat", 0.0, 100.0),
"BR101.Agitation.PV": ("rpm", 0.0, 200.0),
"BR101.Titer.PV": ("g/L", 0.0, 10.0),
"BR101.OnlineGlucose.PV": ("g/L", 0.0, 12.0),
}


async def build_server() -> tuple[Server, dict]:
server = Server()
await server.init()
server.set_endpoint(ENDPOINT)
idx = await server.register_namespace(NAMESPACE)

# explicit string NodeId (ns=2;s=BR101) — legible, the way a real DCS names nodes
br = await server.nodes.objects.add_object(
ua.NodeId("BR101", idx), ua.QualifiedName("BR101", idx))
nodes = {}
for tag, (unit, low, high) in TAGS.items():
var = await br.add_variable(
ua.NodeId(tag, idx), ua.QualifiedName(tag, idx), 0.0)
await var.set_writable()
# make the unit + range browsable, not a guess
eu = ua.EUInformation()
eu.NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact"
eu.DisplayName = ua.LocalizedText(unit)
await var.add_property(idx, "EngineeringUnits", eu)
await var.add_property(idx, "EURange", ua.Range(Low=float(low), High=float(high)))
nodes[tag] = var
return server, nodes

A few details earn their keep. First, the endpoint opc.tcp://0.0.0.0:4841/bioproc/ is OPC UA's native binary TCP transport — efficient, and the default a client dials. (0.0.0.0 means "listen on every network interface on this machine," and 4841 is the TCP port — the numbered door on the host that OPC UA traffic arrives at.) Second, register_namespace claims a URI (https://example.org/bioproc) so our BR101 object lives in its own namespace — a labelled bucket of names that keeps our nodes from colliding with the server's own built-in nodes (the URI is used purely as a globally unique label, not as a web address you can visit); clients resolve it by index, which is why the client code later asks for the namespace number before browsing. Third, we give each node an explicit string NodeId like ns=2;s=BR101.Titer.PV instead of letting the stack auto-assign a bare number — legible, and the way a real DCS (Distributed Control System — the plant's central process controller, met in full in the "In the real world" section below) names its parameters. Fourth, we add BR101 as an object and hang variables under it — that hierarchy is the self-description: a browsing client sees BR101BR101.Titer.PV and immediately knows the titer belongs to that bioreactor. Fifth, each variable carries an EngineeringUnits and an EURange Property, so the unit (g/L) and the expected span (0..10) are browsable metadata, not a convention the reader has to know in advance. (One unit earns a word of physiology: BR101.DO.PV is in %sat — dissolved oxygen as a percentage of air saturation, typically held at a ~30–40% setpoint for a CHO culture, which is why the trace sits in the low-to-mid 30s% late in the run, when the still-dense, actively respiring culture — the cells consume oxygen as they grow and metabolise — had its peak oxygen demand a day or two earlier and is still drawing heavily.) The next section opens one of these nodes all the way up.

When a value is written, asyncua stamps it with a Good status code and a source timestamp by default [8]. That pairing — value plus quality plus time — is the librarian's index card, and it is the part naive transports throw away.

Reading it back: the round-trip proof

The repo doesn't just describe this; it proves it runs. The demo_roundtrip() function starts the server, replays ten minutes of the trace late in the batch, while titer is still climbing toward harvest (one simulation step is one minute of a roughly 14-day fed-batch run, so steps 19000–19009 are ten minutes around day 13 — late in the batch, just before harvest, the point where the culture is stopped and the antibody is collected), then connects a client and reads a node back:

# examples/chapters/05-connectivity-opcua-mqtt/opcua_server.py
async def demo_roundtrip() -> dict:
"""Start the server, replay a few steps, read a node with a client, stop."""
from asyncua import Client

server, nodes = await build_server()
state = fed_batch.simulate().state
async with server:
# replay 10 minutes near peak titer so the read is interesting
for step in range(19000, 19010):
await _pump(nodes, state, step)
await asyncio.sleep(0.2)
async with Client(ENDPOINT.replace("0.0.0.0", "127.0.0.1")) as client:
node = await client.nodes.objects.get_child(
[f"{await _ns(client)}:BR101", f"{await _ns(client)}:BR101.Titer.PV"])
titer = await node.read_value()
return {"endpoint": ENDPOINT, "tags": len(nodes), "read_titer_g_L": round(float(titer), 3)}

Run it and you get a deterministic value (the simulator is pinned to SIM_SEED=2026, so the titer you read is byte-identical on your machine):

{"endpoint": "opc.tcp://0.0.0.0:4841/bioproc/", "tags": 6, "read_titer_g_L": 4.902}

The client browsed BR101, found BR101.Titer.PV by name, and read 4.902 g/L — the titer at minute 19009 of our golden batch (the single reference run the whole book replays), late in the run while the product is still accumulating toward harvest. The underlying state the server pumped in is just as concrete:

step=19000 temp_C=36.96 pH=6.967 DO_pct=31.6 titer_g_L=4.896 glucose_g_L=1.416
step=19001 temp_C=36.98 pH=6.951 DO_pct=33.2 titer_g_L=4.897 glucose_g_L=1.413
...
step=19009 titer_g_L=4.902

This is the smallest honest unit of "connectivity": a value left one process and arrived intact in another, with its identity preserved. The chapter's test suite (tests/test_chapters.py::test_ch05_opcua_roundtrip) asserts exactly that — tags == 6 and 0 < read_titer_g_L < 10 — so the round-trip can never silently rot.

Anatomy of a node: what a value actually carries

The round-trip returned 4.902, but that number arrived wearing a great deal of clothing. Ask the client for the whole node — demo_attributes() in the same file calls read_data_value() instead of the bare read_value() — and you get back every field OPC UA travels with:

{
"node_id": "ns=2;s=BR101.Titer.PV",
"browse_name": "2:BR101.Titer.PV",
"data_type": "Double",
"value": 4.902,
"status": "Good",
"status_hex": "0x00000000",
"unit": "g/L",
"range": [0.0, 10.0],
"has_source_ts": true,
"has_server_ts": true,
"access_level": 3
}

Every field is a deliberate part of the OPC UA address-space model (IEC 62541-3) [13]. Walk them the way the UaExpert browser (the standard desktop OPC UA inspection tool) would when you click the node — this is the answer to "what form does a value take":

  • NodeId — the address. ns=2;s=BR101.Titer.PV is the node's unique, server-scoped identity, and it has a strict shape: ns=<index>;<type>=<identifier>. The <type> flag is one of i (numeric), s (string), g (GUID), or b (opaque bytes). We chose s, a readable string; the stack's default would have been i — a bare integer like ns=2;i=6. A client never guesses a NodeId; it browses to discover one, then caches it for fast reads.
  • NamespaceIndex vs namespace URI. The ns=2 is only a local handle. Index 0 is always the OPC UA core namespace and index 1 the server's own; our https://example.org/bioproc happened to land at 2 here and could be 5 on another server. The URI is the portable name, the index a per-server shortcut — which is precisely why the client resolves the URI to an index first (get_namespace_index) and never hard-codes the 2.
  • BrowseName vs DisplayName. Two names, two jobs. The BrowseName (2:BR101.Titer.PV, a QualifiedName = namespace index + text) is for machines: it is what you walk to build a browse path. The DisplayName (a LocalizedText = locale + text) is for humans and can be localized. Neither identifies the node — only the NodeId does.
  • NodeClass and DataType. BR101.Titer.PV is a Variable (it holds a value); BR101 above it is an Object (it holds none, only structure). The DataType is Double — and a DataType is itself a node in a type tree, so even "what type is this" is browsable.
  • The Value is a DataValue, never a bare number. This is the crux. A read returns a DataValue struct whose four fields travel together: the Value itself, carried in a Variant — a self-typed container, so 4.902 moves as "Double 4.902," never as a row of anonymous bytes — plus the StatusCode, the SourceTimestamp, and the ServerTimestamp.
  • StatusCode — the quality, in two bits. A 32-bit code whose top two bits are the verdict: Good is 0x00000000, Uncertain is 0x40000000, Bad is 0x80000000; the lower bits name the reason (Bad_SensorFailure, Uncertain_LastUsableValue, and so on) [15]. This is the OPC UA-native quality — and notably not the legacy OPC DA 192 we will meet on the Sparkplug wire shortly; UA Good is simply zero.
  • Two timestamps, on purpose. SourceTimestamp is when the sensor sampled; ServerTimestamp is when the server stamped it. In the demo they sit microseconds apart, but on a real link they diverge — a cached value, a slow field bus — and keeping both is how a downstream historian reconstructs true sample time instead of mere arrival time.
  • AccessLevel — what you may do. A bitmask: CurrentRead = 1, CurrentWrite = 2, HistoryRead = 4. Our writable nodes report 3 — read plus write.
  • EngineeringUnits and EURange — the unit, as data. Hanging off the variable as Properties (reached by a HasProperty reference, not stored as plain Attributes) are an EngineeringUnits — an EUInformation struct whose DisplayName is "g/L" and whose NamespaceUri points at the UNECE (United Nations Economic Commission for Europe) unit-code list — the international registry that gives every engineering unit a standard code — and an EURange (Low/High = 0..10). This is where the unit stops being a convention you hope everyone shares and becomes a fact the client can read [15].
  • References — the typed edges. A node is a junction in a graph. BR101.Titer.PV is reached from BR101 by a HasComponent reference, points to its type via HasTypeDefinition (here BaseDataVariableType), and to its unit via HasProperty. Each reference type is itself a node, so the meaning of an edge is browsable too.

An OPC UA node drawn as an identity card: the BR101.Titer.PV variable with rows for its NodeId, BrowseName, DisplayName, NodeClass and DataType, a highlighted DataValue block holding the value as a Variant plus StatusCode, SourceTimestamp and ServerTimestamp, then AccessLevel and the EngineeringUnits and EURange properties, and a panel of typed References to the parent object, the type definition, and the unit properties. One node, fully unpacked: a read returns not a bare number but a labelled card — identity, type, value, quality, time, unit, range, and the typed edges to its neighbours. Original diagram by the authors, created with AI assistance.

That whole card — identity, type, value, quality, time, unit, range, and edges — is what the librarian hands back for one tag. It is the entire difference between a number and a measurement.

Where this sits across the series

This DataValue card is the open-source implementation of a story two sister books already told. The 4.902 g/L it carries was physically made in the production bioreactor — the stirred-tank step where a CHO culture secretes the antibody that a probe then samples. Book 2 names the same reading as a data-point and its open challenge: the OPC UA DataValue and Sparkplug metric are exactly the structure of value plus unit, timestamp, and quality it defines, born at the instruments and sensors on the skid (a pre-assembled, frame-mounted equipment package). This chapter is where that data-point finally becomes running code.

How a Raman analyzer actually exposes its data

Everything above has unpacked a scalarBR101.Titer.PV is one float, one Double, riding in one DataValue. That is the shape almost every tag on the floor takes. But the moment you bolt a real PAT (Process Analytical Technology) analyzer onto the bioreactor, the wire carries something different in kind, and it is worth seeing now so the rest of the book does not surprise you.

Take an in-line Raman analyzer — the kind that watches the broth in real time and feeds the titer soft-sensor we build later. The representative industry instrument is the Endress+Hauser Kaiser Raman Rxn2/Rxn4 family of process analyzers (Kaiser Optical Systems became part of Endress+Hauser), driven by its embedded Raman RunTime control software. Raman RunTime drops into exactly the connectivity story this chapter tells: it exposes its live spectra and computed results over OPC UA — the same self-describing address space we just dissected, and the protocol it recommends precisely because it can carry the full spectral data — while the register-sized computed results are also reachable over Modbus (the register-based protocol we meet again in Connecting Legacy & Commercial Skids: Modbus, Siemens S7, PLC4X).

So far, so familiar. The twist is the payload. A Raman reading is not one number — it is a spectrum: a whole vector of intensities, one per wavenumber (the Raman shift, in cm⁻¹). The analytics chapter, Process Analytics: SPC, MVDA & Soft Sensors, uses exactly 701 such points (wn_400wn_1800). When that analyzer publishes over OPC UA, the value in the DataValue is not a Double but an array — a Variant carrying a vector — wearing the same status code and timestamps the scalar wore. The librarian still hands you a fully labelled card; the card just describes 701 numbers taken together as one observation, not a single reading.

And the analyzer's native on-disk form is its own dialect again. A Raman instrument does not write CSV; its native spectral file format is SPC (the Thermo Galactic / GRAMS .spc format), a binary container purpose-built to hold an intensity-versus-wavenumber array with its axis and acquisition metadata intact. (The analyzer reserves CSV for the derived model results, not the spectrum itself.) So a single analyzer can present the same spectrum three ways — an SPC file on disk, an OPC UA value on the wire, and, downstream, a row in a column store — and none of them is a scalar tag and none of them is CSV.

This is the seam the rest of the platform has to respect. A vector does not fit the one-float-per-timestamp model a tag historian is built around, which is precisely the problem the historian chapter names head-on in The Open-Source Historian: Choosing and Running a Time-Series Storestoring a spectrum is not storing a tag. Carry the warning forward from here: the transport can move a spectrum faithfully, but the moment it lands you owe it a container shaped like the array it is, not the scalar slot the rest of the tags drop into.

The same job in other languages

asyncua is our reference, but OPC UA is a polyglot world and the book uses three open stacks deliberately. open62541 is a C99 implementation (MPL v2.0) certified against the OPC UA Server Profile and supporting the Basic256Sha256 security policy — the right choice when you need a tiny, fast server embedded near the instrument [6]. node-opcua is an MIT-licensed Node.js/TypeScript SDK, ideal when your collector lives in the application tier alongside web services [7]. They all speak the same wire protocol, so a node-opcua collector, the UaExpert desktop browser, or Telegraf's OPC UA input plugin can subscribe to our asyncua server without modification. That interoperability is the standard doing its job.

Standardising the shape, not just the wire

The polyglot interoperability we just saw moves a value faithfully from one stack to another — but it says nothing about how that value is arranged. Our BR101 address space, with its BR101.Titer.PV nodes, is a layout we invented. Base OPC UA (IEC 62541, the OPC 10000 series) is deliberately domain-agnostic: it standardises the meta-model — the rules for how to build any address space out of objects, variables, and typed references (a model of how to make models), but never says what a bioreactor's address space should contain. Point two vendors' bioreactors at it and you can get two entirely different trees for the same physical thing.

A companion specification closes that gap. It is a standardised information model layered on the base: a published set of object types, variables, and semantics for one domain or device class, so that conformant devices expose the same shape and a client can recognise a device by its model instead of by a vendor's datasheet. They are called "companion" because the OPC Foundation co-develops them with an industry body — the majority are these joint specifications — and there are now more than 430 of them [16]. Many build in a stack: at the base sits OPC UA DI (Devices, OPC 10000-100 / IEC 62541-100), a generic device model — nameplate, health, vendor identity — that richer specs inherit and extend [17].

Four of these matter for a line like ours, and the book meets each where it is actually used:

  • PA-DIM (Process Automation Device Information Model, OPC 30081) builds on DI to standardise the data and NAMUR-style diagnostics of process instruments — the pH, temperature, and flow transmitters bolted to the bioreactor skid we capture in Upstream Capture: The Production Bioreactor. It is co-owned by a nine-organisation alliance (FieldComm Group, OPC Foundation, PROFIBUS & PROFINET International, NAMUR, ODVA, ZVEI, VDMA, FDT Group, and ISA100 WCI) — the clearest sign of how hard cross-vendor instrument semantics are to agree on [18].
  • PackML (OPC 30050), derived from ISA-88 (the batch-control standard that defines the equipment hierarchy — Units, equipment modules — a plant is organised into), gives a packaging or fill machine a standard state model and "PackTags" over OPC UA — the shape we lean on in the Fill-Finish, Packaging & Environmental Monitoring chapter [19].
  • OPC UA LADS (Laboratory and Analytical Device Standard, OPC 30500), also built on DI, gives lab and analytical instruments one self-describing model instead of dozens of drivers — the backbone of The Analytical Lab: Instruments, LIMS & ELN chapter [20].
  • MTP (Module Type Package, VDI/VDE/NAMUR 2658) is not an OPC companion spec but a modular-automation standard whose runtime layer rides on an OPC UA information model; it describes a skid's services so a higher-level system can orchestrate it plug-and-produce — the idea behind the modular equipment model in The Batch & Equipment Data Model: ISA-88/95 in PostgreSQL chapter [21].

And here is the honest verdict this chapter owes you. There is no companion specification for a CHO fed-batch bioreactor or a Protein A capture step — the headline analytes this whole book follows (titer, viable-cell density, online glucose) have no standard model to conform to. That is exactly why opcua_server.py invents its own BR101.* layout, and it is the realistic majority case on a real floor today: most servers still expose vendor-specific address spaces, and companion-spec adoption across the process industries is emerging, not settled — PA-DIM and MTP are young, and the first commercial pharma plant built on MTP appeared only recently. So treat companion specifications as the direction of travel: where a transmitter, a fill line, or a lab instrument already has a standard shape, prefer it; where the bioreactor itself does not yet, fall back on the naming discipline of Chapter 5 (the consistent hierarchical Asset.Measurement.PV dotted-path convention) so the model you invent is at least rigorously self-consistent.

How a client and server actually talk

We have read a node and dissected it, but glossed over how the conversation gets set up. OPC UA's reputation for being "heavyweight" comes almost entirely from this handshake — and every step of it buys something concrete. Client and server move through an ordered service set (IEC 62541-4) [14]; our asyncua client runs all of it for you inside a single async with Client(...), but it pays to know what is happening on the wire.

A sequence diagram with two lifelines — an OPC UA client on the left and the server on the right — stepping through five phases. Discover: the client calls GetEndpoints and the server returns its endpoint URLs and security policies. Open a secure channel: OpenSecureChannel with the Basic256Sha256 policy, signed and encrypted, certificates checked against trust lists. Create and activate a session: CreateSession then ActivateSession carrying the user identity. Browse and read: Browse BR101 returns the child nodes, then Read of the titer node returns a DataValue of 4.902, Good, with timestamps. Subscribe: CreateSubscription and CreateMonitoredItems, after which the server returns changing values, 4.897 then 4.902, by answering the Publish requests the client keeps open.

  1. Discover — GetEndpoints. Before any security, the client dials the server's discovery endpoint and asks what do you offer? The server returns one EndpointDescription per endpoint: the URL, the SecurityPolicy (such as Basic256Sha256), the MessageSecurityMode (None, Sign — messages are signed so tampering is detectable but the contents are still readable on the wire, or SignAndEncrypt — signed and encrypted so the contents are hidden too), and which user-identity tokens it accepts. The client now knows how to connect securely without a pre-shared config file.
  2. Open a secure channel — OpenSecureChannel. The client establishes a SecureChannel: a signed-and-(optionally-)encrypted transport tunnel. This is where the application certificates and trust lists from the next section do their work — the channel is the cryptographic envelope every later message rides inside. Note the layering: the channel secures the transport; it has not yet authenticated a user.
  3. Create and activate a session — CreateSession, then ActivateSession. A Session is the application-level conversation that rides on the channel. CreateSession opens it (not yet usable); ActivateSession is where the user identity is supplied and checked — anonymous, username/password, or an X.509 certificate. Splitting the two matters: a session survives a dropped channel and can be re-bound to a fresh one, so a brief network blip does not lose your subscriptions.
  4. Browse and read — the View and Attribute services. Now the useful part. Browse walks a node's references to discover its children — this is exactly how the client found BR101.Titer.PV under BR101 without being told it existed — and Read fetches attributes, returning the full DataValue we dissected above. (History lives behind a separate HistoryRead.)
  5. Subscribe — and let the changes come to you. Reading on a timer (polling) is simple but wasteful: you re-fetch values that have not moved, and still miss fast transients between polls. The alternative is a Subscription. The client calls CreateSubscription (setting a publishing interval — how often notifications are delivered) and CreateMonitoredItems (one per node, each with its own sampling interval and an optional deadband so trivial wiggles are filtered out). Then the data flows the other way: as values change, the server returns them.

The repo proves this too. demo_subscription() registers one monitored item on the titer node and lets the server stream the changes:

{"endpoint": "opc.tcp://0.0.0.0:4841/bioproc/", "notifications": 8, "first_g_L": 4.897, "last_g_L": 4.902}

Several change notifications arrived without the client asking again (eight on the reference run; the exact count is timing-dependent, since the demo drives changes against a 100 ms publishing interval) — the titer climbing the deterministic 4.897 → 4.902 as the golden batch advances. And one honest subtlety the diagram is careful about: OPC UA does not have the server open a socket back to the client. The client keeps one or more Publish requests parked at the server, and the server answers each the moment a monitored value changes — or, if nothing changes for a while, with an empty keep-alive so the client knows the subscription is still alive. It feels like a push; mechanically it is the server replying to a request the client left waiting. That distinction is why subscriptions cross firewalls that would block a genuine server-initiated callback.

Steps 1–3 are also the join with what comes next: they are exactly where a field deployment either turns security on, or quietly leaves it off.

Turning on the security (the part everyone skips)

Here is the uncomfortable truth this chapter refuses to soften. OPC UA can be locked down beautifully — the OPC UA Security Model (OPC 10000-2 / IEC 62541-2) defines signed-and-encrypted channels, application certificates, and trust lists that say which peers a server will even talk to [2]. The Basic256Sha256 security policy uses SHA-256 and 2048-bit-plus RSA keys; it is the modern baseline.

But can and does are different planets. A 2020 internet-wide measurement study found that 92% of reachable OPC UA deployments had insecure configurations, and — most damning — of 564 servers advertising the Basic256Sha256 policy, 409 presented certificates that did not even match that policy, falling back to MD5/SHA-1 signatures or short keys [3]. That sample was internet-reachable servers — a well-segmented plant server sits behind a firewall and was not in it — but the misconfiguration is identical, and the lesson stands: a server can claim strong security and still hand you a broken credential. The protocol was never the weak link; the deployment was.

So when you move past our laptop demo (which uses an open opc.tcp:// endpoint for teaching), real security is a few deliberate steps. With asyncua, you load the server's own certificate and key, set the allowed policy, and — the step everyone forgets — pin a trust list so the server rejects any client whose certificate isn't on it:

# Illustrative hardening — what the production deployment adds on top of the
# demo server; this is NOT in opcua_server.py (the runnable demo has no TLS).
from pathlib import Path

from asyncua import ua
from asyncua.crypto.truststore import TrustStore
from asyncua.crypto.validator import CertificateValidator, CertificateValidatorOptions

await server.load_certificate("certs/server-cert.pem")
await server.load_private_key("certs/server-key.pem")
server.set_security_policy([ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt])

# Strict trust: only clients whose certs live in the trust folder may connect.
# A TrustStore loads the trusted peer certs (and CRLs), and a CertificateValidator
# rejects any client that isn't trusted.
trust_store = TrustStore(trust_locations=[Path("certs/trusted")], crl_locations=[])
await trust_store.load()
validator = CertificateValidator(
CertificateValidatorOptions.TRUSTED | CertificateValidatorOptions.PEER_CLIENT,
trust_store,
)
server.set_certificate_validator(validator)

The lesson the data forces on us: advertising Basic256Sha256 is worthless if you accept self-signed strangers or skip certificate validation. The honest checklist is encrypt the channel, validate the chain, and keep the trust list short and reviewed. This is also where pure OSS (open-source software) does fine on the wire but offers you no built-in certificate lifecycle — issuance, rotation, revocation. In a GxP plant (a regulated, quality-controlled facility — GxP is the umbrella for the Good x Practice rules, such as Good Manufacturing Practice, that govern making medicines) you bolt that onto a real PKI (public-key infrastructure — a Global Discovery Server or your site CA), and you document it. The stack is free; the discipline is not.

A two-lane diagram of the plant connectivity backbone. The top lane shows OPC UA: the BR101 bioreactor as a browsable address-space tree, each node carrying value, unit, timestamp and quality, connected over a Basic256Sha256 signed-and-encrypted channel to a collector, with a trust list gating which clients may join. The bottom lane shows MQTT with Sparkplug B: BR101 publishing an NBIRTH announcement and DBIRTH metric definitions to the Mosquitto broker, ongoing DDATA change messages, and a pre-registered NDEATH will message the broker fires automatically when the device drops, with the historian subscribing downstream.

Two complementary transports: OPC UA answers "tell me everything about this tag, securely, on request," while Sparkplug B over MQTT announces "here is who I am and what changed," and guarantees the network learns the instant a device dies. Original diagram by the authors, created with AI assistance.

MQTT and Sparkplug B: the self-announcing town crier

OPC UA is request-driven and heavyweight; it shines for rich browsing and secure point-to-point reads. But a plant with hundreds of devices and thin network links also wants a lightweight, fan-out path. That is MQTT (OASIS standard, also published as ISO/IEC 20922) — a publish/subscribe protocol where devices publish to topics and a broker fans messages out to whoever subscribed [4]. It is famously frugal, which is why it runs on everything from a soil sensor to a bioreactor skid. (One honest caveat to the tidy dichotomy: OPC UA is not only request-driven — OPC UA PubSub (IEC 62541-14) adds a broker- or UDP-based publish/subscribe mode that can itself ride MQTT, overlapping Sparkplug's territory. It is younger and far less deployed than the client–server mode this book builds on, so we treat OPC UA as the request/response half and Sparkplug as the pub/sub half — the split you still meet most often on a real floor.)

Our broker is Eclipse Mosquitto [9]. The dev-stack config, examples/platform/mosquitto/mosquitto.conf, is short and — importantly — honest about being dev-only:

# examples/platform/mosquitto/mosquitto.conf
# Mosquitto broker config for the local dev stack (Chapter 7).
# Dev-only: anonymous access on the plain 1883 listener. Chapter 28 (operating &
# securing) replaces this with TLS + per-client ACLs; never ship anonymous in
# a real plant.
listener 1883
allow_anonymous true

# enable the $SYS topic tree so the healthcheck can confirm the broker is alive
sys_interval 10

persistence true
persistence_location /mosquitto/data/
log_dest stdout

Read the comment as a promise: allow_anonymous true on the plain 1883 listener is fine for a laptop and forbidden on a plant. Mosquitto supports MQTT over TLS with client certificates [9]; Chapter 28 swaps this file for a TLS listener and per-client access-control lists. Showing the insecure dev config and labelling it loudly is exactly the discipline this book preaches — we never let a convenient default sneak into production.

Sparkplug B: giving the bus a heartbeat

Raw MQTT has a problem for industrial use: it is stateless and topic-anarchic. Stateless means the broker keeps no memory of what devices or signals should exist — it only relays whatever happens to arrive. Topic-anarchic means any device can publish anything to any string, and if a device falls off the network, subscribers have no idea — they just stop hearing from it, indistinguishable from "nothing changed." Sparkplug B (Eclipse Sparkplug 3.0.0) is the open specification that fixes this by defining a strict topic namespace and a birth/death lifecycle [5]. The reference encodings live in Eclipse Tahu (EPL-2.0), which provides Sparkplug B implementations in Java, Python, and C [10].

Sparkplug organises every message on the bus into a small, fixed vocabulary — nine message types, and a Sparkplug-aware consumer knows them all:

  • NBIRTH / DBIRTH — birth certificates. When an edge node (the BR101 controller) connects it publishes an NBIRTH announcing itself; for each device beneath it (our reactor skid) a DBIRTH defines every metric it will ever report — name, datatype, current value. This is the self-description, the moment the bus learns BR101 has a Titer.PV that is a float in g/L. The birth is a contract: nothing may appear later that was not declared in one.
  • NDATA / DDATA — the changes. After birth, the node sends only what moved — an NDATA for node-level metrics, a DDATA for device metrics — carrying the changed values and nothing else. This is report by exception: an unchanged value need not be resent until the next birth, the same economy as an OPC UA subscription's deadband, and the reason a Sparkplug bus stays quiet over a stable process [5].
  • NCMD / DCMD — the write-back. The flow is not one-way. A primary host (a SCADA supervisory-control system, or an MES — the manufacturing-execution layer) can publish an NCMD or DCMD to write a metric back down — a new setpoint, or the reserved Node Control/Rebirth flag that orders an edge node to re-announce its whole birth.
  • NDEATH / DDEATH — death certificates. The announcement that a node, or a single device beneath it, has gone offline.
  • STATE — the host's own pulse. The primary host publishes its liveness on a STATE topic, so edge nodes can tell whether their consumer-of-record is even listening (more on this below).

The genius is how a node death is delivered. Sparkplug leans on MQTT's Will message: when the edge node connects, it hands the broker its NDEATH payload in advance, registered at QoS 1 so it cannot be silently dropped [4][5]. If the connection then dies — crash, cable pull, power loss — the broker itself publishes that pre-registered death certificate. No polling, no timeout guessing; the network learns within the keep-alive window that BR101 is gone. For a process where a silently dead sensor could mean an unnoticed temperature excursion, that guarantee earns its keep.

Anatomy of a Sparkplug message: topic, envelope, and metric

The OPC UA read arrived wearing a great deal of clothing; a Sparkplug message is cut from the same cloth, just folded differently. The address is on the outside, in the topic; the meaning is inside, in self-describing metrics. Here is a DBIRTH for our bioreactor — the shape Eclipse Tahu produces, shown decoded (the wire itself is compact binary, not text — see below):

topic: spBv1.0/newark/DBIRTH/BR101/reactor
{
"timestamp": 1768759740000,
"seq": 1,
"metrics": [
{ "name": "BR101.Titer.PV", "alias": 1, "datatype": "Float", "value": 4.902, "properties": { "unit": "g/L", "quality": 192 } },
{ "name": "BR101.Temp.PV", "alias": 2, "datatype": "Float", "value": 36.96, "properties": { "unit": "degC", "quality": 192 } }
]
}

Walk it the way a Sparkplug decoder would when it ingests the message — this is the answer to "what form does a Sparkplug reading take":

  • The topic is the address — five rigid fields. spBv1.0 / newark / DBIRTH / BR101 / reactor is namespace / group_id / message_type / edge_node_id / device_id. spBv1.0 literally means Sparkplug B, version 1.0 (the older spAv1.0 is deprecated). The group is our site (lowercase, to match the UNS (Unified Namespace) path convention from Chapter 5); the last field — the device — is present only for device-level messages, so a node-level NBIRTH or NDEATH drops it and rides a four-field topic. That fixed shape is what lets any Sparkplug consumer discover the whole plant by subscribing to spBv1.0/# (the # wildcard matches every topic level below it, while a + matches exactly one level), and it dovetails with the Unified Namespace idea we develop in later chapters.
  • The wire is Protocol Buffers, not JSON. The JSON above is a decoding. On the wire a Sparkplug B payload is a compact Google Protocol Buffers binary blob [5] — which is why it is frugal enough for thin links, and why a consumer needs the Sparkplug schema to read it at all. (The lone exception is the STATE message, which really is JSON text.)
  • The envelope — timestamp and seq. Wrapping the metrics are a payload timestamp and a sequence number seq. The seq is a single byte — eight bits, so 256 distinct values — that counts 0 → 255 and wraps back to 0; the NBIRTH resets it to 0 and every later message adds one. A consumer that sees seq jump — 7 then 9 — knows it missed message 8, and can demand a fresh birth (an NCMD carrying Node Control/Rebirth). It is the bus's built-in gap detector, the rough equivalent of the quality flag that tells an OPC UA reader something is wrong.
  • The metric — name, type, value, and a label card of its own. Each metric carries its name, its datatype (Float here), the value, an optional metric-level timestamp, and a properties set — where our unit (g/L) and quality ride. That is exactly the value-plus-meaning discipline OPC UA packs into a DataValue, only announced rather than answered. Metrics also carry flags Sparkplug defines but our birth leaves at their defaults: is_historical (a back-filled value), is_transient (don't store it), is_null (declared but currently without a value).
  • Aliases — name it once, then send a number. Notice each metric also has an alias (1, 2). The birth spends the bytes to give every metric both its full name and a small integer; from then on the NDATA and DDATA messages send only the alias and the value, never the name again — a titer update collapses to essentially {1: 4.903}. It is the same move as OPC UA browsing a NodeId once and caching it: pay for the identity at birth, then ride cheap.
  • quality: 192 — a guest from an older protocol. That 192 (0xC0) is not an OPC UA status; it is the legacy OPC DA (Classic) Good code. Many Sparkplug edge nodes front a legacy OPC DA server and pass its quality straight through, so you meet 192 on the wire — whereas the OPC UA server we built earlier reports Good as plain 0 (0x00000000), which is exactly what asyncua stamps on each write. Same idea, two lineages.

A Sparkplug DBIRTH drawn as an identity card. A rose header band names the message by its topic, spBv1.0/newark/DBIRTH/BR101/reactor, with the five topic fields labelled namespace, group, message type, edge node and device. Below, an envelope block holds the payload timestamp and the seq sequence number that counts 0 to 255 and wraps. A highlighted metric block shows one metric, BR101.Titer.PV, with its alias, datatype Float, value 4.902, and a properties row carrying unit g/L and quality 192, plus the is_historical, is_transient and is_null flags. A footer note explains that the wire payload is Protocol Buffers binary, not JSON, and that later NDATA messages send only the alias and value. One Sparkplug message, fully unpacked: the topic is the address, the envelope carries a timestamp and a wrapping sequence number, and each metric is its own labelled card — name, alias, type, value, unit, quality, and flags — encoded on the wire as Protocol Buffers, not text. Original diagram by the authors, created with AI assistance.

The session lifecycle: birth, data, command, death

OPC UA's conversation was a point-to-point handshake; Sparkplug's is a broadcast life story, and every chapter of it runs through the broker. The edge node never talks to a consumer directly — it publishes, Mosquitto fans out, and a primary host listens. Walk the whole life of BR101 on the bus:

  1. Connect — and hand over the Will first. The edge node opens an MQTT connection with a keep-alive (say 60 s) and, in the very same CONNECT, registers its NDEATH as the Will — QoS 1, not retained (an MQTT broker holds onto a retained message and replays it to any late subscriber; a death certificate must never be replayed stale, so the Will is deliberately left non-retained — more on this below), carrying a bdSeq (birth/death sequence number). The death certificate is filed before the node has said a single living word.
  2. NBIRTH — announce and reset the clock. The node publishes its NBIRTH with seq = 0 and the same bdSeq it just put in the Will, so a later death can be matched to this birth. Every node-level metric is declared here.
  3. DBIRTH — the device's full inventory. One DBIRTH per device defines every metric with name, datatype, value, and alias. After this the consumer knows the entire shape of BR101 and holds the alias table needed to decode everything that follows.
  4. NDATA / DDATA — report by exception. As the batch runs, only changed values flow — by alias, at QoS 0 (fire-and-forget: the next change supersedes a lost one anyway). The titer climbs 4.902 → 4.903 → 4.905 and nothing else is said.
  5. NCMD / DCMD — the host writes back. When the primary host must act, it publishes a command down through the broker — a setpoint, or Node Control/Rebirth to force steps 2–3 again (exactly what a consumer does after spotting a seq gap).
  6. NDEATH — the broker speaks for the dead. If BR101 stops answering, the broker waits 1.5 × the keep-alive (~90 s here) with no message and no ping, declares the connection dead as if the network had failed, and publishes the NDEATH Will on the node's behalf [4]. Because that Will carries the birth's bdSeq, a host can ignore a stale death that lands after the node has already reconnected and re-birthed.

One actor presides over all of this: the primary host, the single consumer-of-record for an edge node. It advertises its own liveness with a retained STATE message on spBv1.0/STATE/{host_id} — the only Sparkplug message that is both retained and genuine JSON ({"online": true, "timestamp": …}). Retained is an MQTT flag: the broker keeps the last retained message on a topic and hands it to any client the moment it subscribes, so an edge node that joins late still learns the host is online without waiting for the next update. (This is exactly why the NDEATH Will above is registered not retained — a death certificate is only true at the instant it fires, so you never want the broker replaying a stale one to a fresh subscriber.) An edge node can be told to wait for that STATE before it births, and to disconnect and fail over the instant the host goes online: false, so the plant never streams into a historian that isn't listening. Sparkplug 3.0 tightened exactly this seam: it moved STATE under the spBv1.0/ namespace and replaced the old bare "ONLINE"/"OFFLINE" string with the timestamped JSON, so an out-of-order OFFLINE can be told from a real one [5].

A sequence diagram with three lifelines — the BR101 edge node on the left in rose, the Mosquitto broker in the middle in green, and the primary host or historian on the right in violet — stepping through five phases. Connect: the edge node sends CONNECT with a 60-second keep-alive and registers its NDEATH as the Will at QoS 1, not retained, carrying bdSeq. Birth: the edge node publishes NBIRTH with seq 0 and the same bdSeq, then DBIRTH defining every metric with its alias, and the broker fans each birth out to the host. Data: a loop where DDATA carries only changed values by alias at QoS 0, report by exception, fanned out to the host. Command: the host publishes NCMD Node Control Rebirth through the broker down to the edge node. Death: the edge node goes silent, the broker waits 1.5 times the keep-alive then publishes the NDEATH Will to the host, its bdSeq matching the birth.

Securing the bus (the part everyone skips, again)

OPC UA bakes security into its own specification; Sparkplug does the opposite, and is honest about it. The Sparkplug 3.0 security chapter is explicitly non-normative — the protocol defines no encryption and no authentication of its own, and defers the whole job to MQTT and TLS [5]. That is a division of labour more than a flaw, but it means the security is exactly as good as the broker you point it at — and our dev mosquitto.conf, with allow_anonymous true on a plain 1883 listener, is as good as none.

And the field record is every bit as damning as OPC UA's 92%. A 2018 internet-wide scan by Avast found more than 49,000 MQTT brokers reachable on the public internet, over 32,000 of them with no password at all — wide-open buses any stranger could read from or publish to [22]. That study swept smart-home and IoT brokers rather than pharma plants specifically, but the configuration sin is identical, and a bioreactor bus is no less exposed if you forget the same step.

So hardening MQTT is the mirror image of hardening OPC UA, and Chapter 28 does it in full. The moves: a TLS listener (port 8883) so every byte is encrypted in flight; client certificates — or at minimum real username/password, never anonymous; and per-client ACLs that pin each identity to its own slice of the topic tree, so the BR101 edge node may publish only under spBv1.0/newark/+/BR101/#, the historian may only subscribe, and nothing else is permitted. The honest checklist is the same shape as before: encrypt the transport, authenticate the peer, and scope every client to the narrowest topic filter that still does its job. Sparkplug hands you a clean lifecycle for free; the locked door is still yours to fit.

Syntactic vs semantic: moving bytes vs moving meaning

It is worth naming the deepest idea in this chapter. Syntactic transport is moving bytes from A to B without corruption — TLS, TCP, message framing. Both OPC UA and MQTT do this well. Semantic transport is moving meaning: the byte 4.902 is useless unless the receiver also learns it is a titer, in g/L, measured at a known instant, with Good quality. OPC UA carries that semantics in its address space; Sparkplug carries it in DBIRTH metric definitions. A plain MQTT message of 4.902 to topic temp carries syntax but almost no semantics — which is precisely why Sparkplug exists. Throughout the rest of the book, every time data crosses a boundary, the question is the same: did the meaning survive, or just the bytes? What it means for meaning to survive — a shared model every system can point at, expressed as RDF triples governed by a SHACL shape and grounded in an upper ontology — is the subject of Book 2's Why Numbers Don't Connect and Ontologies and FAIR Data, and becomes a queryable graph in this book's knowledge-graph chapter.

The DataValue as a triple, the quality flag as a constraint

It pays to be concrete about what "the meaning survived" looks like once this reading lands in a graph. Our DataValue is, almost literally, a small bundle of RDF triples (the subject-predicate-object facts a knowledge graph is built from): the read of BR101.Titer.PV becomes bp:obs-19009 bp:onTag "BR101.Titer.PV", bp:obs-19009 bp:value "4.902"^^xsd:float, bp:obs-19009 bp:unit "g/L", bp:obs-19009 bp:resultTime "…"^^xsd:dateTime, and bp:obs-19009 bp:statusCode "Good" — the value, unit, timestamp, and quality flag we just transported, each now a first-class fact rather than a column the next system has to guess at. That is exactly the triple anatomy this book's knowledge-graph chapter builds, and the shared model behind the predicates is the worked subject of Book 4's classes and taxonomy and identifiers and units chapters.

The quality flag is where the wire and the graph meet most usefully. A Bad StatusCode or a 192-that-should-be-Good is not just a transport artifact — it is the single fact a downstream SHACL shape (the Shapes Constraint Language, which validates that graph data has the structure you require) can reject before the number reaches an SPC chart or a release calculation. The same sh:minCount/sh:datatype/sh:message machinery that Book 4's release gate uses to demand a complete, in-spec CQA panel applies one rung earlier here: a shape on the observation can insist every landed reading carry exactly one value, a unit, a timestamp, and a Good status, turning "did the quality flag survive?" into a closed-world pass/fail an ingestion pipeline can enforce. And the attributable half of the regulatory story below is itself a graph pattern: each reading is a prov:Entity attributed (prov:wasAttributedTo) to the OPC UA server or Sparkplug edge node that emitted it, so the chain of custody becomes the PROV-O provenance the relations and genealogy and identifiers and units chapters model — who sent what, recorded as triples, not just hoped for on the wire.

Why it matters

This connectivity backbone is the floor everything else stands on. If the transport loses a sample, mislabels a unit, or drops quality flags, then your historian, your SPC charts, and your soft sensor are all faithfully analyzing garbage. Get the transport right — value, unit, timestamp, quality, all preserved, all secured — and the rest of the platform inherits trustworthy inputs for free.

That floor is what makes any learning model on top of it defensible — and three of the transport's fields turn out to be exactly the fields a model later leans on. The timestamp pair (source vs server) is not housekeeping: a soft sensor trained on Raman spectra must be validated by holding out whole batches rather than shuffling rows, because two readings minutes apart in the same batch are near-duplicates that leak a near-copy of the test answer into training — and you can only group readings into batches if every one arrived with an honest sample time. That leave-one-batch-out discipline (scikit-learn's GroupKFold / LeaveOneGroupOut, grouping on the batch key) is the whole argument of Book 5's the learning problem and models and validation chapters; it begins with a clean timestamp on the wire. The quality flag is the model's first applicability-domain signal — a Bad or Uncertain reading is an input the model was never calibrated on, the kind a model should abstain on rather than confidently extrapolate past, exactly as the applicability-domain gate insists. And the same provenance that makes the transport attributable gives a model its lineage: knowing which probe and which server stamped each reading is how you later tell honest model drift (the calibration going stale, a probe slowly fouling) apart from real process drift (the living culture genuinely shifting campaign to campaign) — the distinction the MLOps and lifecycle chapter builds its two drift detectors around. A reading that arrives without its unit, timestamp, or quality has already destroyed the evidence those checks depend on; the transport either preserves it or the analytics that follow are flying blind.

There is a regulatory edge too. Both EU GMP Annex 11 and FDA 21 CFR Part 11 — the two rulebooks that govern electronic records and electronic signatures in a GxP plant — expect controls that preserve the authenticity and integrity of records as they move between systems — a documented chain of custody, not just a hopeful network [11][12]. A signed-and-encrypted OPC UA channel with a reviewed trust list, or a TLS-secured Sparkplug bus with per-client ACLs, is how you make the transport attributable — you can say who sent what and prove it wasn't tampered with in flight. The insecure-by-default configs we showed are explicitly the thing an inspector would flag. What Annex 11 and Part 11 actually require of an open-source stack — and how attributability and the rest of ALCOA+ are built in by construction — is the subject of Electronic Records & Signatures: Part 11 / Annex 11 with Open Source and ALCOA+ by Construction: Integrity in Code.

In the real world

Walk onto a modern mAb floor and you will find OPC UA almost everywhere control systems meet the IT world — Emerson DeltaV, Siemens PCS 7, and AVEVA PI all speak it — while MQTT/Sparkplug increasingly carries high-fan-out telemetry and edge data. Our fed-batch CHO + Protein A line — fed-batch being the finite batch where nutrients are fed in over the run and the culture is harvested at the end; Protein A is the resin step that captures the antibody — is the dominant approved-antibody modality, and its sensors have been talking OPC for two decades. The intensified/continuous variant — perfusion (the continuous feed-and-bleed mode covered in Book 1) with multi-column capture — only multiplies the tag count: perfusion adds continuous cell-retention (ATF/TFF), a bleed-rate and a perfusion-rate controller, plus a multi-column capture skid whose every column carries its own UV280, conductivity, and pH probes — each a new tag. That is exactly when a lightweight Sparkplug bus earns its place beside OPC UA.

But our demo is one bioreactor; a real suite is many — and a DCS (Distributed Control System) sits in the middle. A commercial floor never runs a lone reactor: a seed train feeds several production vessels, and our opcua_server.py, which exposes a single BR101, is the teaching-sized slice of that. The piece that scales it is the DCS — DeltaV here. Each bioreactor's probes and actuators are wired straight into DeltaV's field I/O, so DeltaV is not a remote client polling each reactor; it is the control system the reactors are wired into, with every vessel configured as its own ISA-88 Unit (BR101, BR102, BR103, …). One DeltaV runs the whole suite.

OPC UA enters one layer up, between the DCS and the IT world, and this is where the client/server roles trip people up. DeltaV runs the OPC UA server — in most plants a wrapper that re-presents DeltaV's older OPC Classic interfaces as OPC UA, a single endpoint whose address space holds every Unit as a sibling subtree (BR101.Titer.PV, BR102.Titer.PV, …). The clients are the consumers: a historian (AVEVA PI), an MES, or the open-source collector this book builds. They all dial that one endpoint and browse or subscribe to whichever Units they need — one server, many clients, every reactor in a single tree. That is the honest mapping of our single-BR101 demo onto a real plant: not one server per vessel, but one DCS aggregating them and re-exposing the lot. (What that wrapper looks like inside — its live-data, alarm, and history faces, DeltaV's three internal historians, and the read-only seam an analytics stack reads it across — is the subject of Bridging to DCS, MES & ERP, where we actually pull DeltaV in.)

So is the OPC UA client "embedded in the bioreactor"? Almost never. What can be embedded in or beside a modern reactor is a server: a single-use skid (a pre-assembled, frame-mounted equipment package — Sartorius, Cytiva, Thermo) often ships its own controller running an OPC UA server — natively on newer lines, or an OPC Classic server behind a UA gateway on others — and there DeltaV (or a small edge gateway) is the client that pulls the skid in and re-exposes it in the same address space. The rule that survives every topology: the thing at the reactor is a data source, so if it speaks OPC UA it is a server; the client always lives with the consumer.

A three-stage architecture diagram. On the left, three bioreactors BR101, BR102 and BR103, plus more, are stacked as boxes carrying field instruments, each hardwired into the field I/O of a central DeltaV DCS. Inside DeltaV an address-space tree holds each reactor as an ISA-88 Unit, and a single OPC UA server endpoint gathers them all. On the right, three OPC UA clients — a historian (AVEVA PI), an MES, and an open-source collector — each connect to that one endpoint and browse every unit. A footer notes the single-use skid variant, where the skid hosts its own embedded OPC UA server and DeltaV is the client. One DeltaV, many reactors: each bioreactor is wired in as its own ISA-88 Unit, and the DCS re-exposes them all through a single OPC UA server that a historian, an MES, and an open-source collector each read as clients. Original diagram by the authors, created with AI assistance.

And the honest verdict for this layer is unusually kind to open source. The OSS stacks here are genuinely production-grade: open62541 [6], node-opcua [7], and asyncua [8] are real OPC UA implementations; Mosquitto [9] and Tahu [10] are mature Eclipse projects. You can build the entire transport backbone on them with no commercial license. What OSS does not hand you is the GxP last mile: a managed PKI for certificate rotation and revocation, a vendor on the hook when an auditor calls, and validation evidence out of the box. No OSS broker or stack is 21 CFR Part 11-compliant by default — compliance is a property of your configured, validated system, not of the download. The wire is open; the certificate lifecycle, the ACL review, and the validation are work you own — what that validated, compliant configuration actually looks like is the subject of Electronic Records & Signatures: Part 11 / Annex 11 with Open Source. Watch the licenses as you scale, too: the OPC UA stacks here are permissive (MPL/MIT/LGPL) and Mosquitto/Tahu are EPL/EDL, but the commercial broker EMQX moved to a BSL (Business Source License) — which, unlike the permissive licenses above, restricts production use until a time-delayed conversion date — so don't assume every MQTT option is free for production.

Key terms

  • OPC UA / IEC 62541 — a self-describing industrial protocol whose address space carries value plus type, unit, timestamp, and quality [1].
  • Address space — the browsable tree of nodes (objects and variables) an OPC UA server exposes; the source of self-description.
  • NodeId — a node's unique, server-scoped address, written ns=<index>;<type>=<id>, where the type flag is i numeric, s string, g GUID, or b opaque; clients browse to discover NodeIds rather than guessing them [13].
  • DataValue — the structure a Variable read returns: the value (in a self-typed Variant) plus a StatusCode, a SourceTimestamp, and a ServerTimestamp — value, quality, and time as one unit, never a bare number.
  • EngineeringUnits / EURange — Properties hung off a Variable (via HasProperty) that make the unit (an EUInformation, e.g. g/L) and the expected span (a Range, Low/High) browsable metadata rather than convention [15].
  • Companion specification — a standardised OPC UA information model layered on the base meta-model (IEC 62541), fixing the object types and semantics for a domain or device class so conformant devices expose the same shape; examples include PA-DIM (process instruments), PackML (packaging), and LADS (lab devices). No such standard yet exists for a bioreactor, which is why our server uses a custom address space [16].
  • SecureChannel vs Session — the SecureChannel is the signed/encrypted transport tunnel (OpenSecureChannel); the Session is the application conversation on top (CreateSession + ActivateSession, where the user identity is checked). A session can be re-bound to a new channel after a drop [14].
  • Subscription / MonitoredItem — the change-driven path: a Subscription (with a publishing interval) holds one or more MonitoredItems (each with a sampling interval and optional deadband); the server returns changed values by answering the client's open Publish requests, instead of being polled [14].
  • Quality flag — a status code travelling with every reading so consumers know whether to trust it. OPC UA's Good is StatusCode 0 (0x00000000), while the legacy OPC DA (Classic) Good code is 192 (0xC0) — the value a Sparkplug bridge fronting a DA server often passes through.
  • Basic256Sha256 — the modern OPC UA security policy (SHA-256, 2048-bit+ RSA) for signed-and-encrypted channels [2].
  • Trust list — the explicit set of peer certificates a server will accept; the step most field deployments botch [3].
  • MQTT — a lightweight publish/subscribe protocol with a central broker; OASIS/ISO 20922 [4]. Each message has a QoS: 0 (at most once, fire-and-forget), 1 (at least once), or 2 (exactly once); Sparkplug uses QoS 0 for data and QoS 1 for the NDEATH Will and STATE.
  • Broker — the MQTT server (here, Mosquitto) that fans published messages out to subscribers.
  • Will message — an MQTT message the broker publishes on a client's behalf when it disconnects unexpectedly (no ping within 1.5× the keep-alive); the basis of Sparkplug death certificates [4].
  • Sparkplug B — an open spec adding a strict topic namespace and a birth/death lifecycle to MQTT; payloads are encoded as Google Protocol Buffers, not JSON [5].
  • NBIRTH / DBIRTH / NDATA / DDATA / NCMD / DCMD / NDEATH / DDEATH — Sparkplug's node/device verbs: births define every metric, DATA reports only changed values (report by exception), CMD writes a metric back from the host, deaths announce going offline.
  • seq / bdSeq — the payload sequence number (seq, 0→255 then wraps; a gap signals a missed message and triggers a rebirth) and the birth/death sequence number (bdSeq, equal in an NBIRTH and its NDEATH Will, so a stale death can be matched to the right birth) [5].
  • Alias — a small integer a birth assigns to each metric alongside its name; later DATA messages send only the alias and value to save bandwidth, the way a client caches an OPC UA NodeId after browsing it once.
  • Primary host / STATE — the single consumer-of-record for an edge node; it publishes a retained JSON STATE (online + timestamp) so edge nodes know whether anyone is listening and can fail over if it goes offline [5].
  • Syntactic vs semantic transport — moving bytes safely vs moving meaning (value + unit + time + quality) intact.
  • RDF triple / SHACL shape / PROV-O — once a DataValue lands in a knowledge graph it becomes RDF triples (subject-predicate-object facts: value, unit, timestamp, quality); a SHACL shape validates that every landed reading carries them (the closed-world gate Book 4's release gate reuses), and PROV-O records each reading as a prov:Entity attributed to the server or edge node that emitted it — the attributable chain of custody as data.
  • Leave-one-batch-out validation / applicability domain — grouping readings by batch (using their honest timestamps) so a soft sensor is graded on held-out batches rather than leaking within-batch neighbours; the quality flag doubles as an applicability-domain signal, marking a reading the model was never calibrated on. Grounded in Book 5's the learning problem and MLOps and lifecycle.

Where this leads

Our bioreactor now speaks fluent OT — OPC UA for rich, secure, browsable reads, and Sparkplug B over MQTT for self-announcing, fan-out telemetry. But raw floor traffic rarely flows straight into a historian; it gets filtered, reshaped, buffered, and routed first. The next chapter, The Edge Gateway: Routing Floor Data with Node-RED, Telegraf & NiFi, builds that middle layer — the open-source plumbing that pulls from these protocols and delivers clean, contextualized streams to everything downstream.