Add PamhagenSysCtrl
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
obj/
|
||||||
|
bin/
|
||||||
|
*.user
|
||||||
|
.vs
|
||||||
|
.idea
|
||||||
|
*.exe
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="PamhagenSysCtrl/PamhagenSysCtrl.csproj" />
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Application x:Class="PamhagenSysCtrl.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="clr-namespace:PamhagenSysCtrl"
|
||||||
|
StartupUri="Windows/MainWindow.xaml"
|
||||||
|
Exit="Application_Exit">
|
||||||
|
<Application.Resources>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using PamhagenSysCtrl.Helpers;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace PamhagenSysCtrl {
|
||||||
|
public partial class App : Application {
|
||||||
|
|
||||||
|
public static PamhagenPlant? Plant;
|
||||||
|
public static Dispatcher MainDispatcher;
|
||||||
|
|
||||||
|
public App() :
|
||||||
|
base() {
|
||||||
|
MainDispatcher = Dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnStartup(StartupEventArgs evt) {
|
||||||
|
base.OnStartup(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async void Application_Exit(object sender, ExitEventArgs evt) {
|
||||||
|
Plant?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
[assembly: ThemeInfo(
|
||||||
|
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// or application resource dictionaries)
|
||||||
|
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// app, or any theme specific resource dictionaries)
|
||||||
|
)]
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace PamhagenSysCtrl.Helpers {
|
||||||
|
public static partial class Extensions {
|
||||||
|
|
||||||
|
[LibraryImport("msvcrt.dll")]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])]
|
||||||
|
private static unsafe partial int memcmp(void* b1, void* b2, long count);
|
||||||
|
|
||||||
|
public static unsafe int CompareBuffers(char[] buffer1, int offset1, char[] buffer2, int offset2, int count) {
|
||||||
|
fixed (char* b1 = buffer1, b2 = buffer2) {
|
||||||
|
return memcmp(b1 + offset1, b2 + offset2, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static string? ReadUntil(this StreamReader reader, string delimiter) {
|
||||||
|
return ReadUntil(reader, delimiter.ToCharArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ReadUntil(this StreamReader reader, char delimiter) {
|
||||||
|
return ReadUntil(reader, [delimiter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ReadUntil(this StreamReader reader, char[] delimiter) {
|
||||||
|
var buf = new char[512];
|
||||||
|
int bufSize = 0, ret;
|
||||||
|
while (!reader.EndOfStream && bufSize < buf.Length - 1) {
|
||||||
|
if ((ret = reader.Read()) == -1)
|
||||||
|
return null;
|
||||||
|
buf[bufSize++] = (char)ret;
|
||||||
|
|
||||||
|
if (bufSize >= delimiter.Length && CompareBuffers(buf, bufSize - delimiter.Length, delimiter, 0, delimiter.Length) == 0)
|
||||||
|
return new string(buf, 0, bufSize);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<string?> ReadUntilAsync(this StreamReader reader, string delimiter) {
|
||||||
|
return ReadUntilAsync(reader, delimiter.ToCharArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<string?> ReadUntilAsync(this StreamReader reader, char delimiter) {
|
||||||
|
return ReadUntilAsync(reader, [delimiter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> ReadUntilAsync(this StreamReader reader, char[] delimiter) {
|
||||||
|
var buf = new char[512];
|
||||||
|
int bufSize = 0;
|
||||||
|
var tmpBuf = new char[1];
|
||||||
|
while (!reader.EndOfStream && bufSize < buf.Length - 1) {
|
||||||
|
if ((await reader.ReadAsync(tmpBuf, 0, 1)) != 1)
|
||||||
|
return null;
|
||||||
|
buf[bufSize++] = tmpBuf[0];
|
||||||
|
|
||||||
|
if (bufSize >= delimiter.Length && CompareBuffers(buf, bufSize - delimiter.Length, delimiter, 0, delimiter.Length) == 0)
|
||||||
|
return new string(buf, 0, bufSize);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace PamhagenSysCtrl.Helpers {
|
||||||
|
public enum FillState { Empty = 0, Filled = 1, Half = 2, Full = 3, Invalid = -1, Unknown = -2 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace PamhagenSysCtrl.Helpers {
|
||||||
|
public enum MotorState { Halt = 0, Forward = 1, Backward = 2, Invalid = -1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace PamhagenSysCtrl.Helpers {
|
||||||
|
public class PamhagenPlant : IDisposable {
|
||||||
|
|
||||||
|
protected PamhagenPlc Plc;
|
||||||
|
|
||||||
|
protected bool IsRunning = true;
|
||||||
|
protected Thread? BackgroundThread;
|
||||||
|
|
||||||
|
public event EventHandler<EventArgs>? Update;
|
||||||
|
|
||||||
|
protected bool ActuatorsChanged = false;
|
||||||
|
protected PamhagenPlc.ActuatorStates Actuators;
|
||||||
|
protected PamhagenPlc.SensorStates Sensors;
|
||||||
|
|
||||||
|
protected bool GetDS(int n) => Sensors.GetDS(n >= 1 && n <= 60 ? n : throw new ArgumentException("Invalid value for n: 1 <= #DS <= 60"));
|
||||||
|
protected bool GetV(int n) => Actuators.GetV(n >= 1 && n <= 60 ? n : throw new ArgumentException("Invalid value for n: 1 <= #V <= 60"));
|
||||||
|
|
||||||
|
public bool IsValveClosed(int n) => GetDS(n);
|
||||||
|
public bool IsValveOpen(int n) => !IsValveClosed(n);
|
||||||
|
public bool WantValveClosed(int n) => GetV(n);
|
||||||
|
public bool WantValveOpen(int n) => !WantValveClosed(n);
|
||||||
|
|
||||||
|
public bool IsA1Selected => Sensors.TroughSelector == 1;
|
||||||
|
public bool IsA2Selected => Sensors.TroughSelector == 2;
|
||||||
|
public FillState FillLevelA1 => Sensors.FillingLevels.A1;
|
||||||
|
public FillState FillLevelA2 => Sensors.FillingLevels.A2;
|
||||||
|
public FillState FillLevelPress1 => Sensors.FillingLevels.Press1;
|
||||||
|
public FillState FillLevelPress2 => Sensors.FillingLevels.Press2;
|
||||||
|
public FillState FillLevelT1 => Sensors.FillingLevelTanks[0];
|
||||||
|
public FillState FillLevelT2 => Sensors.FillingLevelTanks[1];
|
||||||
|
public FillState FillLevelT3 => Sensors.FillingLevelTanks[2];
|
||||||
|
public FillState FillLevelT4 => Sensors.FillingLevelTanks[3];
|
||||||
|
public FillState FillLevelT5 => Sensors.FillingLevelTanks[4];
|
||||||
|
public FillState FillLevelT6 => Sensors.FillingLevelTanks[5];
|
||||||
|
public FillState FillLevelT7 => Sensors.FillingLevelTanks[6];
|
||||||
|
public FillState FillLevelT8 => Sensors.FillingLevelTanks[7];
|
||||||
|
public FillState FillLevelT9 => Sensors.FillingLevelTanks[8];
|
||||||
|
public double GradationOe => Sensors.GradationOe;
|
||||||
|
public MotorState PresseQuerSchnecke => Sensors.PresseQuerSchnecke;
|
||||||
|
|
||||||
|
public bool WantA1Selected => Actuators.TroughSelector;
|
||||||
|
public bool WantA2Selected => !Actuators.TroughSelector;
|
||||||
|
|
||||||
|
public bool IsTroughAugerActive => Actuators.TroughAuger;
|
||||||
|
public bool IsReblerActive => Actuators.Rebler;
|
||||||
|
public bool IsStirrerA1Active => Actuators.StirrerA1;
|
||||||
|
public bool IsStirrerA2Active => Actuators.StirrerA2;
|
||||||
|
public bool IsBeltActive => Actuators.Belt;
|
||||||
|
public bool IsAuger1Active => Actuators.Auger1;
|
||||||
|
public bool IsAuger2Active => Actuators.Auger2;
|
||||||
|
public bool IsGradationPumpActive => Actuators.GradationPump;
|
||||||
|
|
||||||
|
public PamhagenPlant(string portName) {
|
||||||
|
Plc = new(portName);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void RaiseUpdateEvent(EventArgs evt) {
|
||||||
|
App.MainDispatcher.BeginInvoke(Update);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartThread() {
|
||||||
|
await Plc.TestConnection();
|
||||||
|
Actuators = await Plc.ReadOutputs();
|
||||||
|
BackgroundThread = new Thread(new ParameterizedThreadStart(BackgroundLoop));
|
||||||
|
BackgroundThread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
IsRunning = false;
|
||||||
|
BackgroundThread?.Interrupt();
|
||||||
|
BackgroundThread?.Join();
|
||||||
|
Plc.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async void BackgroundLoop(object? parameters) {
|
||||||
|
while (IsRunning) {
|
||||||
|
try {
|
||||||
|
if (ActuatorsChanged) {
|
||||||
|
await Plc.WriteOutputs(Actuators);
|
||||||
|
ActuatorsChanged = false;
|
||||||
|
}
|
||||||
|
Sensors = await Plc.ReadInputs();
|
||||||
|
RaiseUpdateEvent(EventArgs.Empty);
|
||||||
|
} catch (ThreadInterruptedException) {
|
||||||
|
// ignore
|
||||||
|
} catch (Exception exc) {
|
||||||
|
var str = "Bei der SPS ist ein Fehler aufgetreten:\n\n" + exc.Message;
|
||||||
|
if (exc.InnerException != null) str += "\n\n" + exc.InnerException.Message;
|
||||||
|
MessageBox.Show(str, "SPS-Fehler", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Plc.WriteOutputs(new());
|
||||||
|
} catch (Exception exc) {
|
||||||
|
var str = "Beim Schließen der Verbindung zur SPS ist ein Fehler aufgetreten:\n\n" + exc.Message;
|
||||||
|
if (exc.InnerException != null) str += "\n\n" + exc.InnerException.Message;
|
||||||
|
MessageBox.Show(str, "SPS-Fehler", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CommitChanges() {
|
||||||
|
ActuatorsChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MotorState MP1 {
|
||||||
|
get => Actuators.MP1;
|
||||||
|
set => Actuators.MP1 = (value != MotorState.Halt && value != MotorState.Forward && value != MotorState.Backward) ? throw new ArgumentException("Invalid state for MP1") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MotorState MP2 {
|
||||||
|
get => Actuators.MP2;
|
||||||
|
set => Actuators.MP2 = (value != MotorState.Halt && value != MotorState.Forward && value != MotorState.Backward) ? throw new ArgumentException("Invalid state for MP2") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MotorState P12 {
|
||||||
|
get => Actuators.P12;
|
||||||
|
set => Actuators.P12 = (value != MotorState.Halt && value != MotorState.Forward) ? throw new ArgumentException("Invalid state for P12") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MotorState P3 {
|
||||||
|
get => Actuators.P3;
|
||||||
|
set => Actuators.P3 = (value != MotorState.Halt && value != MotorState.Forward) ? throw new ArgumentException("Invalid state for P3") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MotorState P4 {
|
||||||
|
get => Actuators.P4;
|
||||||
|
set => Actuators.P4 = (value != MotorState.Halt && value != MotorState.Forward) ? throw new ArgumentException("Invalid state for P4") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MotorState P5 {
|
||||||
|
get => Actuators.P5;
|
||||||
|
set => Actuators.P5 = (value != MotorState.Halt && value != MotorState.Forward) ? throw new ArgumentException("Invalid state for P5") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MotorState P6 {
|
||||||
|
get => Actuators.P6;
|
||||||
|
set => Actuators.P6 = (value != MotorState.Halt && value != MotorState.Forward) ? throw new ArgumentException("Invalid state for P6") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public void OpenV(int n) => SetV(n, false);
|
||||||
|
public void CloseV(int n) => SetV(n, true);
|
||||||
|
|
||||||
|
public void SetV(int n, bool val) {
|
||||||
|
if (n < 1 || n > 60) throw new ArgumentException("Invalid value for n: 1 <= #V <= 60");
|
||||||
|
Actuators.SetV(n, val);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SelectA1() {
|
||||||
|
Actuators.TroughSelector = true;
|
||||||
|
//if (IsReblerActive) Actuators.StirrerA1 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SelectA2() {
|
||||||
|
Actuators.TroughSelector = false;
|
||||||
|
//if (IsReblerActive) Actuators.StirrerA2 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartRebler() {
|
||||||
|
Actuators.Rebler = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopRebler() {
|
||||||
|
Actuators.Rebler = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System.IO.Ports;
|
||||||
|
|
||||||
|
namespace PamhagenSysCtrl.Helpers {
|
||||||
|
public class PamhagenPlc : IDisposable {
|
||||||
|
|
||||||
|
public record struct SensorStates(
|
||||||
|
long PressureSensors,
|
||||||
|
(FillState A1, FillState A2, FillState Press1, FillState Press2) FillingLevels,
|
||||||
|
int TroughSelector,
|
||||||
|
double GradationOe,
|
||||||
|
FillState[] FillingLevelTanks,
|
||||||
|
MotorState PresseQuerSchnecke
|
||||||
|
) {
|
||||||
|
public readonly bool GetDS(int n) => (PressureSensors & (1 << (n - 1))) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record struct ActuatorStates(
|
||||||
|
long Valves,
|
||||||
|
MotorState MP1, MotorState MP2, MotorState P12, MotorState P3, MotorState P4, MotorState P5, MotorState P6,
|
||||||
|
bool TroughAuger, bool Rebler, bool TroughSelector, bool StirrerA1, bool StirrerA2, bool Belt, bool Auger1, bool Auger2, bool GradationPump
|
||||||
|
) {
|
||||||
|
public readonly bool GetV(int n) => (Valves & (1L << (n - 1))) != 0;
|
||||||
|
public void SetV(int n, bool val) {
|
||||||
|
if (val) {
|
||||||
|
Valves |= 1L << (n - 1);
|
||||||
|
} else {
|
||||||
|
Valves &= ~(1L << (n - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public void CloseV(int n) => SetV(n, true);
|
||||||
|
public void OpenV(int n) => SetV(n, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly T1 Plc;
|
||||||
|
|
||||||
|
public PamhagenPlc(string portName, Parity parity = Parity.Odd) {
|
||||||
|
Plc = new T1(portName, parity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
Plc.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static FillState Int3ToFillState(int n) {
|
||||||
|
return n switch {
|
||||||
|
7 => FillState.Full,
|
||||||
|
6 => FillState.Half,
|
||||||
|
4 => FillState.Filled,
|
||||||
|
0 => FillState.Empty,
|
||||||
|
_ => FillState.Invalid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static FillState Int2ToFillState(int n) {
|
||||||
|
return n switch {
|
||||||
|
2 => FillState.Full,
|
||||||
|
0 => FillState.Filled,
|
||||||
|
1 => FillState.Empty,
|
||||||
|
_ => FillState.Invalid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static FillState Int1ToFillState(int n) {
|
||||||
|
return n switch {
|
||||||
|
1 => FillState.Full,
|
||||||
|
0 => FillState.Unknown,
|
||||||
|
_ => FillState.Invalid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static MotorState Int2ToPumpState(int n, bool inverse) {
|
||||||
|
return n switch {
|
||||||
|
0 => MotorState.Halt,
|
||||||
|
1 => inverse ? MotorState.Backward : MotorState.Forward,
|
||||||
|
2 => inverse ? MotorState.Forward : MotorState.Backward,
|
||||||
|
_ => MotorState.Invalid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static MotorState Int1ToPumpState(int n) {
|
||||||
|
return n switch {
|
||||||
|
1 => MotorState.Forward,
|
||||||
|
_ => MotorState.Halt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<(ushort RW010, ushort RW011, ushort RW012, ushort RW013, ushort RW014, ushort RW015, ushort RW016)> ReadInputRegisters() {
|
||||||
|
var d = await Plc.DataRead("RW010", 7);
|
||||||
|
// RW010, RW011, RW012, RW013 Drucksensoren
|
||||||
|
// RW014 Füllstände A1, A2, Presse1, Presse2, Wannenselekt
|
||||||
|
// RW015 Messwert von Marselli Gerät
|
||||||
|
// RW016 Füllstände der Tanks
|
||||||
|
return (d[0], d[1], d[2], d[3], d[4], d[5], d[6]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SensorStates> ReadInputs() {
|
||||||
|
var (r1, r2, r3, r4, f1, v, f2) = await ReadInputRegisters();
|
||||||
|
|
||||||
|
long pressure = (long)r1 | ((long)r2 << 16) | ((long)r3 << 32) | ((long)r4 << 48);
|
||||||
|
|
||||||
|
var a1 = Int3ToFillState((f1 ) & 0x7); // 111 011 001 000
|
||||||
|
var a2 = Int3ToFillState((f1 >> 3) & 0x7); // 111 011 001 000
|
||||||
|
var p1 = Int1ToFillState((f1 >> 6) & 0x1); // 1 0
|
||||||
|
var p2 = Int1ToFillState((f1 >> 7) & 0x1); // 1 0
|
||||||
|
var wp = ((f1 >> 8) & 0x3); // 10 01
|
||||||
|
|
||||||
|
// Masseligerät : 4mA => 50 Oe
|
||||||
|
// 20mA => 150 Oe
|
||||||
|
// ---> 0mA => 25 Oe
|
||||||
|
// Oe mA T1Wert
|
||||||
|
// 25 0 0
|
||||||
|
// 50 4 800
|
||||||
|
// 75 8 1600
|
||||||
|
// 100 12 2400
|
||||||
|
// 125 16 3200
|
||||||
|
// 150 20 4000
|
||||||
|
// -> factor: 0,03125 + (offset) 25 Oe
|
||||||
|
double oe = v * 0.03125 + 25;
|
||||||
|
|
||||||
|
var t1 = Int2ToFillState((f2 ) & 0x3); // 10 01 00
|
||||||
|
var t2 = Int2ToFillState((f2 >> 2) & 0x3); // 10 01 00
|
||||||
|
var t3 = Int2ToFillState((f2 >> 4) & 0x3); // 10 01 00
|
||||||
|
var t4 = Int2ToFillState((f2 >> 6) & 0x3); // 10 01 00
|
||||||
|
var t5 = Int2ToFillState((f2 >> 8) & 0x3); // 10 01 00
|
||||||
|
var t6 = Int1ToFillState((f2 >> 10) & 0x1); // 1 0
|
||||||
|
var t7 = Int1ToFillState((f2 >> 11) & 0x1); // 1 0
|
||||||
|
var t8 = Int1ToFillState((f2 >> 12) & 0x1); // 1 0
|
||||||
|
var t9 = Int1ToFillState((f2 >> 13) & 0x1); // 1 0
|
||||||
|
var q1 = Int1ToPumpState((f2 >> 15) & 0x1); // 1 0
|
||||||
|
|
||||||
|
return new SensorStates(pressure, (a1, a2, p1, p2), wp == 3 ? -1 : wp == 1 ? 2 : wp == 2 ? 1 : 0, oe, [t1, t2, t3, t4, t5, t6, t7, t8, t9], q1);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<(ushort RW000, ushort RW001, ushort RW002, ushort RW003, ushort RW004, ushort RW005, ushort RW006)> ReadOutputRegisters() {
|
||||||
|
var d = await Plc.DataRead("RW000", 7);
|
||||||
|
// RW000, RW001, RW002, RW003 Ventile
|
||||||
|
// RW004 Pumpensteuerung MP1, MP2, P1/2, P3, P4, P5, P6
|
||||||
|
// RW005 Steuerung Wannenschnecke, Rebler, Förderung, Rührer
|
||||||
|
// RW006 MessungsSteuerung
|
||||||
|
return (d[0], d[1], d[2], d[3], d[4], d[5], d[6]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task WriteOutputRegisters(ushort rw000, ushort rw001, ushort rw002, ushort rw003, ushort rw004, ushort rw005, ushort rw006) {
|
||||||
|
// RW000, RW001, RW002, RW003 Ventile
|
||||||
|
// RW004 Pumpensteuerung MP1, MP2, P1/2, P3, P4, P5, P6
|
||||||
|
// RW005 Steuerung Wannenschnecke, Rebler, Förderung, Rührer
|
||||||
|
// RW006 MessungsSteuerung
|
||||||
|
await Plc.DataWrite("RW000", rw000, rw001, rw002, rw003, rw004, rw005, rw006);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ActuatorStates> ReadOutputs() {
|
||||||
|
var (v1, v2, v3, v4, p1, s1, s2) = await ReadOutputRegisters();
|
||||||
|
|
||||||
|
long valves = (long)v1 | ((long)v2 << 16) | ((long)v3 << 32) | ((long)v4 << 48);
|
||||||
|
|
||||||
|
var mp1 = Int2ToPumpState((p1 ) & 0x3, true);
|
||||||
|
var mp2 = Int2ToPumpState((p1 >> 2) & 0x3, false);
|
||||||
|
var p12 = Int1ToPumpState((p1 >> 4) & 0x1);
|
||||||
|
var p3 = Int1ToPumpState((p1 >> 5) & 0x1);
|
||||||
|
var p4 = Int1ToPumpState((p1 >> 6) & 0x1);
|
||||||
|
var p5 = Int1ToPumpState((p1 >> 7) & 0x1);
|
||||||
|
var p6 = Int1ToPumpState((p1 >> 8) & 0x1);
|
||||||
|
|
||||||
|
var m1 = (s1 & 0x01) != 0;
|
||||||
|
var m2 = (s1 & 0x02) != 0;
|
||||||
|
var m3 = (s1 & 0x04) != 0;
|
||||||
|
var m4 = (s1 & 0x08) != 0;
|
||||||
|
var m5 = (s1 & 0x10) != 0;
|
||||||
|
var m6 = (s1 & 0x20) != 0;
|
||||||
|
var m7 = (s1 & 0x40) != 0;
|
||||||
|
var m8 = (s1 & 0x80) != 0;
|
||||||
|
|
||||||
|
var m9 = (s2 & 0x4) != 0;
|
||||||
|
var state = new ActuatorStates(valves, mp1, mp2, p12, p3, p4, p5, p6, m1, m2, m3, m4, m5, m6, m7, m8, m9);
|
||||||
|
state.SetV(59, (s2 & 0x1) != 0);
|
||||||
|
state.SetV(60, (s2 & 0x2) != 0);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteOutputs(ActuatorStates state) {
|
||||||
|
var v1 = (ushort)((state.Valves ) & 0xFFFF);
|
||||||
|
var v2 = (ushort)((state.Valves >> 16) & 0xFFFF);
|
||||||
|
var v3 = (ushort)((state.Valves >> 32) & 0xFFFF);
|
||||||
|
var v4 = (ushort)((state.Valves >> 48) & 0xFFFF);
|
||||||
|
|
||||||
|
int pumps =
|
||||||
|
(state.MP1 == MotorState.Backward ? 0x001 : 0x000) |
|
||||||
|
(state.MP1 == MotorState.Forward ? 0x002 : 0x000) |
|
||||||
|
(state.MP2 == MotorState.Forward ? 0x004 : 0x000) |
|
||||||
|
(state.MP2 == MotorState.Backward ? 0x008 : 0x000) |
|
||||||
|
(state.P12 == MotorState.Forward ? 0x010 : 0x000) |
|
||||||
|
(state.P3 == MotorState.Forward ? 0x020 : 0x000) |
|
||||||
|
(state.P4 == MotorState.Forward ? 0x040 : 0x000) |
|
||||||
|
(state.P5 == MotorState.Forward ? 0x080 : 0x000) |
|
||||||
|
(state.P6 == MotorState.Forward ? 0x100 : 0x000);
|
||||||
|
|
||||||
|
int devices =
|
||||||
|
(state.TroughAuger ? 0x01 : 0x00) |
|
||||||
|
(state.Rebler ? 0x02 : 0x00) |
|
||||||
|
(state.TroughSelector ? 0x04 : 0x00) |
|
||||||
|
(state.StirrerA1 ? 0x08 : 0x00) |
|
||||||
|
(state.StirrerA2 ? 0x10 : 0x00) |
|
||||||
|
(state.Belt ? 0x20 : 0x00) |
|
||||||
|
(state.Auger1 ? 0x40 : 0x00) |
|
||||||
|
(state.Auger2 ? 0x80 : 0x00);
|
||||||
|
|
||||||
|
int measuring =
|
||||||
|
(state.GetV(59) ? 0x1 : 0x0) |
|
||||||
|
(state.GetV(60) ? 0x2 : 0x0) |
|
||||||
|
(state.GradationPump ? 0x4 : 0x0);
|
||||||
|
|
||||||
|
await WriteOutputRegisters(v1, v2, v3, v4, (ushort)pumps, (ushort)devices, (ushort)measuring);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TestConnection() {
|
||||||
|
await Plc.Test();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.IO.Ports;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace PamhagenSysCtrl.Helpers {
|
||||||
|
public class T1 : IDisposable {
|
||||||
|
|
||||||
|
public enum Mode { HALT = 1, RUN = 2, RUN_F = 3, HOLD = 4, ERROR = 6 }
|
||||||
|
public readonly record struct Status(Mode Mode, int Flags);
|
||||||
|
|
||||||
|
protected SerialPort Serial;
|
||||||
|
protected Stream Stream;
|
||||||
|
protected StreamReader Reader;
|
||||||
|
|
||||||
|
public T1(string portName, Parity parity = Parity.Odd) {
|
||||||
|
Serial = new SerialPort(portName, 9600, parity, 8, StopBits.One);
|
||||||
|
Serial.Open();
|
||||||
|
Stream = Serial.BaseStream ?? Stream.Null;
|
||||||
|
Stream.ReadTimeout = 250;
|
||||||
|
Stream.WriteTimeout = 250;
|
||||||
|
Reader = new(Stream, Encoding.ASCII, false, 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
Reader.Dispose();
|
||||||
|
Stream.Dispose();
|
||||||
|
Serial.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task SendCommand(string cmd, string? data = null, bool checksum = true) {
|
||||||
|
// max 255 bytes
|
||||||
|
byte[] d = new byte[255];
|
||||||
|
int n = Encoding.ASCII.GetBytes($"(A{1:02}{cmd}", d);
|
||||||
|
if (data != null) {
|
||||||
|
if (data.Length > 244) throw new ArgumentException("T1 playload too long");
|
||||||
|
n += Encoding.ASCII.GetBytes(data, 0, data.Length, d, n);
|
||||||
|
}
|
||||||
|
if (checksum) {
|
||||||
|
d[n++] = (byte)'&';
|
||||||
|
byte chk = d.Take(n).Aggregate((s, c) => s = (byte)((s + c) & 0xFF));
|
||||||
|
n += Encoding.ASCII.GetBytes($"{chk:02X}", 0, 2, d, n);
|
||||||
|
}
|
||||||
|
d[n++] = (byte)')';
|
||||||
|
d[n++] = (byte)'\r';
|
||||||
|
await Stream.WriteAsync(d.AsMemory(0, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<(string, string)> ReceiveResponse(string? expectedCmd = null) {
|
||||||
|
var line = await Reader.ReadUntilAsync('\r');
|
||||||
|
if (line == null) {
|
||||||
|
throw new IOException("Verbindung zu SPS (T1) verloren");
|
||||||
|
} else if (line.Length < 9 || !line.StartsWith("(A") || !line.EndsWith(")\r") || line[^5] != '&') {
|
||||||
|
throw new FormatException("Invalid response from PLC (T1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var chk1 = (byte)Convert.ToInt32(line[^4..^2], 16);
|
||||||
|
var chk2 = Encoding.ASCII.GetBytes(line).SkipLast(5).Aggregate((s, c) => (byte)((s + c) & 0xFF));
|
||||||
|
if (chk1 != chk2) {
|
||||||
|
throw new IOException("Ungültige Prüfsumme von SPS (T1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (cmd, data) = (line[3..5], line[5..^5]);
|
||||||
|
if (cmd == "ST" && data[4] == '6') {
|
||||||
|
// TODO check for other error bits?
|
||||||
|
await SendCommand("ER");
|
||||||
|
var (_, res) = await ReceiveResponse("ER");
|
||||||
|
int code = Convert.ToInt32(res);
|
||||||
|
var error = Enum.IsDefined(typeof(T1Error), code) ? (T1Error)code : T1Error.UNKNOWN_ERROR;
|
||||||
|
throw new T1Exception($"PLC Error ({data}): {error.GetName()}" + (error.GetDescription() != null ? $" ({error.GetDescription()})" : ""));
|
||||||
|
} else if (cmd == expectedCmd) {
|
||||||
|
return (cmd, data);
|
||||||
|
} else if (cmd == "CE") {
|
||||||
|
int code = Convert.ToInt32(data);
|
||||||
|
throw code switch {
|
||||||
|
1 => new T1Exception($"Computer Link Error ({data}): Command error (Received command is illegal)"),
|
||||||
|
2 => new T1Exception($"Computer Link Error ({data}): Format error (Received message format is invalid)"),
|
||||||
|
3 => new T1Exception($"Computer Link Error ({data}): Checksum error (Checksum mismatch is detected)"),
|
||||||
|
_ => new T1Exception($"Computer Link Error ({data}): Unknown error"),
|
||||||
|
};
|
||||||
|
} else if (cmd == "EE") {
|
||||||
|
int code = Convert.ToInt32(data);
|
||||||
|
var error = Enum.IsDefined(typeof(T1Error), code) ? (T1Error)code : T1Error.UNKNOWN_ERROR;
|
||||||
|
throw new T1Exception($"PLC Error ({data}): {error.GetName()}" + (error.GetDescription() != null ? $" ({error.GetDescription()})" : ""));
|
||||||
|
} else if (expectedCmd != null) {
|
||||||
|
throw new T1Exception($"Unexpected response from PLC (T1): {cmd} {data}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (cmd, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Test(string data = "PINGPONG") {
|
||||||
|
await SendCommand("TS", data);
|
||||||
|
var (_, res) = await ReceiveResponse("TS");
|
||||||
|
if (data.Replace(" ", "") != res) throw new T1Exception("Wrong test response from PLC (T1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Status> StatusRead() {
|
||||||
|
await SendCommand("ST", checksum: false);
|
||||||
|
var (_, res) = await ReceiveResponse("ST");
|
||||||
|
return new Status((Mode)(res[3] - '0'), ((res[0] - '0') << 4) | (res[1] - '0'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T1Error> ErrorStatusRead() {
|
||||||
|
await SendCommand("ER", checksum: false);
|
||||||
|
var (_, res) = await ReceiveResponse("ER");
|
||||||
|
var code = Convert.ToInt32(res, 10);
|
||||||
|
return Enum.IsDefined(typeof(T1Error), code) ? (T1Error)code : T1Error.UNKNOWN_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ushort[]> DataRead(string register, int length) {
|
||||||
|
return DataRead((register, length));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ushort[]> DataRead(params (string Register, int Length)[] registers) {
|
||||||
|
await SendCommand("DR", string.Join(',', registers.Select(r => $"{r.Register},{r.Length}")));
|
||||||
|
var (_, res) = await ReceiveResponse("DR");
|
||||||
|
if (res.Length != registers.Sum(r => r.Length) * 4) throw new T1Exception("Invalid response length from PLC (T1)");
|
||||||
|
return [.. Enumerable.Range(0, res.Length / 4).Select(i => Convert.ToUInt16(res.Substring(i * 4, 4), 16))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Status> DataWrite(string register, params ushort[] values) {
|
||||||
|
return DataWrite((register, values));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Status> DataWrite(params (string Register, ushort[] Values)[] registers) {
|
||||||
|
await SendCommand("DW", string.Join(',', registers.Select(r => $"{r.Register},{r.Values.Length}," + string.Join(',', r.Values.Select(v => $"{v:04X}")))));
|
||||||
|
var (_, res) = await ReceiveResponse("ST");
|
||||||
|
return new Status((Mode)(res[3] - '0'), ((res[0] - '0') << 4) | (res[1] - '0'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace PamhagenSysCtrl.Helpers {
|
||||||
|
public enum T1Error {
|
||||||
|
UNKNOWN_ERROR = -1,
|
||||||
|
NO_ERROR = 0,
|
||||||
|
SYSTEM_POWER_ON = 10,
|
||||||
|
SYSTEM_POWER_OFF = 11,
|
||||||
|
RAM_CHECK_ERROR = 20,
|
||||||
|
PROGRAM_BCC_ERROR = 21,
|
||||||
|
BATTERY_VOLTAGE_DROP = 22,
|
||||||
|
EEPROM_BCC_ERROR = 23,
|
||||||
|
EEPROM_WARNING = 26,
|
||||||
|
SYSTEM_RAM_CHECK_ERROR = 30,
|
||||||
|
SYSTEM_ROM_BCC_ERROR = 31,
|
||||||
|
PERIPHERAL_LSI_ERROR = 32,
|
||||||
|
CLOCK_CALENDAR_CHECK_ERROR = 33,
|
||||||
|
ILLEGAL_SYSTEM_INTERRUPT = 34,
|
||||||
|
WD_TIMER_ERROR = 35,
|
||||||
|
IO_BUS_ERROR = 40,
|
||||||
|
IO_MISMATCH = 41,
|
||||||
|
IO_NO_ANSWER = 42,
|
||||||
|
IO_PARITY_ERROR = 43,
|
||||||
|
ILLEGAL_IO_REGISTER = 46,
|
||||||
|
COMMUNICATION_BUSY = 51,
|
||||||
|
FORMAT_ERROR = 52,
|
||||||
|
SCAN_TIME_OVER = 64,
|
||||||
|
NO_END_IRET_ERROR = 80,
|
||||||
|
PAIR_INSTRUCTION_ERROR = 81,
|
||||||
|
OPERAND_ERROR = 82,
|
||||||
|
INVALID_PROGRAM = 83,
|
||||||
|
NO_SUBROUTINE_ENTRY = 86,
|
||||||
|
NO_RET_ERROR = 87,
|
||||||
|
SUBROUTINE_NESTING_ERROR = 88,
|
||||||
|
LOOP_NESTING_ERROR = 89,
|
||||||
|
INVALID_FUNCTION_INSTRUCTION = 98,
|
||||||
|
PASSWORD_PROTECT = 106,
|
||||||
|
ILLEGAL_INSTRUCTION = 110,
|
||||||
|
REGISTER_ADDRESS_ERROR = 111,
|
||||||
|
BOUNDARY_ERROR = 112,
|
||||||
|
MEMORY_FULL = 113,
|
||||||
|
MODE_MISMATCH = 114,
|
||||||
|
REGISTER_ADDRESS_SIZE_ERROR = 115,
|
||||||
|
DUPLICATE_ENTRY_NR = 121,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class T1ErrorExtension {
|
||||||
|
public static bool IsError(this T1Error error) {
|
||||||
|
return error switch {
|
||||||
|
T1Error.NO_ERROR => false,
|
||||||
|
T1Error.SYSTEM_POWER_ON => false,
|
||||||
|
T1Error.SYSTEM_POWER_OFF => false,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public static string GetName(this T1Error error) {
|
||||||
|
return error switch {
|
||||||
|
T1Error.NO_ERROR => "No error",
|
||||||
|
T1Error.SYSTEM_POWER_ON => "System power on",
|
||||||
|
T1Error.SYSTEM_POWER_OFF => "System power off",
|
||||||
|
// TODO
|
||||||
|
_ => "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public static string? GetDescription(this T1Error error) {
|
||||||
|
return error switch {
|
||||||
|
T1Error.NO_ERROR => "No error recorded",
|
||||||
|
T1Error.SYSTEM_POWER_ON => "Power on (no error)",
|
||||||
|
T1Error.SYSTEM_POWER_OFF => "Power off (no error)",
|
||||||
|
// TODO
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace PamhagenSysCtrl.Helpers {
|
||||||
|
public class T1Exception(string msg) : Exception(msg) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.IO.Ports" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Window x:Class="PamhagenSysCtrl.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:local="clr-namespace:PamhagenSysCtrl"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="MainWindow" Height="450" Width="800">
|
||||||
|
<Grid>
|
||||||
|
<TextBox x:Name="PortInput" Text="COM1"
|
||||||
|
Margin="10,10,10,10" VerticalAlignment="Top" HorizontalAlignment="Left" Height="25" Width="80" FontSize="14" Padding="2,2,2,2"/>
|
||||||
|
<Button x:Name="ConnectButton" Content="Verbinden"
|
||||||
|
Margin="100,10,10,10" VerticalAlignment="Top" HorizontalAlignment="Left" Height="25" Width="80" FontSize="14"
|
||||||
|
Click="ConnectButton_Click"/>
|
||||||
|
|
||||||
|
<Grid x:Name="ButtonPanel" Margin="5,50,5,5">
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using PamhagenSysCtrl.Helpers;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace PamhagenSysCtrl {
|
||||||
|
public partial class MainWindow : Window {
|
||||||
|
|
||||||
|
public MainWindow() {
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
for (int i = 1; i <= 60; i++) {
|
||||||
|
var b = new Button() {
|
||||||
|
Content = $"V{i}",
|
||||||
|
Margin = new(10 + ((i - 1) / 10) * 50, 10 + ((i - 1) % 10) * 40, 10, 10),
|
||||||
|
BorderThickness = new(2),
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
Height = 30,
|
||||||
|
Width = 40,
|
||||||
|
FontSize = 14,
|
||||||
|
};
|
||||||
|
var borderStyle = new Style(typeof(Border));
|
||||||
|
borderStyle.Setters.Add(new Setter(Border.CornerRadiusProperty, new CornerRadius(5)));
|
||||||
|
b.Resources.Add(typeof(Border), borderStyle);
|
||||||
|
b.Click += (sender, evt) => {
|
||||||
|
if (App.Plant == null) return;
|
||||||
|
if (App.Plant.WantValveOpen(i)) {
|
||||||
|
App.Plant.CloseV(i);
|
||||||
|
} else {
|
||||||
|
App.Plant.OpenV(i);
|
||||||
|
}
|
||||||
|
App.Plant.CommitChanges();
|
||||||
|
};
|
||||||
|
ButtonPanel.Children.Add(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void ConnectButton_Click(object? sender, RoutedEventArgs evt) {
|
||||||
|
try {
|
||||||
|
App.Plant = new(PortInput.Text);
|
||||||
|
App.Plant.Update += OnUpdate;
|
||||||
|
await App.Plant.StartThread();
|
||||||
|
} catch (Exception exc) {
|
||||||
|
App.Plant?.Dispose();
|
||||||
|
App.Plant = null;
|
||||||
|
var str = "Beim Aufbauen einer Verbindung zur SPS ist ein Fehler aufgetreten:\n\n" + exc.Message;
|
||||||
|
if (exc.InnerException != null) str += "\n\n" + exc.InnerException.Message;
|
||||||
|
MessageBox.Show(str, "SPS-Fehler", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnUpdate(object? sender, EventArgs evt) {
|
||||||
|
if (sender is not PamhagenPlant plant) return;
|
||||||
|
|
||||||
|
for (int i = 1; i <= 60; i++) {
|
||||||
|
var target = plant.WantValveClosed(i);
|
||||||
|
var actual = plant.IsValveClosed(i);
|
||||||
|
if (ButtonPanel.Children[i - 1] is not Button b)
|
||||||
|
continue;
|
||||||
|
b.BorderBrush = target ? Brushes.DarkRed : Brushes.SlateGray;
|
||||||
|
b.Foreground = target ? Brushes.DarkRed : Brushes.Black;
|
||||||
|
b.Background = actual ? Brushes.MistyRose : Brushes.WhiteSmoke;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user