<?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#, Part 3: Safe Writes and a Clean Abstraction Layer</title>
      <dc:creator>Mridul Krishna</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:18:59 +0000</pubDate>
      <link>https://dev.to/mridulkrishna/talking-to-a-plc-from-c-part-3-safe-writes-and-a-clean-abstraction-layer-57nn</link>
      <guid>https://dev.to/mridulkrishna/talking-to-a-plc-from-c-part-3-safe-writes-and-a-clean-abstraction-layer-57nn</guid>
      <description>&lt;p&gt;We've come a long way. In &lt;a href="https://dev.to/mridulkrishna/talking-to-a-plc-from-c-a-net-developers-intro-to-beckhoff-ads-15k8"&gt;Part 1&lt;/a&gt; we connected a C# app to a Beckhoff PLC and did our first read and write. In Part 2 we stopped polling, subscribed to notifications, and learned to read a whole struct in one shot.&lt;/p&gt;

&lt;p&gt;So far, though, we've mostly been &lt;em&gt;reading&lt;/em&gt;. This post is about the other direction — writing back — and it's where the stakes change. Reading is passive. Writing changes what a physical machine does. Get it wrong and you don't get a stack trace; you get a scrapped part, a crashed axis, or worse.&lt;/p&gt;

&lt;p&gt;And then we'll do the thing that separates a script from a product: hide all of this behind a clean interface, so the rest of your application never touches ADS at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing a struct back
&lt;/h2&gt;

&lt;p&gt;Mechanically, writing is the mirror of the read from Part 2. Same layout rules, same one-call efficiency:&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;setpoints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;JobSetpoints&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;LaserPower&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// watts&lt;/span&gt;
    &lt;span class="n"&gt;CuttingSpeed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3.5f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// m/min&lt;/span&gt;
    &lt;span class="n"&gt;GasPressure&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2.0f&lt;/span&gt;   &lt;span class="c1"&gt;// bar&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="nf"&gt;WriteValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GVL.JobSetpoints"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setpoints&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One call, and the entire setpoint block lands in the PLC. Which raises the obvious question: why write the &lt;em&gt;whole&lt;/em&gt; struct instead of just the field that changed?&lt;/p&gt;

&lt;p&gt;Two reasons, and the second one matters more than it looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistency.&lt;/strong&gt; If you write field-by-field across several calls, the PLC's scan can run &lt;em&gt;between&lt;/em&gt; your writes — and act on a state that's half-old, half-new. New power, old speed, for one cycle. Writing the block in a single call keeps your values together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consequences.&lt;/strong&gt; These aren't database rows. &lt;code&gt;LaserPower = 15000&lt;/code&gt; because someone fat-fingered a form is a physical event. So the write path is the right place to be paranoid — validate &lt;em&gt;before&lt;/em&gt; the value ever leaves your app.&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;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;SetSetpoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JobSetpoints&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LaserPower&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ArgumentOutOfRangeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LaserPower&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CuttingSpeed&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;20f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ArgumentOutOfRangeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CuttingSpeed&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="nf"&gt;WriteValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GVL.JobSetpoints"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&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;And the rule that ties back to everything in this series: &lt;strong&gt;write on change, not on a clock.&lt;/strong&gt; Push a setpoint when the user actually changes it. Don't loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commands need a handshake, not a fire-and-forget
&lt;/h2&gt;

&lt;p&gt;Setting a value is one thing. Telling the machine to &lt;em&gt;do&lt;/em&gt; something — start cutting, home an axis, run a job — is another. For those, a bare write is not enough, because a write tells you the value arrived, not that the machine acted on it, finished, or failed.&lt;/p&gt;

&lt;p&gt;The pattern every automation engineer will recognise is a &lt;strong&gt;handshake&lt;/strong&gt;: you write the data, raise an &lt;code&gt;Execute&lt;/code&gt; request, and wait for the PLC to raise &lt;code&gt;Done&lt;/code&gt; (or &lt;code&gt;Error&lt;/code&gt;) back. The PLC owns the state machine; your app just requests and waits. It's the same shape as a PLCopen function block.&lt;/p&gt;

&lt;p&gt;Here's that handshake in C#, using a notification (from Part 2) so we &lt;em&gt;wait&lt;/em&gt; on the Done bit instead of polling it:&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;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteJobAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;TaskCompletionSource&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="kt"&gt;uint&lt;/span&gt; &lt;span class="n"&gt;handle&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="nf"&gt;AddDeviceNotificationEx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"GVL.stJob.bDone"&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;50&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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnNotify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&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;AdsNotificationExEventArgs&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;{&lt;/span&gt;
        &lt;span class="k"&gt;if&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="n"&gt;Handle&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&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="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrySetResult&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;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;AdsNotificationEx&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;OnNotify&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&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;WriteValueAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GVL.stJob.bExecute"&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;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrySetCanceled&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// resolves when the PLC sets bDone&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&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;WriteValueAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GVL.stJob.bExecute"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// reset the request&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;AdsNotificationEx&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;OnNotify&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="nf"&gt;DeleteDeviceNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;);&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;Notice what this buys you: the caller gets a clean &lt;code&gt;await ExecuteJobAsync()&lt;/code&gt; that completes when the &lt;em&gt;machine&lt;/em&gt; is actually done — no polling loop, no timer, no guessing. The &lt;code&gt;finally&lt;/code&gt; resets the request bit and releases the notification even if something throws or the operation is cancelled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now hide all of it
&lt;/h2&gt;

&lt;p&gt;Here's the part I'd argue matters most for anyone building a real HMI.&lt;/p&gt;

&lt;p&gt;Everything above — &lt;code&gt;AdsClient&lt;/code&gt;, &lt;code&gt;AmsNetId&lt;/code&gt;, symbol strings like &lt;code&gt;"GVL.stJob.bExecute"&lt;/code&gt;, notification handles — is &lt;em&gt;plumbing&lt;/em&gt;. If it leaks into your ViewModels, your forms, and your business logic, you've welded your entire application to Beckhoff. Your UI can't be tested without a live PLC. Swapping to a simulator means touching a hundred files. Every symbol-name typo is a landmine scattered across the codebase.&lt;/p&gt;

&lt;p&gt;So don't let it leak. Put a wall between "the app" and "the PLC," and let only a thin gateway cross it.&lt;/p&gt;

&lt;p&gt;Start with an interface that speaks the language of the &lt;em&gt;machine&lt;/em&gt;, not of ADS:&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;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IMachineGateway&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IDisposable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsConnected&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;event&lt;/span&gt; &lt;span class="n"&gt;EventHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ConnectionChanged&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;event&lt;/span&gt; &lt;span class="n"&gt;EventHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MachineStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StatusChanged&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ConnectAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SetSetpointsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JobSetpoints&lt;/span&gt; &lt;span class="n"&gt;setpoints&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteJobAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&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;Nothing in there mentions ADS. No &lt;code&gt;AmsNetId&lt;/code&gt;, no symbol paths, no &lt;code&gt;AdsClient&lt;/code&gt;. Just machine concepts.&lt;/p&gt;

&lt;p&gt;The TwinCAT implementation is the &lt;em&gt;only&lt;/em&gt; place that knows the truth:&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;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TwinCatMachineGateway&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IMachineGateway&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;AdsClient&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="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;uint&lt;/span&gt; &lt;span class="n"&gt;_statusHandle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsConnected&lt;/span&gt; &lt;span class="p"&gt;=&amp;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;IsConnected&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;event&lt;/span&gt; &lt;span class="n"&gt;EventHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ConnectionChanged&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;event&lt;/span&gt; &lt;span class="n"&gt;EventHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MachineStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StatusChanged&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ConnectAsync&lt;/span&gt;&lt;span class="p"&gt;()&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="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;// Live machine status -&amp;gt; a plain .NET event the app understands&lt;/span&gt;
        &lt;span class="n"&gt;_statusHandle&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="nf"&gt;AddDeviceNotificationEx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"GVL.MachineStatus"&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="m"&gt;100&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="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MachineStatus&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;AdsNotificationEx&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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="k"&gt;if&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="n"&gt;Handle&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;_statusHandle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;StatusChanged&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;Invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MachineStatus&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="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="n"&gt;ConnectionChanged&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;Invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SetSetpointsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JobSetpoints&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                   &lt;span class="c1"&gt;// paranoia lives here&lt;/span&gt;
        &lt;span class="k"&gt;return&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;WriteValueAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GVL.JobSetpoints"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;AsTask&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExecuteJobAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="cm"&gt;/* the handshake from above */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_statusHandle&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;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DeleteDeviceNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_statusHandle&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="nf"&gt;Dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JobSetpoints&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* range checks */&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;Now look at what your WPF ViewModel becomes:&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="n"&gt;_gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusChanged&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Dispatcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Invoke&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SpindleTemp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SpindleTemp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// user clicks "Run"&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteJobAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has no idea a Beckhoff PLC is on the other end. And that's the whole point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can test the UI with no hardware.&lt;/strong&gt; Write a &lt;code&gt;FakeMachineGateway : IMachineGateway&lt;/code&gt; that returns canned status and pretends to run jobs. Your whole front end becomes developable and testable on a laptop with no machine in sight — which, for an HMI team, is the difference between "we can work" and "we're all queuing for the one test rig."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Symbol strings live in exactly one file.&lt;/strong&gt; Rename a PLC variable, fix it once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection handling is centralised.&lt;/strong&gt; Reconnection, error translation, lifecycle — one place, driven by the client's connection-state events rather than a heartbeat timer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADS failures become domain results.&lt;/strong&gt; Catch &lt;code&gt;AdsErrorException&lt;/code&gt; at the boundary and translate it into something your app understands, so an &lt;code&gt;AdsErrorCode&lt;/code&gt; never surfaces in a button-click handler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few rules that keep the wall standing: make the gateway a &lt;strong&gt;single long-lived instance&lt;/strong&gt; (don't spin up an &lt;code&gt;AdsClient&lt;/code&gt; per view); never let an ADS type appear in the interface's signatures; and let the gateway own the full ADS lifecycle so &lt;code&gt;Dispose&lt;/code&gt; actually cleans up notifications and the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole series, in one breath
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1&lt;/strong&gt; — connect, and do a single read and write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2&lt;/strong&gt; — stop polling; subscribe to notifications, and read a whole struct in one call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3&lt;/strong&gt; — write back with validation, use a handshake for commands, and hide ADS behind an interface the rest of your app can actually live with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put those together and you've got the real backbone of an HMI: live machine state flowing in, validated commands going out, and an application architecture that doesn't know — or care — that there's a PLC behind the curtain. Which, not coincidentally, is exactly the foundation you'd want if you ever wanted to put something smarter on top of that gateway later.&lt;/p&gt;




&lt;p&gt;That wraps the series — thank you to everyone who followed along and commented; it genuinely made writing it worthwhile.&lt;/p&gt;

&lt;p&gt;If you're building HMIs or industrial apps in .NET, I'd love to know how &lt;em&gt;you&lt;/em&gt; structure the boundary between your UI and the control layer. And if there's a topic you'd want me to go deeper on next — WPF data binding to live PLC values, simulators, testing strategies — tell me in the comments. 👋&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>iot</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Talking to a PLC from C#, Part 2: Notifications and Whole-Struct Reads</title>
      <dc:creator>Mridul Krishna</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:51:33 +0000</pubDate>
      <link>https://dev.to/mridulkrishna/talking-to-a-plc-from-c-part-2-notifications-and-whole-struct-reads-44a0</link>
      <guid>https://dev.to/mridulkrishna/talking-to-a-plc-from-c-part-2-notifications-and-whole-struct-reads-44a0</guid>
      <description>&lt;p&gt;In Part 1 we got a C# app talking to a PLC over ADS: connect, read one variable, write one back. Right at the end I made a promise and then walked away from it — I told you &lt;em&gt;not&lt;/em&gt; to poll your values on a timer, and that reading whole structured types into C# was where things get good.&lt;/p&gt;

&lt;p&gt;Time to make good on both.&lt;/p&gt;

&lt;p&gt;This post covers the two techniques that turn "I can read a variable" into "I can build a real HMI on top of this": &lt;strong&gt;device notifications&lt;/strong&gt; (the PLC tells &lt;em&gt;you&lt;/em&gt; when something changes) and &lt;strong&gt;struct reads&lt;/strong&gt; (grab an entire block of machine state in one shot instead of a dozen round trips).&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop polling. Let the PLC push.
&lt;/h2&gt;

&lt;p&gt;The tempting pattern after Part 1 is a loop on a timer: every 200 ms, read the temperature, update the screen. It works in a demo and quietly rots in production — stale data, needless traffic, and a mess that gets worse with every variable you add.&lt;/p&gt;

&lt;p&gt;ADS has a proper answer: &lt;strong&gt;device notifications&lt;/strong&gt;. You register interest in a symbol once, hand ADS a callback, and it pushes updates to you. No client-side clock, no busy loop.&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="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;// "Tell me when rSpindleTemp changes." The sampling happens on the&lt;/span&gt;
&lt;span class="c1"&gt;// ADS server side — this is not a timer in your app.&lt;/span&gt;
&lt;span class="kt"&gt;uint&lt;/span&gt; &lt;span class="n"&gt;handle&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="nf"&gt;AddDeviceNotificationEx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"GVL.rSpindleTemp"&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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&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;AdsNotificationEx&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="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&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="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// decoded for you because we passed typeof(float)&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;$"Spindle temp: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;temp&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth understanding rather than copy-pasting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OnChange&lt;/code&gt; vs &lt;code&gt;Cyclic&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;AdsTransMode.OnChange&lt;/code&gt; fires only when the value actually changes. &lt;code&gt;AdsTransMode.Cyclic&lt;/code&gt; fires on every interval regardless. For an HMI you almost always want &lt;code&gt;OnChange&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cycleTime&lt;/code&gt;&lt;/strong&gt; is how often the &lt;em&gt;server&lt;/em&gt; samples the variable to decide whether it changed — think of it as the resolution of "change," not a timer in your process. 100 ms is plenty for a temperature readout; a fast-moving axis position might want less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maxDelay&lt;/code&gt;&lt;/strong&gt; lets the server batch several changes together before sending, to cut chatter. &lt;code&gt;0&lt;/code&gt; means "send as soon as you notice."&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Times are milliseconds here, but the &lt;code&gt;NotificationSettings&lt;/code&gt; overloads have shifted across TwinCAT.Ads releases (some take a &lt;code&gt;TimeSpan&lt;/code&gt;) — glance at the signature your version exposes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The gotcha that will bite you in WinForms/WPF
&lt;/h3&gt;

&lt;p&gt;Your notification handler &lt;strong&gt;does not run on the UI thread.&lt;/strong&gt; ADS raises it on a background thread. Touch a WinForms control or a WPF element directly from there and you'll earn a cross-thread exception — or worse, an intermittent one that only shows up on a customer's machine.&lt;/p&gt;

&lt;p&gt;Marshal back to the UI thread:&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="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AdsNotificationEx&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="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&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="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// WinForms:&lt;/span&gt;
    &lt;span class="n"&gt;lblTemp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginInvoke&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lblTemp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&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="n"&gt;temp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;F1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; °C"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// WPF:&lt;/span&gt;
    &lt;span class="c1"&gt;// Dispatcher.Invoke(() =&amp;gt; TempText.Text = $"{temp:F1} °C");&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The client can also be handed a synchronization context so it marshals events for you — convenient, but the default is a background thread, so assume that unless you've set it up otherwise.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Clean up after yourself
&lt;/h3&gt;

&lt;p&gt;Notifications are a server-side resource, and the server has a finite number of them. Register them in a loop without releasing, and you'll eventually hit a wall that looks nothing like the actual cause. When you're done — and on disconnect — delete them:&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="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DeleteDeviceNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reading a whole struct in one round trip
&lt;/h2&gt;

&lt;p&gt;Real machine state isn't one variable — it's twenty. Reading them one call at a time is both slow (every read is a network round trip) and race-prone (your twenty values are each from a slightly different moment). The fix: read the whole block at once.&lt;/p&gt;

&lt;p&gt;Say the PLC has this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TYPE ST_MachineStatus :
STRUCT
    bRunning     : BOOL;
    iState       : INT;
    rSpindleTemp : REAL;
    sJobName     : STRING(80);
END_STRUCT
END_TYPE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You mirror it with a C# type whose memory layout matches &lt;em&gt;exactly&lt;/em&gt;, then read it in a single call:&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;System.Runtime.InteropServices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;StructLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LayoutKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sequential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Pack&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;MachineStatus&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;MarshalAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UnmanagedType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;Running&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// PLC BOOL  -&amp;gt; 1 byte&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;short&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// PLC INT   -&amp;gt; 2 bytes (INT is 16-bit!)&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;SpindleTemp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// PLC REAL  -&amp;gt; 4 bytes&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;MarshalAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UnmanagedType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ByValTStr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SizeConst&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;81&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;JobName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// PLC STRING(80) -&amp;gt; 80 chars + null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;MachineStatus&lt;/span&gt; &lt;span class="n"&gt;status&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="n"&gt;MachineStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"GVL.MachineStatus"&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;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JobName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: state=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, temp=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SpindleTemp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;F1&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One call, one consistent snapshot of the machine. And you can wire it straight into a notification — pass &lt;code&gt;typeof(MachineStatus)&lt;/code&gt; to &lt;code&gt;AddDeviceNotificationEx&lt;/code&gt; and cast &lt;code&gt;e.Value&lt;/code&gt; — so the whole status block pushes to you on change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the layouts actually match
&lt;/h3&gt;

&lt;p&gt;This is where the afternoons disappear, so it's worth slowing down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alignment/packing must match.&lt;/strong&gt; By default TwinCAT and .NET don't pad structures the same way. The clean fix is to force byte packing on both sides: put &lt;code&gt;{attribute 'pack_mode' := '1'}&lt;/code&gt; above the PLC &lt;code&gt;STRUCT&lt;/code&gt;, and use &lt;code&gt;Pack = 1&lt;/code&gt; in the C# &lt;code&gt;[StructLayout]&lt;/code&gt;. Keep those two numbers equal, forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BOOL is one byte — C# &lt;code&gt;bool&lt;/code&gt; isn't.&lt;/strong&gt; Marshaled, a C# &lt;code&gt;bool&lt;/code&gt; defaults to 4 bytes and shoves everything after it out of alignment. &lt;code&gt;[MarshalAs(UnmanagedType.I1)]&lt;/code&gt; pins it to one byte to match the PLC &lt;code&gt;BOOL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STRING is ASCII and fixed-width.&lt;/strong&gt; &lt;code&gt;STRING(80)&lt;/code&gt; is 81 bytes (the null terminator counts). &lt;code&gt;ByValTStr&lt;/code&gt; with &lt;code&gt;SizeConst = 81&lt;/code&gt; handles it. Note this is &lt;em&gt;not&lt;/em&gt; &lt;code&gt;WSTRING&lt;/code&gt; — that one is Unicode and needs different handling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field order is the contract.&lt;/strong&gt; &lt;code&gt;LayoutKind.Sequential&lt;/code&gt; maps fields in declaration order, so the C# order must match the PLC declaration order one-for-one. Reorder one and everything downstream reads as garbage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two definitions are coupled.&lt;/strong&gt; Change the PLC struct and the C# struct has to change with it. Keep them side by side, comment the pairing, and if you do this a lot, consider generating the C# side from the PLC symbols.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If hand-mapping layouts makes you nervous, TwinCAT.Ads also exposes a symbol loader that can read values by symbol without you declaring the layout at all — worth a look for one-off reads. But for the hot path in an HMI, the explicit struct is fast, predictable, and easy to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't poll on a timer — register a &lt;strong&gt;device notification&lt;/strong&gt; and let the PLC push changes.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;OnChange&lt;/code&gt; for HMI values; understand &lt;code&gt;cycleTime&lt;/code&gt; and &lt;code&gt;maxDelay&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your notification callback is on a &lt;strong&gt;background thread&lt;/strong&gt; — marshal to the UI before touching controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete&lt;/strong&gt; notifications when you're done; they're a limited server resource.&lt;/li&gt;
&lt;li&gt;Read a &lt;strong&gt;whole struct&lt;/strong&gt; in one round trip for a consistent snapshot — but the C# layout has to match the PLC exactly: packing, &lt;code&gt;BOOL&lt;/code&gt; as &lt;code&gt;I1&lt;/code&gt;, fixed-width strings, field order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get these two patterns solid and you've got the real backbone of an HMI: live values flowing in without polling, and full machine state readable in a single call.&lt;/p&gt;




&lt;p&gt;Next in the series: writing structs back safely, and wrapping all of this in a small abstraction so the rest of your app never has to know ADS exists.&lt;/p&gt;

&lt;p&gt;If you're doing .NET on the industrial side, I'd genuinely like to hear how you handle PLC-to-C# type mapping — hand-rolled structs like this, code generation, or something smarter? 👋&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>iot</category>
      <category>tutorial</category>
    </item>
    <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>
