Skip to content
Merged
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
90 changes: 90 additions & 0 deletions src/AR.Iec61850/Discovery/LiveIedDataSetTypeProbePlanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using AR.Iec61850.Mms;

namespace AR.Iec61850.Discovery;

/// <summary>
/// Builds bounded typed-LN hints from live MMS DataSet directories. A successful
/// DataSet directory is independent evidence when GetNameList stopped early.
/// No SCL, guessed LN names, values, or extra MMS name scans are used.
/// </summary>
public static class LiveIedDataSetTypeProbePlanner
{
public static IReadOnlyList<MmsFcResolvedPoint> BuildVerifiedMemberHints(
MmsDiscoveryResult discovery,
int maxExtraLogicalNodes = 48,
int maxMemberHints = 2048)
{
ArgumentNullException.ThrowIfNull(discovery);
var extraLimit = Math.Clamp(maxExtraLogicalNodes, 0, 256);
var pointLimit = Math.Clamp(maxMemberHints, 0, 8192);
if (pointLimit == 0)
return Array.Empty<MmsFcResolvedPoint>();

// Only domains returned by this association can authorize probes.
var domains = discovery.Snapshot.DomainVariables.Keys
.Where(domain => !string.IsNullOrWhiteSpace(domain))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (domains.Count == 0)
return Array.Empty<MmsFcResolvedPoint>();

static string RootKey(string domain, string ln) => domain + "\u001f" + ln;
var knownRoots = discovery.IedDirectory.LogicalDevices.Values
.SelectMany(ld => ld.LogicalNodes.Keys.Select(ln => RootKey(ld.Name, ln)))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var newRoots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var addedReferences = new HashSet<string>(StringComparer.Ordinal);
var results = new List<MmsFcResolvedPoint>();

// ST first (event / control feedback), then MX; stable DataSet and
// membership order within each priority tier.
var liveMembers = discovery.DataSetDirectories
.Where(directory => directory.IsSuccess)
.OrderBy(directory => directory.DataSetReference, StringComparer.OrdinalIgnoreCase)
.SelectMany(directory => directory.Members)
.OrderBy(member => string.Equals(member.FunctionalConstraint, "ST",
StringComparison.OrdinalIgnoreCase) ? 0 : 1);

foreach (var member in liveMembers)
{
if (results.Count >= pointLimit)
break;
if (!domains.Contains(member.Domain) ||
string.IsNullOrWhiteSpace(member.MmsItemName) ||
!(string.Equals(member.FunctionalConstraint, "ST", StringComparison.OrdinalIgnoreCase) ||
string.Equals(member.FunctionalConstraint, "MX", StringComparison.OrdinalIgnoreCase)))
continue;

// Do not accept a fabricated LN, incorrect FC, or ambiguous DO.
if (!MmsIedModelDirectoryBuilder.TryParseLiveMmsVariable(
member.Domain, member.MmsItemName, out var point) ||
!string.Equals(point.LogicalNode, member.LogicalNode, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(point.FunctionalConstraint, member.FunctionalConstraint,
StringComparison.OrdinalIgnoreCase) ||
!string.Equals(point.DataObjectPath, member.DataObjectPath,
StringComparison.OrdinalIgnoreCase))
continue;

var root = RootKey(point.Domain, point.LogicalNode);
if (!knownRoots.Contains(root) && !newRoots.Contains(root))
{
if (newRoots.Count >= extraLimit)
continue;
newRoots.Add(root);
}

if (addedReferences.Add(point.MmsReference))
results.Add(new MmsFcResolvedPoint
{
Domain = point.Domain,
LogicalNode = point.LogicalNode,
FunctionalConstraint = point.FunctionalConstraint,
DataObjectPath = point.DataObjectPath,
MmsItemName = point.MmsItemName,
Source = "LiveMmsDataSetDirectoryTypeHint",
Confidence = member.Confidence
});
}

return results;
}
}
23 changes: 23 additions & 0 deletions src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,29 @@ public static IReadOnlyList<MmsObjectReference> BuildLogicalNodeRootCandidates(M
/// </summary>
public static class LiveIedVariableTypeProbeExecutor
{
/// <summary>
/// Optional DataSet-first late-root recovery from successful live MMS directory
/// evidence. Retains current single-pass LN hierarchy probing and existing
/// negotiated-window policies; never changes the legacy directory overload.
/// </summary>
public static Task<IReadOnlyList<MmsVariableAccessAttributesResult>> ProbeSmartAsync(
MmsClientSession session,
MmsDiscoveryResult discovery,
MmsSmartDiscoveryOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(discovery);
options ??= new MmsSmartDiscoveryOptions();

var verified = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(
discovery,
options.MaxDataSetTypeExtraLogicalNodes,
options.MaxDataSetTypeMemberHints);
discovery.IedDirectory.AddSupplementalPoints(verified);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Promote late roots only after a successful type probe

When a DataSet contains a stale or unavailable member, or the subsequent GVA fails because of a negative response, disconnect, or cancellation, this mutates discovery.IedDirectory before any type result is verified. The new LN and point therefore remain in the canonical directory and can be consumed by export and InitialFcReadPlanner.FromLiveDirectory even though probing never confirmed them. The existing ProbeDataSetMemberTypesSmartAsync path deliberately adds membership seeds only for successful responses with non-null TypeSpecification; this overload should preserve that gate rather than publishing the hints before executing the probes.

Useful? React with 👍 / 👎.

return ProbeSmartAsync(session, discovery.IedDirectory, options, cancellationToken);
}

public static Task<IReadOnlyList<MmsVariableAccessAttributesResult>> ProbeSmartAsync(
MmsClientSession session,
MmsIedModelDirectory directory,
Expand Down
9 changes: 9 additions & 0 deletions src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ public sealed class MmsSmartDiscoveryOptions

public bool ReadDataSetDirectories { get; init; }
public int MaxDataSetDirectoryReads { get; init; } = 64;

/// <summary>
/// Limit on additional live DataSet-verified logical-node roots in the opt-in
/// type-probe overload (no additional GetNameList pages or cyclic polls).
/// </summary>
public int MaxDataSetTypeExtraLogicalNodes { get; init; } = 48;

/// <summary>Hard cap on admitted positional DataSet member type hints.</summary>
public int MaxDataSetTypeMemberHints { get; init; } = 2048;
}

public sealed partial class MmsClientSession
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Mms;

namespace AR.Iec61850.Tests.Discovery;

public sealed class LiveIedDataSetTypeProbePlannerTests
{
[Fact]
public void TruncatedNameList_StillAdmitsVerifiedStaticFeedbackRoots()
{
var discovery = Sample();
Assert.Equal(1, discovery.IedDirectory.LogicalNodeCount);
var hints = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(discovery);

Assert.Contains(hints, x => x.LogicalNode == "eveGGIO1" && x.MmsItemName == "eveGGIO1$ST$Ind11");
Assert.Contains(hints, x => x.LogicalNode == "CSWI16" && x.MmsItemName == "CSWI16$ST$Pos");
Assert.Contains(hints, x => x.LogicalNode == "MMXU1" && x.MmsItemName == "MMXU1$MX$A");
Assert.DoesNotContain(hints, x => x.LogicalNode is "UNTRUSTED" or "BADFC" or "BADLN");

// The MMXU membership was already enumerated and must not be duplicated.
Assert.Equal(2, discovery.IedDirectory.AddSupplementalPoints(hints));
Assert.Equal(0, discovery.IedDirectory.AddSupplementalPoints(hints));
var roots = LiveIedVariableTypeProbePlanner.BuildLogicalNodeRootCandidates(discovery.IedDirectory);
Assert.Equal(3, roots.Count);
Assert.Contains(roots, root => root.Item == "CSWI16");
Assert.Contains(roots, root => root.Item == "eveGGIO1");
}

[Fact]
public void Budget_BoundsMissingRoots_AndDeduplicatesExactMemberships()
{
var discovery = Sample();
var hints = LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(
discovery, maxExtraLogicalNodes: 1, maxMemberHints: 16);

Assert.Contains(hints, x => x.LogicalNode == "MMXU1");
Assert.Contains(hints, x => x.LogicalNode == "CSWI16");
Assert.DoesNotContain(hints, x => x.LogicalNode == "eveGGIO1");
Assert.Equal(hints.Count, hints.Select(x => x.MmsReference)
.Distinct(StringComparer.Ordinal).Count());
Assert.Empty(LiveIedDataSetTypeProbePlanner.BuildVerifiedMemberHints(
discovery, maxExtraLogicalNodes: 0, maxMemberHints: 0));
}

private static MmsDiscoveryResult Sample()
{
var snapshot = new MmsDiscoverySnapshot
{
DomainVariables = new Dictionary<string, IReadOnlyList<string>>
{
["IEDLD0"] = ["MMXU1$MX$A"]
}
};
return new MmsDiscoveryResult
{
Snapshot = snapshot,
IedDirectory = MmsIedModelDirectoryBuilder.Build(snapshot),
DataSetDirectories =
[
new MmsDataSetDirectoryResult
{
IsSuccess = true,
DataSetReference = "IEDLD0/LLN0.Analog",
Members =
[
Member("MMXU1$MX$A", "MMXU1", "A", "MX"),
Member("CSWI16$ST$Pos", "CSWI16", "Pos", "ST"),
Member("CSWI16$ST$Pos", "CSWI16", "Pos", "ST"),
Member("eveGGIO1$ST$Ind11", "eveGGIO1", "Ind11", "ST"),
Member("UNTRUSTED$ST$Pos", "UNTRUSTED", "Pos", "ST", "OTHER"),
Member("BADFC$MX$A", "BADFC", "A", "ST")
]
},
new MmsDataSetDirectoryResult
{
IsSuccess = false,
DataSetReference = "IEDLD0/LLN0.Bad",
Members = [Member("BADLN$ST$Pos", "BADLN", "Pos", "ST")]
}
]
};
}

private static MmsDataSetDirectoryMember Member(string name, string ln, string path,
string fc, string domain = "IEDLD0")
=> new()
{
Domain = domain, MmsItemName = name, LogicalNode = ln,
DataObjectPath = path, FunctionalConstraint = fc, Confidence = 100
};
}
Loading