You know your way around C#. You've built WinForms tools, maybe some WPF, wired up APIs and databases. Then one day you land on an industrial project, someone points at a laser-cutting machine humming in the corner, and says:
"The controller's already running. We just need your app to read the temperature off it and flip a couple of outputs."
And you realise every tutorial you've ever read assumed the thing on the other end was a web server or a database — not a physical machine.
I've spent most of the last 15 years in exactly this corner of software, and the honest truth is the first connection to a PLC is the hardest part. Not because it's complicated, but because nobody writes about it. So this is the post I wish I'd had on day one.
What we're actually talking to
On a Beckhoff system, two things matter:
- TwinCAT is the runtime that runs the PLC logic on the controller. Your control program — the ladder logic, the structured text, the state machines — lives here.
- ADS (Automation Device Specification) is the messaging protocol you use to talk to it. Reading a variable, writing a variable, subscribing to changes — all of it rides on ADS.
Think of ADS as the PLC's API. Your C# app is just another client making calls.
There are three concepts that trip up every newcomer, so let's name them up front:
-
AmsNetId — the address of the target, like an IP but with six octets:
192.168.1.10.1.1. Very often it's literally your controller's IP with.1.1tacked on. This is not the same as the IP address, and mixing them up is a rite of passage. - ADS port — identifies which service on the target you want. The first PLC runtime on TwinCAT 3 lives on port 851 (it was 801 on TwinCAT 2). You'll memorise 851 whether you want to or not.
- The AMS router — a background service that actually routes your messages. It ships with TwinCAT. If it isn't running, nothing works, and the error won't be obvious. On a dev machine without full TwinCAT, install Beckhoff's standalone ADS setup so you get the router.
The setup
Before a single line of code:
- TwinCAT (or the standalone ADS runtime) installed, so the AMS router is running.
- The NuGet package:
Beckhoff.TwinCAT.Ads. The modern 6.x version targets .NET Standard, so it works from .NET Framework and .NET Core / .NET 6+ alike. - If you're connecting to a remote controller, an ADS route between the two machines — both sides have to know and trust each other. You set this up once in the TwinCAT router tool. Locally, you can skip this.
The first connection
Here's the whole thing — connect, read one value, write one back:
using TwinCAT.Ads;
using var client = new AdsClient();
// Connect to the local PLC runtime on port 851.
// For a remote target, swap in its AmsNetId: new AmsNetId("192.168.1.10.1.1")
client.Connect(AmsNetId.Local, 851);
// Read a variable by its symbolic path in the PLC
short temperature = client.ReadValue<short>("GVL.iTemperature");
Console.WriteLine($"Temperature: {temperature}");
// Write a variable back
client.WriteValue("MAIN.bStartHeater", true);
That's it. No sockets, no byte packing — you address PLC variables by name (GVL.iTemperature, MAIN.bStartHeater), and the library resolves the rest. The first time you see a real value come back from a real machine, it genuinely feels like magic.
The mistake everyone makes next
Once reading works, the instinct is obvious: you need the temperature to stay current, so you drop the read into a loop on a timer. Tick every 200 ms, read the value, update the UI. Done.
Don't.
Polling on a timer is the thing I'd most warn my past self away from. It hammers the PLC with requests it didn't ask for, it scales terribly the moment you're watching more than a handful of variables, and it makes your app's data always slightly stale — you find out about a change up to one timer-tick late, every time.
ADS already solves this. It's event-driven: you tell the PLC "notify me when this value changes," and it pushes the update to you. You register a device notification on a symbol, hand it a callback, and your code reacts only when something actually happens:
// Notify me whenever iTemperature changes (on-change, not on a clock)
client.AddDeviceNotification(
"GVL.iTemperature",
new NotificationSettings(AdsTransMode.OnChange, cycleTime: 100, maxDelay: 0),
userData: null);
client.AdsNotification += (sender, e) =>
{
// fires only when the value changes — no polling loop in sight
Console.WriteLine("Temperature changed");
};
Same idea for keeping a connection healthy — lean on the library's connection state events rather than a heartbeat timer you babysit yourself. Let the protocol do the work it was designed to do.
A few gotchas that'll cost you an afternoon
-
PLC types are not C# types. A PLC
INTis 16-bit — that's a C#short/Int16, notint. Read a PLCINTinto a C#intand you'll get garbage or an outright error. The map you'll want:BOOL → bool,INT → short,DINT → int,REAL → float,LREAL → double. - AmsNetId ≠ IP address. Six octets, not four. When a connection silently fails, this is the first thing to check.
- The router is the usual culprit. "Target machine not found" almost always means a missing route or a stopped router — not your code.
- Connections drop. Machines get power-cycled, cables get kicked. Assume the link will die and handle reconnection deliberately, instead of assuming a happy path that only exists in the demo.
Where to go from here
Once connect / read / write / notify click into place, the rest opens up fast: reading whole structured types straight into C# classes, batching many reads into a single request, and building a proper abstraction so the rest of your app never touches ADS directly.
But that first working read is the milestone. If you get a value back from a real machine today, you're past the hardest part.
Is anyone else here doing .NET on the industrial / automation side? I'd love to hear what tripped you up early — the factory-floor corner of software is quiet on here, and it'd be great to find the others. 👋
Top comments (0)