Skip to main content

OPC UA

Subscribes to variables on an OPC UA server over opc.tcp:// — the classic client/server profile a PLC exposes — and publishes each value change as an event.

When to use it

Use it when the server offers no other way in.

If the plant already bridges OPC UA to MQTT — OPC UA PubSub over MQTT on recent firmware, Telegraf's inputs.opcua, Kepware, Ignition — point the mqtt transport at that broker instead. There is no OPC UA session to keep alive next to the scene, and it runs in a browser. This transport is for the servers that offer no such bridge.

Install

npm install node-opcua-client

Unlike the other optional peers, this one is not a development dependency of the package: its dependency tree is large, and the transport uses a handful of its methods. Install it yourself in your project.

Configuration

sources: [
{
kind: "opcua",
config: {
endpoint_url: "opc.tcp://plc.example.com:4840",
nodes: [
{ node_id: 'ns=3;s="DB_Line1"."Temperature"', channel: "plc/line1/temperature" },
{ node_id: 'ns=3;s="DB_Line1"."MotorSpeed"', channel: "plc/line1/speed" },
],
publishing_interval: 500,
},
},
],
OptionTypeDefaultDescription
endpoint_urlstringRequired. opc.tcp://host:port
nodesArray<string | OpcUaNodeSpec>Required. Variables to monitor. See below
publishing_intervalnumber1000How often the server publishes collected samples, in ms
sampling_intervalnumberpublishing_intervalHow often the server samples the nodes, in ms
queue_sizenumber1Samples buffered per node between publications
security_mode"None" | "Sign" | "SignAndEncrypt""None"Message security to negotiate
security_policystring"None"Short name or full policy URI
usernamestringanonymousUser to authenticate as
passwordstringGoes with username
application_namestring"livelink-agent"Announced to the server, carried by the client certificate
max_retrynumber3Connection attempts before giving up

Each node is either a bare node id string, or an OpcUaNodeSpec:

FieldTypeDescription
node_idstringRequired, e.g. ns=3;s="DB_Line1"."Temperature" or ns=2;i=1042
channelstringChannel to publish this node's samples on. Defaults to the node id
sampling_intervalnumberOverrides the transport-wide sampling interval for this node alone

Alias your node ids

This is the single most useful thing you can do on this page.

A raw node id is one opaque segment full of ;, = and quotes. Channel patterns match over /-separated segments, so a mapping could never select on it:

// Unusable as a channel selector.
nodes: ['ns=3;s="DB_Line1"."Temperature"'],

Give each node a channel and the mappings read like MQTT topics:

nodes: [
{ node_id: 'ns=3;s="DB_Line1"."Temperature"', channel: "plc/line1/temperature" },
{ node_id: 'ns=3;s="DB_Line2"."Temperature"', channel: "plc/line2/temperature" },
],
// ...and now this works:
const mapping: EventMapping = { channel: "plc/+/temperature" /* ... */ };

What arrives

Each value change becomes one event:

{
channel: "plc/line1/temperature", // the alias, or the node id
payload: { node_id: 'ns=3;s="DB_Line1"."Temperature"', value: 62.5, status: "Good" },
received_at: Date,
source_timestamp: Date, // the server's own clock
}

A mapping reads the value off the payload:

updates: event => {
const { value } = event.payload as { value: unknown };
if (typeof value !== "number") {
return null;
}
return { id: "line1", update: { local_transform: { scale: [1, value / 100, 1] } } };
},

Values are decoded to something a component can hold: 64-bit integers are recombined from the two 32-bit halves the protocol carries them in, and typed arrays become plain arrays.

Samples whose status is Bad carry no meaningful value and are dropped rather than written into the scene, logging once per node instead of at every publication.

Sampling and publishing

Two intervals, and they mean different things:

  • sampling_interval — how often the server reads the underlying variable.
  • publishing_interval — how often the server sends what it has collected. This is the latency floor of the whole ingestion; sampling faster than this only fills a queue that queue_size: 1 then discards.

Both are requests. The server answers with the intervals it will actually honour, and those are the ones that hold. Most servers refuse to go below 50 ms. A request the server revises upward is logged once rather than absorbed silently, because everything downstream of a halved sample rate looks like a bug somewhere else.

Match your source's own cycle: a PLC that updates a variable once per 25 ms scan cycle has nothing more to give, whatever you ask for.

Security

security_mode and security_policy default from each other: naming a policy implies SignAndEncrypt, and asking for Sign or SignAndEncrypt without a policy implies Basic256Sha256. Either both are "None", or neither is.

config: {
endpoint_url: "opc.tcp://plc.example.com:4840",
nodes: [/* ... */],
security_mode: "SignAndEncrypt", // implies Basic256Sha256
username: "operator",
password: process.env.PLC_PASSWORD,
}

Finding the namespace index

Node ids embed a namespace index — the 3 in ns=3;s="DB1"."Temp" — and that index is assigned by the server at startup. It can differ between servers, and between firmware versions of the same server.

The URI behind it is stable, and every OPC UA server publishes the mapping between the two in a standard node. Reading it at startup costs one short-lived session and saves your users from wondering why nothing arrives:

const { OPCUAClient, AttributeIds } = await import("node-opcua-client");
const client = OPCUAClient.create({ endpointMustExist: false });
await client.connect(ENDPOINT_URL);
const session = await client.createSession();

// Server_NamespaceArray: the namespace URIs, in index order.
const { value } = await session.read({ nodeId: "ns=0;i=2255", attributeId: AttributeIds.Value });
const namespace_index = (value.value as Array<string>).indexOf("http://example.com/MyNamespace/");

await session.close();
await client.disconnect();

Connection failures

max_retry is deliberately bounded (three attempts by default). Sources are started sequentially and awaited, so a transport retrying forever would hang the whole ingestion instead of reporting.

A failed start surfaces on the ingestion's on-error event and is retried by the next session to bind — which is the loop that actually survives a PLC being down when the agent comes up.

Try it

The OPC UA Ingestion sample agent drives a machine cell from Microsoft's simulated PLC, iot-edge-opc-plc, which needs no PLC of your own:

docker run --rm -it -p 50000:50000 -p 8080:8080 --name opcplc \
mcr.microsoft.com/iotedge/opc-plc:latest \
--pn=50000 --autoaccept --unsecuretransport --ct=50 --sc=100

It publishes a handful of deterministic signals — counters, sine waves carrying injected anomalies, a toggling boolean — which the sample's agent maps onto the parts of a small machine cell it spawns itself.