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,
},
},
],
| Option | Type | Default | Description |
|---|---|---|---|
endpoint_url | string | — | Required. opc.tcp://host:port |
nodes | Array<string | OpcUaNodeSpec> | — | Required. Variables to monitor. See below |
publishing_interval | number | 1000 | How often the server publishes collected samples, in ms |
sampling_interval | number | publishing_interval | How often the server samples the nodes, in ms |
queue_size | number | 1 | Samples buffered per node between publications |
security_mode | "None" | "Sign" | "SignAndEncrypt" | "None" | Message security to negotiate |
security_policy | string | "None" | Short name or full policy URI |
username | string | anonymous | User to authenticate as |
password | string | — | Goes with username |
application_name | string | "livelink-agent" | Announced to the server, carried by the client certificate |
max_retry | number | 3 | Connection attempts before giving up |
Each node is either a bare node id string, or an OpcUaNodeSpec:
| Field | Type | Description |
|---|---|---|
node_id | string | Required, e.g. ns=3;s="DB_Line1"."Temperature" or ns=2;i=1042 |
channel | string | Channel to publish this node's samples on. Defaults to the node id |
sampling_interval | number | Overrides 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 thatqueue_size: 1then 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.