DEV Community

Cover image for Talking to a PLC from C#, Part 2: Notifications and Whole-Struct Reads
Mridul Krishna
Mridul Krishna

Posted on

Talking to a PLC from C#, Part 2: Notifications and Whole-Struct Reads

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 not to poll your values on a timer, and that reading whole structured types into C# was where things get good.

Time to make good on both.

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

Stop polling. Let the PLC push.

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.

ADS has a proper answer: device notifications. You register interest in a symbol once, hand ADS a callback, and it pushes updates to you. No client-side clock, no busy loop.

using TwinCAT.Ads;

using var client = new AdsClient();
client.Connect(AmsNetId.Local, 851);

// "Tell me when rSpindleTemp changes." The sampling happens on the
// ADS server side — this is not a timer in your app.
uint handle = client.AddDeviceNotificationEx(
    "GVL.rSpindleTemp",
    new NotificationSettings(AdsTransMode.OnChange, cycleTime: 100, maxDelay: 0),
    userData: null,
    typeof(float));

client.AdsNotificationEx += (sender, e) =>
{
    float temp = (float)e.Value;   // decoded for you because we passed typeof(float)
    Console.WriteLine($"Spindle temp: {temp}");
};
Enter fullscreen mode Exit fullscreen mode

A few things worth understanding rather than copy-pasting:

  • OnChange vs Cyclic. AdsTransMode.OnChange fires only when the value actually changes. AdsTransMode.Cyclic fires on every interval regardless. For an HMI you almost always want OnChange.
  • cycleTime is how often the server 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.
  • maxDelay lets the server batch several changes together before sending, to cut chatter. 0 means "send as soon as you notice."

Times are milliseconds here, but the NotificationSettings overloads have shifted across TwinCAT.Ads releases (some take a TimeSpan) — glance at the signature your version exposes.

The gotcha that will bite you in WinForms/WPF

Your notification handler does not run on the UI thread. 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.

Marshal back to the UI thread:

client.AdsNotificationEx += (sender, e) =>
{
    float temp = (float)e.Value;
    // WinForms:
    lblTemp.BeginInvoke(() => lblTemp.Text = $"{temp:F1} °C");
    // WPF:
    // Dispatcher.Invoke(() => TempText.Text = $"{temp:F1} °C");
};
Enter fullscreen mode Exit fullscreen mode

(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.)

Clean up after yourself

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:

client.DeleteDeviceNotification(handle);
Enter fullscreen mode Exit fullscreen mode

Reading a whole struct in one round trip

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.

Say the PLC has this:

TYPE ST_MachineStatus :
STRUCT
    bRunning     : BOOL;
    iState       : INT;
    rSpindleTemp : REAL;
    sJobName     : STRING(80);
END_STRUCT
END_TYPE
Enter fullscreen mode Exit fullscreen mode

You mirror it with a C# type whose memory layout matches exactly, then read it in a single call:

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct MachineStatus
{
    [MarshalAs(UnmanagedType.I1)]
    public bool Running;        // PLC BOOL  -> 1 byte

    public short State;         // PLC INT   -> 2 bytes (INT is 16-bit!)

    public float SpindleTemp;   // PLC REAL  -> 4 bytes

    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 81)]
    public string JobName;      // PLC STRING(80) -> 80 chars + null
}
Enter fullscreen mode Exit fullscreen mode
MachineStatus status = client.ReadValue<MachineStatus>("GVL.MachineStatus");
Console.WriteLine($"{status.JobName}: state={status.State}, temp={status.SpindleTemp:F1}");
Enter fullscreen mode Exit fullscreen mode

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

Making the layouts actually match

This is where the afternoons disappear, so it's worth slowing down:

  • Alignment/packing must match. By default TwinCAT and .NET don't pad structures the same way. The clean fix is to force byte packing on both sides: put {attribute 'pack_mode' := '1'} above the PLC STRUCT, and use Pack = 1 in the C# [StructLayout]. Keep those two numbers equal, forever.
  • BOOL is one byte — C# bool isn't. Marshaled, a C# bool defaults to 4 bytes and shoves everything after it out of alignment. [MarshalAs(UnmanagedType.I1)] pins it to one byte to match the PLC BOOL.
  • STRING is ASCII and fixed-width. STRING(80) is 81 bytes (the null terminator counts). ByValTStr with SizeConst = 81 handles it. Note this is not WSTRING — that one is Unicode and needs different handling.
  • Field order is the contract. LayoutKind.Sequential 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.
  • The two definitions are coupled. 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.

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.

The short version

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

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.


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.

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? 👋

Top comments (0)