Skip to content

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.

  1. Select the online runtime in the VS Code Runtime pane.
  2. Open Structured Text: Add Beckhoff ADS Device or open the runtime-host setup page at /setup/ads.
  3. Discover the TwinCAT target. Broadcast discovery is easiest when UDP 48899 is allowed; otherwise enter the TwinCAT IP and AMS Net ID manually.
  4. In Devices & Connections, choose the logical ADS port before browsing symbols. Port 851 is the default PLC runtime; other examples include 301 for an I/O server, 501 for NC/Motion, and 852 or later for additional PLC runtimes. Each port is a separate ADS server with its own symbol namespace; this setting does not search all ports.
  5. 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.
  6. Run the ADS Doctor from the runtime host.
  7. Import TwinCAT symbols into ads.toml, a cached symbol snapshot, and the single generated ST file src/generated/ads_generated.st.
  8. Deploy or reload the project bundle, then verify ads.status and generated value/_quality globals 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.

  1. Enable [runtime.ads_server] in runtime.toml.
  2. Set one concrete listen IP, an AMS Net ID, and logical ads_port = 851.
  3. Add the globals to expose. Leave writes_enabled = false unless a specific write path is required.
  4. Allowlist the TwinCAT client with its AMS Net ID and source IP or CIDR.
  5. Start the truST runtime.
  6. 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.
  7. Browse the truST target on logical port 851. Exposed globals appear with the global. prefix, for example global.TankLevel and global.Setpoint.
  8. Read first. Test writes only against a guarded writable variable such as global.Setpoint.

TwinCAT Setup Hints

  • ADS uses TCP 48898 for router transport and UDP 48899 for discovery and route-related setup. The logical PLC ADS port is usually 851, 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 = false when the section is omitted.
  • listen is required when enabled and must be one concrete IP address; 0.0.0.0 is 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 = false by default, and write-back also requires the symbol to match writable.

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:

  1. loopback self-test passes,
  2. an independent external client such as pyads browses, reads, subscribes, and performs any guarded write that is enabled,
  3. 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 48898 while the logical ADS port remains 851
  • 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 as global.TankLevel, global.PumpRunning, global.Setpoint, and global.StatusWord
  • src/main.st: simple logic that keeps values changing through the normal scan
  • runtime.toml: ADS server config for local pyads smoke testing
  • io.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:

  1. Start the truST runtime with [runtime.ads_server] enabled.
  2. In TwinCAT XAE, open SYSTEM > Routes.
  3. Use Add > Broadcast Search when UDP 48899 is 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.
  4. Browse the truST target on logical ADS port 851.
  5. Read symbols with their full wire names, for example global.TankLevel and global.Setpoint.
  6. 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_VERSION read
  • notification subscription
  • guarded write and restore for global.Setpoint
  • optional ads.server.doctor call with external_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:

  1. Browse for the truST ADS target and confirm it appears in Broadcast Search.
  2. 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.
  3. Browse symbols and confirm datatype upload works.
  4. Read global.TankLevel, global.PumpRunning, and global.StatusWord.
  5. Write only a guarded test variable such as global.Setpoint, then confirm the runtime audit logs an ads.server.write event.

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 reviewed ads.toml bindings into live runtime scan-boundary updates when the runtime is built with ads-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 bindings
  • ads/snapshots/line1.symbols.json: cached TwinCAT symbol metadata
  • src/generated/ads_generated.st: generated value and _quality globals
  • src/main.st: sample logic using generated ADS globals
  • src/config.st: task binding
  • runtime.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:

  1. Put the TwinCAT PLC runtime in Run mode.
  2. Confirm the PLC logical ADS port is 851.
  3. 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.
  4. Allow TCP 48898 and UDP 48899 between the TwinCAT machine and the truST runtime host.
  5. Prefer a simple private subnet during first setup. The live lab used 192.168.77.10/24 for truST and 192.168.77.11/24 for TwinCAT.
  6. 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/10 address, Linux may treat it as Tailscale/CGNAT traffic. A simple dedicated subnet such as 192.168.77.10/24 for truST and 192.168.77.11/24 for 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.xml and ...\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 48898 connects but read_state times out with ADS error 1861, 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.