Skip to main content

ControlForge DataLayer (Multi-Node Variable Sharing)

June 2026 | ControlForge v1.0.1069

DataLayer is ControlForge's inter-node variable bus: one runtime publishes a slice of its tags, another runtime reads them as if they were local — no Modbus poll, no OPC UA session, no hand-rolled socket. It is how a cluster of ControlForge nodes (a boss and its minions, a server and its clients, peers in a mesh) share live values. The same mechanism also bridges to a Bosch ctrlX Data Layer, so ControlForge running as a ctrlX app can read and write the controller's native Data Layer tree.

Reads happen through four DL_* ST functions; the link itself is watched and re-armed through two HTTP endpoints. You never configure the transport in ST — the mesh wiring (TCP / shared memory / direct) lives in YAML (see the Configuration Guide, section DataLayer, and section ctrlX Data Layer Bridge). This guide covers the reading side: how ST gets at a shared value and how you confirm the link is alive.


The two ways a remote value reaches your ST

A subscribing node receives every remote variable under a generated global name:

REMOTE_<NODEID>_<VARNAME>

So a value named DL_COUNTER published by node plc1 arrives locally as the global REMOTE_PLC1_DL_COUNTER. There are two equally valid ways to read it:

  1. Read the auto-generated global directly. Declare it as a VAR_GLOBAL so the parser knows the tag exists, then use it like any other tag:

    VAR_GLOBAL
    REMOTE_plc3_ECHO : DINT; // value 'ECHO' published by node plc3
    LAST_FROM_PLC3 : DINT;
    END_VAR

    IF REMOTE_plc3_ECHO > LAST_FROM_PLC3 THEN
    LAST_FROM_PLC3 := REMOTE_plc3_ECHO;
    END_IF;
  2. Resolve it by name with DL_GET — no global declaration needed, you pass the node id and variable name as strings and get the value back:

    rb_cnt := DL_GET('plc1', 'DL_COUNTER'); // == REMOTE_PLC1_DL_COUNTER

Use the global form when the set of remote tags is fixed and you want compile-time names; use DL_GET when the node id is dynamic (e.g. you fail over between an active and a standby node and the node id is itself a variable).


The DataLayer ST functions

There are exactly four DL_* functions in the live registry (verify any time with GET /api/docs/functions?search=DL_). All four take the node id and variable name as two STRING arguments — that is the form working ST uses and what the interpreter implements. (The registry prints a condensed (var_path: STRING) signature; the real call is two args.)

FunctionReal call formReturnsWhat it gives you
DL_GETDL_GET(node_id, var_name)the value (ANY)the latest received value of a remote tag; 0 if not present yet
DL_EXISTSDL_EXISTS(node_id, var_name)BOOLwhether that remote tag has ever been received
DL_GET_TSDL_GET_TS(node_id, var_name)DINTUnix microsecond timestamp of when the value was last received
DL_LATENCY_USDL_LATENCY_US(node_id, var_name)DINTmicroseconds since the value was published — network + processing age; -1 if never seen

Both node_id and var_name are matched case-insensitively ('plc1' and 'PLC1' resolve the same).

Reading shared values — worked example

A client node reads a sine value and two health metrics from an upstream server stress-servers:

VAR_GLOBAL
DL_CLI_Echo : REAL;
DL_CLI_Latency : INT;
server_fresh : BOOL;
END_VAR

// Latest value of REMOTE_STRESS-SERVERS_DL_SRV_SIN0
DL_CLI_Echo := DL_GET('stress-servers', 'DL_SRV_SIN0');

// Only trust it if we have actually received it
server_fresh := DL_EXISTS('stress-servers', 'DL_SRV_SIN0');

// Round-trip age in microseconds, clamped to an INT for the HMI
DL_CLI_Latency := DINT_TO_INT(DL_LATENCY_US('stress-servers', 'DL_SRV_SIN0') MOD 32767);

Failover pattern — dynamic node id

Because DL_GET takes the node id as a string, you can switch which node you read from at runtime:

VAR
activeNode : STRING;
latency : DINT;
END_VAR

IF primaryHealthy THEN
activeNode := 'edge-boss-a';
ELSE
activeNode := 'edge-boss-b';
END_IF;

CRAC_return_temp := DL_GET(activeNode, 'DL_CRAC_return_temp');
latency := DL_LATENCY_US(activeNode, 'DL_HEARTBEAT');

A common health gate: treat a node as alive only while DL_LATENCY_US(...) stays under a threshold (and is not -1), and fail over when it does not.


This is the tag browser for the whole mesh: the local node's identity, the variables this node publishes, and every remote node discovered with its current values. (Tag datalayer in the OpenAPI spec — GET /api/openapi.json.)

When DataLayer is not configured, the response is simply:

{ "enabled": false }

When it is up, the shape carries the local node info plus the published/remote breakdown:

{
"enabled": true,
"name": "DataLayer",
"type": "direct",
"node_id": "pump-ctrl",
"connected": true,
"address": ":4222",
"publish_prefixes": ["DL_", "MB_"],
"local_published": [
{ "name": "DL_COUNTER", "value": 4127 }
],
"remote_nodes": [
{
"node_id": "PLC1",
"var_count": 2,
"variables": [
{ "name": "REMOTE_PLC1_DL_COUNTER", "value": 991 },
{ "name": "REMOTE_PLC1_DL_HEARTBEAT", "value": 1718900000 }
]
}
]
}

connected is the live link flag, remote_nodes is what you are actually receiving, and local_published is what this node is putting on the wire (filtered by publish_prefixes). Exact keys vary slightly by transport — the direct/shared-memory bridge adds counters like publish_count, sync_input_count, and remote_vars_count.

TOKEN=$(curl -s -X POST http://localhost:8302/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"goplc","password":"goplc"}' | jq -r .token)

curl -s http://localhost:8302/api/datalayer/status -H "Authorization: Bearer $TOKEN" | jq

Re-arming the link — POST /api/datalayer/reconnect

Stops the current DataLayer bridge and starts a fresh one without restarting the process — the runtime-failover hook for a DataLayer connection that has gone bad, or for moving a TCP client to a new server address on the fly.

The body is optional; an empty address keeps the configured one:

{ "address": "newhost:4232" }
# Reconnect at the configured address
curl -s -X POST http://localhost:8302/api/datalayer/reconnect \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'

# Or fail over to a new server address
curl -s -X POST http://localhost:8302/api/datalayer/reconnect \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"address":"10.0.0.45:4232"}'

Success returns the new link identity:

{ "status": "reconnected", "address": "10.0.0.45:4232", "node_id": "pump-ctrl" }

Error cases: 400 if no DataLayer is configured at all (there is nothing to reconnect — fix the YAML first), 503 if the scheduler is unavailable, 500 if the new bridge fails to create or start.


The Bosch ctrlX Data Layer bridge

When ControlForge runs as a ctrlX app, the same DataLayer machinery bridges to the controller's native ctrlX Data Layer tree. From ST it looks identical — you read shared values with the DL_* functions and watch the link with GET /api/datalayer/status — the difference is purely in configuration: the bridge is pointed at the ctrlX broker instead of a peer ControlForge node. That wiring (ctrlx_datalayer: in YAML) lives in the Configuration Guide, section ctrlX Data Layer Bridge, and the on-device deployment is covered by the CTRLX_DL_BRIDGE runbook.


Notes & limits

  • Configuration is YAML, not ST. Which transport (direct, memory, shm, tcp), the node id, the publish prefixes, and the subscribe list all live in the datalayer: config block — see the Configuration Guide. This guide is only the read + status side.
  • A subscribing node receives variables as REMOTE_<NODEID>_<VARNAME>. Declare those as VAR_GLOBAL if you read them by name; DL_GET resolves the same global for you from the node-id/var-name pair.
  • DL_GET returns 0 (not an error) for a value never received. Gate on DL_EXISTS or a fresh DL_LATENCY_US before trusting a remote value — a missing value and a real zero look the same otherwise.
  • DL_LATENCY_US returns -1 when the variable has never been seen. Any non-negative result is a real age in microseconds; -1 means "no data," not "zero latency."
  • publish_prefixes controls what this node shares. If set, only matching tags are published; if empty, every tag is. The local_published list in /api/datalayer/status reflects this filter.
  • Reconnect is per-bridge, not a process restart. Use it for DataLayer failover; it does not touch the rest of the runtime (other tasks, other drivers keep running).