Skip to content
Draft
45 changes: 41 additions & 4 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e)
}
var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel;
var currentPage = _iedBrowserNavigation.Current;
var selectedDo = !readAll &&
var selectedDo = !readAll && sender is not Button { Tag: "LN" } &&
ReferenceEquals(_iedBrowserRenderedPage, currentPage) &&
currentPage?.Payload is LiveIedLogicalNodeModel
? _iedBrowserSelectedDoReference : null;
Expand Down Expand Up @@ -463,6 +463,15 @@ await _runtime.ReadBrowserValuesAsync(device, targets,
}


private void IedBrowserGooseSubscribe_Click(object sender, RoutedEventArgs e)
{
if (SelectedDevice is not { IsDemo: false } device ||
_iedBrowserRenderedPage?.Kind != "GoCB")
return;
// Shared Npcap-based subscriber, no second GOOSE receive/decoder path.
OpenIedGooseSubscriber(device);
}

private void RefreshIedBrowserReadButtons()
{
if (IedBrowserReadPageButton == null) return;
Expand All @@ -476,6 +485,16 @@ private void RefreshIedBrowserReadButtons()
// Opening a browsed SCL is always offline-safe. Explicit Read can then
// open a cached-model MMS association; no background connect or polling.
var selectedKind = IedBrowserReadPlan.SelectionKind(_iedBrowserNavigation.Current);
if (IedBrowserGooseSubscribeButton is not null)
{
IedBrowserGooseSubscribeButton.Visibility =
_iedBrowserRenderedPage?.Kind == "GoCB"
? Visibility.Visible : Visibility.Collapsed;
IedBrowserGooseSubscribeButton.IsEnabled =
device is { IsDemo: false } && _iedBrowserReadOperation == null;
IedBrowserGooseSubscribeButton.Content = IsGooseCapturing
? "View GOOSE Capture" : "Subscribe GOOSE";
}
IedBrowserReadPageButton.Content = selectedKind == IedBrowserReadSelectionKind.ReadOnlyRcb
? "Inspect RCB"
: connected ? "Read" : "Connect & Read";
Expand All @@ -490,6 +509,25 @@ private void RefreshIedBrowserReadButtons()
: connected
? "Read this model-verified object through bounded MMS reads."
: "Connect using this IED model and read selected values on demand; no RCB or cyclic polling.";
var focusedDo = selectedKind == IedBrowserReadSelectionKind.FcAttributes &&
_iedBrowserNavigation.Current?.Payload is LiveIedLogicalNodeModel &&
!string.IsNullOrWhiteSpace(_iedBrowserSelectedDoReference);
if (IedBrowserReadLnButton is not null)
{
IedBrowserReadLnButton.Visibility =
focusedDo ? Visibility.Visible : Visibility.Collapsed;
IedBrowserReadLnButton.IsEnabled = ready;
}
if (focusedDo)
{
IedBrowserReadPageButton.Content = "Read DO";
IedBrowserReadPageButton.ToolTip = "Read only " +
_iedBrowserSelectedDoReference +
"; choose Read LN for the entire Logical Node.";
}
else if (selectedKind == IedBrowserReadSelectionKind.FcAttributes &&
_iedBrowserNavigation.Current?.Payload is LiveIedLogicalNodeModel)
IedBrowserReadPageButton.Content = "Read LN";
IedBrowserReadPageButton.IsEnabled = ready;
IedBrowserReadAllButton.IsEnabled = ready;
IedBrowserCancelReadButton.IsEnabled = _iedBrowserReadOperation != null;
Expand Down Expand Up @@ -1209,9 +1247,7 @@ private void IedBrowserDetailRow_MouseUp(
_iedBrowserNavigation.Current?.Payload is LiveIedLogicalNodeModel)
{
_iedBrowserSelectedDoReference = detail.ReadObjectReference;
IedBrowserReadPageButton.ToolTip =
"Read all model-proven safe FC attributes in selected DO " +
detail.ReadObjectReference + "; no writes or automatic polling.";
RefreshIedBrowserReadButtons(); // expose separate Read DO and Read LN scope
}
if (!detail.HasChildren) return;
ToggleIedBrowserDetails(detail);
Expand Down Expand Up @@ -1392,6 +1428,7 @@ private void RenderIedBrowserSelection(
connected, SelectedDevice?.SclWorkspace?.DesignModel));
_iedBrowserLastLiveVersion = -1;
RefreshIedBrowserVisibleValues();
RefreshIedBrowserReadButtons();
// Folder entry opens at its first child, regardless of the scroll
// position in the previously inspected RCB, LN or DataSet.
if (_iedBrowserDetails.Rows.Count > 0)
Expand Down
11 changes: 10 additions & 1 deletion MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1983,10 +1983,18 @@
Style="{StaticResource SoftButton}" Margin="0,0,6,0"
Click="IedBrowserReadValues_Click"
ToolTip="Read values for the current Browser object over MMS. Live monitoring uses the report stream."/>
<Button x:Name="IedBrowserReadLnButton" Content="Read LN" Tag="LN"
Visibility="Collapsed" Style="{StaticResource SoftButton}" Margin="0,0,6,0"
Click="IedBrowserReadValues_Click"
ToolTip="Read all model-proven attributes in this LN, not just the focused Data Object."/>
<Button x:Name="IedBrowserReadAllButton" Content="Read All" Tag="All"
Style="{StaticResource SoftButton}" Margin="0,0,6,0"
Click="IedBrowserReadValues_Click"
ToolTip="Read model-proven IEC data attributes sequentially; progress appears on the IED card."/>
<Button x:Name="IedBrowserGooseSubscribeButton" Content="Subscribe GOOSE"
Style="{StaticResource SoftButton}" Margin="0,0,6,0"
Visibility="Collapsed" Click="IedBrowserGooseSubscribe_Click"
ToolTip="Open existing passive GOOSE Subscriber for this IED; choose a capture adapter. Does not enable GoEna or write MMS."/>
<Button x:Name="IedBrowserCancelReadButton" Content="Cancel Read"
Style="{StaticResource SoftButton}" Margin="0,0,6,0"
IsEnabled="False" Click="IedBrowserCancelRead_Click"
Expand Down Expand Up @@ -2274,7 +2282,8 @@
<DataGridTemplateColumn Header="Filename" Width="*" MinWidth="185">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Margin="10,0,5,0">
<!-- Vector geometry supplied for the File List:
no font icon and no raster dependency. -->
<!-- Stretch an SVG-space geometry into a real 18px
Expand Down
17 changes: 16 additions & 1 deletion Models/IedBrowserDetailSectionLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,23 @@ public static IReadOnlyList<IedBrowserDetailRow> Attach(
continue;
}
var end = index + 1;
while (end < raw.Count && raw[end].Kind != "Section")
while (end < raw.Count)
{
if (raw[end].Kind == "Section")
{
// TrgOps and OptFlds are children of the same RCB, not
// independent root sections. Their FlagGroup rows remain
// the existing engine-backed read-only projections.
if (section.Name == "Control Block attributes" &&
raw[end].Name is "Trigger options" or "Optional fields")
{
end++;
continue;
}
break;
}
end++;
}
if (end == index + 1)
{
result.Add(section);
Expand Down
63 changes: 62 additions & 1 deletion Models/IedBrowserReadPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,68 @@ public static IReadOnlyList<IedBrowserReadTarget> BuildInitialPriority(
string.IsNullOrWhiteSpace(x.attr.SclBType)
? x.attr.MmsType : x.attr.SclBType))
.ToArray();
return candidates;
// SGCB is not an LN DataObject. Its exact GetNameList FC inventory
// must be admitted through the same bounded read plan, not skipped
// because the generic attribute walker cannot see the control block.
// Only read-only NumOfSG/ActSG/LActTm; never EditSG/CnfEdit/SE.
var settings = verifiedLiveModel.SettingGroupControls
.SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "SGCB"))
.GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint,
StringComparer.Ordinal)
.Where(group => group.Count() == 1)
.Select(group => group.First())
.Where(target => !ledger.TryGet(target.Reference,
target.FunctionalConstraint, out var observed) ||
observed.IsStale || observed.Association != association)
.OrderBy(target => target.Reference.EndsWith(".LActTm",
StringComparison.Ordinal) ? 1 : 0)
.ThenBy(target => target.Reference, StringComparer.Ordinal)
.ToArray();
// Likewise, GoEna/NdsCom belong to the exact live GO control
// inventory, not ordinary data-object attributes or received GOOSE
// frames. A passive subscriber cannot prove either MMS status.
// Limit background status reads for dense GoCB catalogs; unvisited
// objects retain honest Not read evidence.
var goose = verifiedLiveModel.GooseControlBlocks
.SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "GoCB"))
.GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint,
StringComparer.Ordinal)
.Where(group => group.Count() == 1)
.Select(group => group.First())
.Where(target => !ledger.TryGet(target.Reference,
target.FunctionalConstraint, out var observed) ||
observed.IsStale || observed.Association != association)
.OrderBy(target => target.Reference.EndsWith(".NdsCom",
StringComparison.Ordinal) ? 1 : 0)
.ThenBy(target => target.Reference, StringComparer.Ordinal)
.Take(8).ToArray();
// Critical station Mod/Beh/Health must never starve behind multiple
// optional control blocks or slow LActTm reads on a 10s envelope.
// The priority-sorted LN plan already puts these at priority zero.
var critical = candidates.Where(target =>
IsCriticalStationStatus(target.Reference, target.FunctionalConstraint));
return critical.Concat(settings).Concat(goose).Concat(candidates)
.DistinctBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint.Trim().ToUpperInvariant(),
StringComparer.Ordinal)
.Take(maxTargets).ToArray();
}

private static bool IsCriticalStationStatus(string reference, string fc)
{
if (!string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase))
return false;
var normalized = IedBrowserLiveValues.CanonicalReference(reference);
var slash = normalized.LastIndexOf('/');
var dot = normalized.IndexOf('.', slash + 1);
if (dot < 0) return false;
var ln = normalized[(slash + 1)..dot];
return (ln == "LLN0" || IsLnClass(ln, "LPHD")) &&
(normalized.EndsWith(".Mod.stVal", StringComparison.Ordinal) ||
normalized.EndsWith(".Beh.stVal", StringComparison.Ordinal) ||
normalized.EndsWith(".Health.stVal", StringComparison.Ordinal));
}

private static int InitialPriority(
Expand Down
86 changes: 86 additions & 0 deletions tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,90 @@ public void BrowserReadPlanAddsSafeControlTargetsOnlyForExplicitSelection()
"GoCB/SCL", "SCL", "GoCB", 3, Block("GSEControl", "GO", "BCU",
status: "Model", fields: ["GoEna"])), readAll: false));
}
[Fact]
public void FullLiveInitialHydrationIncludesOnlyVerifiedReadOnlySgcbStatus()
{
var verified = Block(fields: ["NumOfSG", "ActSG", "LActTm", "EditSG", "CnfEdit"]);
var model = new LiveIedModelDiscoveryDocument
{
LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
SettingGroupControls = [verified]
};
var values = new IedBrowserLiveValues();
var session = values.BeginAssociation();
var plan = IedBrowserReadPlan.BuildInitialPriority(model, values, 32);
Assert.Equal(3, plan.Count);
Assert.True(plan[0].Reference.EndsWith(".ActSG", StringComparison.Ordinal));
Assert.True(plan[1].Reference.EndsWith(".NumOfSG", StringComparison.Ordinal));
Assert.True(plan[2].Reference.EndsWith(".LActTm", StringComparison.Ordinal));
Assert.All(plan, target => Assert.Equal("SP", target.FunctionalConstraint));
Assert.Contains(plan, target => target.Reference.EndsWith(".ActSG", StringComparison.Ordinal));
Assert.DoesNotContain(plan, target => target.Reference.Contains("EditSG", StringComparison.Ordinal) ||
target.Reference.Contains("CnfEdit", StringComparison.Ordinal));

Assert.True(values.PublishForAssociation(session, "LD/LLN0.SGCB.ActSG",
"SP", "1", "", "", "MMS Read"));
Assert.Equal(2, IedBrowserReadPlan.BuildInitialPriority(model, values, 32).Count);
Assert.Single(IedBrowserReadPlan.BuildInitialPriority(model, values, 1));
values.BeginAssociation();
Assert.Equal(3, IedBrowserReadPlan.BuildInitialPriority(model, values, 32).Count);
}

[Fact]
public void InitialSgcbPlanRejectsSclOnlyAndAmbiguousInventory()
{
var valid = Block(fields: ["NumOfSG", "ActSG"]);
var model = new LiveIedModelDiscoveryDocument
{
LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
SettingGroupControls = [valid, valid,
Block(status: "SCL", fields: ["LActTm"])]
};
var ledger = new IedBrowserLiveValues();
ledger.BeginAssociation();
Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32));
}

[Fact]
public void FullLiveHydrationIncludesOnlyExactGoCbReadOnlyState()
{
var goose = Block("GSEControl", "GO", "BCU",
fields: ["GoEna", "NdsCom", "DatSet", "ConfRev"]);
var model = new LiveIedModelDiscoveryDocument
{
LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
GooseControlBlocks = [goose]
};
var ledger = new IedBrowserLiveValues();
var association = ledger.BeginAssociation();
var plan = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32);
Assert.Equal(2, plan.Count);
Assert.All(plan, target => Assert.Equal("GO", target.FunctionalConstraint));
Assert.Contains(plan, target => target.Reference.EndsWith(".GoEna", StringComparison.Ordinal));
Assert.Contains(plan, target => target.Reference.EndsWith(".NdsCom", StringComparison.Ordinal));
Assert.DoesNotContain(plan, target => target.Reference.EndsWith(".DatSet", StringComparison.Ordinal));

Assert.True(ledger.PublishForAssociation(association,
"LD/LLN0.BCU.GoEna", "GO", "true", "", "", "MMS Read"));
Assert.Single(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32));
ledger.BeginAssociation();
Assert.Equal(2, IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32).Count);
}

[Fact]
public void InitialGoCbStatusNeverBorrowsSclOnlyOrDuplicateControlIdentity()
{
var valid = Block("GSEControl", "GO", "BCU", fields: ["GoEna", "NdsCom"]);
var model = new LiveIedModelDiscoveryDocument
{
LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
GooseControlBlocks = [valid, valid,
Block("GSEControl", "GO", "OTHER", status: "SCL",
fields: ["GoEna"])]
};
var ledger = new IedBrowserLiveValues();
ledger.BeginAssociation();
Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace ARSAS.Tests;
public sealed class M214BrowserCollapsibleReportSectionsTests
{
[Fact]
public void RcbSections_AreExpandedByDefault_AndOperatorMayCollapseIndependently()
public void RcbFlagsCollapseWithControlBlockWhileLastReportRemainsIndependent()
{
var source = new[]
{
Expand All @@ -29,7 +29,7 @@ public void RcbSections_AreExpandedByDefault_AndOperatorMayCollapseIndependently
row.Kind == "Section" && row.Name == "Information received in last Report");
Assert.True(controls.IsExpanded);
Assert.True(received.IsExpanded);
Assert.Equal(2, controls.GetChildren().Count);
Assert.Equal(4, controls.GetChildren().Count);
Assert.Equal(2, received.GetChildren().Count);
model.Toggle(controls);
Assert.False(controls.IsExpanded);
Expand All @@ -38,7 +38,7 @@ public void RcbSections_AreExpandedByDefault_AndOperatorMayCollapseIndependently
Assert.Contains(model.Rows, row => row.Name == "Data");
model.Toggle(received);
Assert.DoesNotContain(model.Rows, row => row.Name == "Received frame count");
Assert.Contains(model.Rows, row => row.Name == "Trigger options");
Assert.DoesNotContain(model.Rows, row => row.Name == "Trigger options");
model.Toggle(controls);
model.Toggle(received);
Assert.Equal(source.Length, model.Rows.Count);
Expand Down
Loading
Loading