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:
| Model | ST prefix | Use it for | |
|---|---|---|---|
| Client/Server | sessions, read/write/browse nodes | OPCUA_CLIENT_*, OPCUA_SERVER_* | SCADA polling, on-demand reads/writes, browsing an address space |
| PubSub (UADP) | fire-and-forget DataSet streaming | UADP_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, andDataSetWriterId. A subscriber only accepts datagrams whose triple matches the one it was created with. This is how multiple publishers share one address. - Transport address —
host: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.
- Unicast (
- 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.
4. Minimal two-runtime link
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):
- Produce samples at some rate.
- 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.
- While the uplink is down, stop delivering and
SF_STOREeach new sample — the durable buffer (SF_COUNT) grows. - On restore, replay the buffered samples in order through the UADP publisher,
then
SF_CLEARthe 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
| Function | Purpose |
|---|---|
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.