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