How to implement batch OPC UA subscriptions in AS6

Background — Why the Change Was Made

If you are migrating a project from Automation Studio 4 to AS6 and you use OPC UA subscriptions, you will immediately hit a build error that stops the entire compilation:

'UA_MonitoredItemAdd' is not defined
'UA_MonitoredItemRemove' is not defined
'UA_GetNamespaceIndex' is not defined

This is not a bug in your code — B&R intentionally removed these function blocks when AS6 introduced the updated AsOpcUac library, which is now based on the OPC 30001 PLC client specification (IEC 61131-3 v1.2). The old single-item function blocks (UA_MonitoredItemAdd, UA_MonitoredItemRemove) were listed as obsolete in that client specification, and B&R took the opportunity of the AS6 major release (which permits breaking changes) to remove them entirely and replace them with list-based equivalents.

“These blocks are listed as obsolete in the client specification. The type of application is fundamentally different from the way the new list blocks must be applied, which is why the decision was made to discontinue the blocks.”
Migration to AR 6, AsOpcUac help

The architectural difference is fundamental: where the old API registered one node per function block call, the new API registers up to 64 nodes in a single call. This is not just a renaming — it requires rethinking the subscription setup loop.

The good news: beyond fixing the build, the new approach delivers a significant performance benefit during subscription initialization. In a real-world project with ~2,400 monitored variables split across 8 modules, we measured ~60× fewer state machine iterations needed to set up all subscriptions.


Complete API Change Table

Here is a consolidated reference of all renames and removals in the AsOpcUac library between AS4 and AS6. This covers everything you will encounter during migration:

Function Blocks

AS4 (Old) AS6 (New) Notes
UA_MonitoredItemAdd UA_MonitoredItemAddList Registers up to 64 nodes per call
UA_MonitoredItemRemove UA_MonitoredItemRemoveList Removes a list of items; requires SubscriptionHdl
UA_GetNamespaceIndex UA_NamespaceGetIndex Renamed for spec conformance; now case-sensitive on NamespaceUri

Online help references:

Data Types

AS4 (Old) AS6 (New)
UAMonitoringSettings UAMonitoringParameter
UAIdentifierType_String UAIT_String
UAIdentifierType_Numeric UAIT_Numeric
UASecurityMsgMode_None UASMM_None
UASecurityPolicy_None UASP_None
UANodeAdditionalInfo.AttributeId UANodeAdditionalInfo.AttributeID (capital D)

The Core Migration: UA_MonitoredItemAdd → UA_MonitoredItemAddList

What Changed in the Interface

The new UA_MonitoredItemAddList FB has several significant interface differences beyond the name:

Parameter AS4 approach AS6 approach
Target variables Plain STRING per item UAMonitoredVariables structure — .Values holds the variable name
Quality IDs Output DWORD per item Must specify variable name in UAMonitoredVariables.NodeQualityIDs (persistent array!)
Timestamps Output per item Optional — specify variable name in UAMonitoredVariables.TimeStamps
Monitoring settings UAMonitoringSettings UAMonitoringParameter (renamed)
Sync mode Implicit via QueueSize Explicit SyncMode input — use UAMS_FwSync for firmware sync
Subscription handle Not required SubscriptionHdl is now mandatory input
MinLostValueCount N/A New IN/OUT array — required for queue overflow tracking
Items per call 1 Up to 64 (MAX_INDEX_MONITORLIST + 1 = 64)

Important: NodeQualityIDs Requires a Persistent Variable

The most non-obvious aspect of the new API: the NodeQualityIDs field in UAMonitoredVariables does not receive the quality value directly — it holds the name of a variable where the firmware will write the quality. That variable must remain valid for the entire lifetime of the monitored item (until UA_MonitoredItemRemoveList is called).

The practical solution is to declare a persistent array and reference its elements by name:

(* Variable declaration *)
NodeQualityIDs_ALL : ARRAY[0..MAX_NODEIDS_INDEXES] OF DWORD;
sQualIdxStr        : STRING[10];

(* Build element name string at runtime — in the chunking loop *)
Variables_Chunk[iItemsInChunk].Values := NodeUrl[iSourceIdx];
brsitoa(INT_TO_DINT(iSourceIdx), ADR(sQualIdxStr));
Variables_Chunk[iItemsInChunk].NodeQualityIDs := 'NodeQualityIDs_ALL[';
brsstrcat(ADR(Variables_Chunk[iItemsInChunk].NodeQualityIDs), ADR(sQualIdxStr));
brsstrcat(ADR(Variables_Chunk[iItemsInChunk].NodeQualityIDs), ADR(']'));

Note on string assignment: Use := for the initial string prefix ('NodeQualityIDs_ALL['). The AS compiler does not accept multi-character string literals as the argument to ADR(), so brsstrcpy(ADR(...), ADR('prefix')) will fail with “Expression expected”. Single-character literals (like ']') are accepted.

The B&R sample project (OpcUa_Sample_Array) uses getMembName() for the same purpose. Using brsitoa + direct := + brsstrcat from AsBrStr achieves the same result without needing getMembName.


Before / After Code Examples

Variable Declarations (.var file)

AS4:

UA_MonitoredItemAdd_0  : UA_MonitoredItemAdd;
UA_MonitoredItemAdd_1  : UA_MonitoredItemAdd;
MonitoringSettings_0   : UAMonitoringSettings;

AS6:

UA_MonitoredItemAddList_0  : UA_MonitoredItemAddList;
UA_MonitoredItemAddList_1  : UA_MonitoredItemAddList;
MonitoringSettings_0       : UAMonitoringParameter;

(* Chunk arrays — max 64 items per call *)
ChunkSize                  : UINT;
ChunkCount                 : UINT;
iChunkStartIdx             : INT;
iItemsInChunk              : INT;
iChunk                     : INT;
iSourceIdx                 : INT;

NodeHdls_Chunk             : ARRAY[0..63] OF DWORD;
Variables_Chunk            : ARRAY[0..63] OF UAMonitoredVariables;   (* NOT STRING anymore! *)
NodeAddInfo_Chunk          : ARRAY[0..63] OF UANodeAdditionalInfo;
MonitoringSettings_Chunk   : ARRAY[0..63] OF UAMonitoringParameter;
MonitoredItemHdls_Chunk    : ARRAY[0..63] OF DWORD;
NodeErrorIDs_Chunk         : ARRAY[0..63] OF DWORD;
ValuesChanged_Chunk        : ARRAY[0..63] OF BOOL;
MinLostValueCount_Chunk    : ARRAY[0..63] OF UINT;
NodeQualityIDs_ALL         : ARRAY[0..MAX_NODEIDS_INDEXES] OF DWORD; (* persistent! *)
sQualIdxStr                : STRING[10];

Action File — MonitoredItemAdd.st

AS4:

UA_MonitoredItemAdd_0(
    Execute           := ExecuteMonitoredItemAdd_0,
    NodeHandle        := NodeHdl_0[index],
    Variable          := NodeUrl[index],
    MonitoringSettings:= MonitoringSettings_0,
    Timeout           := T#10s);

AS6:

UA_MonitoredItemAddList_0(
    Execute              := ExecuteMonitoredItemAddList_0,
    SubscriptionHdl      := SubscriptionHdl_0,
    NodeHdlCount         := ChunkCount,
    NodeHdls             := NodeHdls_Chunk,
    SyncMode             := UAMS_FwSync,
    NodeAddInfos         := NodeAddInfo_Chunk,
    Timeout              := T#10s,
    Variables            := Variables_Chunk,
    MonitoringParameter  := MonitoringSettings_Chunk,
    ValuesChanged        := ValuesChanged_Chunk,
    MinLostValueCount    := MinLostValueCount_Chunk);

(* IMPORTANT: Explicitly copy outputs back — they are not written to chunk arrays automatically *)
IF UA_MonitoredItemAddList_0.Done OR UA_MonitoredItemAddList_0.Error THEN
    MonitoredItemHdls_Chunk := UA_MonitoredItemAddList_0.MonitoredItemHdls;
    NodeErrorIDs_Chunk      := UA_MonitoredItemAddList_0.NodeErrorIDs;
END_IF;

State Machine — Chunking Pattern (ADD state)

This is the core of the migration for large variable lists. Instead of iterating one item at a time, you fill a 64-item buffer and call the batch FB once per chunk.

AS4 (single-item loop — 500 iterations for 500 variables):

IF CaseClientReadList = UA_MONITORED_ITEM_ADD THEN
    ExecuteMonitoredItemAdd_0 := TRUE;
    IF UA_MonitoredItemAdd_0.Done THEN
        MonitoredItemHdl_0[index] := UA_MonitoredItemAdd_0.MonitoredItemHdl;
        (* Find next node and continue *)
        index := index + 1;
        CaseClientReadList := UA_NODE_GET_HANDLE;
    END_IF;
END_IF;

AS6 (chunking loop — ~8 iterations for 500 variables):

IF CaseClientReadList = UA_MONITORED_ITEM_ADD THEN
    (* Step 1: Fill chunk arrays — up to 64 valid items starting from current index *)
    iChunkStartIdx := index;
    iItemsInChunk  := 0;

    FOR iChunk := 0 TO (ChunkSize - 1) DO
        iSourceIdx := iChunkStartIdx + iChunk;
        IF (iSourceIdx <= MAX_NODEIDS_INDEXES) AND
           (NodeID[iSourceIdx].Identifier <> '') AND
           (NodeUrl[iSourceIdx] <> '') THEN

            NodeHdls_Chunk[iItemsInChunk]   := NodeHdl_0[iSourceIdx];
            NodeAddInfo_Chunk[iItemsInChunk] := NodeInfo[iSourceIdx];
            MonitoringSettings_Chunk[iItemsInChunk].SamplingInterval := MonitoringSettings_0.SamplingInterval;

            (* Build target variable name for Values and NodeQualityIDs *)
            Variables_Chunk[iItemsInChunk].Values := NodeUrl[iSourceIdx];
            brsitoa(INT_TO_DINT(iSourceIdx), ADR(sQualIdxStr));
            Variables_Chunk[iItemsInChunk].NodeQualityIDs := 'NodeQualityIDs_ALL[';
            brsstrcat(ADR(Variables_Chunk[iItemsInChunk].NodeQualityIDs), ADR(sQualIdxStr));
            brsstrcat(ADR(Variables_Chunk[iItemsInChunk].NodeQualityIDs), ADR(']'));

            iItemsInChunk := iItemsInChunk + 1;
        END_IF;
    END_FOR;
    ChunkCount := iItemsInChunk;

    (* Step 2: Trigger batch call using FlipFlop alternating instances *)
    IF iItemsInChunk > 0 THEN
        IF NOT(FlipFlop) THEN
            ExecuteMonitoredItemAddList_0 := TRUE;
        ELSE
            ExecuteMonitoredItemAddList_1 := TRUE;
        END_IF;
    END_IF;

    (* Step 3: Wait for Done and copy results back *)
    IF UA_MonitoredItemAddList_0.Done OR UA_MonitoredItemAddList_1.Done THEN
        FOR iChunk := 0 TO (iItemsInChunk - 1) DO
            iSourceIdx := iChunkStartIdx + iChunk;
            MonitoredItemHdl_0[iSourceIdx] := MonitoredItemHdls_Chunk[iChunk];
            IF NodeErrorIDs_Chunk[iChunk] <> 0 THEN
                BadVariables[BadVarsIndex]         := NodeID[iSourceIdx].Identifier;
                BadVariablesUrl[BadVarsIndex]      := NodeUrl[iSourceIdx];
                BadVariables_ErrorID[BadVarsIndex] := DWORD_TO_UDINT(NodeErrorIDs_Chunk[iChunk]);
                BadVarsIndex := BadVarsIndex + 1;
            END_IF;
        END_FOR;

        (* Step 4: Advance index to next chunk *)
        FOR i := (iChunkStartIdx + ChunkSize) TO MAX_NODEIDS_INDEXES DO
            IF NodeID[i].Identifier <> '' THEN
                index := i;
                CaseClientReadList := UA_NODE_GET_HANDLE;
                EXIT;
            END_IF;
        END_FOR;

        FlipFlop := UA_MonitoredItemAddList_0.Done;
    END_IF;
END_IF;

Remove Action — MonitoredItemRemove.st

The remove FB also changed — it now requires SubscriptionHdl and MonitoredItemHdlCount. Reuse the MonitoredItemHdls_Chunk array (set [0] for single-item removal):

AS4:

UA_MonitoredItemRemove_0(
    Execute          := ExecuteMonitoredItemRemove_0,
    MonitoredItemHdl := MonitoredItemHdl_0[index],
    Timeout          := T#10s);

AS6:

(* In state machine before triggering: *)
MonitoredItemHdls_Chunk[0] := MonitoredItemHdl_0[index];

(* In action file: *)
UA_MonitoredItemRemoveList_0(
    Execute               := ExecuteMonitoredItemRemove_0,
    SubscriptionHdl       := SubscriptionHdl_0,
    MonitoredItemHdlCount := 1,
    MonitoredItemHdls     := MonitoredItemHdls_Chunk,
    Timeout               := T#10s);

Performance Impact

The batch API eliminates thousands of round-trips during subscription initialization. Real-world data from a project with 2,404 total monitored variables:

Module Variables AS4 iterations AS6 iterations Speed-up
BoolSubscription 801 801 ~13 62×
Subscription 500 500 ~8 62×
AuxSubscription 351 351 ~6 58×
WizSubscription 351 351 ~6 58×
StringSubscription 301 301 ~5 60×
VizuSubs 100 100 ~2 50×
Total 2,404 2,404 ~40 ~60×

For small lists (< 64 items), no chunking loop is needed — simply fill the chunk array once and set ChunkCount to the actual item count.


Known Limitation — ValuesChanged / MinLostValueCount in Chunked Setup

The AS6 help states: “The variables connected to IN/OUT parameters must remain valid over the service life of the MonitoredItem.”

In a chunked setup, the ValuesChanged_Chunk and MinLostValueCount_Chunk arrays are reused for every chunk — meaning only the last chunk’s monitored items have valid change-detection and queue overflow tracking via these arrays.

Impact in practice: This is typically a non-issue when QueueSize = 0 (firmware sync mode, no queuing), because queue overflows cannot occur and the application logic does not rely on ValuesChanged_Chunk after setup is complete. If you need per-item ValuesChanged tracking in STANDBY state, you need separate permanent arrays matching your full variable list.


Tips for Migration

  1. Do your renames globally — use Find & Replace across the project for type/enum renames (they affect many files). The full mapping is in the Migration to AR 6 help page.

  2. Verify against the AS6 help and sample project — The AS6 interface for UA_MonitoredItemAddList changed significantly between the release candidate (used in some early documentation) and the final release. Cross-check parameter names against the official help before coding.

  3. The B&R sample project for AS6 OPC UA is located at \AS6\Samples\OpcUa_Sample.zip in the AS installation folder. The relevant example is OpcUa_Sample_Array/ClientBasic/MonitoredItemList.

  4. Start with INIT — add ChunkSize := 64; to your _INIT state. This makes the magic number explicit and easy to document.

  5. Small modules (< 64 items) don’t need the full chunking loop — just populate Variables_Chunk[0], set ChunkCount := 1, and call the batch FB. Same API, simpler usage.

  6. ADR() with string literalsADR('multi-char-string') is not valid in B&R AS. Use := for initial assignment, then brsstrcat to append. ADR(']') with a single character works.

  7. Type conversion — AS6 removed implicit DWORD → UDINT. Use DWORD_TO_UDINT(x). Also, C-style cast DINT(x) is invalid — use INT_TO_DINT(x) or UINT_TO_DINT(x) depending on the source type.


Online Help References

Topic Link
UA_MonitoredItemAddList B&R Online Help
UA_MonitoredItemRemoveList B&R Online Help
Migration to AR 6 (full change table) B&R Online Help
3 Likes

Hi there,

thanks for the article.
For me, it’s a little bit too long for basically just little changes (filling lists vs single items isn’t a huge deal, imo), but that’s just my personal opinion.

Anyway a few things I found during reading:

Unsure why you only find a factor of 60 here when 2400 / 64 (per List) equals to 37.5, so 38 list calls? Or do I understand your state machine iterations wrong somehow?

Edit: I think I got it wrong in my thinking… factor should be around 64 due to that being the list limit, so 60 seems about right


This is not correct.
Simple example I set up just now and it compiled without warning and error:

image

Unsure where you would get an error there, but this is valid ST syntax and works across various AS versions.

Best regards