Skip to main content

ControlForge OPC UA Protocol Guide

James M. Belcher Founder, JMB Technical Services LLC April 2026 | ControlForge v1.0.533


1. Architecture Overview

ControlForge implements OPC UA as both a client and a server, giving you two distinct roles in a single runtime. The client connects outward to PLCs, historians, and SCADA gateways (Kepware, Ignition, Beckhoff TwinCAT). The server exposes ControlForge's variables as OPC UA nodes so that any compliant client can read and subscribe.

Both roles are programmed entirely in IEC 61131-3 Structured Text. No external configuration files are required — you can create connections, map nodes, and start servers from ST code alone (though YAML config is also supported).

RoleDirectionUse Case
ClientControlForge --> remote serverRead sensors from Kepware, write setpoints to Ignition, browse Beckhoff address space
ServerRemote client --> ControlForgeExpose process data to SCADA, let Ignition poll ControlForge tags, feed a historian

System Diagram


2. OPC UA Client

The client side provides 20 functions covering the full lifecycle: create, connect, map nodes, read/write typed values, browse the remote address space, and clean up.

2.1 Connection Management

OPCUA_CLIENT_CREATE -- Create Client Instance

(* Minimal — no security *)
ok := OPCUA_CLIENT_CREATE('kepware', 'opc.tcp://10.0.0.50:49320');

(* With security policy and mode *)
ok := OPCUA_CLIENT_CREATE('ignition', 'opc.tcp://10.0.0.60:4840',
'Basic256Sha256', 'SignAndEncrypt');
ParamTypeDescription
nameSTRINGUnique instance name (used by all subsequent calls)
endpointSTRINGFull OPC UA endpoint URL
policySTRING(optional) Security policy: 'None', 'Basic256Sha256'
modeSTRING(optional) Message security mode: 'None', 'Sign', 'SignAndEncrypt'

Returns TRUE on success. Fails if the name is already in use.

Security defaults: When policy and mode are omitted, the client connects with SecurityPolicy#None and MessageSecurityMode#None. This is fine for isolated plant networks but should never be used across untrusted network segments.

OPCUA_CLIENT_CONNECT -- Establish Session

IF OPCUA_CLIENT_CONNECT('kepware') THEN
state := 10; (* connected *)
END_IF;

Opens a TCP connection and activates an OPC UA session. This is a blocking call — it will return FALSE if the server is unreachable or rejects the security handshake.

OPCUA_CLIENT_DISCONNECT -- Close Session

OPCUA_CLIENT_DISCONNECT('kepware');

Gracefully closes the session and TCP connection. The client instance remains configured and can be reconnected.

OPCUA_CLIENT_IS_CONNECTED -- Check Session State

IF NOT OPCUA_CLIENT_IS_CONNECTED('kepware') THEN
(* reconnect logic *)
state := 1;
END_IF;

Returns TRUE if the session is active. Use this in your scan loop to detect dropped connections.

OPCUA_CLIENT_DELETE -- Destroy Client Instance

OPCUA_CLIENT_DELETE('kepware');

Disconnects (if connected) and frees all resources. The instance name becomes available for reuse.

OPCUA_CLIENT_LIST -- Enumerate All Clients

clients := OPCUA_CLIENT_LIST();
(* Returns: ['kepware', 'ignition', 'beckhoff'] *)

Returns an array of all client instance names. Useful for diagnostics and cleanup.

OPCUA_CLIENT_GET_ENDPOINT -- Get Configured Endpoint

url := OPCUA_CLIENT_GET_ENDPOINT('kepware');
(* Returns: 'opc.tcp://10.0.0.50:49320' *)

2.2 Node Management

OPCUA_CLIENT_ADD_NODE -- Map a Remote Node to a Local Tag

ok := OPCUA_CLIENT_ADD_NODE('kepware', 'Temperature',
'ns=2;s=Channel1.Device1.Temperature',
'REAL', FALSE);

ok := OPCUA_CLIENT_ADD_NODE('kepware', 'Setpoint',
'ns=2;s=Channel1.Device1.Setpoint',
'REAL', TRUE);
ParamTypeDescription
nameSTRINGClient instance name
tagSTRINGLocal tag name for this mapping
nodeIDSTRINGOPC UA Node ID (e.g. 'ns=2;s=Tag1' or 'ns=2;i=1001')
dataTypeSTRINGExpected data type: 'BOOL', 'INT', 'REAL', 'STRING'
writableBOOLTRUE if this node should be writable

Returns TRUE on success. Node mappings persist for the lifetime of the client instance.

Node ID formats: OPC UA supports several Node ID encodings. The two most common are string-based (ns=2;s=MyTag) used by Kepware and Ignition, and numeric (ns=2;i=1001) used by Beckhoff and Siemens. The ns is the namespace index — namespace 0 is the OPC UA standard namespace, namespace 2+ are vendor/user-defined.

OPCUA_CLIENT_GET_MAPPINGS -- List All Node Mappings

mappings := OPCUA_CLIENT_GET_MAPPINGS('kepware');
(* Returns: [
{"tag": "Temperature", "nodeID": "ns=2;s=Channel1.Device1.Temperature",
"dataType": "REAL", "writable": false},
{"tag": "Setpoint", "nodeID": "ns=2;s=Channel1.Device1.Setpoint",
"dataType": "REAL", "writable": true}
] *)

Returns an array of maps describing each registered node mapping.


2.3 Reading Nodes

ControlForge provides both a generic read and four typed reads. The typed reads avoid the overhead of runtime type inspection and return the correct ST data type directly.

OPCUA_CLIENT_READ_NODE -- Generic Read (Any Type)

value := OPCUA_CLIENT_READ_NODE('kepware', 'ns=2;s=Channel1.Device1.Temperature');

Returns the node value as ANY. The runtime infers the OPC UA data type from the server's response.

OPCUA_CLIENT_READ_BOOL -- Read Boolean

running := OPCUA_CLIENT_READ_BOOL('kepware', 'ns=2;s=Channel1.Device1.Running');

OPCUA_CLIENT_READ_INT -- Read Integer

speed := OPCUA_CLIENT_READ_INT('kepware', 'ns=2;s=Channel1.Device1.SpeedRPM');

Handles OPC UA Int16, Int32, UInt16, and UInt32 transparently.

OPCUA_CLIENT_READ_REAL -- Read Floating Point

temp := OPCUA_CLIENT_READ_REAL('kepware', 'ns=2;s=Channel1.Device1.Temperature');

Handles both OPC UA Float and Double.

OPCUA_CLIENT_READ_STRING -- Read String

product := OPCUA_CLIENT_READ_STRING('kepware', 'ns=2;s=Channel1.Device1.ProductID');

2.4 Writing Nodes

Symmetric to reads. Each write returns TRUE on success.

OPCUA_CLIENT_WRITE_NODE -- Generic Write

ok := OPCUA_CLIENT_WRITE_NODE('kepware', 'ns=2;s=Channel1.Device1.Setpoint', 72.5);

OPCUA_CLIENT_WRITE_BOOL

ok := OPCUA_CLIENT_WRITE_BOOL('kepware', 'ns=2;s=Channel1.Device1.Enable', TRUE);

OPCUA_CLIENT_WRITE_INT

ok := OPCUA_CLIENT_WRITE_INT('kepware', 'ns=2;s=Channel1.Device1.SpeedCmd', 1750);

OPCUA_CLIENT_WRITE_REAL

ok := OPCUA_CLIENT_WRITE_REAL('kepware', 'ns=2;s=Channel1.Device1.TempSetpoint', 75.0);

OPCUA_CLIENT_WRITE_STRING

ok := OPCUA_CLIENT_WRITE_STRING('kepware', 'ns=2;s=Channel1.Device1.Recipe', 'BATCH_42');

2.5 Browsing the Address Space

OPCUA_CLIENT_BROWSE -- Discover Child Nodes

children := OPCUA_CLIENT_BROWSE('kepware', 'ns=2;s=Channel1.Device1');
(* Returns: ['Temperature', 'Pressure', 'SpeedRPM', 'Running', 'Setpoint'] *)
ParamTypeDescription
nameSTRINGClient instance name
nodeIDSTRINGParent node to browse from. Use 'i=85' for the Objects folder (root).

Returns an array of child node names. Use this to explore an unfamiliar server's address space interactively or to build dynamic tag discovery logic.

Browsing Kepware: Start at 'i=85' (Objects folder), then drill into the channel name, then device name. Kepware exposes tags as Channel.Device.TagName.

Browsing Ignition: Ignition's default tag provider appears under ns=2. Browse from 'ns=2;s=[default]' to see the tag tree.


2.6 Example: Full Client Lifecycle

PROGRAM POU_OPCUAClient
VAR
state : INT := 0;
connected : BOOL;
temperature : REAL;
pressure : REAL;
running : BOOL;
setpoint : REAL := 75.0;
write_ok : BOOL;
cycles : DINT := 0;
END_VAR

CASE state OF
0: (* CREATE — one-time init *)
IF OPCUA_CLIENT_CREATE('kepware', 'opc.tcp://10.0.0.50:49320') THEN
state := 1;
END_IF;

1: (* CONNECT *)
IF OPCUA_CLIENT_CONNECT('kepware') THEN
state := 10;
END_IF;

10: (* RUNNING — read/write every scan *)
connected := OPCUA_CLIENT_IS_CONNECTED('kepware');
IF NOT connected THEN
state := 1; (* reconnect *)
END_IF;

(* Read process values *)
temperature := OPCUA_CLIENT_READ_REAL('kepware', 'ns=2;s=Channel1.Device1.Temperature');
pressure := OPCUA_CLIENT_READ_REAL('kepware', 'ns=2;s=Channel1.Device1.Pressure');
running := OPCUA_CLIENT_READ_BOOL('kepware', 'ns=2;s=Channel1.Device1.Running');

(* Write setpoint *)
write_ok := OPCUA_CLIENT_WRITE_REAL('kepware', 'ns=2;s=Channel1.Device1.TempSetpoint', setpoint);

cycles := cycles + 1;

ELSE
state := 0;
END_CASE;
END_PROGRAM

3. OPC UA Server

The server side provides 16 functions. You create a server, start it, and then use typed Set/Get calls to publish and read back variable values. Nodes are auto-created on first Set call — no manual node registration required.

3.1 Server Lifecycle

OPCUA_SERVER_CREATE -- Create Server Instance

ok := OPCUA_SERVER_CREATE('main', 4840);
ParamTypeDescription
nameSTRINGUnique server instance name
portINTTCP port to listen on (4840 is the IANA-assigned OPC UA default)

Returns TRUE on success. The server is created but not yet listening.

OPCUA_SERVER_START -- Begin Listening

ok := OPCUA_SERVER_START('main');

Binds to the configured port and begins accepting OPC UA client connections. The endpoint URL will be opc.tcp://<host-ip>:<port>.

OPCUA_SERVER_STOP -- Stop Listening

ok := OPCUA_SERVER_STOP('main');

Closes all active client sessions and stops accepting new connections. The server instance and its node configuration are preserved.

OPCUA_SERVER_IS_RUNNING -- Check Server State

IF NOT OPCUA_SERVER_IS_RUNNING('main') THEN
OPCUA_SERVER_START('main');
END_IF;

OPCUA_SERVER_DELETE -- Destroy Server Instance

OPCUA_SERVER_DELETE('main');

Stops the server (if running) and frees all resources.

OPCUA_SERVER_LIST -- Enumerate All Servers

servers := OPCUA_SERVER_LIST();
(* Returns: ['main', 'backup'] *)

OPCUA_SERVER_GET_ENDPOINT -- Get Endpoint URL

url := OPCUA_SERVER_GET_ENDPOINT('main');
(* Returns: 'opc.tcp://10.0.0.196:4840' *)

Returns the full endpoint URL. Useful for logging or passing to other systems.

OPCUA_SERVER_GET_STATS -- Server Statistics

stats := OPCUA_SERVER_GET_STATS('main');
(* Returns JSON:
{"sessions": 2, "nodes": 15, "reads": 48210, "writes": 312,
"uptime_s": 86400, "errors": 0} *)

Returns a JSON string with runtime statistics. Parse with ControlForge's JSON functions for alarming or dashboarding.


3.2 Setting Variables (Publishing Data)

Each Set call writes a value to the server's address space. If the node does not exist, it is automatically created with the correct OPC UA data type. Subsequent calls update the value in place.

OPCUA_SERVER_SET_INT

ok := OPCUA_SERVER_SET_INT('main', 'SpeedRPM', 1750);

OPCUA_SERVER_SET_REAL

ok := OPCUA_SERVER_SET_REAL('main', 'Temperature', 72.5);

OPCUA_SERVER_SET_BOOL

ok := OPCUA_SERVER_SET_BOOL('main', 'MotorRunning', TRUE);

OPCUA_SERVER_SET_STRING

ok := OPCUA_SERVER_SET_STRING('main', 'ActiveRecipe', 'BATCH_42');

Auto-create behavior: The first time you call SET_REAL('main', 'Temperature', ...), the server creates an OPC UA Variable node named Temperature with data type Float under the server's Objects folder. Remote clients immediately see the new node without restart or reconfiguration.


3.3 Getting Variables (Reading Back)

These read the current value from the server's address space. Useful when remote clients write values that your ST logic needs to consume (e.g., setpoints from a SCADA operator screen).

OPCUA_SERVER_GET_INT

sp := OPCUA_SERVER_GET_INT('main', 'SpeedSetpoint');

OPCUA_SERVER_GET_REAL

temp_sp := OPCUA_SERVER_GET_REAL('main', 'TempSetpoint');

OPCUA_SERVER_GET_BOOL

enable := OPCUA_SERVER_GET_BOOL('main', 'RemoteEnable');

OPCUA_SERVER_GET_STRING

recipe := OPCUA_SERVER_GET_STRING('main', 'RecipeCommand');

3.4 Example: Expose Process Data to SCADA

PROGRAM POU_OPCUAServer
VAR
state : INT := 0;
(* Process values — updated by control logic *)
temperature : REAL := 72.5;
pressure : REAL := 14.7;
motor_on : BOOL := FALSE;
speed_rpm : INT := 0;
(* Setpoints — written by remote SCADA clients *)
temp_sp : REAL;
speed_sp : INT;
remote_en : BOOL;
END_VAR

CASE state OF
0: (* CREATE and START — one-time init *)
IF OPCUA_SERVER_CREATE('scada', 4840) THEN
IF OPCUA_SERVER_START('scada') THEN
state := 10;
END_IF;
END_IF;

10: (* RUNNING — publish process values every scan *)
(* Publish current process state *)
OPCUA_SERVER_SET_REAL('scada', 'Temperature', temperature);
OPCUA_SERVER_SET_REAL('scada', 'Pressure', pressure);
OPCUA_SERVER_SET_BOOL('scada', 'MotorRunning', motor_on);
OPCUA_SERVER_SET_INT('scada', 'SpeedRPM', speed_rpm);

(* Read setpoints written by SCADA operators *)
temp_sp := OPCUA_SERVER_GET_REAL('scada', 'TempSetpoint');
speed_sp := OPCUA_SERVER_GET_INT('scada', 'SpeedSetpoint');
remote_en := OPCUA_SERVER_GET_BOOL('scada', 'RemoteEnable');

ELSE
state := 0;
END_CASE;
END_PROGRAM

4. Security Policies

OPC UA defines three security levels. ControlForge supports all of them via the optional parameters on OPCUA_CLIENT_CREATE.

PolicyModeWire EncryptionAuthenticationUse Case
NoneNoneNoAnonymousIsolated plant networks, development
Basic256Sha256SignNoCertificatesTamper detection without encryption overhead
Basic256Sha256SignAndEncryptAES-256CertificatesProduction — untrusted segments, compliance

4.1 No Security (Default)

ok := OPCUA_CLIENT_CREATE('dev', 'opc.tcp://10.0.0.50:4840');

Equivalent to passing 'None', 'None'. No certificates, no encryption. Messages are plaintext on the wire.

4.2 Sign Only

ok := OPCUA_CLIENT_CREATE('audit', 'opc.tcp://10.0.0.50:4840',
'Basic256Sha256', 'Sign');

Messages include a SHA-256 signature so tampering is detected, but payload is unencrypted. Lower CPU overhead than full encryption — suitable when eavesdropping is not a concern but integrity matters.

4.3 Sign and Encrypt

ok := OPCUA_CLIENT_CREATE('secure', 'opc.tcp://10.0.0.50:4840',
'Basic256Sha256', 'SignAndEncrypt');

Full AES-256 encryption plus SHA-256 signatures. Required for compliance with IEC 62443 and most enterprise security policies.

Certificate management: When using Basic256Sha256, ControlForge auto-generates a self-signed certificate on first use. For production, replace the auto-generated cert with a CA-signed certificate via the runtime configuration. The remote server must trust ControlForge's certificate (add it to the server's trusted certificates store).


5. Connecting to Common Platforms

5.1 Kepware KEPServerEX

Kepware is the most common OPC UA gateway in industrial environments. It exposes PLC tags through channels and devices.

PROGRAM POU_Kepware
VAR
state : INT := 0;
temp : REAL;
valve : BOOL;
END_VAR

CASE state OF
0: (* Kepware default port is 49320 *)
IF OPCUA_CLIENT_CREATE('kep', 'opc.tcp://10.0.0.50:49320') THEN
state := 1;
END_IF;

1:
IF OPCUA_CLIENT_CONNECT('kep') THEN
state := 10;
END_IF;

10: (* Node IDs follow Channel.Device.Tag pattern *)
temp := OPCUA_CLIENT_READ_REAL('kep', 'ns=2;s=Modbus.PLC1.Temperature');
valve := OPCUA_CLIENT_READ_BOOL('kep', 'ns=2;s=Modbus.PLC1.ValveOpen');

IF NOT OPCUA_CLIENT_IS_CONNECTED('kep') THEN
state := 1;
END_IF;

ELSE
state := 0;
END_CASE;
END_PROGRAM

Kepware tips:

  • Default OPC UA port: 49320
  • Node IDs are ns=2;s=ChannelName.DeviceName.TagName
  • Enable the OPC UA server interface in Kepware's project properties
  • For browsing, start at 'i=85' (Objects folder) then drill into channels

5.2 Ignition by Inductive Automation

Ignition exposes its tag providers as OPC UA namespaces.

PROGRAM POU_Ignition
VAR
state : INT := 0;
level : REAL;
alarm : BOOL;
sp : REAL := 50.0;
END_VAR

CASE state OF
0: (* Ignition default OPC UA port is 4096, though 4840 is common *)
IF OPCUA_CLIENT_CREATE('ign', 'opc.tcp://10.0.0.60:4096') THEN
state := 1;
END_IF;

1:
IF OPCUA_CLIENT_CONNECT('ign') THEN
state := 10;
END_IF;

10: (* Ignition tags: ns=2, path starts with [provider] *)
level := OPCUA_CLIENT_READ_REAL('ign', 'ns=2;s=[default]Tank/Level');
alarm := OPCUA_CLIENT_READ_BOOL('ign', 'ns=2;s=[default]Tank/HighAlarm');

OPCUA_CLIENT_WRITE_REAL('ign', 'ns=2;s=[default]Tank/Setpoint', sp);

IF NOT OPCUA_CLIENT_IS_CONNECTED('ign') THEN
state := 1;
END_IF;

ELSE
state := 0;
END_CASE;
END_PROGRAM

Ignition tips:

  • Default OPC UA endpoint: opc.tcp://<host>:4096 (configurable in Gateway settings)
  • Tag paths use / as separator: [default]Folder/Subfolder/Tag
  • The [default] prefix is the tag provider name
  • Enable "OPC UA Server" module in the Ignition Gateway

5.3 Beckhoff TwinCAT 3

TwinCAT exposes PLC variables through its OPC UA Server (TS6100).

PROGRAM POU_Beckhoff
VAR
state : INT := 0;
encoder : DINT;
axis_pos : REAL;
servo_en : BOOL;
END_VAR

CASE state OF
0: (* TwinCAT OPC UA default port: 4840 *)
IF OPCUA_CLIENT_CREATE('tc3', 'opc.tcp://10.0.0.34:4840') THEN
state := 1;
END_IF;

1:
IF OPCUA_CLIENT_CONNECT('tc3') THEN
state := 10;
END_IF;

10: (* TwinCAT uses numeric node IDs: ns=4;s=MAIN.variableName *)
encoder := OPCUA_CLIENT_READ_INT('tc3', 'ns=4;s=MAIN.nEncoderCount');
axis_pos := OPCUA_CLIENT_READ_REAL('tc3', 'ns=4;s=MAIN.fAxisPosition');
servo_en := OPCUA_CLIENT_READ_BOOL('tc3', 'ns=4;s=MAIN.bServoEnable');

IF NOT OPCUA_CLIENT_IS_CONNECTED('tc3') THEN
state := 1;
END_IF;

ELSE
state := 0;
END_CASE;
END_PROGRAM

TwinCAT tips:

  • Install TF6100 (OPC UA Server) function in TwinCAT
  • Variables must have the {attribute 'OPC.UA.DA' := '1'} pragma in TwinCAT ST
  • Default namespace for PLC variables is ns=4
  • Node IDs follow ns=4;s=ProgramName.VariableName
  • TwinCAT supports Basic256Sha256 — use SignAndEncrypt for production

6. Node Browsing

Browsing lets you explore a server's address space without knowing the exact Node IDs in advance. This is essential when integrating with unfamiliar systems.

6.1 Interactive Discovery

PROGRAM POU_Browser
VAR
state : INT := 0;
children : ARRAY[0..99] OF STRING;
END_VAR

CASE state OF
0:
IF OPCUA_CLIENT_CREATE('browse', 'opc.tcp://10.0.0.50:49320') THEN
state := 1;
END_IF;

1:
IF OPCUA_CLIENT_CONNECT('browse') THEN
state := 10;
END_IF;

10: (* Browse the root Objects folder *)
children := OPCUA_CLIENT_BROWSE('browse', 'i=85');
(* Typical result: ['Server', 'Channel1', 'Channel2', '_System'] *)

(* Drill deeper into Channel1 *)
children := OPCUA_CLIENT_BROWSE('browse', 'ns=2;s=Channel1');
(* Result: ['Device1', 'Device2'] *)

(* Drill into Device1 *)
children := OPCUA_CLIENT_BROWSE('browse', 'ns=2;s=Channel1.Device1');
(* Result: ['Temperature', 'Pressure', 'Speed', 'Running'] *)

state := 99; (* done *)

ELSE
(* idle *)
END_CASE;
END_PROGRAM

6.2 Well-Known Starting Points

ServerRoot Browse NodeNotes
Any OPC UA server'i=85'Objects folder — the universal starting point
Kepware'ns=2;s=ChannelName'One level per channel, then device, then tags
Ignition'ns=2;s=[default]'Tag provider name in brackets, then folder tree
Beckhoff'ns=4;s=MAIN'PLC program name, then variables
Siemens'ns=3;s="DataBlock"'DB names in quotes

7. YAML Configuration

In addition to ST-based setup, you can configure OPC UA servers and clients in the ControlForge YAML config file. The YAML configuration starts the server/client automatically at runtime boot.

7.1 Server via YAML

protocols:
opcua:
enabled: true
port: 4840
endpoint: "opc.tcp://0.0.0.0:4840/controlforge"
security_mode: None # or: Sign, SignAndEncrypt
# security_policy: Basic256Sha256

When enabled: true, the runtime creates and starts an OPC UA server automatically. Variables published via OPCUA_SERVER_SET_* calls in ST become visible to remote clients immediately.

7.2 Client via YAML

protocols:
opcua:
enabled: true
port: 4840

# Client connections are configured in ST code.
# The YAML config enables the OPC UA subsystem;
# CREATE/CONNECT calls in ST handle client instances.

8. Advanced Patterns

8.1 Multi-Server Client (Protocol Bridge)

Read from multiple OPC UA servers and consolidate data into a single ControlForge namespace. This is a common pattern for bridging isolated networks.

PROGRAM POU_ProtocolBridge
VAR
state : INT := 0;
(* Values from different servers *)
boiler_temp : REAL;
chiller_temp : REAL;
pump_running : BOOL;
END_VAR

CASE state OF
0: (* Create clients to two different servers *)
OPCUA_CLIENT_CREATE('boiler', 'opc.tcp://10.0.0.50:49320');
OPCUA_CLIENT_CREATE('chiller', 'opc.tcp://10.0.1.50:4840');
(* Create local server to republish *)
OPCUA_SERVER_CREATE('bridge', 4841);
OPCUA_SERVER_START('bridge');
state := 1;

1: (* Connect both *)
IF OPCUA_CLIENT_CONNECT('boiler') AND OPCUA_CLIENT_CONNECT('chiller') THEN
state := 10;
END_IF;

10: (* Read from both, republish on local server *)
boiler_temp := OPCUA_CLIENT_READ_REAL('boiler', 'ns=2;s=Boiler.Temperature');
chiller_temp := OPCUA_CLIENT_READ_REAL('chiller', 'ns=2;s=Chiller.SupplyTemp');
pump_running := OPCUA_CLIENT_READ_BOOL('boiler', 'ns=2;s=Boiler.PumpRunning');

(* Republish to local OPC UA server *)
OPCUA_SERVER_SET_REAL('bridge', 'BoilerTemp', boiler_temp);
OPCUA_SERVER_SET_REAL('bridge', 'ChillerTemp', chiller_temp);
OPCUA_SERVER_SET_BOOL('bridge', 'PumpRunning', pump_running);

(* Reconnect handling *)
IF NOT OPCUA_CLIENT_IS_CONNECTED('boiler') OR
NOT OPCUA_CLIENT_IS_CONNECTED('chiller') THEN
state := 1;
END_IF;

ELSE
state := 0;
END_CASE;
END_PROGRAM

8.2 Bidirectional SCADA Gateway

ControlForge acts as both client (reading a PLC via Kepware) and server (exposing data to Ignition). Operator setpoints flow from Ignition through ControlForge into the PLC.

PROGRAM POU_SCADAGateway
VAR
state : INT := 0;
(* Process data — read from PLC via Kepware *)
pv_temp : REAL;
pv_level : REAL;
pv_running : BOOL;
(* Setpoints — written by Ignition operator *)
sp_temp : REAL;
sp_level : REAL;
cmd_start : BOOL;
END_VAR

CASE state OF
0: (* Init everything *)
OPCUA_CLIENT_CREATE('plc', 'opc.tcp://10.0.0.50:49320');
OPCUA_SERVER_CREATE('scada', 4840);
OPCUA_SERVER_START('scada');
state := 1;

1:
IF OPCUA_CLIENT_CONNECT('plc') THEN
state := 10;
END_IF;

10: (* Steady state *)
(* Read from PLC *)
pv_temp := OPCUA_CLIENT_READ_REAL('plc', 'ns=2;s=Line1.Reactor.Temperature');
pv_level := OPCUA_CLIENT_READ_REAL('plc', 'ns=2;s=Line1.Reactor.Level');
pv_running := OPCUA_CLIENT_READ_BOOL('plc', 'ns=2;s=Line1.Reactor.Running');

(* Publish PVs to SCADA *)
OPCUA_SERVER_SET_REAL('scada', 'Reactor_Temperature', pv_temp);
OPCUA_SERVER_SET_REAL('scada', 'Reactor_Level', pv_level);
OPCUA_SERVER_SET_BOOL('scada', 'Reactor_Running', pv_running);

(* Read setpoints from SCADA operators *)
sp_temp := OPCUA_SERVER_GET_REAL('scada', 'Reactor_TempSetpoint');
sp_level := OPCUA_SERVER_GET_REAL('scada', 'Reactor_LevelSetpoint');
cmd_start := OPCUA_SERVER_GET_BOOL('scada', 'Reactor_StartCmd');

(* Write setpoints back to PLC *)
OPCUA_CLIENT_WRITE_REAL('plc', 'ns=2;s=Line1.Reactor.TempSP', sp_temp);
OPCUA_CLIENT_WRITE_REAL('plc', 'ns=2;s=Line1.Reactor.LevelSP', sp_level);
OPCUA_CLIENT_WRITE_BOOL('plc', 'ns=2;s=Line1.Reactor.StartCmd', cmd_start);

IF NOT OPCUA_CLIENT_IS_CONNECTED('plc') THEN
state := 1;
END_IF;

ELSE
state := 0;
END_CASE;
END_PROGRAM

8.3 Node Mapping with Add Node

For performance-critical applications, pre-register nodes with ADD_NODE to enable batch operations and reduce per-read overhead.

PROGRAM POU_MappedReads
VAR
state : INT := 0;
temp : REAL;
ok : BOOL;
END_VAR

CASE state OF
0:
IF OPCUA_CLIENT_CREATE('kep', 'opc.tcp://10.0.0.50:49320') THEN
state := 1;
END_IF;

1: (* Register node mappings before connecting *)
ok := OPCUA_CLIENT_ADD_NODE('kep', 'Temperature',
'ns=2;s=Channel1.Device1.Temperature', 'REAL', FALSE);
ok := OPCUA_CLIENT_ADD_NODE('kep', 'Setpoint',
'ns=2;s=Channel1.Device1.Setpoint', 'REAL', TRUE);
ok := OPCUA_CLIENT_ADD_NODE('kep', 'Running',
'ns=2;s=Channel1.Device1.Running', 'BOOL', FALSE);
state := 2;

2:
IF OPCUA_CLIENT_CONNECT('kep') THEN
state := 10;
END_IF;

10: (* Read using mapped Node IDs *)
temp := OPCUA_CLIENT_READ_REAL('kep', 'ns=2;s=Channel1.Device1.Temperature');

IF NOT OPCUA_CLIENT_IS_CONNECTED('kep') THEN
state := 2;
END_IF;

ELSE
state := 0;
END_CASE;
END_PROGRAM

9. Diagnostics and Troubleshooting

9.1 Connection Failures

SymptomLikely CauseFix
CREATE returns FALSEDuplicate instance nameUse a unique name or DELETE the existing one
CONNECT returns FALSEServer unreachableVerify endpoint URL, check firewall (TCP port), confirm server is running
CONNECT fails with securityCertificate not trustedAdd ControlForge's auto-generated cert to the server's trust store
IS_CONNECTED flips to FALSESession timeout or network dropImplement reconnect logic in your state machine
Reads return zero/emptyWrong Node ID or namespaceUse BROWSE to verify the exact node path
Writes return FALSENode is read-only on the serverCheck server-side access permissions; verify writable flag in ADD_NODE

9.2 Server Diagnostics

(* Check server health *)
stats := OPCUA_SERVER_GET_STATS('main');
(* Parse error count *)
(* IF errors > last_errors THEN trigger alarm *)

9.3 Runtime Logging

Enable OPC UA debug logging in the YAML config:

runtime:
log_level: debug
log_modules:
- opcua

This produces per-message traces including connection attempts, session state changes, read/write operations, and security handshake details.

9.4 Common Node ID Mistakes

WRONG: 'ns=2;s=Channel1/Device1/Temperature' (* forward slashes *)
RIGHT: 'ns=2;s=Channel1.Device1.Temperature' (* Kepware uses dots *)

WRONG: 'ns=2;s=Temperature' (* missing path *)
RIGHT: 'ns=2;s=Channel1.Device1.Temperature' (* full qualified path *)

WRONG: 'ns=2;i=Temperature' (* string in numeric ID *)
RIGHT: 'ns=2;i=1001' (* numeric must be integer *)
RIGHT: 'ns=2;s=Temperature' (* string uses 's=' *)

Appendix A: Client Function Quick Reference

FunctionSignatureReturns
OPCUA_CLIENT_CREATE(name, endpoint [, policy] [, mode])BOOL
OPCUA_CLIENT_CONNECT(name)BOOL
OPCUA_CLIENT_DISCONNECT(name)BOOL
OPCUA_CLIENT_IS_CONNECTED(name)BOOL
OPCUA_CLIENT_DELETE(name)BOOL
OPCUA_CLIENT_LIST()ARRAY
OPCUA_CLIENT_ADD_NODE(name, tag, nodeID, dataType, writable)BOOL
OPCUA_CLIENT_GET_MAPPINGS(name)ARRAY
OPCUA_CLIENT_GET_ENDPOINT(name)STRING
OPCUA_CLIENT_READ_NODE(name, nodeID)ANY
OPCUA_CLIENT_READ_BOOL(name, nodeID)BOOL
OPCUA_CLIENT_READ_INT(name, nodeID)INT
OPCUA_CLIENT_READ_REAL(name, nodeID)REAL
OPCUA_CLIENT_READ_STRING(name, nodeID)STRING
OPCUA_CLIENT_WRITE_NODE(name, nodeID, value)BOOL
OPCUA_CLIENT_WRITE_BOOL(name, nodeID, value)BOOL
OPCUA_CLIENT_WRITE_INT(name, nodeID, value)BOOL
OPCUA_CLIENT_WRITE_REAL(name, nodeID, value)BOOL
OPCUA_CLIENT_WRITE_STRING(name, nodeID, value)BOOL
OPCUA_CLIENT_BROWSE(name, nodeID)ARRAY

Appendix B: Server Function Quick Reference

FunctionSignatureReturns
OPCUA_SERVER_CREATE(name, port)BOOL
OPCUA_SERVER_START(name)BOOL
OPCUA_SERVER_STOP(name)BOOL
OPCUA_SERVER_IS_RUNNING(name)BOOL
OPCUA_SERVER_DELETE(name)BOOL
OPCUA_SERVER_LIST()ARRAY
OPCUA_SERVER_GET_ENDPOINT(name)STRING
OPCUA_SERVER_GET_STATS(name)STRING (JSON)
OPCUA_SERVER_SET_INT(name, varName, value)BOOL
OPCUA_SERVER_SET_REAL(name, varName, value)BOOL
OPCUA_SERVER_SET_BOOL(name, varName, value)BOOL
OPCUA_SERVER_SET_STRING(name, varName, value)BOOL
OPCUA_SERVER_GET_INT(name, varName)INT
OPCUA_SERVER_GET_REAL(name, varName)REAL
OPCUA_SERVER_GET_BOOL(name, varName)BOOL
OPCUA_SERVER_GET_STRING(name, varName)STRING

ControlForge v1.0.533 | OPC UA Client (20 functions) + Server (16 functions) Security: None, Basic256Sha256 (Sign, SignAndEncrypt)

(c) 2026 JMB Technical Services LLC. All rights reserved. Back to All Guides