<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mridul Krishna</title>
    <description>The latest articles on DEV Community by Mridul Krishna (@mridulkrishna).</description>
    <link>https://dev.to/mridulkrishna</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4128141%2Fca1871b2-5515-40ac-9ade-54f8974b9131.png</url>
      <title>DEV Community: Mridul Krishna</title>
      <link>https://dev.to/mridulkrishna</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mridulkrishna"/>
    <language>en</language>
    <item>
      <title>Talking to a PLC from C#: A .NET Developer's Intro to Beckhoff ADS</title>
      <dc:creator>Mridul Krishna</dc:creator>
      <pubDate>Wed, 16 Sep 2026 13:44:10 +0000</pubDate>
      <link>https://dev.to/mridulkrishna/talking-to-a-plc-from-c-a-net-developers-intro-to-beckhoff-ads-15k8</link>
      <guid>https://dev.to/mridulkrishna/talking-to-a-plc-from-c-a-net-developers-intro-to-beckhoff-ads-15k8</guid>
      <description>&lt;p&gt;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:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The controller's already running. We just need your app to read the temperature off it and flip a couple of outputs."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're actually talking to
&lt;/h2&gt;

&lt;p&gt;On a Beckhoff system, two things matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TwinCAT&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADS&lt;/strong&gt; (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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think of ADS as the PLC's API. Your C# app is just another client making calls.&lt;/p&gt;

&lt;p&gt;There are three concepts that trip up every newcomer, so let's name them up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AmsNetId&lt;/strong&gt; — the address of the target, like an IP but with six octets: &lt;code&gt;192.168.1.10.1.1&lt;/code&gt;. Very often it's literally your controller's IP with &lt;code&gt;.1.1&lt;/code&gt; tacked on. This is &lt;em&gt;not&lt;/em&gt; the same as the IP address, and mixing them up is a rite of passage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADS port&lt;/strong&gt; — identifies &lt;em&gt;which service&lt;/em&gt; on the target you want. The first PLC runtime on TwinCAT 3 lives on port &lt;strong&gt;851&lt;/strong&gt; (it was 801 on TwinCAT 2). You'll memorise 851 whether you want to or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AMS router&lt;/strong&gt; — 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Before a single line of code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;TwinCAT (or the standalone ADS runtime) installed, so the AMS router is running.&lt;/li&gt;
&lt;li&gt;The NuGet package: &lt;code&gt;Beckhoff.TwinCAT.Ads&lt;/code&gt;. The modern 6.x version targets .NET Standard, so it works from .NET Framework and .NET Core / .NET 6+ alike.&lt;/li&gt;
&lt;li&gt;If you're connecting to a &lt;em&gt;remote&lt;/em&gt; controller, an &lt;strong&gt;ADS route&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The first connection
&lt;/h2&gt;

&lt;p&gt;Here's the whole thing — connect, read one value, write one back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;TwinCAT.Ads&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AdsClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Connect to the local PLC runtime on port 851.&lt;/span&gt;
&lt;span class="c1"&gt;// For a remote target, swap in its AmsNetId: new AmsNetId("192.168.1.10.1.1")&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AmsNetId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;851&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Read a variable by its symbolic path in the PLC&lt;/span&gt;
&lt;span class="kt"&gt;short&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;short&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"GVL.iTemperature"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Temperature: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Write a variable back&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MAIN.bStartHeater"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No sockets, no byte packing — you address PLC variables by name (&lt;code&gt;GVL.iTemperature&lt;/code&gt;, &lt;code&gt;MAIN.bStartHeater&lt;/code&gt;), 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake everyone makes next
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Don't.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;always slightly stale&lt;/em&gt; — you find out about a change up to one timer-tick late, every time.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;device notification&lt;/strong&gt; on a symbol, hand it a callback, and your code reacts only when something actually happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Notify me whenever iTemperature changes (on-change, not on a clock)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDeviceNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"GVL.iTemperature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;NotificationSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AdsTransMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnChange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cycleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;userData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AdsNotification&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// fires only when the value changes — no polling loop in sight&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Temperature changed"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few gotchas that'll cost you an afternoon
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PLC types are not C# types.&lt;/strong&gt; A PLC &lt;code&gt;INT&lt;/code&gt; is 16-bit — that's a C# &lt;code&gt;short&lt;/code&gt; / &lt;code&gt;Int16&lt;/code&gt;, not &lt;code&gt;int&lt;/code&gt;. Read a PLC &lt;code&gt;INT&lt;/code&gt; into a C# &lt;code&gt;int&lt;/code&gt; and you'll get garbage or an outright error. The map you'll want: &lt;code&gt;BOOL → bool&lt;/code&gt;, &lt;code&gt;INT → short&lt;/code&gt;, &lt;code&gt;DINT → int&lt;/code&gt;, &lt;code&gt;REAL → float&lt;/code&gt;, &lt;code&gt;LREAL → double&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AmsNetId ≠ IP address.&lt;/strong&gt; Six octets, not four. When a connection silently fails, this is the first thing to check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The router is the usual culprit.&lt;/strong&gt; "Target machine not found" almost always means a missing route or a stopped router — not your code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connections drop.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;Is anyone else here doing .NET on the industrial / automation side? I'd love to hear what tripped &lt;em&gt;you&lt;/em&gt; up early — the factory-floor corner of software is quiet on here, and it'd be great to find the others. 👋&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>iot</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
