From 950b4f56b6ec0031ead55c5c546eb6c0eb919b31 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 9 Oct 2026 16:43:06 +0700 Subject: [PATCH 1/3] fix(P7.6F): deduplicate canonical command targets and explain evidence-limited MMS denials --- MainWindow.xaml.cs | 29 +- Models/MonitorModels.cs | 423 ++++++++++++++++++ .../CommandIdentityRejectionP76FTests.cs | 76 ++++ 3 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 5f8139ff5..f7bce312d 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1585,8 +1585,8 @@ private static string BuildQuickControlResult(Iec61850ControlCommandResult resul var stopped = stage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent ? " • Operate NOT sent" : string.Empty; - var cause = string.IsNullOrWhiteSpace(result.AddCause) ? string.Empty : $" • AddCause={result.AddCause}"; - return $"IED REJECTED {stage}: {result.Message}{cause}{stopped}{suffix}"; + var detail = BuildControlRejectionDetail(result); + return $"IED REJECTED {stage}: {detail}{stopped}{suffix}"; } } @@ -1595,6 +1595,31 @@ private static string BuildQuickControlResult(Iec61850ControlCommandResult resul : $"{result.Stage}: {result.Message}{suffix}"; } + // Report only the actual IEC 61850/MMS evidence. F650 can reject an + // Operate with object-access-denied while in Local, but the same MMS + // service error is also possible for other causes. Never fabricate the + // remote/interlock/auth state unless the IED supplies AddCause. + internal static string BuildControlRejectionDetail(Iec61850ControlCommandResult result) + { + ArgumentNullException.ThrowIfNull(result); + var actual = string.IsNullOrWhiteSpace(result.Message) + ? "The IED did not provide a detailed rejection message." + : result.Message.Trim(); + var error = string.IsNullOrWhiteSpace(result.ControlError) + ? string.Empty : $" • ControlError={result.ControlError.Trim()}"; + var cause = string.IsNullOrWhiteSpace(result.AddCause) + ? string.Empty : $" • AddCause={result.AddCause.Trim()}"; + var hasExplicitReason = !string.IsNullOrWhiteSpace(result.ControlError) || + !string.IsNullOrWhiteSpace(result.AddCause); + // A control refusal is not proof of any particular relay state. + var guidance = !hasExplicitReason && + (actual.Contains("object-access-denied", StringComparison.OrdinalIgnoreCase) || + actual.Contains("access denied", StringComparison.OrdinalIgnoreCase)) + ? " • IED did not disclose the cause; check Local/Remote, command authorization and interlocks on the device." + : string.Empty; + return actual + error + cause + guidance; + } + private async void ControlDetails_Click(object sender, RoutedEventArgs e) { if (sender is not Button button || button.Tag is not SignalDefinition signal || !signal.IsControlSignal) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 3269c0cd1..11fe73808 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -596,6 +596,19 @@ private void RefreshCommandSignals() .Where(signal => signal.IsSelected && signal.IsValidControlObject) .Where(signal => signal.ControlModelResolved && signal.ControlSupportsOperate) .Where(signal => !signal.IsGenericControl) + // One logical IEC 61850 CO target can belong to many DataSets/RCBs. + // A control operating surface must show exactly one action per + // device-scoped DataObject; DataSet memberships remain untouched. + // Choose a stable representative and avoid multiple live action + // buttons for the same MMS Oper target. + .GroupBy(signal => NormalizeCommandTarget(signal.ObjectReference), + StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(signal => signal.ControlModelResolved && signal.ControlSupportsOperate) + .ThenByDescending(signal => !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + .ThenBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.DisplayReference, StringComparer.OrdinalIgnoreCase) + .First()) .OrderBy(signal => signal.SortPriority) .ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase) .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase) @@ -603,6 +616,416 @@ private void RefreshCommandSignals() CommandSignals.ReplaceAll(selected); } + private static string NormalizeCommandTarget(string? reference) + => (reference ?? string.Empty).Trim().Replace('\\', '/').Replace(' + Raise(nameof(EndpointText)); + Raise(nameof(SclEndpointHint)); + Raise(nameof(SignalCount)); + Raise(nameof(SelectedSignalCount)); + Raise(nameof(SelectedLiveSignalCount)); + Raise(nameof(SelectedControlSignalCount)); + Raise(nameof(PointCount)); + Raise(nameof(IsActionEnabled)); + Raise(nameof(CanEditSignals)); + Raise(nameof(CanStartMonitor)); + Raise(nameof(CanStartOrStopMonitor)); + Raise(nameof(CanPlayAction)); + Raise(nameof(CanStopAction)); + Raise(nameof(CanRescan)); + Raise(nameof(CanSaveScl)); + Raise(nameof(SaveSclToolTip)); + Raise(nameof(CacheStateText)); + Raise(nameof(HasSclDesignModel)); + Raise(nameof(RequiresEndpointBinding)); + Raise(nameof(HasSclConfigurationDrift)); + Raise(nameof(SclVerificationText)); + Raise(nameof(ReadyWorkspaceTitle)); + Raise(nameof(ReadyWorkspaceMessage)); + Raise(nameof(ConnectionActionLabel)); + Raise(nameof(MonitorActionLabel)); + Raise(nameof(ActivityText)); + Raise(nameof(SummaryText)); + Raise(nameof(IdentityText)); + Raise(nameof(ConnectionGlyph)); + Raise(nameof(ConnectionToolTip)); + Raise(nameof(MonitorGlyph)); + Raise(nameof(MonitorToolTip)); + Raise(nameof(ConfigureToolTip)); + Raise(nameof(PlayToolTip)); + Raise(nameof(StopToolTip)); + Raise(nameof(RemoveToolTip)); + } +} + +public sealed class Iec61850MonitorPoint : ObservableObject +{ + private string _value = "-"; + private string _quality = "Unknown"; + private string _deviceTimestamp = "-"; + private string _sourceMode = "Waiting"; + private string _reason = "-"; + private string _status = "Queued"; + private long _sequence; + private bool _isRecentlyChanged; + private bool _isAnnunciatorSelected; + + public string DeviceId { get; set; } = string.Empty; + public string DeviceName { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public string SignalName { get; set; } = string.Empty; + public string IecReference { get; set; } = string.Empty; + public string QualityReference { get; set; } = string.Empty; + public string TimestampReference { get; set; } = string.Empty; + public string FunctionalConstraint { get; set; } = string.Empty; + public string IecDataType { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Unit { get; set; } = string.Empty; + public string DataSetReference { get; set; } = string.Empty; + public string ReportControlReference { get; set; } = string.Empty; + public int PollingIntervalMs { get; set; } = 1000; + + public string PointKey => $"{DeviceId}|{NormalizeReference(IecReference)}"; + public string IecTelegram => StripIedNamePrefix(IecReference, DeviceName); + public string Value + { + get => _value; + set + { + if (Set(ref _value, string.IsNullOrWhiteSpace(value) ? "-" : value)) + { + Raise(nameof(DisplayValue)); + Raise(nameof(ValueTone)); + Raise(nameof(ValueVisualKind)); + } + } + } + public string DisplayValue => Value; + public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); + public string ValueTone => Iec61850ValueStatePresentation.Classify(Value, IecDataType); + public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( + Value, + IecDataType, + Category, + IecReference); + public string Quality { get => _quality; set => Set(ref _quality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + public string DeviceTimestamp { get => _deviceTimestamp; set => Set(ref _deviceTimestamp, string.IsNullOrWhiteSpace(value) ? "-" : value); } + public string SourceMode { get => _sourceMode; set => Set(ref _sourceMode, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + public string Reason { get => _reason; set => Set(ref _reason, string.IsNullOrWhiteSpace(value) ? "-" : value); } + public string Status { get => _status; set => Set(ref _status, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + // Runtime ordering metadata is not rendered. Avoid an unnecessary WPF property-change + // notification for every poll/report sample; the value still guards stale snapshots. + public long Sequence { get => _sequence; set => _sequence = value; } + public bool IsRecentlyChanged { get => _isRecentlyChanged; set => Set(ref _isRecentlyChanged, value); } + public bool IsAnnunciatorSelected { get => _isAnnunciatorSelected; set => Set(ref _isAnnunciatorSelected, value); } + public bool CanUseAsAnnunciator => FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase); + public string AnnunciatorSelectionToolTip => CanUseAsAnnunciator + ? "Latch this IEC 61850 ST point in Alarm Annunciator using SOE/Event Log edges." + : "Alarm Annunciator selection is limited to IEC 61850 ST status points."; + + /// + /// Applies a process value and returns true only for a real semantic transition. + /// Initial population and formatting-only differences are deliberately ignored. + /// + public bool ApplyProcessValue(string? value) + { + var next = string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + var previous = Value; + Value = next; + return IsInitializedProcessValue(previous) && + IsInitializedProcessValue(next) && + !AreSemanticallyEquivalent(previous, next); + } + + private static bool IsInitializedProcessValue(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length > 0 && text != "-" && + !text.Equals("Pending", StringComparison.OrdinalIgnoreCase) && + !text.Equals("Unknown", StringComparison.OrdinalIgnoreCase); + } + + internal static bool AreSemanticallyEquivalent(string? left, string? right) + { + var a = NormalizeSemanticValue(left); + var b = NormalizeSemanticValue(right); + if (a == b) return true; + + if (TryParseSemanticNumber(a, out var an) && TryParseSemanticNumber(b, out var bn)) + return an == bn; + + return false; + } + + private static bool TryParseSemanticNumber(string value, out decimal number) + { + if (value == "binary:1") + { + number = 1m; + return true; + } + if (value == "binary:0") + { + number = 0m; + return true; + } + + return decimal.TryParse( + value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out number); + } + + private static string NormalizeSemanticValue(string? value) + { + var text = (value ?? string.Empty).Trim().ToLowerInvariant(); + if (text is "true" or "on" or "active" or "asserted") return "binary:1"; + if (text is "false" or "off" or "inactive" or "deasserted") return "binary:0"; + if (text.Contains("open") && text.Contains("01")) return "dbpos:01"; + if (text.Contains("closed") && text.Contains("10")) return "dbpos:10"; + if (text.Contains("intermediate") && text.Contains("00")) return "dbpos:00"; + if (text.Contains("bad") && text.Contains("11")) return "dbpos:11"; + return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.').Replace("..", ".").ToLowerInvariant(); + + internal static string StripIedNamePrefix(string? reference, string? iedName) + { + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + var name = (iedName ?? string.Empty).Trim(); + var slash = text.IndexOf('/'); + if (slash <= 0 || string.IsNullOrWhiteSpace(name)) + return text; + + var domain = text[..slash]; + if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) || domain.Length <= name.Length) + return text; + + return domain[name.Length..] + text[slash..]; + } +} + +public sealed class Iec61850PointSnapshot +{ + public required Iec61850MonitorPoint Point { get; init; } + public string PreviousValue { get; init; } = "-"; + public string Value { get; init; } = "-"; + public string Quality { get; init; } = "Unknown"; + public string DeviceTimestamp { get; init; } = "-"; + public string SourceMode { get; init; } = "MMS Polling"; + public string Reason { get; init; } = "cyclic"; + public string Status { get; init; } = "Live"; + /// + /// True only when the runtime observed a real semantic process-value transition. + /// This flag survives UI batching so a fast report/poll sequence cannot erase the + /// three-second commissioning highlight before WPF renders it. + /// + public bool IsValueEdge { get; init; } + public bool IsReportTraffic { get; init; } + public long Sequence { get; init; } +} + +public sealed class Iec61850EventEntry +{ + public long Sequence { get; init; } + public string DeviceId { get; init; } = string.Empty; + public string PointKey { get; init; } = string.Empty; + public string DeviceTimestamp { get; init; } = "-"; + public string DeviceName { get; init; } = string.Empty; + public string IpAddress { get; init; } = string.Empty; + public string SignalName { get; init; } = string.Empty; + public string IecReference { get; init; } = string.Empty; + public string IecDataType { get; init; } = string.Empty; + public string OldValue { get; init; } = "-"; + public string NewValue { get; init; } = "-"; + public string Quality { get; init; } = "Unknown"; + public string SourceMode { get; init; } = string.Empty; + public string Reason { get; init; } = string.Empty; + public string EdgeType + { + get + { + if (TryParseBinaryState(OldValue, out var oldState) && + TryParseBinaryState(NewValue, out var newState) && + oldState != newState) + { + return newState ? "Rising" : "Falling"; + } + + return "State change"; + } + } + + public string ChangeText => $"{EdgeType} · {OldValue} → {NewValue}"; + public string EventValue => string.IsNullOrWhiteSpace(NewValue) ? "-" : NewValue; + public string DisplayValue => EventValue; + public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); + public string ValueTone => Iec61850ValueStatePresentation.Classify(EventValue, IecDataType); + public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( + EventValue, + IecDataType, + string.Empty, + IecReference); + public string QualityTone => Iec61850QualityPresentation.Classify(Quality); + + public string IecTelegram => Iec61850MonitorPoint.StripIedNamePrefix(IecReference, DeviceName); + + private static bool TryParseBinaryState(string? value, out bool state) + { + state = false; + var text = (value ?? string.Empty).Trim(); + if (bool.TryParse(text, out state)) + return true; + + if (text.Equals("1", StringComparison.OrdinalIgnoreCase) || + text.Equals("1.0", StringComparison.OrdinalIgnoreCase) || + text.Equals("on", StringComparison.OrdinalIgnoreCase) || + text.Equals("active", StringComparison.OrdinalIgnoreCase) || + text.Equals("asserted", StringComparison.OrdinalIgnoreCase)) + { + state = true; + return true; + } + + if (text.Equals("0", StringComparison.OrdinalIgnoreCase) || + text.Equals("0.0", StringComparison.OrdinalIgnoreCase) || + text.Equals("off", StringComparison.OrdinalIgnoreCase) || + text.Equals("inactive", StringComparison.OrdinalIgnoreCase) || + text.Equals("deasserted", StringComparison.OrdinalIgnoreCase)) + { + state = false; + return true; + } + + return false; + } +} + +public sealed class Iec61850TesterProject +{ + public int SchemaVersion { get; set; } = 3; + public string ProjectName { get; set; } = "ARSAS Session"; + public int DefaultPollingIntervalMs { get; set; } = 1000; + public List Devices { get; set; } = new(); +} + +public sealed class Iec61850TesterDeviceProfile +{ + public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); + public string Name { get; set; } = "IED"; + public string IdentitySource { get; set; } = string.Empty; + public string LogicalDeviceSummary { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public int Port { get; set; } = 102; + public bool AllowDynamicDataSetWrites { get; set; } = true; + public bool DiscoverySucceeded { get; set; } + public string SclSourcePath { get; set; } = string.Empty; + public string SclSourceSha256 { get; set; } = string.Empty; + public string SclIedName { get; set; } = string.Empty; + public string SclAccessPointName { get; set; } = string.Empty; + public List SelectedReferences { get; set; } = new(); + public List AnnunciatorReferences { get; set; } = new(); + public List CachedSignals { get; set; } = new(); +} + +/// +/// Stable, JSON-friendly discovery snapshot. It deliberately stores model metadata, +/// not live values, so an opened project can reconnect immediately without presenting +/// stale process data as current. +/// +public sealed class Iec61850CachedSignalProfile +{ + public string Name { get; set; } = string.Empty; + public string ObjectReference { get; set; } = string.Empty; + public string DisplayReference { get; set; } = string.Empty; + public string FunctionalConstraint { get; set; } = string.Empty; + public string DataType { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Unit { get; set; } = string.Empty; + public string Confidence { get; set; } = "Medium"; + public string DataSetReference { get; set; } = string.Empty; + public string ReportControlReference { get; set; } = string.Empty; + public string ReportCoverageReason { get; set; } = string.Empty; + public string QualityReference { get; set; } = string.Empty; + public string TimestampReference { get; set; } = string.Empty; + public string Source { get; set; } = "Project cache"; + public bool IsReportCapable { get; set; } + public string ReportCoverage { get; set; } = "Polling fallback"; + public bool IsControlSignal { get; set; } + public string ControlCdc { get; set; } = string.Empty; + public string ControlModelReference { get; set; } = string.Empty; + public string ControlStatusReference { get; set; } = string.Empty; + public string ControlModelText { get; set; } = "Auto-detect"; + public string ControlValueType { get; set; } = string.Empty; + public bool IsSelected { get; set; } + + public static Iec61850CachedSignalProfile FromSignal(SignalDefinition signal) + { + ArgumentNullException.ThrowIfNull(signal); + return new Iec61850CachedSignalProfile + { + Name = signal.Name, + ObjectReference = signal.ObjectReference, + DisplayReference = signal.DisplayReference, + FunctionalConstraint = signal.FunctionalConstraint, + DataType = signal.DataType, + Category = signal.Category, + Unit = signal.Unit, + Confidence = signal.Confidence, + DataSetReference = signal.DataSetReference, + ReportControlReference = signal.ReportControlReference, + ReportCoverageReason = signal.ReportCoverageReason, + QualityReference = signal.QualityReference, + TimestampReference = signal.TimestampReference, + Source = signal.Source, + IsReportCapable = signal.IsReportCapable, + ReportCoverage = signal.ReportCoverage, + IsControlSignal = signal.IsControlSignal, + ControlCdc = signal.ControlCdc, + ControlModelReference = signal.ControlModelReference, + ControlStatusReference = signal.ControlStatusReference, + ControlModelText = signal.ControlModelText, + ControlValueType = signal.ControlValueType, + IsSelected = signal.IsSelected + }; + } + + public SignalDefinition ToSignal() + => new() + { + Name = Name, + ObjectReference = ObjectReference, + DisplayReference = DisplayReference, + FunctionalConstraint = FunctionalConstraint, + DataType = DataType, + Category = Category, + Unit = Unit, + Confidence = Confidence, + DataSetReference = DataSetReference, + ReportControlReference = ReportControlReference, + ReportCoverageReason = ReportCoverageReason, + QualityReference = QualityReference, + TimestampReference = TimestampReference, + Source = string.IsNullOrWhiteSpace(Source) ? "Project cache" : Source, + IsReportCapable = IsReportCapable, + ReportCoverage = ReportCoverage, + IsControlSignal = IsControlSignal, + ControlCdc = ControlCdc, + ControlModelReference = ControlModelReference, + ControlStatusReference = ControlStatusReference, + ControlModelText = ControlModelText, + ControlValueType = ControlValueType, + IsSelected = IsSelected, + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-", + ProbeStatus = "Restored from saved discovery model" + }; +} +, '.'); + public void RefreshComputed() { Raise(nameof(EndpointText)); diff --git a/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs b/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs new file mode 100644 index 000000000..2ad3243ae --- /dev/null +++ b/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs @@ -0,0 +1,76 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester; + +namespace ARSAS.Tests; + +public sealed class CommandIdentityRejectionP76FTests +{ + [Fact] + public void CommandDock_DeduplicatesDifferentDataSetsByExactControlObject() + { + var device = new Iec61850MonitorDevice(); + var first = Command("BCUGEF650/CSWI1.Pos", "BCUGEF650/LLN0.CSWI"); + var second = Command("BCUGEF650/CSWI1.Pos", "BCUGEF650/LLN0.MMXU_SOGI1"); + var third = Command("BCUGEF650/CSWI10.Pos", "BCUGEF650/LLN0.CSWI"); + device.Signals.Add(first); + device.Signals.Add(second); + device.Signals.Add(third); + device.RefreshCommandSignalProjection(); + Assert.Equal(2, device.CommandSignals.Count); + Assert.Contains(device.CommandSignals, s => s.ObjectReference == "BCUGEF650/CSWI1.Pos"); + Assert.Contains(device.CommandSignals, s => s.ObjectReference == "BCUGEF650/CSWI10.Pos"); + Assert.Equal(3, device.Signals.Count); // reporting membership unchanged + } + + [Fact] + public void CommandDock_DoesNotMergeDifferentLDsOrIEDs() + { + var device = new Iec61850MonitorDevice(); + device.Signals.Add(Command("BCUGEF650/CSWI1.Pos", "a")); + device.Signals.Add(Command("BCUGEOTHER/CSWI1.Pos", "b")); + device.RefreshCommandSignalProjection(); + Assert.Equal(2, device.CommandSignals.Count); + } + + [Fact] + public void DeniedMmsWithoutAddCause_DoesNotInventLocalInterlockOrAuthorization() + { + var result = new Iec61850ControlCommandResult + { + Message = "MMS Confirmed-Write rejected: item[0]=object-access-denied (3)." + }; + var message = MainWindow.BuildControlRejectionDetail(result); + Assert.Contains("object-access-denied", message); + Assert.Contains("check Local/Remote", message); + Assert.Contains("did not disclose the cause", message); + Assert.DoesNotContain("AddCause=", message); + Assert.DoesNotContain("ControlError=", message); + } + + [Fact] + public void RealAddCause_IsPreservedNotOverriddenBySpeculativeLocalDiagnosis() + { + var result = new Iec61850ControlCommandResult + { + Message = "Control rejected by IED", + AddCause = "blocked-by-interlocking", + ControlError = "1" + }; + var message = MainWindow.BuildControlRejectionDetail(result); + Assert.Contains("AddCause=blocked-by-interlocking", message); + Assert.Contains("ControlError=1", message); + Assert.DoesNotContain("check Local/Remote", message); + } + + private static SignalDefinition Command(string reference, string dataset) => new() + { + ObjectReference = reference, + DisplayReference = reference, + DataSetReference = dataset, + IsControlSignal = true, + IsSelected = true, + ControlModelResolved = true, + ControlCdc = "DPC", + ControlModelText = "Direct · Normal security" + }; +} From 983176003b94cc014054bd614991078062964caa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 9 Oct 2026 16:45:37 +0700 Subject: [PATCH 2/3] feat(P7.6F): nonmodal auto-dismiss control rejection shout with protocol-grounded causes --- MainWindow.xaml | 30 +++++++++ MainWindow.xaml.cs | 67 +++++++++++++++++++ .../CommandIdentityRejectionP76FTests.cs | 53 ++++++++++++++- 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index cfe5d6a27..6482c8cfc 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -1793,5 +1793,35 @@ + + + + + + + + + + + + + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index f7bce312d..964567086 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -35,6 +35,12 @@ public partial class MainWindow : Window, INotifyPropertyChanged private readonly Dictionary> _controlFeedbackIndex = new(StringComparer.OrdinalIgnoreCase); private readonly DispatcherTimer _uiFlushTimer; private readonly DispatcherTimer _progressAnimationTimer; + // Shared, resettable timer: concurrent commands replace the same toast + // without spawning timers/tasks for every fast-control outcome. + private readonly DispatcherTimer _controlShoutTimer = new(DispatcherPriority.Normal) + { + Interval = TimeSpan.FromSeconds(5) + }; private Iec61850MonitorDevice? _selectedDevice; private string _newDeviceIp = "192.168.1.10"; private string _newDevicePort = "102"; @@ -115,6 +121,11 @@ public MainWindow() { InitializeComponent(); DataContext = this; + _controlShoutTimer.Tick += (_, _) => + { + _controlShoutTimer.Stop(); + ControlShoutCard.Visibility = Visibility.Collapsed; + }; _uiFlushTimer = new DispatcherTimer(DispatcherPriority.Background) { @@ -1538,6 +1549,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo signal.ControlCurrentValue = result.FeedbackValue; signal.ControlLastResult = BuildQuickControlResult(result); + if (!result.IsSuccess) + { + var shout = BuildControlShout(result); + ShowControlShout(shout.Title, shout.Detail); + } SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}"); clickStopwatch.Stop(); AddLog(result.IsSuccess ? "INFO" : "WARN", device.Name, @@ -1551,6 +1567,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo catch (Exception ex) { signal.ControlLastResult = $"Command failed: {ex.Message}"; + ShowControlShout("Command failed", "The operation could not complete. Check Diagnostics for the technical reason."); AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}"); SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}"); MarkDiagnosticAlert(); @@ -1620,6 +1637,56 @@ internal static string BuildControlRejectionDetail(Iec61850ControlCommandResult return actual + error + cause + guidance; } + internal static (string Title, string Detail) BuildControlShout(Iec61850ControlCommandResult result) + { + ArgumentNullException.ThrowIfNull(result); + var cause = (result.AddCause ?? string.Empty).Trim(); + var normalized = cause.ToLowerInvariant().Replace('_', '-').Replace(' ', '-'); + var title = normalized.Contains("interlock", StringComparison.Ordinal) + ? "Command blocked by interlock" + : normalized.Contains("synchro", StringComparison.Ordinal) + ? "Command blocked by synchrocheck" + : normalized.Contains("authority", StringComparison.Ordinal) || + normalized.Contains("authorization", StringComparison.Ordinal) || + normalized.Contains("access-violation", StringComparison.Ordinal) + ? "Command authorization denied" + : normalized.Contains("mode", StringComparison.Ordinal) + ? "Command blocked by operating mode" + : "Command rejected by IED"; + + // Even when the engineer knows the hardware was in Local, generic MMS + // access-denied does not encode that fact. Avoid a false positive: + // show Local/Remote as a troubleshooting hint, not proven rejection. + var evidence = BuildControlRejectionDetail(result); + var detail = title == "Command rejected by IED" && + evidence.Contains("object-access-denied", StringComparison.OrdinalIgnoreCase) + ? "IED refused the command (MMS access denied). Check BCU Local/Remote and permissions; see Diagnostics." + : title == "Command blocked by interlock" + ? "IED reports interlocking protection. Verify the permissives before retrying." + : title == "Command blocked by synchrocheck" + ? "IED reports synchrocheck protection. Check synchronization conditions." + : title == "Command authorization denied" + ? "IED reports insufficient control authority. Check the operator access level." + : title == "Command blocked by operating mode" + ? "IED reports an operating-mode restriction. Check Local/Remote and control mode." + : evidence; + return (title, detail.Length <= 220 ? detail : detail[..220] + "…"); + } + + private void ShowControlShout(string title, string detail) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(() => ShowControlShout(title, detail)); + return; + } + _controlShoutTimer.Stop(); + ControlShoutTitle.Text = title; + ControlShoutDetail.Text = detail; + ControlShoutCard.Visibility = Visibility.Visible; + _controlShoutTimer.Start(); + } + private async void ControlDetails_Click(object sender, RoutedEventArgs e) { if (sender is not Button button || button.Tag is not SignalDefinition signal || !signal.IsControlSignal) diff --git a/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs b/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs index 2ad3243ae..4c78d5854 100644 --- a/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs +++ b/tests/ARSAS.Tests/CommandIdentityRejectionP76FTests.cs @@ -62,6 +62,58 @@ public void RealAddCause_IsPreservedNotOverriddenBySpeculativeLocalDiagnosis() Assert.DoesNotContain("check Local/Remote", message); } + [Theory] + [InlineData("blocked-by-interlocking", "Command blocked by interlock")] + [InlineData("blocked-by-synchrocheck", "Command blocked by synchrocheck")] + [InlineData("no-access-authority", "Command authorization denied")] + [InlineData("blocked-by-mode", "Command blocked by operating mode")] + public void Toast_UsesRealIedAddCauseWhenAvailable(string addCause, string expected) + { + var result = new Iec61850ControlCommandResult + { + Message = "Control service rejected", + AddCause = addCause + }; + var toast = MainWindow.BuildControlShout(result); + Assert.Equal(expected, toast.Title); + } + + [Fact] + public void Toast_GenericMmsAccessDeniedDoesNotClaimLocalIsProven() + { + var result = new Iec61850ControlCommandResult + { + Message = "MMS Confirmed-Write rejected: object-access-denied (3)" + }; + var toast = MainWindow.BuildControlShout(result); + Assert.Equal("Command rejected by IED", toast.Title); + Assert.Contains("Check BCU Local/Remote", toast.Detail); + Assert.DoesNotContain("confirmed local", toast.Detail, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Toast_XamlIsNonModalAndTimerAutoDismisses() + { + var xaml = Read("MainWindow.xaml"); + var code = Read("MainWindow.xaml.cs"); + Assert.Contains("x:Name=\"ControlShoutCard\"", xaml); + Assert.Contains("Panel.ZIndex=\"90\"", xaml); + Assert.Contains("Interval = TimeSpan.FromSeconds(5)", code); + Assert.Contains("ControlShoutCard.Visibility = Visibility.Collapsed", code); + } + + private static string Read(string path) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var file = Path.Combine(directory.FullName, path); + if (File.Exists(file)) return File.ReadAllText(file); + directory = directory.Parent; + } + throw new FileNotFoundException(path); + } + private static SignalDefinition Command(string reference, string dataset) => new() { ObjectReference = reference, @@ -69,7 +121,6 @@ public void RealAddCause_IsPreservedNotOverriddenBySpeculativeLocalDiagnosis() DataSetReference = dataset, IsControlSignal = true, IsSelected = true, - ControlModelResolved = true, ControlCdc = "DPC", ControlModelText = "Direct · Normal security" }; From c08273201542d4372d1f05bc8d4f8d6ef0685add Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 9 Oct 2026 16:47:56 +0700 Subject: [PATCH 3/3] fix(P7.6F): restore full monitor model and retain scoped command dedup only --- Models/MonitorModels.cs | 422 +--------------------------------------- 1 file changed, 4 insertions(+), 418 deletions(-) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 11fe73808..a932011c5 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -596,16 +596,12 @@ private void RefreshCommandSignals() .Where(signal => signal.IsSelected && signal.IsValidControlObject) .Where(signal => signal.ControlModelResolved && signal.ControlSupportsOperate) .Where(signal => !signal.IsGenericControl) - // One logical IEC 61850 CO target can belong to many DataSets/RCBs. - // A control operating surface must show exactly one action per - // device-scoped DataObject; DataSet memberships remain untouched. - // Choose a stable representative and avoid multiple live action - // buttons for the same MMS Oper target. - .GroupBy(signal => NormalizeCommandTarget(signal.ObjectReference), + // One control object can belong to multiple IEC 61850 DataSets. + // Keep the source rows intact for reporting, one action per target. + .GroupBy(signal => (signal.ObjectReference ?? string.Empty).Trim(), StringComparer.OrdinalIgnoreCase) .Select(group => group - .OrderByDescending(signal => signal.ControlModelResolved && signal.ControlSupportsOperate) - .ThenByDescending(signal => !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + .OrderByDescending(signal => !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) .ThenBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase) .ThenBy(signal => signal.DisplayReference, StringComparer.OrdinalIgnoreCase) .First()) @@ -616,416 +612,6 @@ private void RefreshCommandSignals() CommandSignals.ReplaceAll(selected); } - private static string NormalizeCommandTarget(string? reference) - => (reference ?? string.Empty).Trim().Replace('\\', '/').Replace(' - Raise(nameof(EndpointText)); - Raise(nameof(SclEndpointHint)); - Raise(nameof(SignalCount)); - Raise(nameof(SelectedSignalCount)); - Raise(nameof(SelectedLiveSignalCount)); - Raise(nameof(SelectedControlSignalCount)); - Raise(nameof(PointCount)); - Raise(nameof(IsActionEnabled)); - Raise(nameof(CanEditSignals)); - Raise(nameof(CanStartMonitor)); - Raise(nameof(CanStartOrStopMonitor)); - Raise(nameof(CanPlayAction)); - Raise(nameof(CanStopAction)); - Raise(nameof(CanRescan)); - Raise(nameof(CanSaveScl)); - Raise(nameof(SaveSclToolTip)); - Raise(nameof(CacheStateText)); - Raise(nameof(HasSclDesignModel)); - Raise(nameof(RequiresEndpointBinding)); - Raise(nameof(HasSclConfigurationDrift)); - Raise(nameof(SclVerificationText)); - Raise(nameof(ReadyWorkspaceTitle)); - Raise(nameof(ReadyWorkspaceMessage)); - Raise(nameof(ConnectionActionLabel)); - Raise(nameof(MonitorActionLabel)); - Raise(nameof(ActivityText)); - Raise(nameof(SummaryText)); - Raise(nameof(IdentityText)); - Raise(nameof(ConnectionGlyph)); - Raise(nameof(ConnectionToolTip)); - Raise(nameof(MonitorGlyph)); - Raise(nameof(MonitorToolTip)); - Raise(nameof(ConfigureToolTip)); - Raise(nameof(PlayToolTip)); - Raise(nameof(StopToolTip)); - Raise(nameof(RemoveToolTip)); - } -} - -public sealed class Iec61850MonitorPoint : ObservableObject -{ - private string _value = "-"; - private string _quality = "Unknown"; - private string _deviceTimestamp = "-"; - private string _sourceMode = "Waiting"; - private string _reason = "-"; - private string _status = "Queued"; - private long _sequence; - private bool _isRecentlyChanged; - private bool _isAnnunciatorSelected; - - public string DeviceId { get; set; } = string.Empty; - public string DeviceName { get; set; } = string.Empty; - public string IpAddress { get; set; } = string.Empty; - public string SignalName { get; set; } = string.Empty; - public string IecReference { get; set; } = string.Empty; - public string QualityReference { get; set; } = string.Empty; - public string TimestampReference { get; set; } = string.Empty; - public string FunctionalConstraint { get; set; } = string.Empty; - public string IecDataType { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public string Unit { get; set; } = string.Empty; - public string DataSetReference { get; set; } = string.Empty; - public string ReportControlReference { get; set; } = string.Empty; - public int PollingIntervalMs { get; set; } = 1000; - - public string PointKey => $"{DeviceId}|{NormalizeReference(IecReference)}"; - public string IecTelegram => StripIedNamePrefix(IecReference, DeviceName); - public string Value - { - get => _value; - set - { - if (Set(ref _value, string.IsNullOrWhiteSpace(value) ? "-" : value)) - { - Raise(nameof(DisplayValue)); - Raise(nameof(ValueTone)); - Raise(nameof(ValueVisualKind)); - } - } - } - public string DisplayValue => Value; - public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); - public string ValueTone => Iec61850ValueStatePresentation.Classify(Value, IecDataType); - public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( - Value, - IecDataType, - Category, - IecReference); - public string Quality { get => _quality; set => Set(ref _quality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - public string DeviceTimestamp { get => _deviceTimestamp; set => Set(ref _deviceTimestamp, string.IsNullOrWhiteSpace(value) ? "-" : value); } - public string SourceMode { get => _sourceMode; set => Set(ref _sourceMode, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - public string Reason { get => _reason; set => Set(ref _reason, string.IsNullOrWhiteSpace(value) ? "-" : value); } - public string Status { get => _status; set => Set(ref _status, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - // Runtime ordering metadata is not rendered. Avoid an unnecessary WPF property-change - // notification for every poll/report sample; the value still guards stale snapshots. - public long Sequence { get => _sequence; set => _sequence = value; } - public bool IsRecentlyChanged { get => _isRecentlyChanged; set => Set(ref _isRecentlyChanged, value); } - public bool IsAnnunciatorSelected { get => _isAnnunciatorSelected; set => Set(ref _isAnnunciatorSelected, value); } - public bool CanUseAsAnnunciator => FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase); - public string AnnunciatorSelectionToolTip => CanUseAsAnnunciator - ? "Latch this IEC 61850 ST point in Alarm Annunciator using SOE/Event Log edges." - : "Alarm Annunciator selection is limited to IEC 61850 ST status points."; - - /// - /// Applies a process value and returns true only for a real semantic transition. - /// Initial population and formatting-only differences are deliberately ignored. - /// - public bool ApplyProcessValue(string? value) - { - var next = string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); - var previous = Value; - Value = next; - return IsInitializedProcessValue(previous) && - IsInitializedProcessValue(next) && - !AreSemanticallyEquivalent(previous, next); - } - - private static bool IsInitializedProcessValue(string? value) - { - var text = (value ?? string.Empty).Trim(); - return text.Length > 0 && text != "-" && - !text.Equals("Pending", StringComparison.OrdinalIgnoreCase) && - !text.Equals("Unknown", StringComparison.OrdinalIgnoreCase); - } - - internal static bool AreSemanticallyEquivalent(string? left, string? right) - { - var a = NormalizeSemanticValue(left); - var b = NormalizeSemanticValue(right); - if (a == b) return true; - - if (TryParseSemanticNumber(a, out var an) && TryParseSemanticNumber(b, out var bn)) - return an == bn; - - return false; - } - - private static bool TryParseSemanticNumber(string value, out decimal number) - { - if (value == "binary:1") - { - number = 1m; - return true; - } - if (value == "binary:0") - { - number = 0m; - return true; - } - - return decimal.TryParse( - value, - System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, - out number); - } - - private static string NormalizeSemanticValue(string? value) - { - var text = (value ?? string.Empty).Trim().ToLowerInvariant(); - if (text is "true" or "on" or "active" or "asserted") return "binary:1"; - if (text is "false" or "off" or "inactive" or "deasserted") return "binary:0"; - if (text.Contains("open") && text.Contains("01")) return "dbpos:01"; - if (text.Contains("closed") && text.Contains("10")) return "dbpos:10"; - if (text.Contains("intermediate") && text.Contains("00")) return "dbpos:00"; - if (text.Contains("bad") && text.Contains("11")) return "dbpos:11"; - return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); - } - - private static string NormalizeReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.').Replace("..", ".").ToLowerInvariant(); - - internal static string StripIedNamePrefix(string? reference, string? iedName) - { - var text = (reference ?? string.Empty).Trim().Replace('$', '.'); - var name = (iedName ?? string.Empty).Trim(); - var slash = text.IndexOf('/'); - if (slash <= 0 || string.IsNullOrWhiteSpace(name)) - return text; - - var domain = text[..slash]; - if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) || domain.Length <= name.Length) - return text; - - return domain[name.Length..] + text[slash..]; - } -} - -public sealed class Iec61850PointSnapshot -{ - public required Iec61850MonitorPoint Point { get; init; } - public string PreviousValue { get; init; } = "-"; - public string Value { get; init; } = "-"; - public string Quality { get; init; } = "Unknown"; - public string DeviceTimestamp { get; init; } = "-"; - public string SourceMode { get; init; } = "MMS Polling"; - public string Reason { get; init; } = "cyclic"; - public string Status { get; init; } = "Live"; - /// - /// True only when the runtime observed a real semantic process-value transition. - /// This flag survives UI batching so a fast report/poll sequence cannot erase the - /// three-second commissioning highlight before WPF renders it. - /// - public bool IsValueEdge { get; init; } - public bool IsReportTraffic { get; init; } - public long Sequence { get; init; } -} - -public sealed class Iec61850EventEntry -{ - public long Sequence { get; init; } - public string DeviceId { get; init; } = string.Empty; - public string PointKey { get; init; } = string.Empty; - public string DeviceTimestamp { get; init; } = "-"; - public string DeviceName { get; init; } = string.Empty; - public string IpAddress { get; init; } = string.Empty; - public string SignalName { get; init; } = string.Empty; - public string IecReference { get; init; } = string.Empty; - public string IecDataType { get; init; } = string.Empty; - public string OldValue { get; init; } = "-"; - public string NewValue { get; init; } = "-"; - public string Quality { get; init; } = "Unknown"; - public string SourceMode { get; init; } = string.Empty; - public string Reason { get; init; } = string.Empty; - public string EdgeType - { - get - { - if (TryParseBinaryState(OldValue, out var oldState) && - TryParseBinaryState(NewValue, out var newState) && - oldState != newState) - { - return newState ? "Rising" : "Falling"; - } - - return "State change"; - } - } - - public string ChangeText => $"{EdgeType} · {OldValue} → {NewValue}"; - public string EventValue => string.IsNullOrWhiteSpace(NewValue) ? "-" : NewValue; - public string DisplayValue => EventValue; - public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); - public string ValueTone => Iec61850ValueStatePresentation.Classify(EventValue, IecDataType); - public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( - EventValue, - IecDataType, - string.Empty, - IecReference); - public string QualityTone => Iec61850QualityPresentation.Classify(Quality); - - public string IecTelegram => Iec61850MonitorPoint.StripIedNamePrefix(IecReference, DeviceName); - - private static bool TryParseBinaryState(string? value, out bool state) - { - state = false; - var text = (value ?? string.Empty).Trim(); - if (bool.TryParse(text, out state)) - return true; - - if (text.Equals("1", StringComparison.OrdinalIgnoreCase) || - text.Equals("1.0", StringComparison.OrdinalIgnoreCase) || - text.Equals("on", StringComparison.OrdinalIgnoreCase) || - text.Equals("active", StringComparison.OrdinalIgnoreCase) || - text.Equals("asserted", StringComparison.OrdinalIgnoreCase)) - { - state = true; - return true; - } - - if (text.Equals("0", StringComparison.OrdinalIgnoreCase) || - text.Equals("0.0", StringComparison.OrdinalIgnoreCase) || - text.Equals("off", StringComparison.OrdinalIgnoreCase) || - text.Equals("inactive", StringComparison.OrdinalIgnoreCase) || - text.Equals("deasserted", StringComparison.OrdinalIgnoreCase)) - { - state = false; - return true; - } - - return false; - } -} - -public sealed class Iec61850TesterProject -{ - public int SchemaVersion { get; set; } = 3; - public string ProjectName { get; set; } = "ARSAS Session"; - public int DefaultPollingIntervalMs { get; set; } = 1000; - public List Devices { get; set; } = new(); -} - -public sealed class Iec61850TesterDeviceProfile -{ - public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); - public string Name { get; set; } = "IED"; - public string IdentitySource { get; set; } = string.Empty; - public string LogicalDeviceSummary { get; set; } = string.Empty; - public string IpAddress { get; set; } = string.Empty; - public int Port { get; set; } = 102; - public bool AllowDynamicDataSetWrites { get; set; } = true; - public bool DiscoverySucceeded { get; set; } - public string SclSourcePath { get; set; } = string.Empty; - public string SclSourceSha256 { get; set; } = string.Empty; - public string SclIedName { get; set; } = string.Empty; - public string SclAccessPointName { get; set; } = string.Empty; - public List SelectedReferences { get; set; } = new(); - public List AnnunciatorReferences { get; set; } = new(); - public List CachedSignals { get; set; } = new(); -} - -/// -/// Stable, JSON-friendly discovery snapshot. It deliberately stores model metadata, -/// not live values, so an opened project can reconnect immediately without presenting -/// stale process data as current. -/// -public sealed class Iec61850CachedSignalProfile -{ - public string Name { get; set; } = string.Empty; - public string ObjectReference { get; set; } = string.Empty; - public string DisplayReference { get; set; } = string.Empty; - public string FunctionalConstraint { get; set; } = string.Empty; - public string DataType { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public string Unit { get; set; } = string.Empty; - public string Confidence { get; set; } = "Medium"; - public string DataSetReference { get; set; } = string.Empty; - public string ReportControlReference { get; set; } = string.Empty; - public string ReportCoverageReason { get; set; } = string.Empty; - public string QualityReference { get; set; } = string.Empty; - public string TimestampReference { get; set; } = string.Empty; - public string Source { get; set; } = "Project cache"; - public bool IsReportCapable { get; set; } - public string ReportCoverage { get; set; } = "Polling fallback"; - public bool IsControlSignal { get; set; } - public string ControlCdc { get; set; } = string.Empty; - public string ControlModelReference { get; set; } = string.Empty; - public string ControlStatusReference { get; set; } = string.Empty; - public string ControlModelText { get; set; } = "Auto-detect"; - public string ControlValueType { get; set; } = string.Empty; - public bool IsSelected { get; set; } - - public static Iec61850CachedSignalProfile FromSignal(SignalDefinition signal) - { - ArgumentNullException.ThrowIfNull(signal); - return new Iec61850CachedSignalProfile - { - Name = signal.Name, - ObjectReference = signal.ObjectReference, - DisplayReference = signal.DisplayReference, - FunctionalConstraint = signal.FunctionalConstraint, - DataType = signal.DataType, - Category = signal.Category, - Unit = signal.Unit, - Confidence = signal.Confidence, - DataSetReference = signal.DataSetReference, - ReportControlReference = signal.ReportControlReference, - ReportCoverageReason = signal.ReportCoverageReason, - QualityReference = signal.QualityReference, - TimestampReference = signal.TimestampReference, - Source = signal.Source, - IsReportCapable = signal.IsReportCapable, - ReportCoverage = signal.ReportCoverage, - IsControlSignal = signal.IsControlSignal, - ControlCdc = signal.ControlCdc, - ControlModelReference = signal.ControlModelReference, - ControlStatusReference = signal.ControlStatusReference, - ControlModelText = signal.ControlModelText, - ControlValueType = signal.ControlValueType, - IsSelected = signal.IsSelected - }; - } - - public SignalDefinition ToSignal() - => new() - { - Name = Name, - ObjectReference = ObjectReference, - DisplayReference = DisplayReference, - FunctionalConstraint = FunctionalConstraint, - DataType = DataType, - Category = Category, - Unit = Unit, - Confidence = Confidence, - DataSetReference = DataSetReference, - ReportControlReference = ReportControlReference, - ReportCoverageReason = ReportCoverageReason, - QualityReference = QualityReference, - TimestampReference = TimestampReference, - Source = string.IsNullOrWhiteSpace(Source) ? "Project cache" : Source, - IsReportCapable = IsReportCapable, - ReportCoverage = ReportCoverage, - IsControlSignal = IsControlSignal, - ControlCdc = ControlCdc, - ControlModelReference = ControlModelReference, - ControlStatusReference = ControlStatusReference, - ControlModelText = ControlModelText, - ControlValueType = ControlValueType, - IsSelected = IsSelected, - Value = "-", - Quality = "Unknown", - DeviceTimestamp = "-", - ProbeStatus = "Restored from saved discovery model" - }; -} -, '.'); - public void RefreshComputed() { Raise(nameof(EndpointText));