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
12 changes: 9 additions & 3 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1707,7 +1707,13 @@ void Put(string name, string value, string authority)
var wireSlots = _iedBrowserDetails.Rows
.Where(row => row.GooseSlot >= 0 && row.GooseReference.Length == 0)
.ToArray();
var limit = Math.Min(frameView.Leaves.Count, 128);
// Unbound allData is evidence only if the ENTIRE received PDU
// has complete, contiguous wire slots. Do not project a partial
// decoder/model mismatch as real GOOSE values or as FCDA identity.
var unbound = ordered is null
? IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView)
: null;
var limit = Math.Min(unbound?.Count ?? 0, 128);
if (ordered is not null)
{
foreach (var slot in wireSlots)
Expand Down Expand Up @@ -1751,9 +1757,9 @@ _iedBrowserGooseDirectory is { } directory &&
{
if (row.GooseSlot < 0) continue;
if (row.GooseReference.Length == 0 &&
ordered is null && row.GooseSlot < frameView.Leaves.Count)
ordered is null && unbound is not null && row.GooseSlot < unbound.Count)
{
row.Value = frameView.Leaves[row.GooseSlot].Value;
row.Value = unbound[row.GooseSlot].Value;
row.ObservationSource = "GOOSE raw allData · unbound";
row.ObservationTooltip =
$"allData[{row.GooseSlot + 1}] from {frameView.GoCbRef} " +
Expand Down
95 changes: 84 additions & 11 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1822,6 +1822,14 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo

DismissControlShout();
var clickStopwatch = Stopwatch.StartNew();
Guid dispatchedAssociation = Guid.Empty;
bool IsRetiredAssociation() =>
dispatchedAssociation != Guid.Empty &&
!_runtime.IsControlAssociationCurrent(device.DeviceId, dispatchedAssociation);
void LogStaleResult() => AddLog("WARN", device.Name,
$"CONTROL_STALE_RESULT_IGNORED: {signal.ObjectReference}; " +
$"sequence={claim.Sequence}; association={dispatchedAssociation:N}; " +
"no old control result published into the current GUI model.");
signal.ControlLastResult = $"Dispatching {claim.RequestedValue}…";
SetStatus($"{device.Name}: dispatching {signal.Name} = {claim.RequestedValue}…");
AddLog("INFO", device.Name,
Expand Down Expand Up @@ -1849,6 +1857,10 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
}

dispatchedAssociation = _runtime.GetControlAssociationId(device.DeviceId);
if (dispatchedAssociation == Guid.Empty)
throw new InvalidOperationException(
"Control NOT SENT: no current MMS association after connection.");
AddLog("INFO", device.Name,
$"IEC 61850 control execution started: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}; wire send is not assumed until native evidence is returned.");
var result = await _runtime.ExecuteControlAsync(
Expand All @@ -1865,8 +1877,13 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
CommandTerminationTimeoutMs = 10000,
OriginCategory = "StationControl"
},
_applicationCancellation.Token);
_applicationCancellation.Token, dispatchedAssociation);

if (IsRetiredAssociation())
{
LogStaleResult();
return;
}
if (!string.IsNullOrWhiteSpace(result.ControlModelText))
signal.ControlModelText = result.ControlModelText;
if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")
Expand All @@ -1889,12 +1906,22 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (OperationCanceledException)
{
if (IsRetiredAssociation())
{
LogStaleResult();
return;
}
signal.ControlLastResult = "Command cancelled; if Operate was already dispatched, verify actual IED position.";
SetStatus($"{device.Name}: {signal.Name} command cancelled; verify the final process state.");
}
catch (InvalidOperationException ex) when (
ex.Message.Contains("command NOT SENT", StringComparison.OrdinalIgnoreCase))
{
if (IsRetiredAssociation())
{
LogStaleResult();
return;
}
// A rejected local admission is NOT an MMS failure and not an
// UNKNOWN process outcome. Surface the precise busy/stop reason
// instead of the generic "Command failed" control popup.
Expand All @@ -1911,6 +1938,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
(ex.Message.Contains("in progress", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("unresolved", StringComparison.OrdinalIgnoreCase)))
{
if (IsRetiredAssociation())
{
LogStaleResult();
return;
}
signal.ControlLastResult = "Command NOT SENT: " + ex.Message;
AddLog("WARN", device.Name,
$"CONTROL_UI_LOCAL_GATE: {signal.ObjectReference}; {ex.Message}");
Expand All @@ -1921,6 +1953,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (TimeoutException ex)
{
if (IsRetiredAssociation())
{
LogStaleResult();
return;
}
signal.ControlLastResult =
"Control outcome UNKNOWN — MMS flight exceeded deadline. " +
"Verify IED process state and reconnect before another command.";
Expand All @@ -1933,6 +1970,11 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}
catch (Exception ex)
{
if (IsRetiredAssociation())
{
LogStaleResult();
return;
}
signal.ControlLastResult = $"Command failed: {ex.Message}";
AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}");
SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}");
Expand Down Expand Up @@ -2070,19 +2112,37 @@ private async Task ReinspectConfiguredControlAsync(
return;
}

// A successful CF Write/readback proves only ctlModel. A fresh typed
// descriptor + CDC/ctlVal/status verification must gate Operate.
// Successful CF Write/readback proves only ctlModel, not Operate.
// Pin BOTH the native association and the model's epoch before await.
// A late inspection from a retired socket must not revoke, overwrite
// or report success for the replacement association's Command Dock.
var expectedMmsAssociation = _runtime.GetControlAssociationId(device.DeviceId);
if (expectedMmsAssociation == Guid.Empty)
{
SetStatus($"{device.Name}: ctlModel inspection requires a current MMS association.");
return;
}
bool IsCurrentPostWriteInspection() =>
Iec61850ControlInspectionPublication.IsCurrentPostWriteTarget(
expectedMmsAssociation,
token => _runtime.IsControlAssociationCurrent(device.DeviceId, token),
writeAssociation, device.LiveValues.Association,
device.IsConnected, device.Signals.Contains(signal));

signal.ControlInspectionBusy = true;
try
{
// The predicate is evaluated by the facade on the WPF caller
// context BEFORE it mutates SignalDefinition or handles a fault.
// A post-await-only check here would be too late.
var capabilities = await _runtime.InspectControlAsync(
device.DeviceId, signal, _applicationCancellation.Token);
if (!device.IsConnected || writeAssociation != device.LiveValues.Association ||
!device.Signals.Contains(signal))
device.DeviceId, signal, _applicationCancellation.Token,
IsCurrentPostWriteInspection);
if (!IsCurrentPostWriteInspection())
{
signal.ClearLiveControlAuthority();
signal.ControlModelText = "Auto-detect";
SetStatus($"{device.Name}: ctlModel inspection belongs to a replaced association.");
AddLog("WARN", device.Name,
$"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; " +
"an obsolete MMS association cannot publish or revoke current control evidence.");
return;
}
signal.ControlCurrentValue = capabilities.CurrentValue;
Expand All @@ -2098,16 +2158,27 @@ private async Task ReinspectConfiguredControlAsync(
}
catch (OperationCanceledException)
{
if (!IsCurrentPostWriteInspection())
{
AddLog("WARN", device.Name,
$"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; cancelled after association replacement.");
return;
}
signal.ClearLiveControlAuthority();
signal.ControlModelText = "Auto-detect";
SetStatus($"{device.Name}: ctlModel reinspection cancelled; control remains disabled.");
}
catch (Exception ex)
{
if (!IsCurrentPostWriteInspection())
{
AddLog("WARN", device.Name,
$"CTL_MODEL_POSTWRITE_STALE_IGNORED: {signal.ObjectReference}; retired inspection fault {ex.GetType().Name}.");
return;
}
// An incomplete read must never re-enable Operate. Do not
// masquerade as a spinner forever after an actual failure.
signal.ClearLiveControlAuthority();
signal.ControlModelText = "Auto-detect";
signal.ControlModelText = "Verification required";
signal.ControlInspectionEvidence = $"Fresh typed ctlModel inspection failed: {ex.Message}";
signal.ControlLastResult = "Command disabled: verification required.";
Expand All @@ -2117,8 +2188,10 @@ private async Task ReinspectConfiguredControlAsync(
}
finally
{
// Release the original inspection's visual busy hint, but do not
// rebuild live control projections from a retired MMS association.
signal.ControlInspectionBusy = false;
if (device.IsConnected)
if (IsCurrentPostWriteInspection())
{
device.RefreshCommandSignalProjection();
RebuildControlFeedbackIndex(device);
Expand Down
36 changes: 33 additions & 3 deletions Models/IedBrowserGooseInspection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,22 +90,52 @@ public static bool IsFrameCurrent(
return matches.Length == 1 ? matches[0] : null;
}

/// <summary>
/// Admit only a complete and contiguous received allData vector when no
/// exact FCDA directory can be verified. The returned entries remain
/// explicitly unbound: their position never proves an IEC reference or FC.
/// Validate the WHOLE PDU before showing even the first bounded UI rows.
/// </summary>
public static IReadOnlyList<GooseLeafValueSnapshot>? OrderedUnboundWireEntries(
GooseStreamSnapshot frame)
{
ArgumentNullException.ThrowIfNull(frame);
var count = frame.WireDataSetEntryCount;
if (count <= 0 || count != frame.Leaves.Count)
return null;
for (var index = 0; index < count; index++)
{
var leaf = frame.Leaves[index];
if (leaf.DataSetIndex != index || leaf.Order != index + 1 ||
string.IsNullOrWhiteSpace(leaf.Value) ||
leaf.Value == "<missing in frame>")
return null;
}
return frame.Leaves;
}

public static IReadOnlyList<GooseLeafValueSnapshot>? VerifiedMembers(
GooseStreamSnapshot frame, LiveIedDataSetModel directory)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(directory);
var members = directory.Members.OrderBy(x => x.Index).ToArray();
// A cached directory is not evidence for a different received datSet.
// Bind its complete reference before interpreting any FCDA leaf.
if (members.Length == 0 || frame.WireDataSetEntryCount != members.Length ||
frame.Leaves.Count != members.Length ||
members.Select(x => x.Index).Distinct().Count() != members.Length)
string.IsNullOrWhiteSpace(directory.Reference) ||
!string.Equals(Canonical(frame.DataSetReference),
Canonical(directory.Reference), StringComparison.Ordinal))
return null;
// Validate entire ordered vector before exposing any member value.
// Validate the full zero-based directory, one-based presentation order,
// and each exact wire reference/FC before publishing any member value.
for (var slot = 0; slot < members.Length; slot++)
{
var declared = members[slot];
var wire = frame.Leaves[slot];
if (wire.DataSetIndex != slot ||
if (declared.Index != slot || wire.DataSetIndex != slot ||
wire.Order != slot + 1 ||
Canonical(wire.SignalReference) != Canonical(declared.Reference) ||
!string.Equals(wire.FunctionalConstraint, declared.FunctionalConstraint,
StringComparison.OrdinalIgnoreCase) ||
Expand Down
9 changes: 9 additions & 0 deletions Models/IedBrowserReadPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,15 @@ public static IReadOnlyList<IedBrowserReadTarget> BuildInitialPriority(
// 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.
// A syntactically valid SGCB/GoCB from an unrelated MMS domain is
// not an authority for this live association. Reuse one exact domain
// index for both bounded read-only control-state inventories.
var liveDomains = verifiedLiveModel.LogicalDevices
.Select(ld => ld.MmsDomain)
.Where(domain => !string.IsNullOrWhiteSpace(domain))
.ToHashSet(StringComparer.Ordinal);
var settings = verifiedLiveModel.SettingGroupControls
.Where(block => liveDomains.Contains(block.Domain))
.SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "SGCB"))
.GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint,
Expand All @@ -460,6 +468,7 @@ public static IReadOnlyList<IedBrowserReadTarget> BuildInitialPriority(
// Limit background status reads for dense GoCB catalogs; unvisited
// objects retain honest Not read evidence.
var goose = verifiedLiveModel.GooseControlBlocks
.Where(block => liveDomains.Contains(block.Domain))
.SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "GoCB"))
.GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint,
Expand Down
74 changes: 74 additions & 0 deletions Services/Iec61850ControlInspectionPublication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

/// <summary>
/// Single synchronous publication point for inspection evidence. The native
/// client owns a detached signal; only an exact current association and control
/// object may update the operator's GUI model. Never infer process state from
/// inspection status, and never turn StatusOnly into command authority.
/// </summary>
public static class Iec61850ControlInspectionPublication
{
/// <summary>
/// The post-Write typed inspection is tied to two independent epochs:
/// the non-reusable MMS session GUID and the live model association.
/// Neither a canceled old read nor a late fault may clear new GUI proof.
/// </summary>
public static bool IsCurrentPostWriteTarget(
Guid expectedMmsAssociation, Func<Guid, bool> isCurrentMmsAssociation,
long expectedModelAssociation, long currentModelAssociation,
bool connected, bool belongsToCurrentModel)
{
ArgumentNullException.ThrowIfNull(isCurrentMmsAssociation);
return expectedMmsAssociation != Guid.Empty &&
connected && belongsToCurrentModel &&
expectedModelAssociation == currentModelAssociation &&
isCurrentMmsAssociation(expectedMmsAssociation);
}

public static bool TryApply(
SignalDefinition guiSignal, Iec61850ControlCapabilities capabilities,
Guid expectedAssociation, Func<Guid, bool> isCurrentAssociation)
{
ArgumentNullException.ThrowIfNull(guiSignal);
ArgumentNullException.ThrowIfNull(capabilities);
ArgumentNullException.ThrowIfNull(isCurrentAssociation);
if (expectedAssociation == Guid.Empty ||
!isCurrentAssociation(expectedAssociation) ||
guiSignal.ControlCommandBusy ||
!string.Equals(guiSignal.ObjectReference, capabilities.ObjectReference,
StringComparison.Ordinal))
return false;

// Do not preserve an old typed CDC after a failed/changed descriptor.
// IsOperationallyReady alone is insufficient for a read-only model.
var verified = capabilities.SupportsOperate &&
!string.IsNullOrWhiteSpace(capabilities.ControlCdc);
if (!verified ||
(guiSignal.VerifiedLiveControlCdc.Length > 0 &&
!string.Equals(guiSignal.VerifiedLiveControlCdc,
capabilities.ControlCdc, StringComparison.OrdinalIgnoreCase)))
guiSignal.ClearLiveControlAuthority();

// All mutations here are synchronous on the invoking UI context:
// a native late callback never owns an ObservableObject reference.
guiSignal.ClearControlConfirmation();
if (verified)
guiSignal.AcceptVerifiedLiveControlCdc(capabilities.ControlCdc);
else
guiSignal.ControlCdc = capabilities.ControlCdc;
guiSignal.ControlValueType = capabilities.ControlValueType;
guiSignal.ControlStatusReference = capabilities.StatusReference;
guiSignal.ControlModelReference = capabilities.ControlModelReference;
guiSignal.ControlModelText = capabilities.ControlModelText;
guiSignal.ControlCurrentValue = capabilities.CurrentValue;
guiSignal.ControlInspectionEvidence =
$"{capabilities.ControlModelReference} | {capabilities.ControlModelText} | " +
$"{capabilities.EngineControlServiceStatus} | {capabilities.DiscoveryEvidence}";
// Operability is published last, never before the typed evidence.
guiSignal.ControlSemanticVerified = verified;
guiSignal.ControlLastResult = capabilities.EngineControlServiceStatus;
return true;
}
}
Loading
Loading