Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -605,8 +605,10 @@ void DrainReadResults()
_iedBrowserReadFailureEvidence = new IedBrowserReadFailureEvidence(sourceDevice);
var readProgress = new Progress<IedBrowserReadProgress>(p =>
{
// Progress belongs to the device that owns this read flight,
// not whichever IED card the operator happens to select now.
// The flight token and association still fence late callbacks.
if (ReferenceEquals(_iedBrowserReadOperation, cts) &&
ReferenceEquals(SelectedDevice, sourceDevice) &&
!cts.IsCancellationRequested &&
sourceDevice.LiveValues.Association == readAssociation)
sourceDevice.ApplyBrowserReadProgress(p.Completed, p.Total);
Expand Down
14 changes: 13 additions & 1 deletion MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,17 @@
<ContextMenu/>
</TextBlock.ContextMenu>
</TextBlock>
<!-- Permanent 7px slot: Browser Read never resizes the IED card
or overlays its relay icon, IP and action bar. -->
<Grid Height="7" Margin="0,3,0,0"
VerticalAlignment="Center" ClipToBounds="True">
<ProgressBar x:Name="BrowserReadProgressLine" Height="3"
Minimum="0" Maximum="100"
Value="{Binding BrowserReadProgressPercent, Mode=OneWay}"
Style="{StaticResource DiscoveryProgressBar}"
Opacity="0" VerticalAlignment="Center"
ToolTip="{Binding BusyStage}"/>
</Grid>
</StackPanel>
<!-- Adaptive five-slot action bar. It spans the card and reserves the relay-icon area so Play/Stop never clip. -->
<Grid Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Margin="62,4,1,0" Panel.ZIndex="10">
Expand Down Expand Up @@ -481,7 +492,8 @@
<Setter TargetName="BusyOverlay" Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsBrowserReading}" Value="True">
<Setter TargetName="BusyOverlay" Property="Visibility" Value="Visible"/>
<!-- Opacity does not affect measure/arrange: zero card jiggle. -->
<Setter TargetName="BrowserReadProgressLine" Property="Opacity" Value="1"/>
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
Expand Down
15 changes: 14 additions & 1 deletion Models/IedBrowserReadPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,20 @@ public static IedBrowserReadSelectionKind SelectionKind(IedBrowserTreeRow? page)
_ => IedBrowserReadSelectionKind.FcAttributes
};

// The live MMS model may declare status-only substitution (SV), operation
// supervision (OR), and blocking (BL) leaves. They are safe to *read*
// explicitly, never to operate or modify. FC CO remains excluded.
private static readonly HashSet<string> ReadableFc = new(
["ST", "MX", "CF", "DC", "SP", "SG", "SE", "EX"], StringComparer.OrdinalIgnoreCase);
["ST", "MX", "CF", "DC", "SP", "SG", "SE", "EX", "SV", "OR", "BL"],
StringComparer.OrdinalIgnoreCase);

// A passive view may read existing safe FCs, but never implicitly probe
// substitution, operation supervision or blocking. One authoritative
// readable-FC catalog is shared with explicit operator Read.
private static bool RequiresExplicitRead(string? fc)
=> string.Equals(fc, "SV", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fc, "OR", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fc, "BL", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// FC CO denotes command parameters, not scalar MMS Read targets.
Expand Down Expand Up @@ -150,6 +162,7 @@ public static IReadOnlyList<IedBrowserReadTarget> BuildFocused(
// Do not publish another reader's result as though this association
// observed it. Existing Report/MMS Read evidence is always reused.
var ordered = metadata.Concat(candidates)
.Where(target => !RequiresExplicitRead(target.FunctionalConstraint))
.Where(target => !ledger.TryGet(target.Reference,
target.FunctionalConstraint, out _))
// Failed targets must not monopolize a subsequent bounded SGCB
Expand Down
28 changes: 24 additions & 4 deletions Models/MonitorModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject
private bool _isStopping;
private bool _isMonitoring;
private bool _isBrowserReading;
private double _browserReadProgressPercent;
private bool _isActive;
private bool _hasReportStream;
private bool _isDemo;
Expand Down Expand Up @@ -392,7 +393,22 @@ public bool IsStopping
public bool IsBrowserReading
{
get => _isBrowserReading;
set => Set(ref _isBrowserReading, value);
set
{
// Every explicit or bounded focused sweep starts at zero, without
// resetting discovery telemetry or reallocating a UI overlay.
if (value && !_isBrowserReading)
BrowserReadProgressPercent = 0d;
Set(ref _isBrowserReading, value);
}
}

// Presentation-only Browser Read progress, isolated from discovery.
// The IED card reserves its thin progress slot even while hidden.
public double BrowserReadProgressPercent
{
get => _browserReadProgressPercent;
private set => Set(ref _browserReadProgressPercent, Math.Clamp(value, 0d, 100d));
}

public bool IsMonitoring
Expand Down Expand Up @@ -484,12 +500,16 @@ public string DiscoveryProgressPercentText

public void ApplyBrowserReadProgress(int completed, int total)
{
if (total <= 0) return;
if (total <= 0 || !IsBrowserReading) return;
var processed = Math.Clamp(completed, 0, total);
var percent = Math.Round(100d * processed / total, 0,
MidpointRounding.AwayFromZero);
// Thousands of leaf Read results must not trigger thousands of WPF
// property notifications. One update per integer percent plus final.
if (BrowserReadProgressPercent == percent && processed != total) return;
BrowserReadProgressPercent = percent;
BusyStage = $"Read-only MMS values · {processed:N0} / {total:N0}";
_discoveryProgressTargetPercent = 100d * processed / total;
DiscoveryProgressStepText = $"Value {processed:N0} of {total:N0}";
DiscoveryProgressPercentText = $"{DiscoveryProgressPercent:0}%";
}

public void ResetDiscoveryProgress(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
using System.Xml.Linq;
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

public sealed class P0GBrowserSubstitutionReadAndIedCardProgressTests
{
private const string LnRef = "SIEBCUCTRL/XCBR1";

private static LiveIedDataAttributeModel Attribute(
string doName, string leaf, string fc, bool wireKnown = true) => new()
{
ObjectReference = LnRef + "." + doName,
AttributePath = doName + "." + leaf,
FunctionalConstraint = fc,
MmsReference = wireKnown
? LnRef + "$" + fc + "$" + doName + "$" + leaf : "",
SclBType = leaf == "subVal" ? "Dbpos" :
leaf == "subQ" ? "Quality" :
leaf == "tOpOk" ? "Timestamp" : "Boolean",
TypeDiscoveryStatus = "Exact"
};

private static (LiveIedModelDiscoveryDocument Model, IedBrowserTreeRow Page)
XcbrPage()
{
var ln = new LiveIedLogicalNodeModel
{
Name = "XCBR1",
LnClass = "XCBR",
DataObjects =
[
new LiveIedDataObjectModel
{
Name = "Pos", Reference = LnRef + ".Pos",
Attributes =
[
Attribute("Pos", "stVal", "ST"),
Attribute("Pos", "subEna", "SV"),
Attribute("Pos", "subVal", "SV"),
Attribute("Pos", "subQ", "SV"),
Attribute("Pos", "subID", "SV"),
Attribute("Pos", "opRcvd", "OR"),
Attribute("Pos", "opOk", "OR"),
Attribute("Pos", "tOpOk", "OR"),
Attribute("Pos", "blkEna", "BL"),
Attribute("Pos", "Oper", "CO"),
Attribute("Pos", "notVerified", "SV", wireKnown: false)
]
}
]
};
var model = new LiveIedModelDiscoveryDocument
{
LogicalDevices =
[
new LiveIedLogicalDeviceModel
{
MmsDomain = "SIEBCUCTRL",
LogicalNodes = [ln]
}
]
};
return (model, new IedBrowserTreeRow(
"SIEBCUCTRL/XCBR1", "XCBR1", "LN", 2, ln));
}

[Fact]
public void ExplicitReadLnIncludesModelVerifiedSubstitutionOperationAndBlocking_WithoutControl()
{
var (model, page) = XcbrPage();
var selected = IedBrowserReadPlan.Build(model, page, readAll: false);
foreach (var (leaf, fc) in new[]
{
("subEna", "SV"), ("subVal", "SV"), ("subQ", "SV"),
("subID", "SV"), ("opRcvd", "OR"), ("opOk", "OR"),
("tOpOk", "OR"), ("blkEna", "BL")
})
{
Assert.Contains(selected, target =>
target.Reference == LnRef + "$" + fc + "$Pos$" + leaf &&
target.FunctionalConstraint == fc);
}
Assert.Contains(selected, target => target.FunctionalConstraint == "ST");
Assert.DoesNotContain(selected, target =>
target.FunctionalConstraint == "CO" ||
target.Reference.Contains("notVerified", StringComparison.Ordinal));
var ordered = IedBrowserReadPlan.PrioritizeExplicitLn(selected);
Assert.Equal(selected.Count, ordered.Count);
}

[Fact]
public void ExplicitReadAllKeepsModelTypesAndDoesNotAdmitSclOnlyOrControl()
{
var (model, page) = XcbrPage();
var all = IedBrowserReadPlan.Build(model, page, readAll: true);
Assert.Contains(all, target =>
target.Reference == LnRef + "$SV$Pos$subVal" &&
target.FunctionalConstraint == "SV" &&
target.DataType == "Dbpos");
Assert.Contains(all, target =>
target.Reference == LnRef + "$SV$Pos$subQ" &&
target.DataType == "Quality");
Assert.Contains(all, target => target.FunctionalConstraint == "OR");
Assert.Contains(all, target => target.FunctionalConstraint == "BL");
Assert.DoesNotContain(all, target =>
target.FunctionalConstraint == "CO" ||
target.Reference.Contains("notVerified", StringComparison.Ordinal));
}

[Fact]
public void PassiveNavigationDoesNotAutomaticallyReadSubstitutionOrBlockingOrOperationState()
{
var (model, page) = XcbrPage();
var ledger = new IedBrowserLiveValues();
ledger.BeginAssociation();
var passive = IedBrowserReadPlan.BuildFocused(model, page, ledger);
Assert.Contains(passive, target => target.FunctionalConstraint == "ST");
Assert.DoesNotContain(passive, target =>
target.FunctionalConstraint is "SV" or "OR" or "BL" or "CO");
}

[Fact]
public void ProgressLineHasFixedSpaceBelowIpAndReadNeverRevealsFullCardOverlay()
{
var xaml = XDocument.Load(FindFile("MainWindow.xaml"));
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
var progress = xaml.Descendants().Single(e =>
(string?)e.Attribute(x + "Name") == "BrowserReadProgressLine");
Assert.Equal("ProgressBar", progress.Name.LocalName);
Assert.Equal("3", (string?)progress.Attribute("Height"));
Assert.Equal("0", (string?)progress.Attribute("Opacity"));
Assert.Contains("BrowserReadProgressPercent", (string?)progress.Attribute("Value"));
var reserved = progress.Parent!;
Assert.Equal("Grid", reserved.Name.LocalName);
Assert.Equal("7", (string?)reserved.Attribute("Height"));
var ip = reserved.Parent!.Elements().Single(e =>
(string?)e.Attribute("Text") == "{Binding EndpointText}");
Assert.Equal("TextBlock", ip.Name.LocalName);
var readTrigger = xaml.Descendants().Single(e =>
e.Name.LocalName == "DataTrigger" &&
(string?)e.Attribute("Binding") == "{Binding IsBrowserReading}");
var setter = Assert.Single(readTrigger.Elements());
Assert.Equal("BrowserReadProgressLine", (string?)setter.Attribute("TargetName"));
Assert.Equal("Opacity", (string?)setter.Attribute("Property"));
Assert.Equal("1", (string?)setter.Attribute("Value"));
Assert.DoesNotContain(readTrigger.Descendants(), e =>
(string?)e.Attribute("TargetName") == "BusyOverlay");
}

[Fact]
public void ProgressIsPerDeviceResetsBetweenSweepsAndDoesNotAffectDiscoveryState()
{
var device = new Iec61850MonitorDevice();
device.IsBrowserReading = true;
device.ApplyBrowserReadProgress(3, 4);
Assert.Equal(75d, device.BrowserReadProgressPercent);
device.IsBrowserReading = false;
device.IsBrowserReading = true;
Assert.Equal(0d, device.BrowserReadProgressPercent);
device.ApplyBrowserReadProgress(4, 4);
Assert.Equal(100d, device.BrowserReadProgressPercent);
device.IsBrowserReading = false;
device.ApplyBrowserReadProgress(1, 2);
Assert.Equal(100d, device.BrowserReadProgressPercent);
}

[Fact]
public void ReadProgressFollowsOwningAssociationWhenOperatorSelectsAnotherIed()
{
var source = File.ReadAllText(FindFile("MainWindow.IedBrowser.cs"));
var callback = source.Split(
"var readProgress = new Progress<IedBrowserReadProgress>(p =>",
StringSplitOptions.None)[1].Split(
"if (device.IsMonitoring)", StringSplitOptions.None)[0];
Assert.Contains("sourceDevice.LiveValues.Association == readAssociation", callback);
Assert.Contains("ReferenceEquals(_iedBrowserReadOperation, cts)", callback);
Assert.DoesNotContain("ReferenceEquals(SelectedDevice, sourceDevice)", callback);
Assert.Contains("sourceDevice.ApplyBrowserReadProgress", callback);
}

private static string FindFile(string name)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var path = Path.Combine(dir.FullName, name);
if (File.Exists(path)) return path;
dir = dir.Parent;
}
throw new FileNotFoundException(name);
}
}
Loading