Click here to Skip to main content
Click here to Skip to main content

Linking Multiple Embedded Controls

, 17 Aug 2011 CPOL
Rate this:
Please Sign up or sign in to vote.
This article explores software architectural improvements for creating a library of controls deployed in Windows Forms, Wonderware InTouch, and WinCC.

Picking up Where we Left Off

In the previous article, we explored the limitations of implementing our controls within WinCC and WonderWare's InTouch. Now we need to examine some aspects of implementing the Data Access Layer. Satisfying our interface contracts with SQL queries appears relatively straight forward for our isotherm control, but populating an entire screen with a separate query for each displayed field is neither efficient nor accepted practice for database programming. The obvious answer is to define some set of data structures or business objects that are meaningful to our application domain.

There are many Object-Relational Mapping tools such as NHibernate to assist in this task if we were only interested in database implementations. However, our primary implementation is sockets, and our primary focus has to be on implementing a socket solution. Perhaps I can write a future article on applying NHibernate to a relational database implementation and how that might integrate into this solution. In this article, however, we'll focus on manual creation of the business objects.

Design Time Rendering

Before we go any further, however, we need to revisit our Isotherm Control and make a small adjustment to how our Isotherm Control acquires the data to render its image. If you recall, we were returning a static array of values from our Data Access Layer, but we need to account for the fact that our Data Access Layer may not be available, either from within the hosting environment at design time or at times when the furnace is empty. It's not enough to check DesignMode to verify that the control is being hosted within a designer, we need to catch anticipated errors from runtime as well.

From the previous article, view the code for the IsothermControl by double-clicking it in the solution explorer. Locate the paint event handler and insert a try-catch block here to assign IsothermColors to a static array when an exception is thrown. After changes, our new paint event handler will look like this.

Private Sub InteropIsothermControl_Paint(ByVal sender As Object, _
	ByVal e As System.Windows.Forms.PaintEventArgs) Handles Me.Paint
'Retrieve our array of temperatures from our data access layer.
Dim IsothermColors As Double(,)
Try
    IsothermColors = IsothermDataProvider.GetIsothermTemperatures()
Catch ex As Exception
    IsothermColors = New Double(,) {{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}
End If

You'll notice that the default array is 3x4. This is going to create a conflict when we later provide a 3x3 array at run-time. Our control is written with the assumption that the size of this array will be fixed, which presents a problem with our current implementation. A 3x3 matrix here will defer the problem, not resolve it, since we know the size of this matrix is not fixed across jobs. The only way to correct the problem is to remove the fixed size restriction. We need to remove the two assertions and remove the if-statement that wraps the instantiation of the bitmap. The result looks like this:

Private Sub InteropIsothermControl_Paint(ByVal sender As Object, _
	ByVal e As System.Windows.Forms.PaintEventArgs) Handles Me.Paint
    'Retrieve our array of temperatures from our data access layer.
    Dim IsothermColors As Double(,)
    Try
        IsothermColors = IsothermDataProvider.GetIsothermTemperatures()
    Catch ex As Exception
        IsothermColors = New Double(,) {{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}
    End If
    
    'Instantiate a bitmap.
    myBitmap = New Bitmap(IsothermColors.GetUpperBound(0) + 1, _
	IsothermColors.GetUpperBound(1) + 1)
      
    For x As Integer = 0 To myBitmap.Width - 1
        For y As Integer = 0 To myBitmap.Height - 1
            myBitmap.SetPixel(x, y, BlackBodyRadiance(IsothermColors(x, y)))
        Next
    Next
    
    'You should play with the interpolation mode, smoothing mode and 
    'pixel offset just for fun.
    e.Graphics.InterpolationMode = _
	System.Drawing.Drawing2D.InterpolationMode.HighQualityBilinear
    e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias
    e.Graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.Half
    'Allow GDI+ to scale your image for you.
    e.Graphics.DrawImage(myBitmap, 0, 0, Me.ClientSize.Width, Me.ClientSize.Height)
End Sub

Continuing Forward

Business Objects

Our data is logically separated into two groups, Furnace Data and Piece Data, and this separation will be reflected in our business objects. Both classes have some data common to all steel furnace installations, and some data unique to the specific job. In most cases, the job-specific data are peripheral to Level 2 operations, such as material handling codes or balance of plant data, data which is important to display but which impacts control only minimally.

Right-click on the solution and add a new class. We're going to call this class BasePiece. There's a lot of data that we have to track for the piece, such as length, width, steel composition -- but this data is used for rendering furnace contents to scale and for other issues not directly related to our discussion. For our purposes, the only data we need here is the isotherm temperatures. We'll add other properties as we need them, but for now our class looks like this:

Namespace MyCorp
    Public Class BasePiece

        Private m_IsothermTemperatures As Double(,)
        Public Property IsothermTemperatures() As Double(,)
            Get
                Return m_IsothermTemperatures
            End Get
            Set(ByVal value As Double(,))
                m_IsothermTemperatures = value
            End Set
        End Property
    End Class
End Namespace

Add another class called BaseFurnace. This class will require a collection for the pieces inside the furnace. We're going to use a Dictionary to hold our pieces because it allows us to lookup a piece from a PieceID string. (Historically, the PieceID refers to the Piece Name, but I'm using the term more generically here to mean a UUID, name, or any other unique identifier which could be assigned by a PLC when the piece is first seen by the automation system.) We also need a SelectedPiece property along with an update event to track the selected piece across screens and between several controls. Our BaseFurnace class looks something like this:

Namespace MyCorp
    Public Class BaseFurnace

        Public Event SelectedPieceChanged(ByVal PieceID As String)

        Private m_Name As String
        Public Property Name() As String
            Get
                Return m_Name
            End Get
            Set(ByVal value As String)
                m_Name = value
            End Set
        End Property

        Private m_Contents As Dictionary(Of String, BasePiece)
        Public Property Contents() As Dictionary(Of String, BasePiece)
            Get
                Return m_Contents
            End Get
            Set(ByVal value As Dictionary(Of String, BasePiece))
                m_Contents = value
            End Set
        End Property

        Private m_SelectedPieceID As String
        Public Property SelectedPieceID() As String
            Get
                Return m_SelectedPieceID
            End Get
            Set(ByVal value As String)
                If Contents IsNot Nothing AndAlso Contents.ContainsKey(value) Then
                    m_SelectedPieceID = value
                    If SelectedPieceChangedEvent IsNot Nothing Then
                        RaiseEvent SelectedPieceChanged(m_SelectedPieceID)
                    End If
                End If
            End Set
        End Property
    End Class
End Namespace

Notice the SelectedPieceChangedEvent object in the code above. Whenever you register an event handler, the compiler uses a bit of prestidigitation to queue the address of the handler into an automagically generated list. This is that list: the name of the event with "Event" appended at the end. The list is created only when the first event handler is added, and it's destroyed when the last handler is removed. We can check existence -- IsNot Nothing -- to determine if a handler is assigned before we raise the event. This prevents throwing the OutOfMemoryException caused by raising an unhandled event within WinCC.

Parsing Socket Streams

From here, we could create a shared collection of furnaces within our DataAccessLayer.vb file and write the code for sockets messages. Basically, we would connect our HMI client to the server and call asynchronous callbacks within the constructor of the DataAccessLayer object and parse the socket messages on a worker thread. Conceptually, this is similar to our current sockets code. However, the parsing of the socket stream remains one of the most frustrating, problematic aspects of creating a functional HMI, so we need to explore those problems and what we can do to mitigate them.

The greatest hurdle we've experienced parsing socket streams is in matching data boundaries. Since sockets transmit data serially, the best way to reduce the amount of debugging required is to limit the amount of data that we have to change with each implementation. If we identify the common data, we can place it at the beginning of the data stream where it can be debugged once for all implementations. To protect and better enforce that separation, we're going to use inheritance. For both of these classes, mark them as MustInherit. We'll leave the declaration of job-specific fields for the child classes which inherit from these.

To increase cohesion between the definition of data fields in our objects and the parsing of those fields from the socket's data stream, we want the parsing method to be part of the business object. However, the base class must be parsed first to ensure our HMI is minimally operational. That is, we want to ensure that we can see and select pieces within the furnace control, even though some or all of the job-specific fields may display incorrectly. If you're familiar with the concept of overriding a control's OnPaint method, your first inclination will likely be to create a parsing routine in the parent that's overridden and called by the child -- similar to how MyBase.OnPaint is called when you override the OnPaint method of many Windows Forms Controls. This is fine if there is no obligation to call the base method.

Template Pattern

In our case, however, it is crucial that the base method be called and called first. To make that happen, we're going to use a Template Pattern. In the BasePiece class, add an abstract (MustOverride) method called ParseJobData. Then add a Parse method which accepts a byte array and an offset integer. When we receive data on a socket, we'll copy a pre-determined number of bytes into a byte array and call the Parse method. Once the Parse method parses out the data for its fields, it then calls JobParseData to allow the child class to parse its own fields. Our revised BasePiece class will look like this.

Namespace MyCorp
    Public MustInherit Class BasePiece

        Private m_IsothermTemperatures As Double(,)
        Public Property IsothermTemperatures() As Double(,)
            Get
                Return m_IsothermTemperatures
            End Get
            Set(ByVal value As Double(,))
                m_IsothermTemperatures = value
            End Set
        End Property

        Protected MustOverride Sub ParseJobData(ByVal data As Byte(), _
		ByVal offset As Integer)

        Public Sub Parse(ByVal data As Byte(), ByVal offset As Integer)
            Dim rows As Int32 = BitConverter.ToInt32(data, offset)
            offset += Marshal.SizeOf(rows)
            Dim columns As Int32 = BitConverter.ToInt32(data, offset)
            offset += Marshal.SizeOf(columns)
            ReDim m_IsothermTemperatures(rows, columns)
            For i As Integer = 0 To rows - 1
                For j As Integer = 0 To columns - 1
                    m_IsothermTemperatures(i, j) = BitConverter.ToDouble(data, offset)
                    offset += Marshal.SizeOf(m_IsothermTemperatures(i, j))
                Next
            Next

            'Done with base data, now parse job-specific data.
            ParseJobData(data, offset)
        End Sub
    End Class
End Namespace

For now, we don't have any job-specific data so there's nothing in our job-specific class, yet we need to implement a concrete class that inherits the BasePiece just to get the program to compile. Again, right-click the solution and add a new class. We're going to reference the base versions of our business objects whenever possible, but since these will both be abstract classes, it will always be safe to cast them as a job-specific version. This will provide us with the benefit of easily isolating those fields in our HMI that are specific to a particular implementation. To make these job-specific objects easy to manage inside of a code library, the class names will likely make use of our internal contract naming scheme, but for the purposes of our discussion, we're going to simply call this JobPiece.

Namespace MyCorp
    Public Class JobPiece
        Inherits BasePiece

        Protected Overrides Sub ParseJobData(ByVal data() As Byte, _
		ByVal offset As Integer)
            'No data fields to parse, so this is empty for now.
        End Sub
    End Class
End Namespace

To finish off, we need to do the same for our BaseFurnace object. In practice, we would separate our parsing routine; one for dynamic or frequently changing data, such as temperatures inside the furnace or positions of the pieces within the furnace, and another for static or infrequently changing data, such as the length and width of the furnace or piece. You might even consider using different IP strategies for these two messages: UDP multicast for the dynamic data and TCP messages to lookup static data only when the dynamic data indicated the need.

For example, you could transmit the dynamic piece data for the furnace, and rely on the local base class data for length and width. If dynamic data is present for a PieceID, but there is no static data to match, the code could then issue a TCP message requesting the static data for that piece. (Since piece length and width are sometimes corrected, you would need to expand these messages to include a last modified time-stamp to make this strategy work.) As you can easily imagine, an entire series of articles could be dedicated just to the implementation of the sockets programming. To maintain focus on the topic of this article, sockets code will be limited to conceptual implementations where they directly impact the design of the Data Access Layer. For now, leave the Parse subroutine of the BaseFurnace class empty. The BaseFurnace and JobFurnace classes are shown below:

Namespace MyCorp
    Public MustInherit Class BaseFurnace

        Public Event SelectedPieceChanged(ByVal PieceID As String)

        Private m_Name As String
        Public Property Name() As String
            Get
                Return m_Name
            End Get
            Set(ByVal value As String)
                m_Name = value
            End Set
        End Property

        Private m_Contents As Dictionary(Of String, BasePiece)
        Public Property Contents() As Dictionary(Of String, BasePiece)
            Get
                Return m_Contents
            End Get
            Set(ByVal value As Dictionary(Of String, BasePiece))
                m_Contents = value
            End Set
        End Property

        Private m_SelectedPieceID As String
        Public Property SelectedPieceID() As String
            Get
                Return m_SelectedPieceID
            End Get
            Set(ByVal value As String)
                If Contents IsNot Nothing AndAlso Contents.ContainsKey(value) Then
                    m_SelectedPieceID = value
                    If SelectedPieceChangedEvent IsNot Nothing Then
                        RaiseEvent SelectedPieceChanged(m_SelectedPieceID)
                    End If
                End If
            End Set
        End Property

        Protected MustOverride Sub ParseJobData_
		(ByVal data As Byte(), ByVal offset As Integer)

        Public Sub Parse(ByVal data As Byte(), ByVal offset As Integer)
            'TODO: Parse BaseFurnace Data.

            'Done with base data, now parse job-specific data.
            ParseJobData(data, offset)
        End Sub
    End Class
End Namespace
Namespace MyCorp
    Public Class JobFurnace
        Inherits BaseFurnace

        Protected Overrides Sub ParseJobData(ByVal data() As Byte, _
		ByVal offset As Integer)
            'We have no data, so there's nothing here to parse. 
            'If you add data, add parsing here.
        End Sub
    End Class
End Namespace

Serving Business Objects

So much has changed within the DataAccessLayer that it will make more sense to discard the entire file from the previous article and start anew. The new DataAccessLayer class will need to handle updates to the data, both by raising events and by processing asynchronous socket messages. Since we will need to account for multiple furnaces, our data class must provide a collection for those furnaces, and I've chosen a SortedList, but an array of furnaces would work equally well. It is important to make this collection Shared though -- we don't want each control to have its own, independent furnace collection. Since the DataAccessLayer object contains the collection of furnaces, it must also manage the currently selected furnace. Since all of our data can be reached via a furnace object, the two properties GetFurnace and SelectedFurnaceIndex are all that need to be exposed by the DataAccessLayer via an interface. If we add the SelectedFurnaceChanged and the DataUpdated events, we can build our new interface accordingly. Right-click the solution and Add New Item, then choose Interface. Name the new interface IGetDataAccessLayer. It should look like this:

Namespace MyCorp
    Public Interface IGetDataAccessLayer
        Event SelectedFuranceChanged(ByVal FurnaceIndex As Integer)
        Event DataUpdated(ByVal FurnaceIndex As Integer)
        Property SelectedFurnaceIndex() As Integer
        ReadOnly Property GetFurnace(ByVal FurnaceIndex As Integer) As BaseFurnace
    End Interface
End Namespace

Our new DataAccessLayer will need to implement this interface. Since we will not be implementing sockets code in this article, we'll need to initialize our furnaces' contents with a few pieces just to make everything work. Our new DataAccessLayer implementation looks like this:

Imports System.Timers
Namespace MyCorp
    Public Class DataAccessLayer
        Implements IGetDataAccessLayer
        Public Event SelectedFuranceChanged(ByVal FurnaceIndex As Integer) _
		Implements IGetDataAccessLayer.SelectedFuranceChanged
        Public Event DataUpdated(ByVal FurnaceIndex As Integer) _
		Implements IGetDataAccessLayer.DataUpdated

        'Check for socket updates every tenth of a second.
        'Checking each tenth, will result in actual updates only about
        'once every second or so because of the exchange of messages.
        Private UpdateTimer As New System.Timers.Timer(5000)

        Private Shared m_Furnace As New SortedList(Of Integer, BaseFurnace)
        Public ReadOnly Property GetFurnace(ByVal FurnaceIndex As Integer) _
		As BaseFurnace Implements IGetDataAccessLayer.GetFurnace
            Get
                Return DataAccessLayer.m_Furnace(FurnaceIndex)
            End Get
        End Property

        Private Shared m_Singleton As DataAccessLayer = New DataAccessLayer
        Public Shared ReadOnly Property DefInstance() As DataAccessLayer
            Get
                Return m_Singleton
            End Get
        End Property

        Private Shared m_SelectedFurnaceIndex As Integer
        Public Property SelectedFurnaceIndex() As Integer Implements _
			IGetDataAccessLayer.SelectedFurnaceIndex
            Get
                Return m_SelectedFurnaceIndex
            End Get
            Set(ByVal value As Integer)
                If m_SelectedFurnaceIndex <> value Then
                    m_SelectedFurnaceIndex = value
                    If SelectedFuranceChangedEvent IsNot Nothing Then
                        RaiseEvent SelectedFuranceChanged(m_SelectedFurnaceIndex)
                    End If
                End If
            End Set
        End Property

        'This, the default constructor, is marked private to ensure
        'that no copies are instantiated. This is intended to be a 
        'Singleton class, and the only way to ensure that is to make
        'sure that no other module can instantiate a new one without
        'going through DefInstance. 
        Private Sub New()
            MyBase.New()
            Me.UpdateTimer.AutoReset = True
            AddHandler UpdateTimer.Elapsed, AddressOf OnUpdateTimerElapsed
            Me.UpdateTimer.Enabled = True

            Dim newFurnace As New JobFurnace
            m_Furnace.Add(0, newFurnace)
            Dim myContents As New Dictionary(Of String, BasePiece)
            myContents("Piece1") = New JobPiece
            myContents("Piece1").IsothermTemperatures = New Double(,) _
		{{240, 159, 240}, {240, 240, 229}, {240, 240, 239}}
            myContents("Piece2") = New JobPiece
            myContents("Piece2").IsothermTemperatures = New Double(,) _
		{{1150, 240, 1150}, {1501, 240, 1501}, {1150, 240, 1150}}
            myContents("Piece3") = New JobPiece
            myContents("Piece3").IsothermTemperatures = New Double(,) _
		{{1150, 501, 1150}, {1501, 501, 1501}, {1150, 1501, 1150}}
            myContents("Piece4") = New JobPiece
            myContents("Piece4").IsothermTemperatures = New Double(,) _
		{{1501, 1501, 1501}, {1501, 1501, 1501}, {1501, 1501, 1501}}
            myContents("Piece5") = New JobPiece
            myContents("Piece5").IsothermTemperatures = New Double(,) _
		{{1501, 1750, 1750}, {1501, 1501, 1750}, {1750, 1750, 1750}}
            m_Furnace(0).Contents = myContents
            m_Furnace(0).SelectedPieceID = "Piece1"

            newFurnace = New JobFurnace
            m_Furnace.Add(1, newFurnace)
            myContents = New Dictionary(Of String, BasePiece)
            myContents("Piece1") = New JobPiece
            myContents("Piece1").IsothermTemperatures = New Double(,) _
		{{1501, 1501, 1501}, {1501, 1750, 1501}, {1501, 1501, 1501}}
            myContents("Piece2") = New JobPiece
            myContents("Piece2").IsothermTemperatures = New Double(,) _
		{{1501, 1501, 1501}, {1501, 1501, 1501}, {1501, 1501, 1501}}
            myContents("Piece3") = New JobPiece
            myContents("Piece3").IsothermTemperatures = New Double(,) _
		{{1150, 501, 1150}, {1501, 1501, 1501}, {1150, 1501, 1150}}
            myContents("Piece4") = New JobPiece
            myContents("Piece4").IsothermTemperatures = New Double(,) _
		{{240, 1150, 240}, {1150, 1501, 1150}, {240, 1150, 240}}
            myContents("Piece5") = New JobPiece
            myContents("Piece5").IsothermTemperatures = New Double(,) _
		{{159, 240, 136}, {240, 1150, 229}, {133, 239, 160}}
            m_Furnace(1).Contents = myContents
            m_Furnace(1).SelectedPieceID = "Piece4"

            Me.SelectedFurnaceIndex = 0
        End Sub

        'Elapsed events are raised on threadpool threads. Be sure
        'to handle all possible exceptions within the event handler
        'and to allow for reentrant execution of the handler. If this
        'behaviour is undesirable, assign the SynchronizingObject to a shared
        'DataAccessLayer object.
        Public Sub OnUpdateTimerElapsed(ByVal source As Object, _
		ByVal e As ElapsedEventArgs)
            Try
                'Notice here that we're running a ten-second process.
                System.Threading.Thread.Sleep(10000)
                Static FurnaceIndex As Integer = 0
                'After the first 10 seconds, though, this will print every 5 seconds.
                'Do you understand why?
                Debug.WriteLine("Hello from UpdateTimer.")
                RaiseEvent DataUpdated(FurnaceIndex)
                FurnaceIndex = (FurnaceIndex + 1) Mod 2
            Catch ex As Exception
                Debug.Print(ex.Message)
            End Try
        End Sub
    End Class

    Public Module FactoryProvider
        Public Function GetDataProvider() As IGetDataAccessLayer
            Return DataAccessLayer.DefInstance
        End Function
    End Module

End Namespace

We're using the OnUpdateTimerElapsed event to create a worker thread similar to how an asynchronous socket callback would work. Because the updates occur on a separate thread, delegates are required to perform callbacks to alternate threads, and this provides an easier mechanism than creating a simulation socket server.

Finally, the InteropIsothermControl needs to be updated to account for the replacement of the interface. At the top of the InteropIsothermControl.vb file, remove the existing definition for the IGetIsothermTemperatures and change our local variable to point to our new interface. The result should look like this:

Public Class InteropIsothermControl

    Private myBitmap As System.Drawing.Bitmap = Nothing
    Private WithEvents DataProvider As IGetDataAccessLayer = GetDataProvider()

Selection Controls

We have a collection of furnaces and each furnace has a collection of pieces. To truly test our implementation, we need to be able to populate and select among these collections.

Furnace Selector

The selection of a furnace is the simplest of our controls because the list is fixed. In practice, this would likely be performed through a menu. Alternatively or additionally, multiple furnace overview controls may also be placed on a single screen and allow the user to click on one for selection. However, a couple of radio buttons is simple and adequate for our needs here. Right-click the solution and add a new VB6 Interop UserControl or User Control, and name it InteropFurnaceSelector. Refer to the previous article to understand the differences between these two types of user controls.

Drag two radio buttons onto the control. The default names of RadioButton1 and RadioButton2 are fine, but change the Text property to "Furnace 1" and "Furnace 2". View the code and add the namespace. Finally, we need a private member variable for our interface. The result should look something like this:

Namespace MyCorp
    <ComClass(InteropFurnaceSelector.ClassId, InteropFurnaceSelector.InterfaceId, _
	InteropFurnaceSelector.EventsId)> _
    Public Class InteropFurnaceSelector

        Private WithEvents DataProvider As DataAccessLayer = GetDataProvider()

Remember to open the InteropFurnaceSelector.Designer.vb file and add the namespace there, but for now, if you're using the VB6 Interop UserControl, expand the "VB6 Events" and comment them out. Add or locate the constructor, and after the call to InitializeComponent, we want to initialize the selected furnace.

Public Sub New()

    ' This call is required by the Windows Form Designer.
    InitializeComponent()
    
    ' Add any initialization after the InitializeComponent() call.
    If DataProvider.SelectedFurnaceIndex = 0 Then
        Me.RadioButton1.Checked = True
    Else
        Me.RadioButton2.Checked = True
    End If
    
    'Raise Load event
    Me.OnCreateControl()
End Sub

Finally, we need to update our DataAccessLayer whenever the furnace is selected, so add a Checked_Changed event handler at the bottom of the control code that looks something like this:

        'Please enter any new code here, below the Interop code

        Private Sub RadioButton_CheckedChanged(ByVal sender As System.Object, _
		ByVal e As System.EventArgs) Handles RadioButton2.CheckedChanged, _
		RadioButton1.CheckedChanged
            If Me.RadioButton1.Checked Then
                DataProvider.SelectedFurnaceIndex = 0
            Else
                DataProvider.SelectedFurnaceIndex = 1
            End If
        End Sub
    End Class
End Namespace

Piece Selector

To finish off our controls, we need to be able to select a piece within the furnace. In practice, we would include a furnace overview control with the furnace contents displayed to scale. User selection of a piece from that overview would then be used by several related controls that display piece data. For our purposes, a simple listbox will do. This will allow us to explore the class definitions we might use to define a furnace and its contents, and provide an opportunity to explore how two or more controls might interact. Right-click the solution and add a new VB6 Interop UserControl or User Control, and name it InteropSelectionControl. Drag a listbox onto the control, dock the listbox to Fill the entire control, and add "Piece1" through "Piece5" to the Items Collection. View the code and add a private member variable to assign to our interface and change the namespace. It should look like this:

Namespace MyCorp

    <ComClass(InteropSelectionControl.ClassId, InteropSelectionControl.InterfaceId, _
	InteropSelectionControl.EventsId)> _
    Public Class InteropSelectionControl

        Private WithEvents DataProvider As IGetDataAccessLayer = GetDataProvider()

Remember to go back and change the InteropSelectionControl.Designer.vb namespace, but first, if you're using the Interop Forms Toolkit, expand the "VB6 Events" and comment them out. Add or locate the constructor and initialize the listbox selection. It should look like this:

Public Sub New()

    ' This call is required by the Windows Form Designer.
    InitializeComponent()
    
    ' Add any initialization after the InitializeComponent() call.
    
    'In practice, we would actually need to fill the "listbox" 
    'with the furnace's contents.
    Me.SelectedFurnace = DataProvider.SelectedFurnaceIndex
    ListBox1.SelectedIndex = ListBox1.FindString_
	(DataProvider.GetFurnace(SelectedFurnace).SelectedPieceID)
    
    'Raise Load event
    Me.OnCreateControl()
End Sub

Our HMI usually contains a full screen with a single furnace overview. There, the furnace displayed is the selected furnace. But we also offer another screen with all of the furnaces available, where the same furnace overview control is displayed multiple times, each assigned to a fixed furnace index. This InteropSelectionControl will need to be able to handle both configurations, and to make that happen, we'll need to add SelectedFurnace and FurnaceSelectionMethod properties, and an enumerated UpdateMethod. For the enumerator, add a new class and name it UpdateMethod. At the top of the file, add our namespace. Change the Class declaration to an Enum declaration, and add two values: "Fixed" and "Selected". The code should look like this:

Namespace MyCorp
    Public Enum UpdateMethod As Integer
        Selected = 0
        Fixed = 1
    End Enum
End Namespace

For the InteropSelectionControl, if you're using the Interop Forms Toolkit, I suggest you expand the "VB6 Properties" and add these two new properties at the bottom. The result should look like this:

Private m_SelectedFurnace As Integer
<Category("Data")> _
<Description("The index of the furnace, this value is overridden 
if the update method is set to Selected.")> _
Public Property SelectedFurnace() As Integer
    Get
        Return m_SelectedFurnace
    End Get
    Set(ByVal value As Integer)
        RemoveHandler DataProvider.GetFurnace(m_SelectedFurnace).SelectedPieceChanged, _
	AddressOf SelectedPieceChanged
        If Me.FurnaceSelectionMethod = UpdateMethod.Fixed Then
            m_SelectedFurnace = value
        Else
            m_SelectedFurnace = Me.DataProvider.SelectedFurnaceIndex
        End If
        AddHandler DataProvider.GetFurnace(m_SelectedFurnace).SelectedPieceChanged, _
	AddressOf SelectedPieceChanged
    End Set
End Property

Private m_FurnaceSelectionMethod As UpdateMethod
<Category("Data")> _
<Description("Binds the furnace selection to the selected furnace.")> _
Public Property FurnaceSelectionMethod() As UpdateMethod
    Get
        Return m_FurnaceSelectionMethod
    End Get
    Set(ByVal value As UpdateMethod)
        m_FurnaceSelectionMethod = value
    End Set
End Property

Finally, we need to handle the various events for the various combinations. This is one of the hardest parts of this design, and a few unanticipated configurations may still produce undesirable behavior. If you discover any, please leave a comment so I can investigate, but for now, the event handlers look like this:

        'Enter any new code here, below the Interop code

        Private Sub ListBox1_SelectedIndexChanged(ByVal sender As System.Object, _
		ByVal e As System.EventArgs) Handles ListBox1.SelectedIndexChanged
            Dim this As ListBox = sender
            If this.SelectedItem IsNot Nothing Then
                DataProvider.GetFurnace(m_SelectedFurnace).SelectedPieceID = _
			this.SelectedItem
            End If
        End Sub

        Private Sub SelectedFurnaceChanged(ByVal FurnaceIndex As Integer) _
		Handles DataProvider.SelectedFuranceChanged
            If Me.FurnaceSelectionMethod = UpdateMethod.Selected Then
                SelectedFurnace = FurnaceIndex
                'TODO: Rebuild the piece list here.
                Me.ListBox1.SelectedIndex = Me.ListBox1.FindString_
		(DataProvider.GetFurnace(SelectedFurnace).SelectedPieceID)
                Me.Refresh()
            End If
        End Sub

        Private Sub ListBox1_Layout(ByVal sender As System.Object, _
	ByVal e As System.Windows.Forms.LayoutEventArgs) Handles ListBox1.Layout
            Dim index As Integer = Me.ListBox1.FindString_
		(DataProvider.GetFurnace(SelectedFurnace).SelectedPieceID)
            Me.ListBox1.SelectedIndex = index
        End Sub

        Private Sub SelectedPieceChanged(ByVal PieceID As String)
            Me.ListBox1.SelectedIndex = Me.ListBox1.FindString(PieceID)
            Me.Refresh()
        End Sub
    End Class
End Namespace

Return to InteropIsothermControl

Finally, we need to return to our InteropIsothermControl and add similar properties and event handlers to keep up with the selected furnace and selected piece under the various configurations. The properties should look similar to those below, and again, if you're using the Interop Forms Toolkit, you should add these in the "VB6 Properties" region.

Private m_SelectedFurnace As Integer
<Category("Data")> _
<Description("The index of the furnace, this value is overridden 
if the update method is set to Selected.")> _
Public Property SelectedFurnace() As Integer
    Get
        Return m_SelectedFurnace
    End Get
    Set(ByVal value As Integer)
        RemoveHandler DataProvider.GetFurnace(m_SelectedFurnace).SelectedPieceChanged, _
	AddressOf SelectedPieceChanged
        If Me.FurnaceSelectionUpdateMethod = UpdateMethod.Fixed Then
            m_SelectedFurnace = value
        Else
            m_SelectedFurnace = Me.DataProvider.SelectedFurnaceIndex
        End If
        AddHandler DataProvider.GetFurnace(m_SelectedFurnace).SelectedPieceChanged, _
	AddressOf SelectedPieceChanged
    End Set
End Property

Private m_FurnaceSelectionUpdateMethod As UpdateMethod
<Category("Data")> _
<Description("Binds the furnace selection to the selected furnace.")> _
Public Property FurnaceSelectionUpdateMethod() As UpdateMethod
    Get
        Return m_FurnaceSelectionUpdateMethod
    End Get
    Set(ByVal value As UpdateMethod)
        m_FurnaceSelectionUpdateMethod = value
    End Set
End Property

Private m_SelectedPiece As String
<Category("Data")> _
<Description("The Piece ID, this value is overridden if the update method 
is set to Selected.")> _
Public Property SelectedPiece() As String
    Get
        Return m_SelectedPiece
    End Get
    Set(ByVal value As String)
        If Me.PieceSelectionUpdateMethod = UpdateMethod.Fixed Then
            m_SelectedPiece = value
        Else
            m_SelectedPiece = Me.DataProvider.GetFurnace_
				(SelectedFurnace).SelectedPieceID
        End If
    End Set
End Property

Private m_PieceSelectionUpdateMethod As UpdateMethod
<Category("Data")> _
<Description("Binds the piece selection to the selected piece for the given furnace.")> _
Public Property PieceSelectionUpdateMethod() As UpdateMethod
    Get
        Return m_PieceSelectionUpdateMethod
    End Get
    Set(ByVal value As UpdateMethod)
        m_PieceSelectionUpdateMethod = value
    End Set
End Property

The event handlers will look something like this:

        Private Sub InteropIsothermControl_Paint(ByVal sender As Object, _
	ByVal e As System.Windows.Forms.PaintEventArgs) Handles Me.Paint
            'Retrieve our array of temperatures from our data access layer.
            Dim IsothermColors As Double(,)
            Try
                IsothermColors = Me.DataProvider.GetFurnace(Me.SelectedFurnace)._
				Contents(Me.SelectedPiece).IsothermTemperatures
            Catch ex As Exception 	'This happens at design, when furnace is empty, 
				'and on error reading furnace contents.
                IsothermColors = New Double(,) {{0, 0, 0, 0}, _
					{0, 0, 0, 0}, {0, 0, 0, 0}}
            End Try

            'Instantiate a bitmap.
            Me.myBitmap = New Bitmap(IsothermColors.GetUpperBound(0) + 1, _
				IsothermColors.GetUpperBound(1) + 1)

            For x As Integer = 0 To Me.myBitmap.Width - 1
                For y As Integer = 0 To Me.myBitmap.Height - 1
                    Me.myBitmap.SetPixel(x, y, BlackBodyRadiance(IsothermColors(x, y)))
                Next
            Next

            'You should play with the interpolation mode, 
            'smoothing mode and pixel offset just for fun.
            e.Graphics.InterpolationMode = _
		System.Drawing.Drawing2D.InterpolationMode.HighQualityBilinear
            e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias
            e.Graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.Half
            'Allow GDI+ to scale your image for you.
            e.Graphics.DrawImage(myBitmap, 0, 0, _
		Me.ClientSize.Width, Me.ClientSize.Height)
        End Sub

        Private Sub SelectedPieceChanged(ByVal PieceID As String)
            If Me.PieceSelectionUpdateMethod = UpdateMethod.Selected Then
                Me.SelectedPiece = PieceID
                Me.Refresh()
            End If
        End Sub

        Private Sub SelectedFurnaceChanged(ByVal FurnaceIndex As Integer) _
			Handles DataProvider.SelectedFuranceChanged
            If Me.FurnaceSelectionUpdateMethod = UpdateMethod.Selected Then
                Me.SelectedFurnace = FurnaceIndex
                If Me.PieceSelectionUpdateMethod = UpdateMethod.Selected Then
                    Me.SelectedPiece = Me.DataProvider.GetFurnace_
					(SelectedFurnace).SelectedPieceID
                End If
                Me.Refresh()
            End If
        End Sub

        'Callback delegate to safely update data on UI thread.
        Private Delegate Sub OnUpdateDataCallback(ByVal FurnaceIndex As Integer)

        'New data available for FurnaceIndex.
        Private Sub OnUpdateData(ByVal FurnaceIndex As Integer) _
			Handles DataProvider.DataUpdated
            'See if running on UI thread.
            If Me.InvokeRequired Then
                'If not, then invoke the UI thread's callback, 
                'passing calling parameters.
                Dim tempDelegate As New OnUpdateDataCallback(AddressOf OnUpdateData)
                Me.Invoke(tempDelegate, FurnaceIndex)
            Else
                'Disposing isn't thread-safe, and can cause
                'InvokeRequired to return a false positive.
                If Me.Disposing Then Exit Sub

                'Might be on the correct thread, but attempting
                'to update a control before it has been drawn.
                'Either set a static member variable here, to check
                'when in OnLoad(...), or if not critical, just exit sub 
                'and catch it on the next update cycle.
                If Not Me.IsHandleCreated Then Exit Sub

                'Check to see if the message is for this furnace.
                If FurnaceIndex = SelectedFurnace Then
                    Debug.Print("Invoke required: {0} for furnace {1}.", _
				Me.InvokeRequired, FurnaceIndex)
                    Me.Refresh()
                End If
            End If
        End Sub
    End Class

End Namespace

In Conclusion

At this juncture, I have no intention of writing any follow up articles to this. I don't know if this implementation will be considered in any future design of our software, but I'm extremely grateful for the opportunity to explore how it could look if it were redesigned using modern Object-Oriented Design architectures. The interested reader will find a copy of my code (using Visual Studio 2005 and Microsoft's Interop Forms Toolkit 2.1) here.

I should point out that no HMIs were actually harmed (or even modified) in the writing of this article. This article was written in the hope that developers could discuss and agree upon a single implementation that had a high probability of success in various implementations and thus avoid multiple, deviant implementations. I offer it here both to share what we have learned and in the hopes that we might benefit from public contributions that could improve upon the design presented.

History

  • Corrected drop-cap. 17 August 2011
  • Corrected more spelling errors, and details for forgotten UpdateMethod enumeration. 11 October 2010
  • System Timers can not easily access shared resources (such as sockets). Asynchronous callbacks already supported by sockets is an easier, equally efficient solution. The code and wording were corrected accordingly. 9 October 2010
  • Revised several paragraphs for improved clarity, created consistency in nomenclature, and corrected spelling errors. 8 October 2010
  • First released October 7, 2010

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

knockNrod
Software Developer (Senior) TenovaCore
United States United States
Rod currently works in The Burgh using .Net technologies to create Human-Machine Interfaces for steel-making furnaces. He has 7 years in the steel industry, and 12 years of experience working in the nuclear industry at Savannah River Site in Aiken, SC. He enjoys riding his Honda Goldwing through the winding Pennsylvania farmlands, and taking his Jeep offroad.

Comments and Discussions

 
QuestionUse in Wonderware Pinmemberak999917-Dec-11 18:06 
Questionplz brack my licence Pinmembersaqibali211918-Aug-11 8:07 
GeneralControls, user controls and libraries PinmemberLus Oliveira22-Nov-10 8:52 
I'm curious about your article... Can u send me a copy?
Luís Oliveira

GeneralRe: Controls, user controls and libraries PinmemberknockNrod23-Jan-11 9:27 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web03 | 2.8.141015.1 | Last Updated 17 Aug 2011
Article Copyright 2010 by knockNrod
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid