For start - I'm no programmer and start learning this only few month ago.
I did create a WPF program using EasyModbusTCP library that are working! and even managed to read modbus asynchronous so its not freezes view.
Now there's a need to expand this program to pull over 600 coils and registers from modbus. Along with this I'm transferring to MVVM pattern with the help of CommunityToolkit.
I do not know and, honestly overwhelm by how to create service inside the ViewModel.
The app is now to complex to show full code so I'll simplify as much as possible. My View is UserControl that have ItemsControl with the collection of another UserControl as ItemsSource.
ConductivityView XAML:
<ItemsControl Grid.Row="2" ItemsSource="{Binding ConductivityTanks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:ConductivityGridElementViewModel}">
<controls:ConductivityGridElement DataContext="{Binding}"></controls:ConductivityGridElement>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
CodeBehind:
public partial class BConductivityView : UserControl
{
ConductivityViewModel conductivityViewModel;
public BConductivityView()
{
InitializeComponent();
conductivityViewModel = new ConductivityViewModel();
this.DataContext = conductivityViewModel;
}
}
In my ConductivityViewModel is where I need to insert future modbus pull service and make it async.
public partial class ConductivityViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<ConductivityGridElementViewModel> _conductivityTanks;
public ConductivityViewModel()
{
_conductivityTanks = new ObservableCollection<ConductivityGridElementViewModel>();
ConductivityTanks.Clear();
//Add some initial dummy data to display
for (int i = 0; i <= 32; i++)
{
ConductivityTanks.Add(new ConductivityGridElementViewModel
{
Id = i.ToString(),
Use = true,
TankNo = i.ToString(),
Sp = (1000 + i).ToString(),
Pv = (1500 + i).ToString(),
Valve = false,
TimeLeft = (4 + i).ToString(),
Timer = (5).ToString(),
SensorMinimum = "0",
SensorMaximum = "10000",
Raw = "1670"
});
}
public void ModbusReadAsync()
{
// Make async call to start modbus pull and update all ConductivityGridElement in ConductivityTanks
}
}
}
My Modbus service class reads PLC and assign static fields in static classes all that looks like this:
public static class ModbusService
{
public static CancellationTokenSource cts = new CancellationTokenSource();
public static ModbusClient MB = new ModbusClient();
private static bool[] coils1 = new bool[120];
private static bool[] coils2 = new bool[40];
private static int[] registers1 = new int[121];
private static int[] registers2 = new int[121];
private static int[] registers3 = new int[121];
public static void ConnectToPLC(string IP, int Port, bool ReConnect)
{
cts.Cancel();
ConnectionExist = false;
MB.Disconnect();
MB = new ModbusClient(IP, Port);
try
{
MB.Connect();
ConnectionExist = true;
ReadPLCAsync();
}
catch (Exception)
{
MessageBox.Show("Connection error.\r\nStart without reading PLC.");
}
}
public static async void ReadPLCAsync()
{
await Task.Run(async () =>
{
await Read(cts.Token);
});
}
private static async Task<bool> Read(CancellationToken cancellationToken)
{
while (ConnectionExist)
{
try
{
coils1 = MB.ReadCoils(0, 120);
coils2 = MB.ReadCoils(121, 40);
registers1 = MB.ReadHoldingRegisters(0, 122);
registers2 = MB.ReadHoldingRegisters(122, 122);
registers3 = MB.ReadHoldingRegisters(244, 122);
General.CPUinRUN = coils1[0];
General.ValveON = coils1[1];
General.SDCardEject = coils1[2];
General.SDCardEject = coils1[3];
Conductivity.MasterON = coils1[4];
Fresh.MasterON = coils1[5];
Level.MasterON = coils1[6];
General.SendEmail = coils1[7];
Array.Copy(coils1, 8, Conductivity.Valve, 0, 32);
Array.Copy(coils1, 40, Conductivity.CValveMan, 0, 32);
Array.Copy(coils1, 72, Fresh.FValve, 0, 32);
Fresh.RunOnSaturday = coils1[104];
Fresh.RunOnSunday = coils1[105];
Fresh.RunOnWeekend = coils1[106];
Array.Copy(coils1, 107, Level.L_Alarm, 0, 10);
Array.Copy(coils1, 117, Level.L_AlarmOFF, 0, 5);
Array.Copy(coils2, 0, Level.L_AlarmOFF, 5, 5);
Array.Copy(coils2, 5, Level.L_Filling, 0, 10);
Array.Copy(coils2, 15, Level.L_InRange, 0, 10);
Array.Copy(coils2, 25, Level.L_Valve, 0, 10);
Conductivity.ON = coils2[35];
Fresh.ON = coils2[36];
Level.ON = coils2[37];
}
catch (ConnectionException)
{
ConnectionExist = false;
MBC.MB.Disconnect();
MessageBox.Show("Problem reading PLC from MBC class.");
}
await Task.Delay(1000);
}
return true;
}
}
In general app looks like this
All right, I'll be really detailed on my implementation so it can be useful to comment and advise on better ways to do it as I'm not c# programmer, have no idea of MVVM CommunityToolkit. Main problems were to
First Navigation: All my Views I implemented in separate UserControls (HomeView, ConductivityView, etc...). Each View has its own ViewModel class. For navigation I created two classes: BaseViewModel and MainViewModel. BaseViewModel class is empty and inherits from ObservableObject. MainViewModel class inherits from BaseViewModel. Here I instantiated all my ViewModels with [ObservableProperty] attribute. MainViewModel:
public partial class MainViewModel : BaseViewModel
{
#region Navigation View Models
[ObservableProperty]
HomeViewModel _homeViewModel;
[ObservableProperty]
ConductivityViewModel _conductivityViewModel;
[ObservableProperty]
PHViewModel _pHViewModel;
[ObservableProperty]
LevelViewModel _levelViewModel;
[ObservableProperty]
FreshViewModel _freshViewModel;
[ObservableProperty]
LogsViewModel _logsViewModel;
[ObservableProperty]
SettingsViewModel _settingsViewModel;
#endregion
private Timer _timer;
[ObservableProperty]
private BaseViewModel _selectedViewModel;
public MainViewModel()
{
HomeViewModel = new HomeViewModel();
ConductivityViewModel = new ConductivityViewModel();
PHViewModel = new PHViewModel();
LevelViewModel = new LevelViewModel();
FreshViewModel = new FreshViewModel();
LogsViewModel = new LogsViewModel();
SettingsViewModel = new SettingsViewModel();
SelectedViewModel = HomeViewModel;
_timer = new Timer(UpdateModel, null, 0, 1000);
}
[RelayCommand]
public void ChangeView(string parameter)
{
if (parameter == "Home")
{
SelectedViewModel = HomeViewModel;
}
else if (parameter == "Conductivity")
{
SelectedViewModel = ConductivityViewModel;
}
else if (parameter == "pH")
{
SelectedViewModel = PHViewModel;
}
else if (parameter == "Level")
{
SelectedViewModel = LevelViewModel;
}
else if (parameter == "Fresh")
{
SelectedViewModel = FreshViewModel;
}
else if (parameter == "Logs")
{
SelectedViewModel = LogsViewModel;
}
else if (parameter == "Settings")
{
SelectedViewModel = SettingsViewModel;
}
}
In the MainWindow.xaml I used ListBox to create navigation panel. I'm using behaviors to access the "Command" property. Example with only one "Home button". There're multiple copies of ListBoxItem for each page with corresponding names and Paths.
<ListBox Grid.Column="0" SelectionMode="Single" x:Name="sidebar" BorderThickness="0" Background="Transparent" ScrollViewer.HorizontalScrollBarVisibility="Disabled" >
<!-- Home -->
<ListBoxItem x:Name="HomeLBI" IsSelected="True">
<ListBoxItem.Resources>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<Border x:Name="back" Background="Transparent" Margin="10 6"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8" Padding="{TemplateBinding Padding}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Path Grid.Column="0" x:Name="icon" Stretch="Uniform" Data="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" Fill="White" Height="30" Width="30"/>
<TextBlock Grid.Column="1" Text="Home" Foreground="White" FontSize="22" Background="Transparent" HorizontalAlignment="Left" VerticalAlignment="Center" Margin="20 0" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" TargetName="back" Value="transparent"/>
<Setter Property="Fill" TargetName="icon" Value="White"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="#99ff99" ShadowDepth="1" Direction="-90" BlurRadius="5"/>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" TargetName="back" Value="transparent"/>
<Setter Property="Fill" TargetName="icon" Value="White"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="#99ff99" ShadowDepth="1" Direction="-90" BlurRadius="5"/>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsSelected" Value="False">
<Setter Property="Background" TargetName="back" Value="transparent"/>
<Setter Property="Fill" TargetName="icon" Value="#FFB1B1B1"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBoxItem.Resources>
<i:Interaction.Triggers>
<i:EventTrigger EventName="Selected" SourceObject="{Binding ElementName=HomeLBI}">
<i:InvokeCommandAction Command="{Binding ChangeViewCommand}" CommandParameter="Home"/>
</i:EventTrigger>
</i:Interaction.Triggers>
</ListBoxItem>
</ListBox>
To make app to recognize what type of data is going to be used on each page I've created ResourceDictionary with DataTemplates and brought it in App.xaml.
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation".......>
<DataTemplate DataType="{x:Type viewmodel:HomeViewModel}">
<view:AHomeView/>
</DataTemplate>
<DataTemplate DataType="{x:Type viewmodel:ConductivityViewModel}">
<view:BConductivityView/>
</DataTemplate>
<DataTemplate DataType="{x:Type viewmodel:PHViewModel}">
<view:CpHView/>
</DataTemplate>
<DataTemplate DataType="{x:Type viewmodel:LevelViewModel}">
<view:DAutoLevelView/>
</DataTemplate>
<DataTemplate DataType="{x:Type viewmodel:FreshViewModel}">
<view:EFreshAddView/>
</DataTemplate>
<DataTemplate DataType="{x:Type viewmodel:LogsViewModel}">
<view:FLogsView/>
</DataTemplate>
<DataTemplate DataType="{x:Type viewmodel:SettingsViewModel}">
<view:GSettingsView/>
</DataTemplate> </ResourceDictionary>
That last step did the trick like magic (magic because at that time it felt like it). That's it for navigation.
My biggest challenge was to create Model part of MVVM. For task like read PLC with Modbus asynchronously there's not much information. So I had to be creative. Each article I read about Model was not helping in my case. I decided to go my way of creating two sets of Models. First set represent data in form that can easily be used by ViewModels. This data displayed in the View, so I can create Collection of it and manipulate it in specific ViewModel. I named those just Models. Second Model - in form that Easymodbus reads from PLC and it easy to navigate that data. I named them DataModels. Example of Model. As you see I implemented [ObservableProperty] attribute so changes in model can propagate to the view:
public partial class ConductivityModel : ObservableObject
{
[ObservableProperty]
private string? _id;
[ObservableProperty]
private bool? _inUse;
[ObservableProperty]
private string? _tankNo;
[ObservableProperty]
private string? _sp;
[ObservableProperty]
private string? _pv;
[ObservableProperty]
private bool? _valve;
[ObservableProperty]
private string? _timeLeft;
[ObservableProperty]
private string? _timer;
[ObservableProperty]
private string? _sensorMinimum;
[ObservableProperty]
private string? _sensorMaximum;
[ObservableProperty]
private string? _raw;
}
DataModels is static classes with static members. EasyModbus gives me data in the type of arrays. There's one of the DataModels:
public static class ConductivityDataModel
{
public static bool TurnON;
public static bool IsON;
public static int[] Tank = new int[100];
public static int[] SP = new int[100];
public static int[] PV = new int[100];
public static bool[] IsValveOn = new bool[100];
public static bool[] TurnManualyOn = new bool[100];
public static bool[] IsValveManualyOn = new bool[100];
public static int[] TimeLeft = new int[100];
public static int[] Timer = new int[100];
public static int[] SensorMin = new int[100];
public static int[] SensorMax = new int[100];
public static int[] Raw = new int[100];
}
That my Models. For reading Modbus I created Modbus class. In this class I have few static members for storing data and few methods. I had to dig into async programming to make it more efficient. There's not much sense to read each Modbus call async as it's client server protocol (I've learned it in the hard way), but I thought that assigning data to variables can be async and in few groups. See maximum number of register that I can read in one go is 125. So I had to break my Read in few smaller ones. So I decided to use this "downtime" to do some work by copy data to DataModels.
{
public class MB
{
public static CancellationTokenSource cancellationSource = new CancellationTokenSource();
public static ModbusClient MBC = new ModbusClient();
public static bool ConnectionExist = false;
private static bool[] coils1 = new bool[120];
private static int[] registers1 = new int[121];
private static int[] registers2 = new int[121];
private static int[] registers3 = new int[121];
public static void Disconnect()
{
ConnectionExist = false;
cancellationSource.Cancel();
Thread.Sleep(1000);
cancellationSource.Dispose();
MBC.Disconnect();
}
public static void ConnectToPLC(string IP, int Port, bool IsReconnecting)
{
if (IsReconnecting)
ConnectionExist = false;
cancellationSource.Cancel();
Thread.Sleep(1500);
if (IsReconnecting)
MBC.Disconnect();
cancellationSource.Dispose();
cancellationSource = new CancellationTokenSource();
MB.MBC = new ModbusClient(IP, Port);
try
{
MBC.Connect();
ConnectionExist = true;
}
catch (Exception)
{
cancellationSource.Cancel();
ConnectionExist = false;
MessageBox.Show("Connection error.\r\nStart without reading PLC.");
}
}
public static void StartReadPLC()
{
new Thread(() =>
{
try
{
Read(cancellationSource.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Canceled!");
}
}).Start();
}
public static async void Read(CancellationToken token)
{
while (true)
{
if (token.IsCancellationRequested)
return;
try
{
ConnectionExist = MBC.Available(30);
if (!ConnectionExist)
{
token.ThrowIfCancellationRequested();
MBC.Disconnect();
throw new ConnectionException();
}
//Conductivity
coils1 = MBC.ReadCoils(0, 96);
await Task.Run(() =>
{
Array.Copy(coils1, 0, ConductivityDataModel.IsValveOn, 0, 32);
Array.Copy(coils1, 32, ConductivityDataModel.TurnManualyOn, 0, 32);
Array.Copy(coils1, 64, ConductivityDataModel.IsValveManualyOn, 0, 32);
});
registers1 = MBC.ReadHoldingRegisters(0, 96);
await Task.Run(() =>
{
Array.Copy(registers1, 0, ConductivityDataModel.Tank, 0, 32);
Array.Copy(registers1, 32, ConductivityDataModel.SP, 0, 32);
Array.Copy(registers1, 64, ConductivityDataModel.PV, 0, 32);
});
registers2 = MBC.ReadHoldingRegisters(96, 96);
await Task.Run(() =>
{
Array.Copy(registers2, 0, ConductivityDataModel.TimeLeft, 0, 32);
Array.Copy(registers2, 32, ConductivityDataModel.Timer, 0, 32);
Array.Copy(registers2, 64, ConductivityDataModel.SensorMin, 0, 32);
});
registers3 = MBC.ReadHoldingRegisters(192, 64);
await Task.Run(() =>
{
Array.Copy(registers3, 0, ConductivityDataModel.SensorMax, 0, 32);
Array.Copy(registers3, 32, ConductivityDataModel.Raw, 0, 32);
});
//Fresh Add
FreshDataModel.IsValveOn = MBC.ReadCoils(96, 32);
registers1 = MBC.ReadHoldingRegisters(256, 64);
await Task.Run(() =>
{
Array.Copy(registers1, 0, FreshDataModel.TimerSet, 0, 32);
Array.Copy(registers1, 32, FreshDataModel.Tank, 0, 32);
});
//Level
coils1 = MBC.ReadCoils(128, 50);
await Task.Run(() =>
{
Array.Copy(coils1, 0, LevelDataModel.IsTankAlarm, 0, 10);
Array.Copy(coils1, 10, LevelDataModel.IsAlarmOFF, 0, 10);
Array.Copy(coils1, 20, LevelDataModel.IsFilling, 0, 10);
Array.Copy(coils1, 30, LevelDataModel.IsInRange, 0, 10);
Array.Copy(coils1, 40, LevelDataModel.IsValve, 0, 10);
});
LevelDataModel.Tank = MBC.ReadHoldingRegisters(320, 10);
//General Com
coils1 = MBC.ReadCoils(499, 15);
await Task.Run(() =>
{
GeneralDataModel.CPUinRUN = coils1[0];
GeneralDataModel.IsValvePowerON = coils1[1];
GeneralDataModel.UseLogsToSD = coils1[2];
GeneralDataModel.SDCardEject = coils1[3];
GeneralDataModel.SendEmail = coils1[4];
GeneralDataModel.IsTestMode = coils1[14];
});
await Task.Run(() =>
{
FreshDataModel.IsRunOnSaturday = coils1[5];
FreshDataModel.IsRunOnSunday = coils1[6];
FreshDataModel.IsRunOnWeekend = coils1[7];
ConductivityDataModel.TurnON = coils1[8];
ConductivityDataModel.IsON = coils1[9];
FreshDataModel.TurnON = coils1[10];
FreshDataModel.IsON = coils1[11];
LevelDataModel.TurnON = coils1[12];
LevelDataModel.IsON = coils1[13];
});
}
catch
{
ConnectionExist = false;
MBC.Disconnect();
MessageBox.Show("Connection to PLC lost.");
cancellationSource.Cancel();
};
await Task.Delay(1000);
}
}
}
To connect to PLC and Start periodically read data I have few methods in MainWindow CodeBehind. This triggers Modbus class to create Modbus Client, connect to PLC and Start the background loop of reading PLC. I realize that this approach isn't right but I'm still working on Settings to be able to change IP and save it.
InitializeComponent();
DataContext = new MainViewModel();
MB.ConnectToPLC("127.0.0.1", 502, false);
MB.StartReadPLC();
As of part where I trigger the View to update it's state I have Timer in MainViewModel (See code at the beginning). For me it's like walk in dark forest with the blindfold. I don't know how it works. Supposedly I'm using callbacks instead of using Timer.Tick event. Anyway this calls the UpdateModel method every 1000 milliseconds. Here I have only ConductivityViewModel updating it's values. But same will be done with all other ViewModels.
public void UpdateModel(object callback)
{
for (int i = 0; i <= 32; i++)
{
ConductivityViewModel.ConductivityCollection[i].TankNo = ConductivityDataModel.Tank[i].ToString();
ConductivityViewModel.ConductivityCollection[i].Sp = ConductivityDataModel.SP[i].ToString();
ConductivityViewModel.ConductivityCollection[i].Pv = ConductivityDataModel.PV[i].ToString();
ConductivityViewModel.ConductivityCollection[i].Valve = ConductivityDataModel.IsValveOn[i];
ConductivityViewModel.ConductivityCollection[i].TimeLeft = ConductivityDataModel.TimeLeft[i].ToString();
ConductivityViewModel.ConductivityCollection[i].Timer = ConductivityDataModel.Timer[i].ToString();
ConductivityViewModel.ConductivityCollection[i].SensorMinimum = ConductivityDataModel.SensorMin[i].ToString();
ConductivityViewModel.ConductivityCollection[i].SensorMaximum = ConductivityDataModel.SensorMax[i].ToString();
ConductivityViewModel.ConductivityCollection[i].Raw = ConductivityDataModel.Raw[i].ToString();
}
Hope this will be helpful for anyone who's dealing with modbus libraries or MVVM navigation.