DEV Community

Cover image for Talking to a PLC from C#, Part 3: Safe Writes and a Clean Abstraction Layer
Mridul Krishna
Mridul Krishna

Posted on

Talking to a PLC from C#, Part 3: Safe Writes and a Clean Abstraction Layer

We've come a long way. In Part 1 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.

So far, though, we've mostly been reading. 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.

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.

Writing a struct back

Mechanically, writing is the mirror of the read from Part 2. Same layout rules, same one-call efficiency:

var setpoints = new JobSetpoints
{
    LaserPower  = 1500,   // watts
    CuttingSpeed = 3.5f,  // m/min
    GasPressure  = 2.0f   // bar
};

client.WriteValue("GVL.JobSetpoints", setpoints);
Enter fullscreen mode Exit fullscreen mode

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

Two reasons, and the second one matters more than it looks.

Consistency. If you write field-by-field across several calls, the PLC's scan can run between 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.

Consequences. These aren't database rows. LaserPower = 15000 because someone fat-fingered a form is a physical event. So the write path is the right place to be paranoid — validate before the value ever leaves your app.

public void SetSetpoints(JobSetpoints s)
{
    if (s.LaserPower is < 0 or > 4000)
        throw new ArgumentOutOfRangeException(nameof(s.LaserPower));
    if (s.CuttingSpeed is < 0 or > 20f)
        throw new ArgumentOutOfRangeException(nameof(s.CuttingSpeed));

    client.WriteValue("GVL.JobSetpoints", s);
}
Enter fullscreen mode Exit fullscreen mode

And the rule that ties back to everything in this series: write on change, not on a clock. Push a setpoint when the user actually changes it. Don't loop.

Commands need a handshake, not a fire-and-forget

Setting a value is one thing. Telling the machine to do 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.

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

Here's that handshake in C#, using a notification (from Part 2) so we wait on the Done bit instead of polling it:

public async Task<bool> ExecuteJobAsync(CancellationToken ct = default)
{
    var done = new TaskCompletionSource<bool>();

    uint handle = client.AddDeviceNotificationEx(
        "GVL.stJob.bDone",
        new NotificationSettings(AdsTransMode.OnChange, cycleTime: 50, maxDelay: 0),
        userData: null,
        typeof(bool));

    void OnNotify(object sender, AdsNotificationExEventArgs e)
    {
        if (e.Handle == handle && (bool)e.Value)
            done.TrySetResult(true);
    }
    client.AdsNotificationEx += OnNotify;

    try
    {
        await client.WriteValueAsync("GVL.stJob.bExecute", true, ct);
        using (ct.Register(() => done.TrySetCanceled()))
            return await done.Task;          // resolves when the PLC sets bDone
    }
    finally
    {
        await client.WriteValueAsync("GVL.stJob.bExecute", false, ct);  // reset the request
        client.AdsNotificationEx -= OnNotify;
        client.DeleteDeviceNotification(handle);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what this buys you: the caller gets a clean await ExecuteJobAsync() that completes when the machine is actually done — no polling loop, no timer, no guessing. The finally resets the request bit and releases the notification even if something throws or the operation is cancelled.

Now hide all of it

Here's the part I'd argue matters most for anyone building a real HMI.

Everything above — AdsClient, AmsNetId, symbol strings like "GVL.stJob.bExecute", notification handles — is plumbing. 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.

So don't let it leak. Put a wall between "the app" and "the PLC," and let only a thin gateway cross it.

Start with an interface that speaks the language of the machine, not of ADS:

public interface IMachineGateway : IDisposable
{
    bool IsConnected { get; }

    event EventHandler<bool> ConnectionChanged;
    event EventHandler<MachineStatus> StatusChanged;

    Task ConnectAsync();
    Task SetSetpointsAsync(JobSetpoints setpoints);
    Task<bool> ExecuteJobAsync(CancellationToken ct = default);
}
Enter fullscreen mode Exit fullscreen mode

Nothing in there mentions ADS. No AmsNetId, no symbol paths, no AdsClient. Just machine concepts.

The TwinCAT implementation is the only place that knows the truth:

public sealed class TwinCatMachineGateway : IMachineGateway
{
    private readonly AdsClient _client = new();
    private uint _statusHandle;

    public bool IsConnected => _client.IsConnected;
    public event EventHandler<bool> ConnectionChanged;
    public event EventHandler<MachineStatus> StatusChanged;

    public Task ConnectAsync()
    {
        _client.Connect(AmsNetId.Local, 851);

        // Live machine status -> a plain .NET event the app understands
        _statusHandle = _client.AddDeviceNotificationEx(
            "GVL.MachineStatus",
            new NotificationSettings(AdsTransMode.OnChange, 100, 0),
            null, typeof(MachineStatus));

        _client.AdsNotificationEx += (s, e) =>
        {
            if (e.Handle == _statusHandle)
                StatusChanged?.Invoke(this, (MachineStatus)e.Value);
        };

        ConnectionChanged?.Invoke(this, true);
        return Task.CompletedTask;
    }

    public Task SetSetpointsAsync(JobSetpoints s)
    {
        Validate(s);                                   // paranoia lives here
        return _client.WriteValueAsync("GVL.JobSetpoints", s).AsTask();
    }

    public Task<bool> ExecuteJobAsync(CancellationToken ct = default)
        => /* the handshake from above */;

    public void Dispose()
    {
        if (_statusHandle != 0) _client.DeleteDeviceNotification(_statusHandle);
        _client.Dispose();
    }

    private static void Validate(JobSetpoints s) { /* range checks */ }
}
Enter fullscreen mode Exit fullscreen mode

Now look at what your WPF ViewModel becomes:

_gateway.StatusChanged += (s, status) =>
    Dispatcher.Invoke(() => SpindleTemp = status.SpindleTemp);

// user clicks "Run"
await _gateway.ExecuteJobAsync();
Enter fullscreen mode Exit fullscreen mode

It has no idea a Beckhoff PLC is on the other end. And that's the whole point:

  • You can test the UI with no hardware. Write a FakeMachineGateway : IMachineGateway 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."
  • Symbol strings live in exactly one file. Rename a PLC variable, fix it once.
  • Connection handling is centralised. Reconnection, error translation, lifecycle — one place, driven by the client's connection-state events rather than a heartbeat timer.
  • ADS failures become domain results. Catch AdsErrorException at the boundary and translate it into something your app understands, so an AdsErrorCode never surfaces in a button-click handler.

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

The whole series, in one breath

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

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.


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

If you're building HMIs or industrial apps in .NET, I'd love to know how you 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. 👋

Top comments (0)