Skip to content
134 changes: 134 additions & 0 deletions src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
namespace AR.Iec61850.Mms;

public sealed partial class MmsClientSession
{
public async Task<MmsRcbAvailabilityResult> CheckReportControlAvailabilityAsync(
MmsReportInventory inventory,
MmsIedModelDirectory? directory = null,
MmsRcbAvailabilityOptions? options = null,
CancellationToken cancellationToken = default)
{
EnsureMmsReady();
ArgumentNullException.ThrowIfNull(inventory);
options ??= new MmsRcbAvailabilityOptions();

var checkedAt = DateTimeOffset.UtcNow;
var warnings = new List<string>();
var snapshots = new List<MmsRcbAvailabilitySnapshot>();
var dataSetDirectories = new Dictionary<string, MmsDataSetDirectoryResult>(StringComparer.OrdinalIgnoreCase);
var callerOwned = options.CallerOwnedRcbReferences
.Select(MmsRcbAvailabilityEvaluator.NormalizeReference)
.Where(reference => reference.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var max = Math.Clamp(options.MaxReportControls, 1, 4096);
var candidates = inventory.ReportControls
.OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference))
.ThenByDescending(candidate => candidate.Buffered)
.ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase)
.ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase)
.ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase)
.Take(max)
.ToArray();

if (inventory.ReportControls.Count > candidates.Length)
warnings.Add($"Availability check was bounded to {candidates.Length} of {inventory.ReportControls.Count} discovered RCBs.");

foreach (var source in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var candidate = CloneReportControl(source);
await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false);
await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false);

MmsDataSetDirectoryResult? dataSetDirectory = null;
var dataSetReference = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference);
if (options.ReadDataSetDirectories && dataSetReference.Length > 0)
{
if (!dataSetDirectories.TryGetValue(dataSetReference, out dataSetDirectory))
{
dataSetDirectory = await GetDataSetDirectoryAsync(candidate.DataSetReference, directory, cancellationToken).ConfigureAwait(false);
dataSetDirectories[dataSetReference] = dataSetDirectory;
}
}

snapshots.Add(MmsRcbAvailabilityEvaluator.Evaluate(
candidate,
dataSetDirectory,
callerOwned.Contains(MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)),
checkedAt));
}

return new MmsRcbAvailabilityResult
{
CheckedAtUtc = checkedAt,
ReportControls = snapshots
.OrderByDescending(item => item.DataSetMemberCount > 0)
.ThenBy(item => AvailabilityRank(item.Availability))
.ThenByDescending(item => item.Buffered)
.ThenBy(item => item.Reference, StringComparer.OrdinalIgnoreCase)
.ToArray(),
Warnings = warnings
};
}

private async Task ProbeOwnerReadOnlyAsync(
MmsReportControlCandidate candidate,
CancellationToken cancellationToken)
{
try
{
var reference = MmsObjectReference.Parse($"{candidate.Reference}.Owner", candidate.FunctionalConstraint);
var read = await ReadSingleVariableAsync(reference, cancellationToken).ConfigureAwait(false);
if (read.IsSuccess)
{
candidate.Owner = NormalizeReportAttributeText(read.Value);
candidate.ProbeDiagnostics.Add($"Owner item={reference.Item}: OK {MmsDataValueRenderer.ToCompactString(read.Value)}");
}
else
{
candidate.ProbeDiagnostics.Add($"Owner item={reference.Item}: {read.Message}");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
candidate.ProbeDiagnostics.Add($"Owner: unsupported or unreadable ({ex.GetType().Name}: {ex.Message})");
}
}

private static MmsReportControlCandidate CloneReportControl(MmsReportControlCandidate source)
=> new()
{
Domain = source.Domain,
LogicalNode = source.LogicalNode,
FunctionalConstraint = source.FunctionalConstraint,
Name = source.Name,
Reference = source.Reference,
Buffered = source.Buffered,
DataSetReference = source.DataSetReference,
ReportId = source.ReportId,
ConfRev = source.ConfRev,
IntegrityPeriodMs = source.IntegrityPeriodMs,
EnabledState = source.EnabledState,
ReservationState = source.ReservationState,
ReservationTimeSeconds = source.ReservationTimeSeconds,
Owner = source.Owner,
BufferTimeMs = source.BufferTimeMs,
TriggerOptions = source.TriggerOptions,
OptionalFields = source.OptionalFields,
Status = source.Status,
Attributes = source.Attributes.ToList()
};

private static int AvailabilityRank(MmsRcbOperationalAvailability availability)
=> availability switch
{
MmsRcbOperationalAvailability.Available => 0,
MmsRcbOperationalAvailability.UsedByCaller => 1,
MmsRcbOperationalAvailability.InUse => 2,
MmsRcbOperationalAvailability.Unknown => 3,
MmsRcbOperationalAvailability.DataSetUnreadable => 4,
MmsRcbOperationalAvailability.DataSetEmpty => 5,
MmsRcbOperationalAvailability.NoDataSet => 6,
_ => 9
};
}
242 changes: 242 additions & 0 deletions src/AR.Iec61850/Mms/MmsRcbAvailability.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
namespace AR.Iec61850.Mms;

public enum MmsRcbOperationalAvailability
{
Available,
InUse,
UsedByCaller,
Unknown,
NoDataSet,
DataSetEmpty,
DataSetUnreadable
}

public enum MmsRcbAvailabilityConfidence
{
Exact,
Reduced,
Unknown
}

public sealed class MmsRcbAvailabilityOptions
{
public int MaxReportControls { get; init; } = 512;
public bool ReadDataSetDirectories { get; init; } = true;
public IReadOnlySet<string> CallerOwnedRcbReferences { get; init; }
= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}

public sealed class MmsRcbAvailabilitySnapshot
{
public DateTimeOffset CheckedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public string Reference { get; init; } = string.Empty;
public string Domain { get; init; } = string.Empty;
public string LogicalNode { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
public bool Buffered { get; init; }
public string DataSetReference { get; init; } = string.Empty;
public string ReportId { get; init; } = string.Empty;
public string ConfRev { get; init; } = string.Empty;
public string EnabledState { get; init; } = string.Empty;
public string ReservationState { get; init; } = string.Empty;
public string ReservationTimeSeconds { get; init; } = string.Empty;
public string Owner { get; init; } = string.Empty;
public bool DataSetDirectoryRead { get; init; }
public bool DataSetDirectorySuccess { get; init; }
public int DataSetMemberCount { get; init; }
public MmsRcbOperationalAvailability Availability { get; init; }
public MmsRcbAvailabilityConfidence Confidence { get; init; } = MmsRcbAvailabilityConfidence.Unknown;
public string Reason { get; init; } = string.Empty;
public IReadOnlyList<string> ProbeDiagnostics { get; init; } = Array.Empty<string>();

public bool IsSelectable => Availability is MmsRcbOperationalAvailability.Available or MmsRcbOperationalAvailability.UsedByCaller;
public string Type => Buffered ? "Buffered" : "Unbuffered";
public string Summary => $"{Reference}: {Availability} ({Confidence}) - {Reason}";
}

public sealed class MmsRcbAvailabilityResult
{
public DateTimeOffset CheckedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public IReadOnlyList<MmsRcbAvailabilitySnapshot> ReportControls { get; init; } = Array.Empty<MmsRcbAvailabilitySnapshot>();
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();

public int AvailableCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Available);
public int InUseCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.InUse);
public int UnknownCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Unknown);
public string Summary => $"RCB availability checked: total={ReportControls.Count}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}.";
}

public static class MmsRcbAvailabilityEvaluator
{
public static MmsRcbAvailabilitySnapshot Evaluate(
MmsReportControlCandidate candidate,
MmsDataSetDirectoryResult? dataSetDirectory,
bool callerOwned,
DateTimeOffset? checkedAtUtc = null)
{
ArgumentNullException.ThrowIfNull(candidate);

var enabled = ParseBool(candidate.EnabledState);
var reserved = ParseBool(candidate.ReservationState);
var reservationSeconds = ParseUnsigned(candidate.ReservationTimeSeconds);
var hasOwner = HasOwner(candidate.Owner);
var hasDataSet = !string.IsNullOrWhiteSpace(candidate.DataSetReference);
var directoryRead = dataSetDirectory is not null;
var directorySuccess = dataSetDirectory?.IsSuccess == true;
var memberCount = dataSetDirectory?.Members.Count ?? 0;

var availability = MmsRcbOperationalAvailability.Unknown;
var confidence = MmsRcbAvailabilityConfidence.Unknown;
var reason = "Runtime ownership state could not be proven from the exposed RCB attributes.";

if (callerOwned)
{
availability = MmsRcbOperationalAvailability.UsedByCaller;
confidence = MmsRcbAvailabilityConfidence.Exact;
reason = "This RCB is active in the caller's current association/session.";
}
else if (enabled == true || reserved == true || reservationSeconds > 0 || hasOwner)
{
availability = MmsRcbOperationalAvailability.InUse;
confidence = MmsRcbAvailabilityConfidence.Exact;
reason = BuildBusyReason(candidate, enabled, reserved, reservationSeconds, hasOwner);
}
else if (!hasDataSet)
{
availability = MmsRcbOperationalAvailability.NoDataSet;
confidence = enabled == false ? MmsRcbAvailabilityConfidence.Exact : MmsRcbAvailabilityConfidence.Reduced;
reason = "The RCB does not reference a static DataSet and cannot be exported as a populated legacy-SAS report block.";
}
else if (directoryRead && !directorySuccess)
{
availability = MmsRcbOperationalAvailability.DataSetUnreadable;
confidence = MmsRcbAvailabilityConfidence.Exact;
reason = string.IsNullOrWhiteSpace(dataSetDirectory?.Message)
? "The referenced DataSet directory could not be read."
: $"The referenced DataSet directory could not be read: {dataSetDirectory.Message}";
}
else if (directorySuccess && memberCount == 0)
{
availability = MmsRcbOperationalAvailability.DataSetEmpty;
confidence = MmsRcbAvailabilityConfidence.Exact;
reason = "The referenced DataSet is empty.";
}
else if (enabled == false && ReservationIsExplicitlyFree(candidate, reserved, reservationSeconds, hasOwner))
{
availability = MmsRcbOperationalAvailability.Available;
confidence = MmsRcbAvailabilityConfidence.Exact;
reason = "RptEna is false, reservation state is explicitly free, and the referenced DataSet is populated.";
}
else if (enabled == false && candidate.Buffered && directorySuccess && memberCount > 0)
{
// Some Edition 1 BRCBs expose RptEna but do not expose Owner/ResvTms.
// Do not convert the missing reservation evidence into a green Available state.
availability = MmsRcbOperationalAvailability.Unknown;
confidence = MmsRcbAvailabilityConfidence.Reduced;
reason = "RptEna is false and the DataSet is populated, but this BRCB does not expose enough reservation evidence to prove availability.";
}
else if (enabled == false && !candidate.Buffered && reserved == null)
{
availability = MmsRcbOperationalAvailability.Unknown;
confidence = MmsRcbAvailabilityConfidence.Reduced;
reason = "RptEna is false, but the URCB Resv state was not returned.";
}

return new MmsRcbAvailabilitySnapshot
{
CheckedAtUtc = checkedAtUtc ?? DateTimeOffset.UtcNow,
Reference = candidate.Reference,
Domain = candidate.Domain,
LogicalNode = candidate.LogicalNode,
Name = candidate.Name,
Mode = candidate.Mode,
Buffered = candidate.Buffered,
DataSetReference = candidate.DataSetReference,
ReportId = candidate.ReportId,
ConfRev = candidate.ConfRev,
EnabledState = candidate.EnabledState,
ReservationState = candidate.ReservationState,
ReservationTimeSeconds = candidate.ReservationTimeSeconds,
Owner = candidate.Owner,
DataSetDirectoryRead = directoryRead,
DataSetDirectorySuccess = directorySuccess,
DataSetMemberCount = memberCount,
Availability = availability,
Confidence = confidence,
Reason = reason,
ProbeDiagnostics = candidate.ProbeDiagnostics.ToArray()
};
}

private static bool ReservationIsExplicitlyFree(
MmsReportControlCandidate candidate,
bool? reserved,
ulong? reservationSeconds,
bool hasOwner)
{
if (hasOwner)
return false;

return candidate.Buffered
? reservationSeconds == 0
: reserved == false;
}

private static string BuildBusyReason(
MmsReportControlCandidate candidate,
bool? enabled,
bool? reserved,
ulong? reservationSeconds,
bool hasOwner)
{
var evidence = new List<string>();
if (enabled == true)
evidence.Add("RptEna=true");
if (reserved == true)
evidence.Add("Resv=true");
if (reservationSeconds > 0)
evidence.Add($"ResvTms={reservationSeconds}");
if (hasOwner)
evidence.Add($"Owner={candidate.Owner}");
return $"The RCB is in use or reserved ({string.Join(", ", evidence)}).";
}

internal static bool? ParseBool(string? value)
{
var text = (value ?? string.Empty).Trim();
if (text.Length == 0 || text == "-")
return null;
if (bool.TryParse(text, out var parsed))
return parsed;
if (text is "1" or "01" || text.Equals("yes", StringComparison.OrdinalIgnoreCase) || text.Equals("on", StringComparison.OrdinalIgnoreCase))
return true;
if (text is "0" or "00" || text.Equals("no", StringComparison.OrdinalIgnoreCase) || text.Equals("off", StringComparison.OrdinalIgnoreCase))
return false;
return null;
}

internal static ulong? ParseUnsigned(string? value)
{
var text = (value ?? string.Empty).Trim();
if (text.Length == 0 || text == "-")
return null;
return ulong.TryParse(text, out var parsed) ? parsed : null;
}

internal static bool HasOwner(string? value)
{
var text = (value ?? string.Empty).Trim();
if (text.Length == 0 || text == "-" || text == "[]" || text.Equals("null", StringComparison.OrdinalIgnoreCase))
return false;

var compact = text.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace(":", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal)
.Replace(" ", string.Empty, StringComparison.Ordinal);
return compact.Length > 0 && compact.Any(character => character != '0');
}

internal static string NormalizeReference(string? reference)
=> (reference ?? string.Empty).Trim().Replace('$', '.');
}
1 change: 1 addition & 0 deletions src/AR.Iec61850/Mms/MmsReportInventory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public sealed class MmsReportControlCandidate
public string EnabledState { get; set; } = string.Empty;
public string ReservationState { get; set; } = string.Empty;
public string ReservationTimeSeconds { get; set; } = string.Empty;
public string Owner { get; set; } = string.Empty;
public string BufferTimeMs { get; set; } = string.Empty;
public string TriggerOptions { get; set; } = string.Empty;
public string OptionalFields { get; set; } = string.Empty;
Expand Down
Loading
Loading