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}");
};
A few things worth understanding rather than copy-pasting:
-
OnChangevsCyclic.AdsTransMode.OnChangefires only when the value actually changes.AdsTransMode.Cyclicfires on every interval regardless. For an HMI you almost always wantOnChange. -
cycleTimeis 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. -
maxDelaylets the server batch several changes together before sending, to cut chatter.0means "send as soon as you notice."
Times are milliseconds here, but the
NotificationSettingsoverloads have shifted across TwinCAT.Ads releases (some take aTimeSpan) — 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");
};
(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);
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
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
}
MachineStatus status = client.ReadValue<MachineStatus>("GVL.MachineStatus");
Console.WriteLine($"{status.JobName}: state={status.State}, temp={status.SpindleTemp:F1}");
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 PLCSTRUCT, and usePack = 1in the C#[StructLayout]. Keep those two numbers equal, forever. -
BOOL is one byte — C#
boolisn't. Marshaled, a C#booldefaults to 4 bytes and shoves everything after it out of alignment.[MarshalAs(UnmanagedType.I1)]pins it to one byte to match the PLCBOOL. -
STRING is ASCII and fixed-width.
STRING(80)is 81 bytes (the null terminator counts).ByValTStrwithSizeConst = 81handles it. Note this is notWSTRING— that one is Unicode and needs different handling. -
Field order is the contract.
LayoutKind.Sequentialmaps 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
OnChangefor HMI values; understandcycleTimeandmaxDelay. - 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,
BOOLasI1, 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)