Connect OPC UA servers

Read an internet-reachable OPC UA server, or push from a plant server with Node-RED or Python.

There are two ways to bring OPC UA data in, depending on where the server is.

OPC UA options
Cloud connectorOn site, push
ServerReachable from the internet (cloud-hosted, or published through a firewall rule or reverse proxy)Inside the plant network, no inbound access
HowInduQT connects and samples the nodes you listNode-RED or a Python script on a PC in the plant reads the server and posts to the ingest API
PlanPlant Monitor or EnterpriseEvery plan
FirewallInbound to the OPC UA portOutbound HTTPS (443) only

Most plants should use the on-site option: nothing in the plant is exposed to the internet.

Cloud connector

  1. Create the site and machines

    Create them in the app or with POST /v1/sites and POST /v1/machines. Set each machine's externalId to the name your source uses (for example B-101), so messages can address it without knowing InduQT ids.

  2. Find the node ids

    Browse the server with UaExpert or a similar client and note the node id of each tag, e.g. ns=2;s=Boiler1.SteamPressure.

  3. Add the connector

    In the app: open the site and choose OPC UA server. Or with the API:

    POST /v1/connectors
    curl -X POST "https://api.induqt.ai/v1/connectors" \
      -H "Authorization: Bearer $INDUQT_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
      "siteId": "6f1c2c0e-4b1a-4c55-9d0e-1f2a3b4c5d6e",
      "type": "opcua",
      "name": "Boiler PLC",
      "config": {
        "endpoint": "opc.tcp://opcua.example.com:4840",
        "intervalMs": 5000,
        "nodes": [
          {
            "nodeId": "ns=2;s=Boiler1.SteamPressure",
            "machine": "B-101",
            "signal": "steam_pressure"
          }
        ]
      }
    }'
OPC UA server settings
NameDescription
endpointrequiredopc.tcp://host:port/path, reachable from the internet. For servers inside the plant network, use Node-RED or the ingest API from a machine on site.
securityModeNone, Sign or SignAndEncrypt. Default None.
username / passwordOptional user identity; encrypted at rest.
intervalMsSampling interval, 1,000–60,000 ms. Default 5,000.
nodesrequiredList of { nodeId, machine, signal }: e.g. { "nodeId": "ns=2;s=Boiler1.SteamPressure", "machine": "B-101", "signal": "steam_pressure" }.

Mapping. Each node names its machine (id or externalId) and signal key.

On site with Node-RED

  1. Install the OPC UA nodes

    In Node-RED, Manage palette → Install node-red-contrib-opcua, or npm install node-red-contrib-opcua in the Node-RED user directory.

  2. Create an API key

    In Settings → API keys, create a key with the ingest scope, limited to the site the data belongs to. See site-scoped keys.

  3. Set the key in Node-RED's environment

    For example docker run -e INDUQT_API_KEY=iq_live_… nodered/node-red, or Environment=INDUQT_API_KEY=… in the systemd unit.

  4. Import the flow

    Menu → Import, paste the flow below, change the endpoint, node ids and machine, then Deploy. The debug node shows each response.

Node-RED flow: OPC UA → InduQT
[
  {
    "id": "iq_tab_opcua",
    "type": "tab",
    "label": "OPC UA → InduQT",
    "disabled": false,
    "info": "Reads boiler tags from the plant OPC UA server every 5 seconds and posts them to the InduQT ingest API. Needs node-red-contrib-opcua and INDUQT_API_KEY in the environment."
  },
  {
    "id": "iq_opc_inject",
    "type": "inject",
    "z": "iq_tab_opcua",
    "name": "Every 5 s",
    "props": [
      {
        "p": "payload"
      }
    ],
    "repeat": "5",
    "crontab": "",
    "once": true,
    "onceDelay": "2",
    "topic": "",
    "payload": "",
    "payloadType": "date",
    "x": 130,
    "y": 140,
    "wires": [
      [
        "iq_opc_item1",
        "iq_opc_item2",
        "iq_opc_item3"
      ]
    ]
  },
  {
    "id": "iq_opc_item1",
    "type": "OpcUa-Item",
    "z": "iq_tab_opcua",
    "item": "ns=2;s=Boiler1.SteamPressure",
    "datatype": "Double",
    "value": "",
    "name": "ns=2;s=Boiler1.SteamPressure",
    "x": 350,
    "y": 80,
    "wires": [
      [
        "iq_opc_client"
      ]
    ]
  },
  {
    "id": "iq_opc_item2",
    "type": "OpcUa-Item",
    "z": "iq_tab_opcua",
    "item": "ns=2;s=Boiler1.SteamFlow",
    "datatype": "Double",
    "value": "",
    "name": "ns=2;s=Boiler1.SteamFlow",
    "x": 350,
    "y": 140,
    "wires": [
      [
        "iq_opc_client"
      ]
    ]
  },
  {
    "id": "iq_opc_item3",
    "type": "OpcUa-Item",
    "z": "iq_tab_opcua",
    "item": "ns=2;s=Boiler1.FlueO2",
    "datatype": "Double",
    "value": "",
    "name": "ns=2;s=Boiler1.FlueO2",
    "x": 350,
    "y": 200,
    "wires": [
      [
        "iq_opc_client"
      ]
    ]
  },
  {
    "id": "iq_opc_client",
    "type": "OpcUa-Client",
    "z": "iq_tab_opcua",
    "endpoint": "iq_opc_endpoint",
    "action": "read",
    "deadbandtype": "a",
    "deadbandvalue": 1,
    "time": 10,
    "timeUnit": "s",
    "certificate": "n",
    "localfile": "",
    "localkeyfile": "",
    "securitymode": "None",
    "securitypolicy": "None",
    "useTransport": false,
    "maxChunkCount": 1,
    "maxMessageSize": 8192,
    "receiveBufferSize": 8192,
    "sendBufferSize": 8192,
    "name": "Read tags",
    "x": 590,
    "y": 140,
    "wires": [
      [
        "iq_opc_map"
      ],
      []
    ]
  },
  {
    "id": "iq_opc_map",
    "type": "function",
    "z": "iq_tab_opcua",
    "name": "Map to InduQT",
    "func": "// Map each OPC UA node to an InduQT signal key.\nconst signals = {\n  'ns=2;s=Boiler1.SteamPressure': 'steam_pressure',\n  'ns=2;s=Boiler1.SteamFlow': 'steam_flow',\n  'ns=2;s=Boiler1.FlueO2': 'flue_o2'\n};\nconst key = signals[msg.topic];\nif (!key) return null;\n\n// Depending on the node version, payload is the value or a DataValue.\nlet v = msg.payload;\nif (v && typeof v === 'object' && 'value' in v) v = v.value && typeof v.value === 'object' && 'value' in v.value ? v.value.value : v.value;\nif (typeof v === 'boolean') v = v ? 1 : 0;\nif (typeof v !== 'number' || !isFinite(v)) return null;\n\nmsg.headers = {\n  'Authorization': 'Bearer ' + env.get('INDUQT_API_KEY'),\n  'Content-Type': 'application/json'\n};\nmsg.payload = { machine: 'B-101', ts: new Date().toISOString(), values: { [key]: v } };\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 790,
    "y": 140,
    "wires": [
      [
        "iq_opc_post"
      ]
    ]
  },
  {
    "id": "iq_opc_post",
    "type": "http request",
    "z": "iq_tab_opcua",
    "name": "POST /v1/ingest",
    "method": "POST",
    "ret": "obj",
    "paytoqs": "ignore",
    "url": "https://api.induqt.ai/v1/ingest",
    "tls": "",
    "persist": false,
    "proxy": "",
    "insecureHTTPParser": false,
    "authType": "",
    "senderr": false,
    "headers": [],
    "x": 990,
    "y": 140,
    "wires": [
      [
        "iq_opc_debug"
      ]
    ]
  },
  {
    "id": "iq_opc_debug",
    "type": "debug",
    "z": "iq_tab_opcua",
    "name": "InduQT response",
    "active": true,
    "tosidebar": true,
    "console": false,
    "tostatus": true,
    "complete": "payload",
    "targetType": "msg",
    "statusVal": "statusCode",
    "statusType": "msg",
    "x": 1190,
    "y": 140,
    "wires": []
  },
  {
    "id": "iq_opc_endpoint",
    "type": "OpcUa-Endpoint",
    "endpoint": "opc.tcp://192.168.10.20:4840",
    "secpol": "None",
    "secmode": "None",
    "none": true,
    "login": false,
    "usercert": false,
    "usercertificate": "",
    "userprivatekey": ""
  }
]

On site with Python

The same job as a short script with asyncua and requests. Run it as a service on a PC that can reach the server.

opcua_to_induqt.py
# pip install asyncua requests
import asyncio, os
from datetime import datetime, timezone

import requests
from asyncua import Client

ENDPOINT = "opc.tcp://192.168.10.20:4840"      # the plant server, reachable from this PC
INGEST = "https://api.induqt.ai/v1/ingest"
KEY = os.environ["INDUQT_API_KEY"]             # ingest-scoped, site-scoped key
MACHINE = "B-101"                              # machine externalId in InduQT
NODES = {                                      # OPC UA node id -> InduQT signal key
    "ns=2;s=Boiler1.SteamPressure": "steam_pressure",
    "ns=2;s=Boiler1.SteamFlow": "steam_flow",
    "ns=2;s=Boiler1.FlueO2": "flue_o2",
}


def post(values):
    body = {"machine": MACHINE, "ts": datetime.now(timezone.utc).isoformat(), "values": values}
    r = requests.post(INGEST, json=body, headers={"Authorization": f"Bearer {KEY}"}, timeout=10)
    if r.status_code >= 300:
        print("InduQT", r.status_code, r.text)


async def main():
    async with Client(url=ENDPOINT) as client:
        nodes = {key: client.get_node(node_id) for node_id, key in NODES.items()}
        while True:
            values = {}
            for key, node in nodes.items():
                v = await node.read_value()
                if isinstance(v, (int, float, bool)):
                    values[key] = float(v)
            if values:
                await asyncio.to_thread(post, values)
            await asyncio.sleep(5)


asyncio.run(main())

Reading many tags? Batch several machines per request with readings; see the ingest API.

Troubleshooting

Troubleshooting
SymptomLikely cause and fix
Cannot connect (cloud)The endpoint is not reachable from the internet, or the server advertises an internal hostname in its endpoint description. Configure the server to advertise its public name.
BadSecurityChecksFailed or certificate rejectedWith Sign or SignAndEncrypt the server must trust the client certificate. Move it from the rejected to the trusted folder on the server.
BadNodeIdUnknownThe node id is wrong. Copy it exactly from the browser, including ns= and the identifier type (s=, i=, g=).
BadUserAccessDeniedAnonymous access is off or the user lacks read rights on the node.
Values arrive as 0/1Boolean tags are stored as 0 and 1. Strings and arrays are skipped.
Node-RED: 401 from InduQTINDUQT_API_KEY is not set in the Node-RED process, so the header reads Bearer undefined. Restart Node-RED after setting it.