Skip to main content

ControlForge OPC UA PubSub (UADP) + Store-and-Forward Guide

James M. Belcher Founder, JMB Technical Services LLC June 2026 | ControlForge v1.0.1081


OPC UA PubSub (UADP) is the broker-less, datagram side of OPC UA: a publisher streams a fixed DataSet of values onto the wire at a fixed interval, and any number of subscribers read them — no sessions, no request/response. ControlForge implements both roles in Structured Text, and this guide shows how to wire two runtimes together and add store-and-forward so a comms outage never loses data.

1. PubSub vs. Client/Server

OPC UA has two communication models. ControlForge supports both:

ModelST prefixUse it for
Client/Serversessions, read/write/browse nodesOPCUA_CLIENT_*, OPCUA_SERVER_*SCADA polling, on-demand reads/writes, browsing an address space
PubSub (UADP)fire-and-forget DataSet streamingUADP_PUB_*, UADP_SUB_*high-rate telemetry, one-to-many distribution, controller-to-controller

PubSub is best-effort by design (UDP). It does not retry or buffer — if a subscriber is down, it simply misses values. That is where store-and-forward (section 5) comes in.

2. Concepts

  • DataSet — the ordered list of fields a publisher sends. You add fields with UADP_PUB_ADD_VAR; the subscriber reads them back by index in the same order (field 0, 1, 2, …).
  • Filter triple — every datagram carries a PublisherId, WriterGroupId, and DataSetWriterId. A subscriber only accepts datagrams whose triple matches the one it was created with. This is how multiple publishers share one address.
  • Transport addresshost:port:
    • Unicast (127.0.0.1:24840) — point-to-point, hermetic, reliable on loopback. Best for a controller-to-controller link or a test.
    • Multicast (239.0.0.22:24840) — one-to-many; needs IGMP / interface setup and tolerates UDP loss. Only the address changes — the ST is identical.
  • Publishing interval — how often the publisher emits the current field values. It re-sends the current values every interval (it is not change-driven), so a value must be held longer than a few intervals for a subscriber to catch it (see the gotcha in section 6).

3. ST function reference

Publisher

(* create a publisher: name, addr, publisherId, writerGroupId, dataSetWriterId, interval_ms *)
ok := UADP_PUB_CREATE('pub1', '127.0.0.1:24840', 100, 7, 11, 50.0);
UADP_PUB_ADD_VAR('pub1', 'counter'); (* DataSet field 0 *)
UADP_PUB_ADD_VAR('pub1', 'level'); (* field 1 *)
UADP_PUB_ADD_VAR('pub1', 'label'); (* field 2 *)
UADP_PUB_START('pub1');

(* each scan: push the current values onto the wire *)
UADP_PUB_SET('pub1', 'counter', 200 + (n MOD 100));
UADP_PUB_SET('pub1', 'level', 1.5 + INT_TO_REAL(n MOD 80) / 10.0);
UADP_PUB_SET('pub1', 'label', CONCAT('srv_', INT_TO_STRING(n)));

UADP_PUB_STOP('pub1'); (* later: stop / tear down *)
UADP_PUB_DELETE('pub1');

Subscriber

(* create with the SAME filter triple as the publisher *)
ok := UADP_SUB_CREATE('sub1', '127.0.0.1:24840', 100, 7, 11);
UADP_SUB_START('sub1');

(* each scan: read fields back by index *)
my_int := UADP_SUB_GET_INT('sub1', 0);
my_real := UADP_SUB_GET_REAL('sub1', 1);
my_str := UADP_SUB_GET_STRING('sub1', 2);

UADP_SUB_STOP('sub1');
UADP_SUB_DELETE('sub1');

Start subscribers before publishers so the socket is bound (and, for multicast, the group joined) before the first datagram arrives.

Two ControlForge runtimes on one host. A publishes; B subscribes.

(* --- Runtime A : PROGRAM Pub --- *)
IF NOT inited THEN
IF UADP_PUB_CREATE('pubA', '127.0.0.1:24840', 100, 7, 11, 50.0) THEN
UADP_PUB_ADD_VAR('pubA', 'seq');
UADP_PUB_START('pubA');
inited := TRUE;
END_IF;
END_IF;
seq := seq + 1;
UADP_PUB_SET('pubA', 'seq', seq);
(* --- Runtime B : PROGRAM Sub --- *)
IF NOT inited THEN
UADP_SUB_CREATE('subB', '127.0.0.1:24840', 100, 7, 11);
UADP_SUB_START('subB');
inited := TRUE;
END_IF;
got := UADP_SUB_GET_INT('subB', 0); (* tracks A's seq live *)

5. Store-and-forward — no loss across an outage

PubSub is best-effort, so if the link to B drops, those values are gone. To guarantee delivery, pair the publisher with the store-and-forward outbox.

The pattern (edge node A):

  1. Produce samples at some rate.
  2. While the uplink is up, deliver one queued sample per scan over UADP. Make the forward rate faster than the production rate so any backlog drains.
  3. While the uplink is down, stop delivering and SF_STORE each new sample — the durable buffer (SF_COUNT) grows.
  4. On restore, replay the buffered samples in order through the UADP publisher, then SF_CLEAR the outbox once everything is delivered.
IF NOT inited THEN
UADP_PUB_CREATE('pubA', '127.0.0.1:24840', 100, 7, 11, 10.0);
UADP_PUB_ADD_VAR('pubA', 'wire_seq');
UADP_PUB_START('pubA');
SF_INIT('data/saf_a.db', 100000, 86400); (* db, max_messages, max_age_s *)
inited := TRUE;
END_IF;

produced := produced + 1; (* (gate to your production rate) *)

IF link_up THEN
SF_ONLINE(TRUE);
IF sent < produced THEN (* deliver one queued sample / scan *)
sent := sent + 1;
UADP_PUB_SET('pubA', 'wire_seq', sent);
END_IF;
IF (sent >= produced) AND (SF_COUNT() > 0) THEN
SF_CLEAR(); (* outbox fully delivered *)
END_IF;
ELSE
SF_ONLINE(FALSE);
SF_STORE('telem', INT_TO_STRING(produced)); (* OUTAGE: buffer it *)
END_IF;

Store-and-forward ST functions

FunctionPurpose
SF_INIT(db, max_msgs, max_age_s)open the durable SQLite outbox (returns BOOL)
SF_STORE(topic, payload) / SF_STORE(topic, prio, payload)buffer a message (returns id)
SF_STORE_JSON(topic[, prio], value)buffer a value as JSON
SF_COUNT()pending messages in the buffer
SF_ONLINE(state) / SF_ONLINE()set / get the simulated link state
SF_GET_PENDING(limit)read pending as an array of records
SF_CLEAR()drop all pending
SF_STATS()stored / forwarded / dropped / bytes counters

SF_FORWARD(url) does not transmit in this build. Its HTTP-POST path is a stub — it drains/confirms the buffer without sending. Forward the backlog over a transport you control instead (here, the UADP publisher). Wiring a real HTTP forward + a receiving ingest endpoint is separate work.

Worked, verified reference

examples/uadp_store_forward/ is a complete, runnable version of this pattern — two runtimes, a test driver that forces an outage, and a verdict proving B.received == A.produced (no loss). See its README to run it.

6. Gotchas

  • Hold values longer than the publish interval. A value must sit on the wire for several publishing intervals for the subscriber to sample it. During fast catch-up (a new value every scan), set a short publish interval (e.g. 10 ms while the scan/hold is 50 ms) and let the subscriber oversample (scan faster than the publisher). Otherwise timing jitter drops values.
  • Match the filter triple and address on both ends, or the subscriber silently receives nothing.
  • Start subscribers before publishers.
  • Multicast needs setup (IGMP join, a multicast-capable interface, firewall) and tolerates loss; unicast loopback is the reliable default for tests.
  • Restart with a fresh process to change UADP wiring — reloading/restarting the runtime over the API does not tear down publisher goroutines.