Beckhoff ADS¶
truST supports Beckhoff ADS in two directions:
- ADS client: truST connects to a Beckhoff TwinCAT PLC and imports TwinCAT symbols into generated ST globals.
- ADS server: truST exposes selected runtime globals as ADS symbols so TwinCAT, pyads, .NET ADS clients, and SCADA tools can browse, read, subscribe, and optionally write them.
Both directions start from the selected runtime, not from a second ADS-specific runtime picker. That keeps routes, source IPs, and AMS Net IDs tied to the host that will actually run the connection.
Quick Start¶
Choose the direction first:
- Use ADS client when truST should read or write symbols from an existing TwinCAT PLC.
- Use ADS server when TwinCAT, an HMI, SCADA, pyads, or a .NET ADS client should browse and read values from truST.
truST Connects To TwinCAT¶
This is the ADS client path. truST imports TwinCAT symbols and turns them into reviewed ST globals.
- Select the online runtime in the VS Code Runtime pane.
- Open Structured Text: Add Beckhoff ADS Device or open the runtime-host
setup page at
/setup/ads. - Discover the TwinCAT target. Broadcast discovery is easiest when UDP 48899 is allowed; otherwise enter the TwinCAT IP and AMS Net ID manually.
- In Devices & Connections, choose the logical ADS port before browsing
symbols. Port
851is the default PLC runtime; other examples include301for an I/O server,501for NC/Motion, and852or later for additional PLC runtimes. Each port is a separate ADS server with its own symbol namespace; this setting does not search all ports. - Add the route on the TwinCAT side so TwinCAT trusts the truST runtime host. The wizard can generate the exact PowerShell, XML, or manual route values.
- Run the ADS Doctor from the runtime host.
- Import TwinCAT symbols into
ads.toml, a cached symbol snapshot, and the single generated ST filesrc/generated/ads_generated.st. - Deploy or reload the project bundle, then verify
ads.statusand generated value/_qualityglobals from the runtime.
The selected server must expose Beckhoff Online Symbolism/Symbol Upload. An I/O
or Motion port can be reachable without exposing a browseable symbol table; for
NC/Motion, TwinCAT symbol generation may need to be enabled for the relevant
axes. Ports 301 and 501 are examples, not hardware-validation claims for a
particular TwinCAT project.
TwinCAT Connects To truST¶
This is the ADS server path. truST exposes selected runtime globals as ADS symbols, and TwinCAT browses truST like an ADS target.
- Enable
[runtime.ads_server]inruntime.toml. - Set one concrete
listenIP, an AMS Net ID, and logicalads_port = 851. - Add the globals to
expose. Leavewrites_enabled = falseunless a specific write path is required. - Allowlist the TwinCAT client with its AMS Net ID and source IP or CIDR.
- Start the truST runtime.
- In TwinCAT, use SYSTEM > Routes or the Target Browser Broadcast
Search to find the truST runtime and add the route to truST. truST
acknowledges the Add Route handshake so TwinCAT can complete local route
setup; the client still must be allowlisted in
[runtime.ads_server.clients]before browse/read/write requests are accepted. - Browse the truST target on logical port
851. Exposed globals appear with theglobal.prefix, for exampleglobal.TankLevelandglobal.Setpoint. - Read first. Test writes only against a guarded writable variable such as
global.Setpoint.
TwinCAT Setup Hints¶
- ADS uses TCP
48898for router transport and UDP48899for discovery and route-related setup. The logical PLC ADS port is usually851, but the route transport and logical ADS server port are different settings. - Route direction matters. In ADS client mode, TwinCAT needs a route back to the truST runtime host. In ADS server mode, the TwinCAT engineering station or client needs a route to the truST runtime host.
- Classic ADS is cleartext and route-based. Keep it on a private OT network and do not expose ADS ports through NAT or the public internet.
- On TwinCAT Usermode Runtime, static routes may live under
C:\ProgramData\Beckhoff\TwinCAT\3.1\Runtimes\UmRT_Default\3.1\. Use the generated route artifact when possible so the correct Usermode paths are handled for you.
The CLI exists for scripting and for the VS Code/web front ends to call. It is not the primary onboarding interface.
Runtime Context¶
ADS onboarding is a child of the selected runtime. VS Code renders the wizard and reads the Runtime pane context; network operations run on the runtime host through the runtime control/web APIs. This prevents creating a route for the developer laptop when the deployed runtime is a Raspberry Pi, IPC, or other remote host.
The setup web page is the canonical production commissioning surface because it runs on the runtime host. VS Code can still help with authoring-only symbol imports from the developer laptop, but those imports are explicitly badged as authoring-only until the runtime-host Doctor passes.
First Things To Decide¶
- ADS client: which runtime host connects to the TwinCAT PLC, which symbols are imported, and which generated ST global names should be created?
- ADS server: which runtime host exposes truST variables, which globals are exposed, which external ADS clients are allowed, and whether any write-back symbols are enabled?
- Both directions: is classic plain ADS acceptable on this private network segment?
VS Code Flow¶
Use these commands from VS Code:
| Command | Use |
|---|---|
Structured Text: Open ADS Devices |
View ADS connection state for the selected runtime. |
Structured Text: Add Beckhoff ADS Device |
Start the runtime-child onboarding wizard. |
Structured Text: Diagnose ADS Connection |
Run the ADS Doctor from the runtime host. |
Structured Text: Import TwinCAT Symbols |
Preview generated ads.toml, snapshot, and ST diffs before writing. |
Structured Text: Add ADS Route |
Open the route-planning path for the selected runtime. |
The ADS panel has no runtime selector. If no online runtime is selected, it
opens the Runtime pane instead of silently falling back to the laptop.
When the selected runtime uses remote plain TCP control, VS Code does not send
TwinCAT credentials over that channel. It fetches the runtime host identity with
ads.identity, then runs the local trust-runtime ads add-route --password-stdin
command so the password travels directly from the engineering computer to the
PLC for that one AddRoute action. The runtime-host Doctor must still pass after
that route is added before the device can be treated as production-ready.
Setup Web Flow¶
Open /setup/ads on the runtime host for commissioning. The page can identify
the target, show the runtime host local IP and AMS Net ID, plan route artifacts,
run the Doctor, import symbols, and link back to /ide for deployment. The
production-ready state requires a deployed bundle plus healthy live ADS status;
a passing offline import alone is not enough.
Discovery¶
Do not confuse ADS target discovery with truST runtime discovery:
- truST runtime discovery uses the runtime-to-runtime mDNS/pairing surfaces.
- ADS target discovery uses Beckhoff ADS UDP discovery and directed identify from the runtime host toward TwinCAT.
If broadcast discovery is blocked by the network, enter the TwinCAT host/IP and AMS Net ID manually. The Doctor still proves whether the runtime host can reach the PLC and whether the route back to the runtime host exists.
Simulator Smoke¶
The pyads testserver is useful for a basic no-TwinCAT wire smoke. It can prove
that truST opens a real ADS TCP connection to 127.0.0.1:48898, derives the
loopback AMS identity, verifies the route path, and reads the ADS runtime state.
It is not a substitute for the TwinCAT lab gate: the stock pyads advanced
handler does not expose a real TwinCAT symbol upload table, so truST cannot use
it to prove symbol upload, handle caching, notifications, online-change symbol
versioning, or guarded writes. Those remain real TwinCAT validation items.
ADS Client: Symbol Import Model¶
ads.toml maps TwinCAT symbols to generated ST globals:
[[connections]]
name = "line1"
target_net_id = "5.23.91.12.1.1"
host = "192.168.10.5"
ams_port = 851
transport = "plain"
insecure_transport = true
[[connections.points]]
symbol = "MAIN.Temperature"
var = "line1_temp"
type = "REAL"
mode = "poll"
[[connections.points]]
symbol = "GVL.LineReady"
var = "line1_ready"
type = "BOOL"
mode = "notify"
notification_mode = "on_change"
Generate the reviewed ST interface from a live TwinCAT symbol import:
trust-runtime ads import-symbols \
--target 192.168.10.5 \
--target-net-id 5.23.91.12.1.1 \
--connection line1 \
--out ads.toml \
--gen src/generated/ads_generated.st
VS Code uses trust-runtime ads import-symbols --dry-run --json for preview
mode. It opens diffs/previews first and writes files only after confirmation.
Validate the checked-in interface without a PLC connection:
trust-runtime ads validate --offline \
--config ads.toml \
--snapshot ads/snapshots/line1.symbols.json \
--generated src/generated/ads_generated.st
The generated file is deterministic and should be reviewed. It is not hidden build output. The generated globals are normal ST declarations, so they appear on the same runtime surfaces as other globals: HMI schemas and values, debug and control reads, historian sampling, and OPC UA export when the OPC UA server is enabled.
Enable live runtime ADS with:
[runtime.ads]
enabled = true
config_path = "ads.toml"
worker_tick_interval_ms = 20
At runtime, ADS workers own the socket I/O. The scan cycle only copies the latest cached read/notification values into generated globals at the input phase and queues changed write values at the output phase.
Routes And Security¶
Classic ADS over port 48898 is cleartext and relies on AMS route trust rather
than modern transport authentication. Plain ADS must be acknowledged explicitly
with transport = "plain" and insecure_transport = true. Keep it on a private
OT segment, default points to reads, and make write points explicit with
access = "write" or access = "read_write".
Secure ADS is not supported in this release. Configure only classic plain ADS on trusted OT networks; do not expose ADS ports to public or NAT-routed networks.
Route creation is explicit. The setup page, VS Code panel, and CLI can generate
PowerShell, StaticRoutes.xml, and manual GUI artifacts using the runtime host
identity. Automatic route-add sends TwinCAT credentials directly to the PLC for
that one action and never stores them in ads.toml, logs, settings, or
generated files. The CLI accepts the password only through --password-stdin
and rejects an empty password before sending any AddRoute packet.
For TwinCAT 3 Usermode Runtime, Beckhoff loads remote-access routes from the
runtime instance files, for example
C:\ProgramData\Beckhoff\TwinCAT\3.1\Runtimes\UmRT_Default\3.1\StaticRoutes.xml.
Some Usermode installations also display and retain the route from the sibling
Target\StaticRoutes.xml. The generated PowerShell searches and updates both
locations when they exist. For Linux/non-Windows ADS clients, generated static
routes use <Flags>0</Flags>.
TwinCAT Usermode Troubleshooting¶
The following checks came from a real TwinCAT 3 Usermode Runtime validation run with truST running as a Linux ADS client.
| Symptom | Cause | Fix |
|---|---|---|
| TwinCAT cannot activate normal Run mode in a VM or Hyper-V laptop. | Kernel realtime runtime is blocked by the host environment. | Use TwinCAT Usermode Runtime/XAR user mode instead of forcing kernel Run mode. |
ping/ADS cannot reach a 100.64.0.0/10 laptop address from Linux. |
That address range overlaps Tailscale/CGNAT routing and may be filtered or routed differently. | Put both machines on a simple dedicated subnet, for example 192.168.77.10/24 for truST and 192.168.77.11/24 for TwinCAT, then use those addresses in ADS config and routes. |
TCP 48898 connects, but ADS read_state times out with ADS error 1861. |
The TCP router accepted the socket, but TwinCAT did not have a usable route back to the Linux client identity loaded. | Add a static route for the runtime host IP/AMS Net ID, use <Flags>0</Flags>, restart TwinCAT Usermode Runtime, and reboot Windows if the router keeps stale route state. |
Deleting the duplicate route file makes the route disappear from SYSTEM > Routes. |
Some Usermode installations show routes from Target\StaticRoutes.xml even when remote access also needs the runtime-root file. |
Keep both Usermode StaticRoutes.xml files aligned. The generated PowerShell updates both when present. |
SYSTEM > Routes shows the route but ADS still times out. |
TwinCAT may not have reloaded the static route contents into the router. | Restart the Usermode Runtime. If the first ADS request still times out after static-file edits, reboot Windows and start the runtime again. |
Symbol upload fails on a library or struct metadata symbol such as ST_LibVersion. |
Whole-STRUCT symbols are not bindable scalar ADS points. | Bind scalar leaf members. The live importer filters unsupported complex symbols and continues with compatible scalar symbols. |
Expected live Doctor proof after the route is correct:
read_state pass PLC runtime responded with state 'run'
symbol_upload pass Uploaded compatible symbols
handle_resolve pass Resolved handle for the selected symbol
sumup_read pass Read value(s) with sum-up read
notification pass Notification subscription worked
symbol_version pass Symbol version returned
If the Doctor reports partial only because write_guarded is skipped, the
read/notification path is working; writes still need an explicit guarded write
test before claiming write-path validation for that PLC.
Run a guarded write only against a safe writable PLC variable. The Doctor writes the probe value, reads it back, restores the original value, and reads back the restored value:
trust-runtime ads doctor \
--target 192.168.77.11 \
--target-net-id 100.67.6.217.1.1 \
--ams-port 851 \
--write-symbol GVL.Setpoint \
--write-type REAL \
--write-value 12.5
The write_guarded step must pass before claiming live write-path validation.
The CLI rejects partial write-probe arguments; --write-symbol,
--write-type, and --write-value must be supplied together.
ADS Server: Expose truST To ADS Clients¶
Enable the ADS server when external Beckhoff tooling needs to browse or read truST runtime variables through ADS. This is the opposite direction from the ADS client import path: the external client connects to truST, and truST owns the symbol table.
The server does not use ads.toml. It is configured in runtime.toml:
[runtime.ads_server]
enabled = true
listen = "192.168.77.10"
ams_net_id = "192.168.77.10.1.1"
ads_port = 851
insecure_transport = true
writes_enabled = false
expose = ["global.TankLevel", "global.PumpRunning", "global.StatusWord"]
writable = []
[[runtime.ads_server.clients]]
ams_net_id = "192.168.77.20.1.1"
source_ip = "192.168.77.20"
Fail-closed defaults matter:
enabled = falsewhen the section is omitted.listenis required when enabled and must be one concrete IP address;0.0.0.0is rejected.- plain ADS requires
insecure_transport = true. expose = []publishes no symbols.- no external client can connect until it is allowlisted by AMS Net ID and source IP or CIDR.
writes_enabled = falseby default, and write-back also requires the symbol to matchwritable.
The ADS server listens on ADS router TCP port 48898. The configured
ads_port is the logical AMS target port that external ADS clients use; 851
is the normal TwinCAT PLC runtime port and is also the default truST ADS server
logical port.
TwinCAT Broadcast Search can discover the ADS server on UDP 48899 and its
Add Route action receives a successful compatibility acknowledgement from
truST. That acknowledgement does not mutate server access policy. The TwinCAT
client AMS Net ID and source IP or CIDR still need to match
[[runtime.ads_server.clients]] before TCP 48898 ADS requests are served.
Generate route instructions for an external ADS client or TwinCAT engineering station with:
trust-runtime ads server route-script \
--route-name trust-runtime-line1 \
--server-ip 192.168.77.10 \
--server-net-id 192.168.77.10.1.1 \
--format powershell
For server mode, the route is added on the external ADS client side so it can reach the truST ADS target. This is the inverse of ADS client onboarding, where the TwinCAT PLC needs a route back to the truST runtime host. The server route artifact does not use the TwinCAT "get fingerprint of remote system" path that caused ADS 1861 during Linux-client route setup.
Run the status and doctor commands against the running runtime control endpoint:
trust-runtime ads server status --project examples/communication/ads_server_basic
trust-runtime ads server symbols --project examples/communication/ads_server_basic
trust-runtime ads server doctor --project examples/communication/ads_server_basic
The loopback self-test proves the server can answer its own basic ADS requests, but it is not external-client proof. pyads proof is stronger because it uses an independent ADS client implementation, but it still does not replace the real TwinCAT merge gate. Production validation for server mode is:
- loopback self-test passes,
- an independent external client such as pyads browses, reads, subscribes, and performs any guarded write that is enabled,
- a real TwinCAT engineering station or runtime browses truST, reads symbols, validates sum-up read/write/read-write requests, subscribes to notifications, and validates any guarded write path.
Start the TwinCAT side with Target Browser, not Scope. The customer path is:
discover or add the route, open truST-ADS -> 851, browse the global.*
symbols, read them, and perform a guarded write to a safe writable symbol such
as global.Setpoint. Target Browser value preview can be used as a quick
live-value sanity check. TwinCAT Scope View is optional interop only; it adds a
separate Scope Server recording workflow and is not required to prove the ADS
server browse/read/write path. For a PLC-side TwinCAT smoke program, use
examples/communication/ads_server_basic/README.md; it shows the required
Tc2_DataExchange library and edge-triggered FB_ReadAdsSymByName /
FB_WriteAdsSymByName calls.
The included pyads smoke script exercises the independent-client step:
python3 scripts/ads_server_pyads_smoke.py \
--target-ip 127.0.0.1 \
--target-net-id 127.0.0.1.1.1 \
--local-net-id 127.0.0.1.1.100 \
--read global.TankLevel:REAL \
--read global.PumpRunning:BOOL \
--notification global.PumpRunning:BOOL \
--write global.Setpoint:REAL:12.5 \
--doctor-endpoint unix:///tmp/trust-runtime-ads-server-basic.sock \
--doctor-token ads-server-smoke-token \
--trust-runtime target/debug/trust-runtime
The script report explicitly keeps "twinCAT_merge_gate_satisfied": false;
that field prevents pyads evidence from being confused with real TwinCAT lab
evidence.
Writes from ADS clients are queued through the ADS server write port and
applied at the runtime boundary. Accepted and rejected writes emit
ads.server.write audit records. Keep writes_enabled = false unless a
specific external client and symbol need a write path, and use a safe variable
such as global.Setpoint for guarded validation.
ADS Server Example¶
This example exposes truST runtime globals as Beckhoff ADS symbols so an external ADS client can browse, read, subscribe, and optionally write a guarded test variable.
What you learn¶
- how
[runtime.ads_server]exposes declared globals as ADS symbols - why the ADS server uses TCP
48898while the logical ADS port remains851 - how source-IP-pinned clients are allowlisted
- how pyads proof differs from the real TwinCAT merge gate
- how writes stay disabled by default and require explicit writable symbols
Files in this folder¶
src/config.st: globals exposed asglobal.TankLevel,global.PumpRunning,global.Setpoint, andglobal.StatusWordsrc/main.st: simple logic that keeps values changing through the normal scanruntime.toml: ADS server config for local pyads smoke testingio.toml,trust-lsp.toml: simulated I/O and project defaults
TwinCAT side checklist¶
Use this checklist when TwinCAT is the ADS client and truST is the ADS server:
- Start the truST runtime with
[runtime.ads_server]enabled. - In TwinCAT XAE, open
SYSTEM > Routes. - Use
Add > Broadcast Searchwhen UDP48899is allowed, select the truST runtime, and add the route. truST acknowledges that Add Route handshake so TwinCAT can finish its local route setup. Actual ADS access is still denied unless the TwinCAT client is listed in[runtime.ads_server.clients]. If broadcast is blocked, use the generated route artifact from Step 4. - Browse the truST target on logical ADS port
851. - Read symbols with their full wire names, for example
global.TankLevelandglobal.Setpoint. - Test writes only on symbols that truST explicitly lists in
writable.
The route belongs on the TwinCAT/client side in server mode. This is the opposite direction from the ADS client import example.
Step 1: Build the project¶
cargo build -p trust-runtime --features ads-server
cargo run -p trust-runtime --features ads-server --bin trust-runtime -- \
build --project examples/communication/ads_server_basic --sources src
Step 2: Start the runtime¶
Run in one terminal:
cargo run -p trust-runtime --features ads-server --bin trust-runtime -- \
play --project examples/communication/ads_server_basic --no-console
The example binds ADS/TCP on 127.0.0.1:48898, UDP identify on
127.0.0.1:48899, and serves logical ADS port 851 under AMS Net ID
127.0.0.1.1.1.
Step 3: Run the pyads smoke test¶
In another terminal, install pyads in a local venv and run:
python3 -m venv .venv-pyads
.venv-pyads/bin/python -m pip install pyads
.venv-pyads/bin/python scripts/ads_server_pyads_smoke.py \
--target-ip 127.0.0.1 \
--target-net-id 127.0.0.1.1.1 \
--local-net-id 127.0.0.1.1.100 \
--read global.TankLevel:REAL \
--read global.PumpRunning:BOOL \
--read global.StatusWord:WORD \
--notification global.PumpRunning:BOOL \
--write global.Setpoint:REAL:12.5 \
--doctor-endpoint unix:///tmp/trust-runtime-ads-server-basic.sock \
--doctor-token ads-server-smoke-token \
--trust-runtime target/debug/trust-runtime
The script performs:
- device info and ADS state read
- symbol browse
- handle resolve and read by handle
- read by name
- sum-up list read
ADSIGRP_SYM_VERSIONread- notification subscription
- guarded write and restore for
global.Setpoint - optional
ads.server.doctorcall withexternal_kind = "pyads"
The JSON report includes "twinCAT_merge_gate_satisfied": false. pyads is an
independent client proof, but it is not the real TwinCAT engineering-station
merge gate.
Step 4: Generate a TwinCAT route artifact¶
For a real engineering station, generate the route artifact from the truST ADS server identity:
cargo run -p trust-runtime --features ads-server --bin trust-runtime -- \
ads server route-script \
--route-name trust-runtime-ads-server-basic \
--server-ip 192.168.77.10 \
--server-net-id 192.168.77.10.1.1 \
--format powershell
Run that artifact on the ADS client side, not on the truST runtime host. In server mode, TwinCAT/pyads/.NET is the client and needs a route to the truST ADS target.
Expected Doctor output¶
Without an external client proof, the Doctor should pass the loopback self-test but remain short of production-ready:
cargo run -p trust-runtime --features ads-server --bin trust-runtime -- \
ads server doctor --project examples/communication/ads_server_basic
After the pyads smoke script completes, attach that evidence:
cargo run -p trust-runtime --features ads-server --bin trust-runtime -- \
ads server doctor \
--project examples/communication/ads_server_basic \
--external-kind pyads \
--external-name pyads-smoke
The expected JSON report has overall = "pass",
external_client_verified = true, and external_client_kind = "pyads". That
still does not complete the real TwinCAT merge gate.
TwinCAT validation¶
For the production gate, use TwinCAT from a Windows engineering station:
- Browse for the truST ADS target and confirm it appears in Broadcast Search.
- Add a route from the TwinCAT machine to the truST runtime host using that
broadcast result. If broadcast is blocked, use the server route artifact from
trust-runtime ads server route-script. The Add Route acknowledgement is a setup compatibility response; it does not add a client to truST's allowlist. - Browse symbols and confirm datatype upload works.
- Read
global.TankLevel,global.PumpRunning, andglobal.StatusWord. - Write only a guarded test variable such as
global.Setpoint, then confirm the runtime audit logs anads.server.writeevent.
For a quick live-value check in TwinCAT XAE, open the Target Browser, select
truST-ADS -> 851 -> global.Setpoint, and use the Target Browser value preview.
Scope View is not required for ADS server validation; it adds a separate Scope
Server recording workflow and should be treated as optional interop, not the
primary proof path.
For a notification proof from the Windows engineering station, use Beckhoff's ADS.NET client and make the test change the value itself. This avoids the common false failure where the subscription is running but the PLC-side mirror variable was edited without pulsing the ADS write command.
$work = "$env:TEMP\trust-ads-notify"
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $work | Out-Null
Set-Location $work
dotnet new console --framework net8.0
dotnet add package Beckhoff.TwinCAT.Ads
Replace Program.cs with:
using System;
using System.Threading;
using TwinCAT.Ads;
class Program
{
static void Main()
{
const string netId = "192.168.77.10.1.1";
const int port = 851;
const string symbol = "global.Setpoint";
using var changed = new ManualResetEventSlim(false);
using var client = new AdsClient();
client.Connect(netId, port);
var valueHandle = client.CreateVariableHandle(symbol);
var notificationHandle = 0u;
var expected = 0.0f;
try
{
var initial = Convert.ToSingle(client.ReadAny(valueHandle, typeof(float)));
expected = initial + 1.25f;
Console.WriteLine($"Initial {symbol} = {initial}");
client.AdsNotificationEx += (_sender, e) =>
{
var value = Convert.ToSingle(e.Value);
Console.WriteLine($"NOTIFICATION {symbol} = {value}");
if (Math.Abs(value - expected) < 0.001f)
{
changed.Set();
}
};
var settings = new NotificationSettings(AdsTransMode.OnChange, 100, 0);
notificationHandle = client.AddDeviceNotificationEx(
symbol,
settings,
null,
typeof(float));
Thread.Sleep(500);
Console.WriteLine($"Writing {symbol} = {expected}");
client.WriteAny(valueHandle, expected);
if (!changed.Wait(TimeSpan.FromSeconds(10)))
{
throw new TimeoutException("No changed notification arrived within 10 seconds.");
}
Console.WriteLine("Notification changed-value proof passed.");
}
finally
{
if (notificationHandle != 0)
{
client.DeleteDeviceNotification(notificationHandle);
}
client.DeleteVariableHandle(valueHandle);
}
}
}
Then run:
dotnet run
Expected output includes the initial value, one notification with the initial
value, the write, and a second notification with the changed value.
Beckhoff ADS.NET sends notification cycle and max-delay values on the ADS wire
in 100 ns units; truST normalizes those values before scheduling samples, so a
cycleTime of 100 in this script produces ordinary 100 ms notification
polling rather than a minutes-long delay.
TwinCAT PLC smoke program¶
Add the TwinCAT PLC library Tc2_DataExchange. Beckhoff documents
FB_ReadAdsSymByName and FB_WriteAdsSymByName there; both are edge-triggered,
so the command input must pulse instead of staying true forever. For the
optional sum-up proof below, also add Tc2_System for ADSRDWRT.
Create a GVL named GVL_truST_Test:
VAR_GLOBAL
TargetNetId : T_AmsNetId := '192.168.77.10.1.1';
TargetPort : T_AmsPort := 851;
CmdReadAll : BOOL := FALSE;
CmdWriteSetpoint : BOOL := FALSE;
CmdSumRead : BOOL := FALSE;
CmdSumReadWrite : BOOL := FALSE;
TankLevel : REAL;
PumpRunning : BOOL;
StatusWord : WORD;
SetpointReadback : REAL;
SetpointWrite : REAL := 55.5;
SumSetpoint : REAL;
SumTankLevel : REAL;
SumResult0 : UDINT;
SumResult1 : UDINT;
SumReadOk : BOOL;
SumRwSetpoint : REAL;
SumRwTankLevel : REAL;
SumRwResult0 : UDINT;
SumRwLength0 : UDINT;
SumRwResult1 : UDINT;
SumRwLength1 : UDINT;
SumReadWriteOk : BOOL;
Busy : BOOL;
AnyError : BOOL;
LastErrorId : UDINT;
END_VAR
For sum-up read, add these DUT declarations:
TYPE ST_truST_SumReadItem :
STRUCT
IndexGroup : UDINT;
IndexOffset : UDINT;
Length : UDINT;
END_STRUCT
END_TYPE
TYPE ST_truST_SumReadRequest2 :
STRUCT
Item0 : ST_truST_SumReadItem;
Item1 : ST_truST_SumReadItem;
END_STRUCT
END_TYPE
TYPE ST_truST_SumReadResponse2 :
STRUCT
Result0 : UDINT;
Result1 : UDINT;
Setpoint : REAL;
TankLevel: REAL;
END_STRUCT
END_TYPE
TYPE ST_truST_SumReadWriteItem :
STRUCT
IndexGroup : UDINT;
IndexOffset : UDINT;
ReadLength : UDINT;
WriteLength : UDINT;
END_STRUCT
END_TYPE
TYPE ST_truST_SumReadWriteRequest2 :
STRUCT
Item0 : ST_truST_SumReadWriteItem;
Item1 : ST_truST_SumReadWriteItem;
END_STRUCT
END_TYPE
TYPE ST_truST_SumReadWriteResponse2 :
STRUCT
Result0 : UDINT;
Length0 : UDINT;
Result1 : UDINT;
Length1 : UDINT;
Setpoint : REAL;
TankLevel: REAL;
END_STRUCT
END_TYPE
Use this MAIN program for the first read/write proof:
PROGRAM MAIN
VAR
fbReadTank : FB_ReadAdsSymByName;
fbReadPump : FB_ReadAdsSymByName;
fbReadStatus : FB_ReadAdsSymByName;
fbReadSetpoint : FB_ReadAdsSymByName;
fbWriteSetpoint : FB_WriteAdsSymByName;
fbSumRead : ADSRDWRT;
fbSumReadWrite : ADSRDWRT;
sumReq : ST_truST_SumReadRequest2;
sumResp : ST_truST_SumReadResponse2;
sumRwReq : ST_truST_SumReadWriteRequest2;
sumRwResp : ST_truST_SumReadWriteResponse2;
step : UINT := 0;
END_VAR
GVL_truST_Test.Busy := step <> 0;
CASE step OF
0:
fbReadTank(bRead := FALSE);
fbReadPump(bRead := FALSE);
fbReadStatus(bRead := FALSE);
fbReadSetpoint(bRead := FALSE);
fbWriteSetpoint(bWrite := FALSE);
fbSumRead(WRTRD := FALSE);
fbSumReadWrite(WRTRD := FALSE);
IF GVL_truST_Test.CmdReadAll THEN
GVL_truST_Test.AnyError := FALSE;
GVL_truST_Test.LastErrorId := 0;
GVL_truST_Test.CmdReadAll := FALSE;
step := 10;
ELSIF GVL_truST_Test.CmdWriteSetpoint THEN
GVL_truST_Test.AnyError := FALSE;
GVL_truST_Test.LastErrorId := 0;
GVL_truST_Test.CmdWriteSetpoint := FALSE;
step := 100;
ELSIF GVL_truST_Test.CmdSumRead THEN
GVL_truST_Test.AnyError := FALSE;
GVL_truST_Test.LastErrorId := 0;
GVL_truST_Test.SumReadOk := FALSE;
GVL_truST_Test.CmdSumRead := FALSE;
// truST assigns the example symbols to index group 16#4020:
// global.Setpoint at offset 1, global.TankLevel at offset 7.
sumReq.Item0.IndexGroup := 16#4020;
sumReq.Item0.IndexOffset := 1;
sumReq.Item0.Length := SIZEOF(GVL_truST_Test.SumSetpoint);
sumReq.Item1.IndexGroup := 16#4020;
sumReq.Item1.IndexOffset := 7;
sumReq.Item1.Length := SIZEOF(GVL_truST_Test.SumTankLevel);
step := 200;
ELSIF GVL_truST_Test.CmdSumReadWrite THEN
GVL_truST_Test.AnyError := FALSE;
GVL_truST_Test.LastErrorId := 0;
GVL_truST_Test.SumReadWriteOk := FALSE;
GVL_truST_Test.CmdSumReadWrite := FALSE;
// Same two reads, but through ADSIGRP_SUMUP_READWRITE (16#F082).
// WriteLength is zero here: this validates the response layout without
// changing the writable Setpoint.
sumRwReq.Item0.IndexGroup := 16#4020;
sumRwReq.Item0.IndexOffset := 1;
sumRwReq.Item0.ReadLength := SIZEOF(GVL_truST_Test.SumRwSetpoint);
sumRwReq.Item0.WriteLength := 0;
sumRwReq.Item1.IndexGroup := 16#4020;
sumRwReq.Item1.IndexOffset := 7;
sumRwReq.Item1.ReadLength := SIZEOF(GVL_truST_Test.SumRwTankLevel);
sumRwReq.Item1.WriteLength := 0;
step := 300;
END_IF
10:
fbReadTank(
bRead := TRUE,
sNetId := GVL_truST_Test.TargetNetId,
nPort := GVL_truST_Test.TargetPort,
sVarName := 'global.TankLevel',
nDestAddr := ADR(GVL_truST_Test.TankLevel),
nLen := SIZEOF(GVL_truST_Test.TankLevel),
tTimeout := T#2S);
IF NOT fbReadTank.bBusy THEN
IF fbReadTank.bError THEN
GVL_truST_Test.AnyError := TRUE;
GVL_truST_Test.LastErrorId := fbReadTank.nErrorId;
step := 0;
ELSE
step := 20;
END_IF
END_IF
20:
fbReadPump(
bRead := TRUE,
sNetId := GVL_truST_Test.TargetNetId,
nPort := GVL_truST_Test.TargetPort,
sVarName := 'global.PumpRunning',
nDestAddr := ADR(GVL_truST_Test.PumpRunning),
nLen := SIZEOF(GVL_truST_Test.PumpRunning),
tTimeout := T#2S);
IF NOT fbReadPump.bBusy THEN
IF fbReadPump.bError THEN
GVL_truST_Test.AnyError := TRUE;
GVL_truST_Test.LastErrorId := fbReadPump.nErrorId;
step := 0;
ELSE
step := 30;
END_IF
END_IF
30:
fbReadStatus(
bRead := TRUE,
sNetId := GVL_truST_Test.TargetNetId,
nPort := GVL_truST_Test.TargetPort,
sVarName := 'global.StatusWord',
nDestAddr := ADR(GVL_truST_Test.StatusWord),
nLen := SIZEOF(GVL_truST_Test.StatusWord),
tTimeout := T#2S);
IF NOT fbReadStatus.bBusy THEN
IF fbReadStatus.bError THEN
GVL_truST_Test.AnyError := TRUE;
GVL_truST_Test.LastErrorId := fbReadStatus.nErrorId;
END_IF
step := 0;
END_IF
100:
fbWriteSetpoint(
bWrite := TRUE,
sNetId := GVL_truST_Test.TargetNetId,
nPort := GVL_truST_Test.TargetPort,
sVarName := 'global.Setpoint',
nSrcAddr := ADR(GVL_truST_Test.SetpointWrite),
nLen := SIZEOF(GVL_truST_Test.SetpointWrite),
tTimeout := T#2S);
IF NOT fbWriteSetpoint.bBusy THEN
IF fbWriteSetpoint.bError THEN
GVL_truST_Test.AnyError := TRUE;
GVL_truST_Test.LastErrorId := fbWriteSetpoint.nErrorId;
step := 0;
ELSE
step := 110;
END_IF
END_IF
110:
fbReadSetpoint(
bRead := TRUE,
sNetId := GVL_truST_Test.TargetNetId,
nPort := GVL_truST_Test.TargetPort,
sVarName := 'global.Setpoint',
nDestAddr := ADR(GVL_truST_Test.SetpointReadback),
nLen := SIZEOF(GVL_truST_Test.SetpointReadback),
tTimeout := T#2S);
IF NOT fbReadSetpoint.bBusy THEN
IF fbReadSetpoint.bError THEN
GVL_truST_Test.AnyError := TRUE;
GVL_truST_Test.LastErrorId := fbReadSetpoint.nErrorId;
END_IF
step := 0;
END_IF
200:
fbSumRead(
NETID := GVL_truST_Test.TargetNetId,
PORT := GVL_truST_Test.TargetPort,
IDXGRP := 16#F080,
IDXOFFS := 2,
WRITELEN := SIZEOF(sumReq),
READLEN := SIZEOF(sumResp),
SRCADDR := ADR(sumReq),
DESTADDR := ADR(sumResp),
WRTRD := TRUE,
TMOUT := T#2S);
IF NOT fbSumRead.BUSY THEN
IF fbSumRead.ERR THEN
GVL_truST_Test.AnyError := TRUE;
GVL_truST_Test.LastErrorId := fbSumRead.ERRID;
ELSE
GVL_truST_Test.SumResult0 := sumResp.Result0;
GVL_truST_Test.SumResult1 := sumResp.Result1;
GVL_truST_Test.SumSetpoint := sumResp.Setpoint;
GVL_truST_Test.SumTankLevel := sumResp.TankLevel;
GVL_truST_Test.SumReadOk := (sumResp.Result0 = 0) AND (sumResp.Result1 = 0);
END_IF
step := 0;
END_IF
300:
fbSumReadWrite(
NETID := GVL_truST_Test.TargetNetId,
PORT := GVL_truST_Test.TargetPort,
IDXGRP := 16#F082,
IDXOFFS := 2,
WRITELEN := SIZEOF(sumRwReq),
READLEN := SIZEOF(sumRwResp),
SRCADDR := ADR(sumRwReq),
DESTADDR := ADR(sumRwResp),
WRTRD := TRUE,
TMOUT := T#2S);
IF NOT fbSumReadWrite.BUSY THEN
IF fbSumReadWrite.ERR THEN
GVL_truST_Test.AnyError := TRUE;
GVL_truST_Test.LastErrorId := fbSumReadWrite.ERRID;
ELSE
GVL_truST_Test.SumRwResult0 := sumRwResp.Result0;
GVL_truST_Test.SumRwLength0 := sumRwResp.Length0;
GVL_truST_Test.SumRwResult1 := sumRwResp.Result1;
GVL_truST_Test.SumRwLength1 := sumRwResp.Length1;
GVL_truST_Test.SumRwSetpoint := sumRwResp.Setpoint;
GVL_truST_Test.SumRwTankLevel := sumRwResp.TankLevel;
GVL_truST_Test.SumReadWriteOk :=
(sumRwResp.Result0 = 0)
AND (sumRwResp.Length0 = SIZEOF(GVL_truST_Test.SumRwSetpoint))
AND (sumRwResp.Result1 = 0)
AND (sumRwResp.Length1 = SIZEOF(GVL_truST_Test.SumRwTankLevel));
END_IF
step := 0;
END_IF
END_CASE
Online, set GVL_truST_Test.CmdReadAll := TRUE for a read pass. Set
GVL_truST_Test.SetpointWrite, then set CmdWriteSetpoint := TRUE for a guarded
write and read-back pass. Set CmdSumRead := TRUE for a SUMUP_READ pass; it
should set SumReadOk = TRUE and copy both global.Setpoint and
global.TankLevel in one ADS read-write command. Set CmdSumReadWrite := TRUE
for a SUMUP_READWRITE response-layout proof; it should set
SumReadWriteOk = TRUE and report SumRwLength0 = 4, SumRwLength1 = 4. If
LastErrorId becomes 1797 or 1798, first check that the route is present,
the truST runtime is running, and the command bit was not left permanently true.
Plain ADS is cleartext and route-based. Keep it on a trusted OT segment.
Quality Variables¶
Each ADS value gets a sibling quality variable:
TYPE
ADS_QUALITY : (Stale := 0, Good := 1, Error := 2);
END_TYPE
VAR_GLOBAL
line1_temp : REAL;
line1_temp_quality : ADS_QUALITY := Stale;
END_VAR
Use the value and quality together in logic and operator views. A generated
quality starts as Stale, moves to Good after a successful read, and moves to
Error when the ADS point reports a point-level error. The runtime updates
these quality globals at scan boundaries alongside the ADS values.
ADS To OPC UA Re-Export¶
ADS-imported globals can be re-exported through OPC UA because they are ordinary declared globals after generation:
[runtime.opcua]
enabled = true
endpoint = "opc.tcp://0.0.0.0:4840"
namespace_uri = "urn:trust:line1"
expose = ["line1_temp", "line1_temp_quality", "line1_ready"]
Wire-level OPC UA serving still requires a runtime built with opcua-wire.
Example¶
This example shows the ADS customer workflow after the VS Code ADS panel or the
runtime-host /setup/ads wizard has imported symbols: a cached TwinCAT symbol
snapshot and ads.toml generate reviewed ST globals that compile without a PLC
connection.
What you learn¶
- how TwinCAT symbols map to generated ST globals
- how cached symbol snapshots make CI/offline builds deterministic
- why plain ADS must be explicitly acknowledged before use
- how
[runtime.ads]turns the reviewedads.tomlbindings into live runtime scan-boundary updates when the runtime is built withads-wire - why production-ready requires a deployed runtime-host Doctor/status proof, not just an offline generated file
Files in this folder¶
ads.toml: ADS connection and point bindingsads/snapshots/line1.symbols.json: cached TwinCAT symbol metadatasrc/generated/ads_generated.st: generated value and_qualityglobalssrc/main.st: sample logic using generated ADS globalssrc/config.st: task bindingruntime.toml,io.toml,trust-lsp.toml: runtime/project defaults
TwinCAT side checklist¶
Use this checklist when truST is the ADS client and TwinCAT is the PLC target:
- Put the TwinCAT PLC runtime in Run mode.
- Confirm the PLC logical ADS port is
851. - Add a route on the TwinCAT side back to the truST runtime host. The route must use the truST runtime host IP and AMS Net ID, not the developer laptop unless the laptop is the runtime host.
- Allow TCP
48898and UDP48899between the TwinCAT machine and the truST runtime host. - Prefer a simple private subnet during first setup. The live lab used
192.168.77.10/24for truST and192.168.77.11/24for TwinCAT. - Use scalar TwinCAT symbols or scalar leaf members for the first import.
If broadcast discovery is blocked, enter the TwinCAT IP and AMS Net ID manually
in the VS Code ADS panel, /setup/ads, or trust-runtime ads doctor.
Step 1: Validate the generated interface offline¶
Why: prove the checked-in generated ST still matches ads.toml and the cached
symbol snapshot before connecting to plant hardware.
trust-runtime ads validate --offline \
--config ads.toml \
--snapshot ads/snapshots/line1.symbols.json \
--generated src/generated/ads_generated.st
Step 2: Build the project¶
Why: confirms the generated ADS globals and ADS_QUALITY type are valid ST.
trust-runtime build --project . --sources src
Step 3: Run against a TwinCAT target¶
Why: [runtime.ads] in runtime.toml loads ads.toml at startup and starts a
background ADS worker per connection. The scan thread only copies cached values
into the generated globals and queues write intents back to the worker.
trust-runtime play --project . --no-console
Live ADS I/O requires a trust-runtime build with feature ads-wire and a
valid TwinCAT route. Without ads-wire, startup fails clearly instead of
silently ignoring ADS.
Before treating the target as ready, run the Doctor from the same runtime host that will run ADS:
trust-runtime ads doctor \
--target 192.168.10.5 \
--target-net-id 5.23.91.12.1.1 \
--ams-port 851
For a healthy read/notification path, expect read_state, symbol_upload,
handle_resolve, sumup_read, notification, and symbol_version to pass.
The command may still report partial when guarded writes are disabled; that
means the read path is proven but the write path has not been validated.
To validate a safe write point, add an explicit guarded write probe. The Doctor writes the probe value, reads it back, restores the original value, and verifies the restore:
trust-runtime ads doctor \
--target 192.168.10.5 \
--target-net-id 5.23.91.12.1.1 \
--ams-port 851 \
--write-symbol GVL.Setpoint \
--write-type REAL \
--write-value 12.5
Use a harmless setpoint or test variable for this probe.
Step 4: Regenerate after a TwinCAT symbol change¶
Why: regenerated ST is intentionally git-reviewed, not hidden build output.
Run this after a live symbol import from the TwinCAT target; CI should still use
validate --offline against the cached snapshot.
trust-runtime ads import-symbols \
--target 192.168.10.5 \
--target-net-id 5.23.91.12.1.1 \
--connection line1 \
--out ads.toml \
--gen src/generated/ads_generated.st \
--force
Use --force only when replacing a reviewed generated file with the new
deterministic output.
TwinCAT Usermode Runtime Notes¶
The Windows/TwinCAT Usermode lab exposed several setup details that are easy to miss:
- If normal Run mode is blocked by Hyper-V or a VM host, use TwinCAT Usermode Runtime/XAR user mode.
- If the PLC and runtime are on a
100.64.0.0/10address, Linux may treat it as Tailscale/CGNAT traffic. A simple dedicated subnet such as192.168.77.10/24for truST and192.168.77.11/24for TwinCAT is easier to prove. - For Linux/non-Windows ADS clients, static routes should use
<Flags>0</Flags>. - TwinCAT Usermode installations can keep two relevant route files:
...\Runtimes\UmRT_Default\3.1\StaticRoutes.xmland...\Runtimes\UmRT_Default\3.1\Target\StaticRoutes.xml. Keep both aligned if the TwinCAT UI shows the route from one file while the router uses the other. - If TCP
48898connects butread_statetimes out with ADS error1861, restart the Usermode Runtime. If the router still behaves as if the old route is loaded, reboot Windows and start TwinCAT again. - TwinCAT symbol tables contain library/STRUCT metadata such as
ST_LibVersion. The importer skips unsupported complex symbols and imports compatible scalar symbols; bind leaf members instead of whole structs.
Security notes¶
This fixture uses transport = "plain" and insecure_transport = true because
classic ADS is cleartext. Keep this traffic on a private OT segment and require
explicit write bindings (access = "write") for any remote command path.