TwinCAT 3 Tutorial: Building an HMI in .NET

This chapter is part of the TwinCAT 3 Tutorial.

Beckhoff offers a paid HMI add-on for TwinCAT 3. That add-on is reasonably priced and offers basic functionality such as buttons, indicators, alarms, and more. There are also 3rd party HMI solutions that are compatible with TwinCAT 3, and there’s an OPC-UA server available for TwinCAT 3, so any HMI, SCADA or historian system that supports OPC-UA should be compatible with TwinCAT 3.

I’m going to describe an alternative. TwinCAT 3 comes with a free DLL that allows you to communicate from a Windows application directly to the PLC over Beckhoff’s ADS protocol. Since you can download a free version of Visual Studio from Microsoft (called the Express Edition, or more recently Community Edition), this is an interesting way to add an HMI to your system without paying additional software costs. Additionally, unlike packaged HMI solutions with their pre-defined set of features, .NET’s functionality is comparatively unlimited. An application written in .NET can do anything an HMI package can do, and more.

I can’t teach you VB.NET or C# in this short tutorial. There are so many excellent resources available online for learning VB.NET or C# that adding anything here would be superfluous, so I’m going to assume that you already know some .NET programming. If so, you probably already understand why using .NET to build your HMI would be a better alternative than paying for a 3rd party solution (in your case, at least).

After you’ve installed TwinCAT 3, the ADS DLLs are located in C:\TwinCAT\AdsApi.

The .NET DLLs are located in the .NET sub-folder. Pick the version corresponding to the .NET version that you’re using. If you’ve downloaded Visual Studio Express or Community edition version 2010 or better, then you’ll want the DLL for version 4.

Simple Example: a Button

In your TwinCAT 3 PLC project, create a Global Variable List called MyGVL, and inside that create a BOOL variable called MyBoolVariable. Go ahead and do that now, then activate the configuration and go online so you can see the value of that variable.

Assuming you have Visual Studio 2010 Express or better installed, open it and use the new project wizard to create a Windows Forms project. I find the Windows Forms graphical editor to be more “HMI-like”. Of course you could also develop your HMI in WPF, but the concepts will be similar.

In the Solution Explorer window, under Solution->HmiTest->References, right click on References and choose Add Reference… from the context menu. Choose the Browse tab, and navigate to the C:\TwinCAT\AdsApi\.NET\v4.0.30319 folder, and select the TwinCAT.Ads.dll file. Click OK.

The new project wizard should have created a new form for you called Form1. Open that form in the forms editor, and drag a button from the Toolbox onto the form (button1). Double-click on the button to create a click event handler (button1_Click).

Put this in the click event handler:

        private void button1_Click(object sender, EventArgs e)
        {
            using (var client = new TwinCAT.Ads.TcAdsClient())
            {
                client.Connect(851);
                client.WriteSymbol("MyGVL.MyBoolVariable", true, 
                    reloadSymbolInfo: true);
                Thread.Sleep(1000);
                client.WriteSymbol("MyGVL.MyBoolVariable", false, 
                    reloadSymbolInfo: true);
            }
        }

This isn’t a very practical example, but it’s a simple introduction to the ADS DLL. When you click the button, it creates a new ADS client. By default, this client will connect to the TwinCAT 3 (or TwinCAT 2) runtime on the local machine (but there’s an optional parameter to Connect which will make it connect to a remote node). You have to specify a port number. The default port number of the first PLC instance is 851 for TwinCAT 3 (it was 801 for TwinCAT 2). If you’re running multiple virtual PLCs in your system, the second would be 852 and so on. The WriteSymbol method looks up a variable by name, and then writes the given value to that variable. In this case, we write true, wait 1000 ms and then write false.

You should be able to run your new Windows Forms program and click the button. If you have the TwinCAT 3 solution open in the background and you’re online with the Global Variable List, you’ll see the value of the variable change to TRUE for 1 second, and then change back to FALSE.

This example is simple, but it has several problems. First, creating a new client object every time is simple but inefficient. Second, the port shouldn’t be specified in each button click handler. Third, the variable name is entered twice and is entered in the client event handler, when really it should be a property of the button. Fourth, just writing true, pausing for 1 second, and writing false actually freezes the HMI during that second, and is just a bad way to do it. We should be using the MouseDown and MouseUp events instead (to write a true and a false value, respectively).

In order to handle cases where the PLC runtime is stopped, we really want to block reads and writes because it can really slow down the HMI waiting for timeouts to expire. We also want to fail gracefully if the variable doesn’t exist (either because of a typo or because the variable name changed on the PLC side and someone forgot to update the HMI).

A Better Architecture

The requirement to use a single ADS client, and the requirement to block communications when the PLC isn’t responding, and to report on bad variable names implies the need for a centralized communication manager object, or at least one object for each virtual PLC.

The requirement for a variable name to be associated with each button or indicator implies the variable name should be a property of the button or indicator control.

I suggest creating custom UserControls for a standard momentary button and a standard indicator, each with custom properties to define the variable name and the indicator colors in the case of the indicator. At the beginning of your program, you instantiate a new communication manager object, and then register all of the buttons and indicators with the communication manager. This can be done fairly painlessly with a few lines of code. Then, adding a new button or indicator is as simple as dragging and dropping one of your custom user controls from the Toolbox onto your form and setting the variable name in the property page.

Example Communication Manager

You can create a custom MomentaryButton by creating a new class and inheriting from Button:

using System.Windows.Forms;
using System.ComponentModel;

namespace HmiTest
{
    class MomentaryButton : Button
    {
        [Description("The BOOL Variable in the PLC to write to"), 
         Category("PLC")]
        public string VariableName { get; set; }
    }
}

Here I’ve taken the regular windows forms Button control and added a custom property called VariableName. When you drag a MomentaryButton out of your Toolbox onto your form, you’ll be able to edit the new VariableName property in the property page. Note that you’ll have to build your project to get it to show up in the Toolbox.

You can create an Indicator by inheriting from Label:

using System.Windows.Forms;
using System.ComponentModel;
using System.Drawing;

namespace HmiTest
{
    [ToolboxItem(true)]
    class Indicator : Label
    {
        public Indicator()
        {
            this.AutoSize = false;
            this.Width = 100;
            this.Height = 25;
            this.TextAlign = ContentAlignment.MiddleCenter;
            this.BorderStyle = BorderStyle.FixedSingle;
            this.OnColor = Color.Green;
            this.OffColor = Color.DarkGray;
        }

        [Description("The BOOL Variable in the PLC to read from"), 
         Category("PLC")]
        public string VariableName { get; set; }

        [Description("Color when the Variable is TRUE"), 
         Category("PLC")]
        public Color OnColor { get; set; }

        [Description("Color when the Variable is FALSE"), 
         Category("PLC")]
        public Color OffColor { get; set; }
    }
}

Here I’ve customized a bit more by adding a default height and width, aligning to center, setting a border and turning off the auto-size feature. In addition to a VariableName property, I’ve also added OnColor and OffColor properties, which will control the background color of the indicator when the value of the variable is TRUE or FALSE, respectively.

Most of the functionality is in the new CommunicationManager class:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Forms;
using TwinCAT.Ads;

namespace HmiTest
{
    class CommunicationManager : IDisposable
    {
        private readonly int port;
        private readonly TcAdsClient client = new TcAdsClient();
        private readonly List<Action> pollActions 
                         = new List<Action>();
        private readonly Dictionary<string, DateTime> readWriteErrors 
                         = new Dictionary<string, DateTime>();
        private bool connected;
        private DateTime? lastErrorTime = null;

        public CommunicationManager(int port)
        {
            this.port = port;
        }

        public void Poll()
        {
            foreach (var action in this.pollActions)
            {
                action();
            }
        }

        public bool IsConnected
        {
            get { return this.connected; }
        }

        public ReadOnlyCollection<string> GetReadWriteErrors()
        {
            var result = this.readWriteErrors.Keys
                .OrderBy(x => x)
                .ToList();
            return result.AsReadOnly();
        }

        public void Register(Control control)
        {
            if (control == null) return;
            if (control is MomentaryButton)
            {
                this.register(control as MomentaryButton);
            }
            else if (control is Indicator)
            {
                this.register(control as Indicator);
            }
        }

        private void register(MomentaryButton momentaryButton)
        {
            momentaryButton.MouseDown += (s, e) =>
                {
                    this.doWithClient(c =>
                    {
                        c.WriteSymbol(momentaryButton.VariableName, 
                            true, 
                            reloadSymbolInfo: true);
                    }, momentaryButton.VariableName);
                };
            momentaryButton.MouseUp += (s, e) =>
                {
                    this.doWithClient(c =>
                    {
                        c.WriteSymbol(momentaryButton.VariableName, 
                            false,
                            reloadSymbolInfo: true);
                    }, momentaryButton.VariableName);
                };
        }

        private void register(Indicator indicator)
        {
            this.pollActions.Add(() =>
                {
                    this.doWithClient(c =>
                    {
                        if (string.IsNullOrWhiteSpace(
                            indicator.VariableName))
                        {
                            return;
                        }
                        bool value = (bool)c.ReadSymbol(
                            indicator.VariableName, typeof(bool),
                            reloadSymbolInfo: true);
                        indicator.BackColor = value 
                            ? indicator.OnColor 
                            : indicator.OffColor;
                    }, indicator.VariableName);
                });
        }

        private void doWithClient(
            Action<TcAdsClient> action, 
            string variableName)
        {
            this.tryConnect();
            if (this.connected)
            {
                try
                {
                    action(this.client);
                    this.readWriteSuccess(variableName);
                }
                catch (AdsException)
                {
                    readWriteError(variableName);
                }
            }
        }

        private void tryConnect()
        {
            if (!this.connected)
            {
                if (this.lastErrorTime.HasValue)
                {
                    // wait a bit before re-establishing connection
                    var elapsed = DateTime.Now
                        .Subtract(this.lastErrorTime.Value);
                    if (elapsed.TotalMilliseconds < 3000)
                    {
                        return;
                    }
                }
                try
                {
                    this.client.Connect(this.port);
                    this.connected = this.client.IsConnected;
                }
                catch (AdsException)
                {
                    connectError();
                }
            }
        }

        private void connectError()
        {
            this.connected = false;
            this.lastErrorTime = DateTime.Now;
        }

        private void readWriteSuccess(string variableName)
        {
            if (this.readWriteErrors.ContainsKey(variableName))
            {
                this.readWriteErrors.Remove(variableName);
            }
        }

        private void readWriteError(string variableName)
        {
            if (this.readWriteErrors.ContainsKey(variableName))
            {
                this.readWriteErrors[variableName] = DateTime.Now;
            }
            else
            {
                this.readWriteErrors.Add(variableName, DateTime.Now);
            }
        }

        public void Dispose()
        {
            this.client.Dispose();
        }
    }
}

Yes, it’s a little beefy for a class, but it does quite a bit, including managing the connectivity to the PLC and recording read/write errors for missing or misspelled variable names. Here’s how you use the CommunicationManager in your form:

First, add a System.Windows.Forms.Timer to your form (in the forms designer) and rename it to tmrPoll. I left the Interval at 100 ms, which is the default.

Then add a readonly instance of CommunicationManager as a form member variable, and add the following in the form’s constructor:

using System.Windows.Forms;

namespace HmiTest
{
    public partial class Form1 : Form
    {
        private readonly CommunicationManager communicationManager 
                         = new CommunicationManager(851);

        public Form1()
        {
            InitializeComponent();

            foreach (var control in this.Controls)
            {
                this.communicationManager
                    .Register(control as Control);
            }
            this.tmrPoll.Tick += (s, e) =>
                {
                    this.communicationManager.Poll();
                };
            this.tmrPoll.Start();
        }
    }
}

Once you’ve done that, then you can drag and drop your new MomentaryButtons and Indicators on your form, set the VariableName, Text, OnColor and OffColor properties, and you’re done.

There are also some troubleshooting features. If you want to display whether the connection to the PLC is “alive”, then use a timer to query the connectionManager.IsConnected property and update something on the screen to indicate it’s connected or not. You could also log the disconnection to a file, etc. You can also get a list of read/write errors by calling connectionManager.GetReadWriteErrors(). Here’s an example of a button click event handler that will call the function, take the result, and display it in a message box:

        private void button1_Click(object sender, EventArgs e)
        {
            var readWriteErrorVariableNames 
                = this.communicationManager.GetReadWriteErrors();
            var readWriteErrors = string.Join(
                Environment.NewLine,
                readWriteErrorVariableNames);
            if (string.IsNullOrWhiteSpace(readWriteErrors))
            {
                MessageBox.Show("None");
            }
            else
            {
                MessageBox.Show(readWriteErrors);
            }
        }

That’s a little crude, but it illustrates the idea.

More Ways to Read and Write Data

For more .NET examples on reading and writing PLC variables, see the following Beckhoff information page: https://infosys.beckhoff.com/english.php?content=../content/1033/tcsample_labview/html/tcsample_labview_overview.htm&id=

These sections are particularly useful:

Some Things to Note

Here are some lessons I’ve learned:

  • If the HMI is running on the same PC as the runtime, ADS is very fast.
  • Try to keep the number of reads per second to less than 200 if on the same PC.
  • The amount of data you get in a single read doesn’t seem to affect performance much, so reading an array or a large structure is very efficient.
  • If an indicator can’t be seen, there’s no reason to poll the value or update it. If you build your HMI as a bunch of tab pages, before you go to read an indicator’s value, check to see if the parent tab page is visible. If not, skip it.
  • The CommunicationManager above isn’t threadsafe.
  • I have reason to believe the TwinCAT.Ads.dll is not threadsafe. If you need a multi-threaded HMI, write a threadsafe wrapper for the client, and serialize all calls with locks.

Further Thoughts

This chapter only scratches the surface of what you can do with .NET and TwinCAT 3 working together. Obviously .NET has strong capabilities for connecting to databases, accessing files and network resources, and communicating with many other peripherals. TwinCAT 3 is a fast real-time system with a large library of industrial hardware to choose from. The ADS DLL joins the performance of TwinCAT 3 with the versatility of .NET programming.

As you can see, adding more custom controls is fairly simple. You can easily add gauges, bar graphs, and other graphical indicators to display data from the PLC. You can use array and structure reads to implement event logging, alarms, and historians, and store that data in a local database, or a database on a server in your data center. You can report on that data with the Microsoft Chart control, or the Microsoft Report Viewer. You can have the HMI send emails when critical events happen.

Customers are calling for more and more connectivity between the automation system and the ERP and MES systems. Data and connectivity are big topics. TwinCAT 3’s seamless integration with .NET is a primary advantage over other automation solutions.




This chapter is part of the TwinCAT 3 Tutorial. Continue to the next chapter: Introduction to Motion Control.