Linking Multiple Embedded Controls
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