-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMainWindow.xaml.cs
More file actions
2476 lines (2222 loc) · 103 KB
/
Copy pathMainWindow.xaml.cs
File metadata and controls
2476 lines (2222 loc) · 103 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using System.Windows.Media;
using System.Windows.Media.Animation;
using AR.Iec61850.Scl.Export;
using AR.Iec61850.Scl.Workspace;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;
using Microsoft.Win32;
namespace ArIED61850Tester;
public partial class MainWindow : Window, INotifyPropertyChanged
{
private readonly Iec61850MonitorRuntime _runtime = new();
private readonly SclWorkspaceService _sclWorkspaceService = new();
private readonly CancellationTokenSource _applicationCancellation = new();
private readonly Dictionary<string, HashSet<string>> _pendingProjectSelections = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<SignalDefinition, Iec61850MonitorDevice> _signalOwners = new();
private readonly ConcurrentDictionary<string, PendingPointUpdate> _pendingPointSnapshots = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Iec61850MonitorPoint> _pointIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<Iec61850EventEntry> _pendingEvents = new();
private readonly ConcurrentQueue<DiagnosticEntry> _pendingDiagnostics = new();
private readonly Dictionary<string, DateTime> _reportPulseUntil = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DateTime> _pointHighlightUntil = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, List<SignalDefinition>> _controlFeedbackIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly DispatcherTimer _uiFlushTimer;
private readonly DispatcherTimer _progressAnimationTimer;
private Iec61850MonitorDevice? _selectedDevice;
private string _newDeviceIp = "192.168.1.10";
private string _newDevicePort = "102";
private int _pollingIntervalMs = 1000;
private string _lastStatusText = "Ready. Add an IEC 61850 IED or open a saved ARSAS project.";
private bool _allowClose;
private bool _shutdownStarted;
private bool _hasUnreadDiagnosticError;
private bool _signalSelectionWizardOpen;
private bool _connectAllInProgress;
private readonly HashSet<string> _autoExpandedCommandDevices = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<Iec61850MonitorDevice> Devices { get; } = new();
public BulkObservableCollection<Iec61850MonitorPoint> GlobalPoints { get; } = new();
public BulkObservableCollection<Iec61850EventEntry> Events { get; } = new();
public BulkObservableCollection<DiagnosticEntry> Logs { get; } = new();
public event PropertyChangedEventHandler? PropertyChanged;
public string NewDeviceIp { get => _newDeviceIp; set => Set(ref _newDeviceIp, value); }
public string NewDevicePort { get => _newDevicePort; set => Set(ref _newDevicePort, value); }
public int PollingIntervalMs { get => _pollingIntervalMs; set => Set(ref _pollingIntervalMs, Math.Clamp(value, 50, 600000)); }
public string LastStatusText { get => _lastStatusText; set => Set(ref _lastStatusText, value); }
public Iec61850MonitorDevice? SelectedDevice
{
get => _selectedDevice;
set
{
if (ReferenceEquals(_selectedDevice, value)) return;
if (_selectedDevice != null)
{
_selectedDevice.IsActive = false;
ClearPendingControlConfirmations(_selectedDevice);
}
_selectedDevice = value;
if (_selectedDevice != null)
{
ClearPendingControlConfirmations(_selectedDevice);
_selectedDevice.IsActive = true;
NewDeviceIp = _selectedDevice.IpAddress;
NewDevicePort = _selectedDevice.Port.ToString(CultureInfo.InvariantCulture);
if (MainTabs != null && MainTabs.SelectedIndex == 2)
_selectedDevice.ClearUnreadEvents();
}
Raise();
Raise(nameof(EmptyExplorerVisibility));
Raise(nameof(SelectedExplorerVisibility));
Raise(nameof(SelectedDeviceNoLivePointsVisibility));
Raise(nameof(ActiveIedTitle));
Raise(nameof(ActiveIedSubtitle));
RaiseWorkspaceCounts();
TryAutoExpandCommandPanelOnce(_selectedDevice);
// ctlModel inspection is preloaded independently of the Expander. Avoid
// changing the row set after the panel's first frame has already painted.
}
}
public string HeaderStatusText => $"{Devices.Count} IED • {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring";
public string DeviceCountText => $"{Devices.Count} device(s)";
public string RuntimeSummaryText => $"Connected {(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} • Monitoring {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} • Values {GlobalPoints.Count} • Events {Events.Count}";
public string ConnectionInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} connected / {Devices.Count} discovered";
public string MonitoringInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring / {GlobalPoints.Count} values";
public string EventInsightText => $"{Events.Count} event(s)";
public Visibility DiagnosticsAlertVisibility => _hasUnreadDiagnosticError ? Visibility.Visible : Visibility.Collapsed;
public Visibility EmptyExplorerVisibility => SelectedDevice == null ? Visibility.Visible : Visibility.Collapsed;
public Visibility SelectedExplorerVisibility => SelectedDevice != null ? Visibility.Visible : Visibility.Collapsed;
public Visibility SelectedDeviceNoLivePointsVisibility => SelectedDevice != null && SelectedDevice.Points.Count == 0
? Visibility.Visible
: Visibility.Collapsed;
public string ActiveIedTitle => SelectedDevice == null ? "IEC 61850 IED" : SelectedDevice.Name;
public string ActiveIedSubtitle => SelectedDevice == null
? string.Empty
: $"{SelectedDevice.EndpointText} • {SelectedDevice.LogicalDeviceSummary} • {SelectedDevice.ActivityText}";
public MainWindow()
{
InitializeComponent();
DataContext = this;
_uiFlushTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromMilliseconds(100)
};
_uiFlushTimer.Tick += UiFlushTimer_Tick;
_progressAnimationTimer = new DispatcherTimer(DispatcherPriority.Render)
{
Interval = TimeSpan.FromMilliseconds(50)
};
_progressAnimationTimer.Tick += ProgressAnimationTimer_Tick;
_runtime.Diagnostic += Runtime_Diagnostic;
_runtime.PointUpdated += Runtime_PointUpdated;
_runtime.EventRaised += Runtime_EventRaised;
_uiFlushTimer.Start();
_progressAnimationTimer.Start();
AddLog("INFO", "System", "ARSAS started — Smart IEC 61850 Communication Tester.");
AddLog("INFO", "IEC61850", "Acquisition: static report → dynamic report → MMS verification/fallback; each IED remains independent.");
InitializeGooseSubscriber();
UpdateNavigationVisuals(0, animate: false);
}
private async void AddRelay_Click(object sender, RoutedEventArgs e)
{
var initialIp = SelectedDevice?.IpAddress ?? NewDeviceIp;
var initialPort = SelectedDevice?.Port ?? 102;
var wizard = new IpConnectWizardWindow(initialIp, initialPort) { Owner = this };
if (wizard.ShowDialog() != true)
return;
NewDeviceIp = wizard.RelayIpAddress;
NewDevicePort = wizard.MmsPort.ToString(CultureInfo.InvariantCulture);
await AddOrDiscoverEndpointAsync(wizard.RelayIpAddress, wizard.MmsPort);
}
private async void OpenScl_Click(object sender, RoutedEventArgs e)
{
var dialog = new OpenFileDialog
{
Title = "Open IEC 61850 SCL",
Filter = "IEC 61850 SCL (*.scd;*.cid;*.icd;*.iid;*.ssd)|*.scd;*.cid;*.icd;*.iid;*.ssd|XML SCL (*.xml)|*.xml|All files (*.*)|*.*",
CheckFileExists = true,
Multiselect = false
};
if (dialog.ShowDialog(this) != true)
return;
var sourceName = Path.GetFileName(dialog.FileName);
SetStatus($"Opening {sourceName} as an offline IEC 61850 design model…");
try
{
var document = await _sclWorkspaceService.OpenAsync(
dialog.FileName,
cancellationToken: _applicationCancellation.Token);
LogSclFindings(sourceName, document.Findings);
if (document.Ieds.Count == 0)
{
SetStatus($"{sourceName}: no IED model was found.");
AddLog("WARN", "SCL", $"{sourceName}: the engine returned no IED workspace.");
return;
}
var added = 0;
var refreshed = 0;
var retained = 0;
Iec61850MonitorDevice? firstImported = null;
foreach (var workspace in document.Ieds)
{
var device = Devices.FirstOrDefault(item =>
item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) &&
item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) &&
item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase));
if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring))
{
retained++;
firstImported ??= device;
continue;
}
var signals = SclWorkspaceSignalMapper.BuildSignals(workspace);
if (device == null)
{
device = new Iec61850MonitorDevice();
Devices.Add(device);
added++;
}
else
{
refreshed++;
}
ApplySclWorkspaceToDevice(device, document, workspace, signals);
firstImported ??= device;
}
if (firstImported != null)
SelectedDevice = firstImported;
MainTabs.SelectedIndex = 0;
UpdateNavigationVisuals(0, animate: true);
RaiseWorkspaceCounts();
var offlineCount = document.Ieds.Count(item => item.CanBrowseOffline);
var endpointCount = document.Ieds.Count(item => !item.RequiresEndpointBinding);
var status = $"{sourceName}: {document.Ieds.Count} IED/AP workspace(s), {offlineCount} offline model(s), {endpointCount} MMS endpoint(s) — {added} added, {refreshed} refreshed, {retained} active retained.";
SetStatus(status);
AddLog("INFO", "SCL", status);
if (firstImported != null && firstImported.Signals.Count > 0)
{
AddLog("INFO", "SCL", $"{firstImported.Name}: SCL model ready. Choose signals; saving the selection will continue to endpoint binding, connection, and monitoring.");
await OpenSignalSelectionWizardAsync(firstImported);
}
}
catch (OperationCanceledException)
{
SetStatus($"{sourceName}: SCL open cancelled.");
}
catch (Exception ex)
{
AddLog("ERROR", "SCL", $"Could not open {sourceName}: {ex.Message}");
SetStatus($"{sourceName}: SCL open failed. Diagnostics is marked with !.");
MarkDiagnosticAlert();
MessageBox.Show(
this,
$"ARSAS could not open this SCL file through the ARIEC61850 engine.\n\n{ex.Message}",
"Open SCL",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
private void ApplySclWorkspaceToDevice(
Iec61850MonitorDevice device,
SclWorkspaceDocument document,
SclIedWorkspace workspace,
IReadOnlyList<SignalDefinition> signals)
{
var previousSelection = device.Signals
.Where(signal => signal.IsSelected)
.Select(signal => NormalizeReference(signal.ObjectReference))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
DetachSignalHandlers(device.Signals);
device.Signals.Clear();
device.RecountSelectedSignals();
var endpoint = workspace.PreferredEndpoint;
device.Name = workspace.IedName;
device.IdentitySource = $"SCL design • {document.SourceName}";
device.LogicalDeviceSummary = BuildSclWorkspaceSummary(workspace);
if (endpoint?.HasUsableAddress == true)
{
device.IpAddress = endpoint.IpAddress;
device.Port = endpoint.Port;
}
else if (string.IsNullOrWhiteSpace(device.IpAddress) || device.IpAddress == "192.168.1.10")
{
device.IpAddress = string.Empty;
device.Port = 102;
}
var allowDynamicReporting = ShouldAllowDynamicReportingForScl(signals);
device.AllowDynamicDataSetWrites = allowDynamicReporting;
device.SclWorkspace = workspace;
device.SclComparison = null;
device.SclSourcePath = document.SourcePath;
device.SclSourceSha256 = document.SourceSha256;
device.SclIedName = workspace.IedName;
device.SclAccessPointName = workspace.AccessPointName;
device.HasDiscoveryCache = signals.Count > 0;
device.Status = workspace.RequiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready";
device.Detail = allowDynamicReporting
? (workspace.RequiresEndpointBinding
? "LD/LN/DO/DA are available offline. Static report coverage is incomplete; after signal selection and endpoint binding, ARSAS will create an association-scoped dynamic DataSet and use a safe free RCB before polling fallback."
: "LD/LN/DO/DA were loaded offline. Static report coverage is incomplete; ARSAS will use static coverage where available and create an association-scoped dynamic DataSet for uncovered selected signals before polling fallback.")
: (workspace.RequiresEndpointBinding
? "LD/LN/DO/DA are available offline. Press Play to bind an MMS endpoint; no discovery traffic was sent while opening the file."
: "LD/LN/DO/DA were loaded offline. Play performs a fast MMS association; Re-scan performs full design-versus-live verification.");
device.AcquisitionMode = allowDynamicReporting
? "SCL design • Smart Dynamic reporting prepared"
: "SCL offline design model";
foreach (var signal in signals)
{
signal.IsSelected = previousSelection.Contains(NormalizeReference(signal.ObjectReference));
signal.PropertyChanged += Signal_PropertyChanged;
_signalOwners[signal] = device;
}
device.Signals.AddRange(signals);
device.RecountSelectedSignals();
device.RefreshComputed();
ScheduleGooseBindingRefreshFromWorkspace();
}
private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace)
{
var coverage = workspace.DesignModel.Coverage;
var ap = string.IsNullOrWhiteSpace(workspace.AccessPointName) ? "AP unassigned" : $"AP {workspace.AccessPointName}";
return $"{ap} • {coverage.LogicalDeviceCount} LD • {coverage.LogicalNodeCount} LN • {coverage.DataObjectCount} DO • {coverage.DataAttributeCount} DA";
}
private void LogSclFindings(string sourceName, IReadOnlyList<SclWorkspaceFinding> findings)
{
var actionableFindings = findings
.Where(finding => !IsSmartDynamicCapabilityHint(finding))
.ToArray();
if (actionableFindings.Length == 0)
return;
var groups = SclFindingAggregator.Group(actionableFindings);
if (groups.Count != actionableFindings.Length)
{
AddLog(
"INFO",
"SCL",
$"{sourceName} • grouped {actionableFindings.Length} actionable finding(s) into {groups.Count} diagnostic group(s). Full typed evidence remains attached to the SCL workspace.");
}
foreach (var group in groups.Take(40))
{
AddLog(
SclFindingAggregator.ToLogLevel(group.Severity),
"SCL",
$"{sourceName} • {group.Code} [{group.Scope}]: {group.ToDiagnosticMessage()}");
}
if (groups.Count > 40)
{
var omittedRawCount = groups.Skip(40).Sum(group => group.Count);
AddLog(
"WARN",
"SCL",
$"{groups.Count - 40} additional diagnostic group(s), representing {omittedRawCount} actionable finding(s), were omitted from the live log.");
}
if (groups.Any(group => SclFindingAggregator.IsBlockingSeverity(group.Severity)))
MarkDiagnosticAlert();
}
private static bool IsSmartDynamicCapabilityHint(SclWorkspaceFinding finding)
{
if (!finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase))
return false;
return finding.Code.Equals("SCL_REPORT_DATASET_UNASSIGNED", StringComparison.OrdinalIgnoreCase) ||
finding.Code.Equals("SCL_REPORT_DATASET_UNRESOLVED", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldAllowDynamicReportingForScl(IReadOnlyCollection<SignalDefinition> signals)
{
return signals.Any(signal =>
signal.CanPublishAsSignal &&
(string.IsNullOrWhiteSpace(signal.DataSetReference) ||
string.IsNullOrWhiteSpace(signal.ReportControlReference)));
}
private void TryAutoExpandCommandPanelOnce(Iec61850MonitorDevice? device)
{
if (device == null || CommandPanelExpander == null || device.CommandSignals.Count == 0)
return;
if (!_autoExpandedCommandDevices.Add(device.DeviceId))
return;
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
{
if (ReferenceEquals(SelectedDevice, device) && CommandPanelExpander != null)
CommandPanelExpander.IsExpanded = true;
}));
}
private bool EnsureSclEndpointBinding(Iec61850MonitorDevice device)
{
if (!device.RequiresEndpointBinding)
return true;
var initialIp = string.IsNullOrWhiteSpace(NewDeviceIp) ? "192.168.1.10" : NewDeviceIp;
var wizard = new IpConnectWizardWindow(initialIp, device.Port <= 0 ? 102 : device.Port) { Owner = this };
if (wizard.ShowDialog() != true)
{
SetStatus($"{device.Name}: endpoint binding cancelled; the SCL model remains available offline.");
return false;
}
device.IpAddress = wizard.RelayIpAddress;
device.Port = wizard.MmsPort;
device.Status = "SCL model ready";
device.Detail = device.AllowDynamicDataSetWrites
? "Endpoint bound locally. Saving the selected signals will connect and arm Smart Dynamic reporting with a safe free RCB before polling fallback."
: "Endpoint bound locally. Play will fast-connect from the SCL design model; Re-scan performs full comparison.";
device.RefreshComputed();
NewDeviceIp = device.IpAddress;
NewDevicePort = device.Port.ToString(CultureInfo.InvariantCulture);
return true;
}
private async void ConnectAllIeds_Click(object sender, RoutedEventArgs e)
{
if (_connectAllInProgress)
return;
var candidates = Devices
.Where(device => !device.IsBusy && !device.IsMonitoring)
.ToArray();
if (candidates.Length == 0)
{
SetStatus(Devices.Count == 0
? "Add or open an IED project before using Connect All."
: "All available IEDs are already monitoring or currently busy.");
return;
}
_connectAllInProgress = true;
var originalSelection = SelectedDevice;
SetStatus($"Connect All: starting {candidates.Length} independent IED workflow(s)…");
try
{
// Start every independent session immediately. Each IED retains its own
// card-local progress overlay, so slow/offline devices do not block others.
var results = await Task.WhenAll(candidates.Select(ConnectAndStartWorkspaceDeviceAsync));
var succeeded = results.Count(result => result);
var monitoring = candidates.Count(device => device.IsMonitoring);
var needsSelection = candidates.Count(device => device.IsConnected && device.SelectedLiveSignalCount == 0);
if (originalSelection != null && Devices.Contains(originalSelection))
SelectedDevice = originalSelection;
SetStatus(needsSelection > 0
? $"Connect All complete: {succeeded}/{candidates.Length} connected, {monitoring} monitoring, {needsSelection} need signal selection."
: $"Connect All complete: {succeeded}/{candidates.Length} connected and {monitoring} monitoring.");
}
finally
{
_connectAllInProgress = false;
RaiseWorkspaceCounts();
}
}
private async Task<bool> ConnectAndStartWorkspaceDeviceAsync(Iec61850MonitorDevice device)
{
try
{
if (device.IsMonitoring)
return true;
if (device.RequiresEndpointBinding)
{
device.Status = "SCL model ready — endpoint required";
device.Detail = "Connect All skipped this offline SCL workspace because no MMS endpoint is bound.";
device.RefreshComputed();
AddLog("WARN", device.Name, "Connect All skipped the SCL workspace because its MMS endpoint is unassigned.");
return false;
}
var connected = device.IsConnected;
if (!connected)
{
connected = device.HasDiscoveryCache && device.Signals.Count > 0
? await ConnectUsingSavedModelAsync(device, selectDevice: false)
: await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false);
}
if (!connected)
return false;
// A project with saved selections becomes live in one click. Newly discovered
// IEDs without a selection remain connected and ready for the edit wizard.
if (device.SelectedLiveSignalCount == 0)
{
device.Status = "Connected — choose signals";
device.Detail = "Use the edit icon to choose signals; Apply & Start Live will start monitoring automatically.";
device.RefreshComputed();
return true;
}
return await StartDeviceMonitorAsync(device, navigateToExplorer: false);
}
catch (Exception ex)
{
AddLog("ERROR", device.Name, $"Connect All workflow failed: {ex.Message}");
MarkDiagnosticAlert();
return false;
}
}
private async void ConnectAndScan_Click(object sender, RoutedEventArgs e)
{
if (!TryReadEndpoint(out var ip, out var port)) return;
await AddOrDiscoverEndpointAsync(ip, port);
}
private async Task AddOrDiscoverEndpointAsync(string ip, int port)
{
var device = Devices.FirstOrDefault(item =>
item.IpAddress.Equals(ip, StringComparison.OrdinalIgnoreCase) && item.Port == port);
if (device == null)
{
device = new Iec61850MonitorDevice
{
// Until the live model reveals the real IEDName, use the endpoint as an
// honest temporary identity. Do not leave a failed card named
// "Discovering IED…" because initial discovery is not auto-retried.
Name = ip,
IpAddress = ip,
Port = port,
AllowDynamicDataSetWrites = true,
Status = "Ready to connect",
Detail = "Click Play to connect and discover the live IEC 61850 model."
};
Devices.Add(device);
}
SelectedDevice = device;
MainTabs.SelectedIndex = 0;
await ConnectAndConfigureDeviceAsync(device, openWizard: true);
}
private void NavButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || !int.TryParse(button.Tag?.ToString(), out var index))
return;
index = Math.Clamp(index, 0, 4);
MainTabs.SelectedIndex = index;
UpdateNavigationVisuals(index, animate: true);
}
private void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!ReferenceEquals(e.Source, MainTabs))
return;
if (MainTabs.SelectedIndex == 2)
{
foreach (var device in Devices)
device.ClearUnreadEvents();
}
else if (MainTabs.SelectedIndex == 3)
{
// Defer optional Npcap/model work until after the selected tab has rendered.
// An unavailable capture dependency must never leave the workspace blank.
ActivateGooseSubscriberWorkspace();
}
else if (MainTabs.SelectedIndex == 4)
{
ClearDiagnosticAlert();
}
UpdateNavigationVisuals(MainTabs.SelectedIndex, animate: true);
}
private void UpdateNavigationVisuals(int index, bool animate)
{
if (WorkflowPillTranslate == null)
return;
var target = Math.Clamp(index, 0, 4) * 150d;
if (animate)
{
var animation = new DoubleAnimation(target, TimeSpan.FromMilliseconds(190))
{
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
WorkflowPillTranslate.BeginAnimation(TranslateTransform.XProperty, animation);
}
else
{
WorkflowPillTranslate.BeginAnimation(TranslateTransform.XProperty, null);
WorkflowPillTranslate.X = target;
}
var buttons = new[] { NavExplorerButton, NavLiveButton, NavEventsButton, NavGooseButton, NavDiagnosticsButton };
for (var i = 0; i < buttons.Length; i++)
buttons[i].Foreground = i == index ? Brushes.White : new SolidColorBrush(Color.FromRgb(71, 84, 103));
}
private async Task<bool> ConnectAndConfigureDeviceAsync(
Iec61850MonitorDevice device,
bool openWizard,
bool selectDevice = true)
{
if (device.IsBusy) return false;
if (!EnsureSclEndpointBinding(device)) return false;
RememberCurrentSelectionForReconnect(device);
RemoveDevicePoints(device.DeviceId);
device.Points.Clear();
device.RefreshComputed();
if (selectDevice)
SelectedDevice = device;
device.ResetDiscoveryProgress(
$"Opening {device.EndpointText}…",
"Discovering IED");
device.IsBusy = true;
await Dispatcher.Yield(DispatcherPriority.Render);
// Progress is stored on the individual device model. Multiple IED discovery
// operations can therefore advance independently without a global overlay.
var progress = new Progress<IedDiscoveryProgress>(device.ApplyDiscoveryProgress);
try
{
var signals = await _runtime.ConnectAndDiscoverAsync(
device,
_applicationCancellation.Token,
progress);
DetachSignalHandlers(device.Signals);
device.Signals.Clear();
device.RecountSelectedSignals();
foreach (var signal in signals)
{
signal.PropertyChanged += Signal_PropertyChanged;
_signalOwners[signal] = device;
}
device.Signals.AddRange(signals);
device.HasDiscoveryCache = signals.Count > 0;
ApplySclLiveComparison(device, signals);
ScheduleGooseBindingRefreshFromWorkspace();
try
{
UserPreferenceStore.RecordSuccessfulEndpoint(device.IpAddress, device.Port, device.Name);
}
catch (Exception ex)
{
AddLog("WARN", device.Name, $"Could not update recent IED history: {ex.Message}");
}
var restoredCount = RestoreSignalSelection(device);
device.RefreshComputed();
RaiseWorkspaceCounts();
SetStatus($"{device.Name}: discovery complete, {device.SignalCount} readable signal(s), {restoredCount} saved selection(s) restored.");
// Let the card-local bar visibly settle at 100%, then release the card
// before opening a modal wizard or starting live monitoring.
await WaitForDiscoveryProgressAnimationAsync(device, TimeSpan.FromMilliseconds(650));
device.CompleteDiscoveryProgressAnimation();
device.BusyStage = "Discovery complete";
device.IsBusy = false;
device.RefreshComputed();
if (openWizard && device.SignalCount > 0)
{
if ((selectDevice || ReferenceEquals(SelectedDevice, device)) && !_signalSelectionWizardOpen)
await OpenSignalSelectionWizardAsync(device, restoredCount);
else
SetStatus($"{device.Name}: discovery complete. Use the edit icon on its IED card to review {restoredCount} restored selection(s).");
}
return true;
}
catch (OperationCanceledException)
{
try
{
await _runtime.StopDeviceAsync(device.DeviceId);
}
catch
{
// Cancellation cleanup is best effort.
}
device.IsConnected = false;
device.Status = "Discovery cancelled";
device.Detail = "Discovery was cancelled. Click Play to try again.";
device.AcquisitionMode = "Not connected";
device.MarkDiscoveryFailed("Discovery cancelled");
SetStatus($"{device.Name}: discovery cancelled.");
return false;
}
catch (Exception ex)
{
try
{
await _runtime.StopDeviceAsync(device.DeviceId);
}
catch
{
// Failure cleanup must not hide the original discovery error.
}
device.IsConnected = false;
if (string.IsNullOrWhiteSpace(device.Name) ||
device.Name.Equals("IED", StringComparison.OrdinalIgnoreCase) ||
device.Name.Equals("Discovering IED…", StringComparison.OrdinalIgnoreCase))
{
device.Name = device.IpAddress;
}
device.Status = "Connection failed";
device.Detail = $"No live IEC 61850 session is active. Click Play to retry {device.EndpointText}.";
device.AcquisitionMode = "Connection failed · click Play to retry";
device.MarkDiscoveryFailed("Connection failed — click Play to retry");
AddLog("ERROR", device.Name, ex.Message);
SetStatus($"{device.Name}: connection/discovery failed. This endpoint is not auto-retried; use Play to retry. Diagnostics is marked with !.");
MarkDiagnosticAlert();
return false;
}
finally
{
if (device.IsConnected)
device.CompleteDiscoveryProgressAnimation();
device.BusyStage = device.IsConnected ? "Discovery complete" : device.BusyStage;
device.IsBusy = false;
}
}
private async Task<bool> ConnectUsingSavedModelAsync(
Iec61850MonitorDevice device,
bool selectDevice = true)
{
if (device.IsBusy) return false;
if (!EnsureSclEndpointBinding(device)) return false;
if (!device.HasDiscoveryCache || device.Signals.Count == 0)
return await ConnectAndConfigureDeviceAsync(device, openWizard: true, selectDevice: selectDevice);
RemoveDevicePoints(device.DeviceId);
device.Points.Clear();
device.RefreshComputed();
if (selectDevice)
SelectedDevice = device;
device.ResetDiscoveryProgress(
$"Opening {device.EndpointText}…",
"Fast connect from saved model");
device.IsBusy = true;
await Dispatcher.Yield(DispatcherPriority.Render);
var progress = new Progress<IedDiscoveryProgress>(device.ApplyDiscoveryProgress);
try
{
await _runtime.ConnectUsingCachedModelAsync(
device,
_applicationCancellation.Token,
progress);
device.RecountSelectedSignals();
await WaitForDiscoveryProgressAnimationAsync(device, TimeSpan.FromMilliseconds(900));
RaiseWorkspaceCounts();
if (device.HasSclDesignModel)
{
device.Status = "Connected — SCL design model";
device.Detail = "MMS association is live and the SCL workspace remains the active model. Re-scan performs a complete design-versus-live comparison.";
device.AcquisitionMode = "SCL design model • live association";
SetStatus($"{device.Name}: fast connected from the SCL design model; full discovery skipped. Use Re-scan to compare the complete live model.");
}
else
{
SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped.");
}
return true;
}
catch (OperationCanceledException)
{
device.IsConnected = false;
device.Status = "Connection cancelled";
device.Detail = "Saved model remains available. Click Play to retry.";
device.AcquisitionMode = "Saved model • disconnected";
device.MarkDiscoveryFailed("Connection cancelled");
SetStatus($"{device.Name}: fast connect cancelled; saved model retained.");
return false;
}
catch (Exception ex)
{
try
{
await _runtime.StopDeviceAsync(device.DeviceId);
}
catch
{
// Failure cleanup is best effort; keep the project cache intact.
}
device.IsConnected = false;
device.Status = "Connection failed";
device.Detail = "Saved signal model is still available. Retry Play or use Re-scan if the IED configuration changed.";
device.AcquisitionMode = "Saved model • connection failed";
device.MarkDiscoveryFailed("Connection failed — saved model retained");
AddLog("ERROR", device.Name, ex.Message);
MarkDiagnosticAlert();
SetStatus($"{device.Name}: fast connect failed. The saved discovery model was retained; use Re-scan only if the IED model changed.");
return false;
}
finally
{
if (device.IsConnected)
device.CompleteDiscoveryProgressAnimation();
device.IsBusy = false;
device.RefreshComputed();
}
}
private void ApplySclLiveComparison(Iec61850MonitorDevice device, IReadOnlyList<SignalDefinition> liveSignals)
{
if (device.SclWorkspace == null)
return;
var expectedModel = SclLiveSignalModelProjection.Build(
device.SclWorkspace.IedName,
device.SclWorkspace.AccessPointName,
SclWorkspaceSignalMapper.BuildSignals(device.SclWorkspace));
var observedModel = SclLiveSignalModelProjection.Build(
device.Name,
device.SclWorkspace.AccessPointName,
liveSignals);
var comparison = SclLiveModelComparer.Compare(expectedModel, observedModel);
device.SclComparison = comparison;
foreach (var finding in comparison.Findings.Take(30))
{
var level = finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase) ? "ERROR" : "INFO";
AddLog(level, "SCL Compare", $"{finding.Kind} • {finding.Message}");
}
if (comparison.Findings.Count > 30)
AddLog("WARN", "SCL Compare", $"{comparison.Findings.Count - 30} additional comparison finding(s) were omitted from the live log.");
if (comparison.IsCompatible)
{
device.IdentitySource = $"SCL + live verified • {Path.GetFileName(device.SclSourcePath)}";
device.AcquisitionMode = "SCL design • live model verified";
device.Detail = $"SCL and live MMS structures are compatible: {comparison.MatchedAttributeCount}/{comparison.ExpectedAttributeCount} expected attributes matched.";
AddLog("INFO", device.Name, device.Detail);
}
else
{
device.IdentitySource = $"SCL drift detected • {Path.GetFileName(device.SclSourcePath)}";
device.AcquisitionMode = "Live discovery • SCL configuration drift";
device.Detail = $"Live discovery found {comparison.BlockingFindingCount} blocking SCL mismatch(es). Live data is shown; review Diagnostics before testing control or reporting.";
MarkDiagnosticAlert();
AddLog("ERROR", device.Name, device.Detail);
}
device.RefreshComputed();
}
private async Task<bool> OpenSignalSelectionWizardAsync(
Iec61850MonitorDevice device,
int restoredSelectionCount = -1,
bool autoStartAfterSave = true)
{
if (device.Signals.Count == 0)
{
SetStatus($"{device.Name}: no saved or live signal model is available. Run discovery first.");
return false;
}
if (_signalSelectionWizardOpen)
{
SetStatus($"{device.Name}: another signal-selection wizard is open. Use this IED card's edit icon after closing it.");
return false;
}
SelectedDevice = device;
var wizard = new SignalSelectionWizardWindow(
device,
restoredSelectionCount < 0 ? device.SelectedSignalCount : restoredSelectionCount)
{
Owner = this,
ShowInTaskbar = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
_signalSelectionWizardOpen = true;
try
{
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
void WizardClosed(object? sender, EventArgs args)
{
wizard.Closed -= WizardClosed;
completion.TrySetResult(wizard.Accepted);
}
wizard.Closed += WizardClosed;
wizard.Show();
var accepted = await completion.Task;
if (!accepted)
{
device.RefreshComputed();
RaiseWorkspaceCounts();
SetStatus($"{device.Name}: signal selection unchanged.");
return false;
}
SaveSignalSelectionMemory(device);
device.RefreshComputed();
RebuildControlFeedbackIndex(device);
if (CommandPanelExpander?.IsExpanded == true && device.IsConnected)
_ = RefreshControlValuesAsync(device);
RaiseWorkspaceCounts();
if (device.SelectedLiveSignalCount == 0)
{
if (device.IsMonitoring)
await StopDeviceMonitorAsync(device);
SetStatus($"{device.Name}: selection saved with no live monitor points. Control objects remain saved, but choose at least one ST/MX signal to start monitoring.");
return true;
}
if (!autoStartAfterSave)
{
SetStatus($"{device.Name}: saved {device.SelectedSignalCount} selection(s) — {device.SelectedLiveSignalCount} live, {device.SelectedControlSignalCount} control.");
return true;
}
// Fast-workflow rule: applying a signal selection is the user's intent to
// see live values. Restart an existing monitor or connect an offline cached
// IED automatically instead of requiring another Play click.
if (device.IsMonitoring)
await StopDeviceMonitorAsync(device);
if (!device.IsConnected)
{
var connected = device.HasDiscoveryCache && device.Signals.Count > 0
? await ConnectUsingSavedModelAsync(device)
: await ConnectAndConfigureDeviceAsync(device, openWizard: false);
if (!connected)
return false;
}
return await StartDeviceMonitorAsync(device);
}
finally
{
_signalSelectionWizardOpen = false;
}
}
private async void IedPlayAction_Click(object sender, RoutedEventArgs e)
{
if (!TryGetDeviceFromButton(sender, out var device) || device.IsBusy) return;
SelectedDevice = device;
if (device.IsDemo)
{
StartDemoDevice(device);
return;
}
if (!device.IsConnected)
{
var discoveryWillOpenWizard = !device.HasDiscoveryCache || device.Signals.Count == 0;
var connected = device.HasDiscoveryCache && device.Signals.Count > 0
? await ConnectUsingSavedModelAsync(device)
: await ConnectAndConfigureDeviceAsync(device, openWizard: discoveryWillOpenWizard);
if (!connected || discoveryWillOpenWizard) return;
}
if (device.IsMonitoring) return;
if (device.SelectedLiveSignalCount == 0)
{
await OpenSignalSelectionWizardAsync(device);
return;
}
await StartDeviceMonitorAsync(device);
}
private async void IedStopAction_Click(object sender, RoutedEventArgs e)
{
if (!TryGetDeviceFromButton(sender, out var device) || device.IsBusy) return;
SelectedDevice = device;
if (device.IsDemo)
{
StopDemoDevice(device);
return;
}
if (device.IsMonitoring)
await StopDeviceMonitorAsync(device);
else if (device.IsConnected)
await StopDeviceConnectionAsync(device);
}
private async void IedConnectionAction_Click(object sender, RoutedEventArgs e)
{
if (!TryGetDeviceFromButton(sender, out var device) || device.IsBusy) return;
SelectedDevice = device;
if (device.IsConnected)
{
SaveSignalSelectionMemory(device);
await StopDeviceConnectionAsync(device);
}