Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
df45ab7
chore(g1): stage deterministic ARSAS control integration
masarray Aug 19, 2026
27c5774
chore(g1): add one-shot ARSAS control applicator
masarray Aug 19, 2026
fe3234e
fix(control): integrate signed type constraints and wire-stage evidence
github-actions[bot] Aug 19, 2026
a71cf12
test(g1): require truthful no-send control UI
masarray Aug 19, 2026
765552d
chore(g1): stage final ordered control wire integration
masarray Aug 19, 2026
75991d4
chore(g1): add one-shot ordered wire integration applicator
masarray Aug 19, 2026
6255914
feat(control): surface ordered SBO and Oper wire evidence
github-actions[bot] Aug 19, 2026
c68df34
test(g1): lock final ordered control wire contract
masarray Aug 19, 2026
0b497cf
test(g1): keep P6.2-B provenance semantic across later engine pin
masarray Aug 19, 2026
6c6bd9c
test(g1): preserve P6.2-B field policy across later engine pin
masarray Aug 19, 2026
d1a151d
test(g1): preserve offline report provenance across later engine pin
masarray Aug 19, 2026
2495d89
G1.1 apply field-derived control diagnostics and origin fix
masarray Aug 19, 2026
66cd852
G1.1 surface IED control rejection and use station origin
github-actions[bot] Aug 19, 2026
1255e5d
G1.1 remove ambiguous control accepted wording
masarray Aug 19, 2026
414b1cf
G1.1 make IED acceptance wording evidence-based
github-actions[bot] Aug 19, 2026
502a1e8
G1.1 lock manual station-control origin
masarray Aug 19, 2026
2c8de5b
G1.1 fix staged rejection regression assertion
masarray Aug 19, 2026
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
45 changes: 43 additions & 2 deletions ControlCommandWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e)
TestMode = TestMode,
FeedbackTimeoutMs = _signal.IsPositionControl ? 12000 : 8000,
CommandTerminationTimeoutMs = 10000,
OriginCategory = "Maintenance"
OriginCategory = "StationControl"
},
_cancellation.Token);

Expand Down Expand Up @@ -272,13 +272,41 @@ private void PopulateValueOptions(string cdc, string currentValue)

private static string BuildCommandResultText(Iec61850ControlCommandResult result)
{
var details = new List<string> { result.Message };
var details = new List<string>();
var rejectedStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);
if (rejectedStep != null)
{
var rejectedStage = rejectedStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) ? "SBOw" : rejectedStep.Action;
details.Add($"IED REJECTED {rejectedStage}: {result.Message}");
var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));
if (rejectedStage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent)
{
details.Add("Operate was NOT sent because SBOw selection failed.");
details.Add("CommandTermination is not expected because Operate never started.");
}
}
else
{
details.Add(result.Message);
}

if (result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase))
details.Add("No MMS control request was sent to the IED.");
else if (!string.IsNullOrWhiteSpace(result.ResponseHex))
details.Add("MMS request/response wire evidence was captured.");
else if (!string.IsNullOrWhiteSpace(result.RequestHex))
details.Add("MMS request encoding was captured, but no MMS response was captured.");
if (result.WireSteps.Count > 0)
details.Add($"Wire sequence: {string.Join(" → ", result.WireSteps.Select(step => step.Action))}.");
if (result.CommandTerminationReceived)
details.Add(result.PositiveTermination ? "Positive CommandTermination received." : "Negative CommandTermination received.");
if (!string.IsNullOrWhiteSpace(result.ControlError))
details.Add($"ControlError: {result.ControlError}.");
if (!string.IsNullOrWhiteSpace(result.AddCause))
{
details.Add($"AddCause: {result.AddCause}.");
details.Add(ExplainAddCause(result.AddCause));
}
if (!string.IsNullOrWhiteSpace(result.LastApplErrorText))
details.Add(result.LastApplErrorText);
if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-")
Expand All @@ -290,6 +318,19 @@ private static string BuildCommandResultText(Iec61850ControlCommandResult result
return string.Join(" ", details.Where(text => !string.IsNullOrWhiteSpace(text)));
}

private static string ExplainAddCause(string addCause)
=> (addCause ?? string.Empty).Trim().ToLowerInvariant() switch
{
"blocked-by-interlocking" => "IED BLOCKED COMMAND BY INTERLOCKING.",
"blocked-by-synchrocheck" => "IED BLOCKED COMMAND BY SYNCHROCHECK.",
"blocked-by-mode" => "IED blocked the command because the active control mode does not permit it.",
"blocked-by-process" => "IED blocked the command by process conditions.",
"blocked-by-health" => "IED blocked the command because of device/process health conditions.",
"no-access-authority" => "IED reports that this client/origin has no control access authority.",
"not-supported" => "IED reports that the requested control condition/service is not supported.",
_ => string.Empty
};

private static bool TryExtractNumber(string? text, out double value)
{
value = 0d;
Expand Down
20 changes: 18 additions & 2 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1242,7 +1242,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
}

AddLog("INFO", device.Name,
$"MMS command submitted: {signal.ObjectReference}; sequence={claim.Sequence}; value={claim.RequestedValue}.");
$"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(
device.DeviceId,
new Iec61850ControlCommandRequest
Expand All @@ -1255,7 +1255,7 @@ private async Task ExecuteClaimedControlAsync(SignalDefinition signal, ControlCo
FeedbackTimeoutMs = signal.IsPositionControl ? 12000 :
(signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000,
CommandTerminationTimeoutMs = 10000,
OriginCategory = "Maintenance"
OriginCategory = "StationControl"
},
_applicationCancellation.Token);

Expand Down Expand Up @@ -1301,6 +1301,22 @@ private static string BuildQuickControlResult(Iec61850ControlCommandResult resul
if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")
timing.Add($"feedback {result.FeedbackElapsedText}");
var suffix = timing.Count == 0 ? string.Empty : $" • {string.Join(" • ", timing)}";

if (!result.IsSuccess)
{
var rejectedStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);
if (rejectedStep != null)
{
var stage = rejectedStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) ? "SBOw" : rejectedStep.Action;
var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));
var stopped = stage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent
? " • Operate NOT sent"
: string.Empty;
var cause = string.IsNullOrWhiteSpace(result.AddCause) ? string.Empty : $" • AddCause={result.AddCause}";
return $"IED REJECTED {stage}: {result.Message}{cause}{stopped}{suffix}";
}
}

return result.IsSuccess
? $"{result.Stage}: {result.FeedbackValue}{suffix}"
: $"{result.Stage}: {result.Message}{suffix}";
Expand Down
13 changes: 12 additions & 1 deletion Models/ControlModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,21 @@ public sealed class Iec61850ControlCommandRequest
public bool SynchroCheck { get; init; }
public bool TestMode { get; init; }
public string Originator { get; init; } = "ARSAS";
public string OriginCategory { get; init; } = "Maintenance";
public string OriginCategory { get; init; } = "StationControl";
public int FeedbackTimeoutMs { get; init; } = 12000;
public int CommandTerminationTimeoutMs { get; init; } = 10000;
}

public sealed class Iec61850ControlWireEvidence
{
public string Action { get; init; } = string.Empty;
public string Reference { get; init; } = string.Empty;
public bool RequestAccepted { get; init; }
public string RequestHex { get; init; } = string.Empty;
public string ResponseHex { get; init; } = string.Empty;
public string Detail { get; init; } = string.Empty;
}

public sealed class Iec61850ControlCommandResult
{
public bool IsSuccess { get; init; }
Expand All @@ -76,4 +86,5 @@ public sealed class Iec61850ControlCommandResult
public string TotalElapsedText { get; init; } = "-";
public string RequestHex { get; init; } = string.Empty;
public string ResponseHex { get; init; } = string.Empty;
public IReadOnlyList<Iec61850ControlWireEvidence> WireSteps { get; init; } = Array.Empty<Iec61850ControlWireEvidence>();
}
56 changes: 55 additions & 1 deletion Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ public async Task<Iec61850ControlCommandResult> ExecuteControlAsync(
throw new InvalidOperationException("The IED must be connected before a command can be sent.");

Log("INFO", session.Device.Name,
$"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}.");
$"Control execution requested: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}; IED acceptance is determined only from native MMS wire evidence.");

var clientStopwatch = Stopwatch.StartNew();
Interlocked.Increment(ref session.ControlCommandActive);
Expand All @@ -507,9 +507,24 @@ public async Task<Iec61850ControlCommandResult> ExecuteControlAsync(
if (!request.TestMode && result.FeedbackConfirmed && !string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-")
ApplyControlFeedbackToMonitor(session, request.Signal, result.FeedbackValue);

var wireState = result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase)
? "NOT SENT TO IED"
: result.WireSteps.Count > 0 && result.WireSteps.All(step => !string.IsNullOrWhiteSpace(step.ResponseHex))
? $"{result.WireSteps.Count} ordered MMS control response(s) captured"
: result.WireSteps.Count > 0
? $"{result.WireSteps.Count} ordered MMS control step(s); incomplete response evidence"
: !string.IsNullOrWhiteSpace(result.ResponseHex)
? "MMS response received"
: !string.IsNullOrWhiteSpace(result.RequestHex)
? "MMS request encoded / no response captured"
: result.ServiceAccepted
? "MMS service accepted"
: "no wire evidence returned";

var protocolEvidence = string.Join("; ", new[]
{
string.IsNullOrWhiteSpace(result.CompletionState) ? null : $"completion={result.CompletionState}",
$"wire={wireState}",
result.CommandTerminationReceived ? $"termination={(result.PositiveTermination ? "positive" : "negative")}" : null,
string.IsNullOrWhiteSpace(result.ControlError) ? null : $"controlError={result.ControlError}",
string.IsNullOrWhiteSpace(result.AddCause) ? null : $"addCause={result.AddCause}",
Expand All @@ -522,6 +537,45 @@ public async Task<Iec61850ControlCommandResult> ExecuteControlAsync(

Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,
$"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");

var rejectedWireStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);
if (rejectedWireStep != null)
{
var rejectedStage = rejectedWireStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase)
? "SBOw"
: rejectedWireStep.Action;
var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));
Log("ERROR", session.Device.Name,
$"CONTROL_REJECTED_BY_IED: stage={rejectedStage}; reference={rejectedWireStep.Reference}; reason={result.Message}; controlError={(string.IsNullOrWhiteSpace(result.ControlError) ? "-" : result.ControlError)}; addCause={(string.IsNullOrWhiteSpace(result.AddCause) ? "-" : result.AddCause)}; OperateSent={operateSent}; origin={request.OriginCategory}/{request.Originator}.");
}

if (result.WireSteps.Count > 0)
{
for (var index = 0; index < result.WireSteps.Count; index++)
{
var step = result.WireSteps[index];
Log(step.RequestAccepted ? "INFO" : "WARN", session.Device.Name,
$"CONTROL_WIRE_STEP: order={index + 1}; action={step.Action}; reference={step.Reference}; accepted={step.RequestAccepted}; requestCaptured={!string.IsNullOrWhiteSpace(step.RequestHex)}; responseCaptured={!string.IsNullOrWhiteSpace(step.ResponseHex)}; detail={step.Detail}");
if (!string.IsNullOrWhiteSpace(step.RequestHex))
Log("INFO", session.Device.Name,
$"CONTROL_WIRE_REQUEST: order={index + 1}; action={step.Action}; reference={step.Reference}; requestHEX={step.RequestHex}");
if (!string.IsNullOrWhiteSpace(step.ResponseHex))
Log("INFO", session.Device.Name,
$"CONTROL_WIRE_RESPONSE: order={index + 1}; action={step.Action}; reference={step.Reference}; responseHEX={step.ResponseHex}");
}
}
else
{
// Compatibility fallback for a local failure or older action result without
// ordered service evidence. Never infer server acceptance from request HEX alone.
if (!string.IsNullOrWhiteSpace(result.RequestHex))
Log("INFO", session.Device.Name,
$"CONTROL_WIRE_REQUEST: {request.Signal.ObjectReference}; requestHEX={result.RequestHex}");
if (!string.IsNullOrWhiteSpace(result.ResponseHex))
Log("INFO", session.Device.Name,
$"CONTROL_WIRE_RESPONSE: {request.Signal.ObjectReference}; responseHEX={result.ResponseHex}");
}

return result;
}

Expand Down
77 changes: 70 additions & 7 deletions Services/NativeIec61850Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,8 @@ private async Task<Iec61850ControlCommandResult> ExecuteControlCoreAsync(
};

ArControl.Iec61850ControlActionResult action;
var wireRequestBeforeControl = _session.LastReadRequestHex;
var wireResponseBeforeControl = _session.LastReadResponseHex;
try
{
action = await RunMmsOperationAsync(
Expand All @@ -1241,11 +1243,23 @@ private async Task<Iec61850ControlCommandResult> ExecuteControlCoreAsync(
}
catch (Exception ex)
{
return ControlFailure(
"Control exception",
$"{ex.GetType().Name}: {ex.Message}",
capabilities,
expectedValue);
var requestChanged = !string.Equals(
wireRequestBeforeControl,
_session.LastReadRequestHex,
StringComparison.Ordinal);
var responseChanged = !string.Equals(
wireResponseBeforeControl,
_session.LastReadResponseHex,
StringComparison.Ordinal);

return requestChanged
? ControlWireUnknownFailure(
ex,
capabilities,
expectedValue,
_session.LastReadRequestHex,
responseChanged ? _session.LastReadResponseHex : string.Empty)
: ControlNotSentFailure(ex, capabilities, expectedValue);
}

if (!action.IsSuccess)
Expand Down Expand Up @@ -1705,7 +1719,7 @@ _ when cdc.Contains("INC", StringComparison.OrdinalIgnoreCase) || cdc.Contains("
private static ArControl.Iec61850OriginCategory ParseOriginCategory(string? text)
=> Enum.TryParse<ArControl.Iec61850OriginCategory>(text, true, out var category)
? category
: ArControl.Iec61850OriginCategory.Maintenance;
: ArControl.Iec61850OriginCategory.StationControl;

private static string FormatControlTimeout(TimeSpan? timeout)
=> timeout.HasValue ? $"{timeout.Value.TotalSeconds:0.###} s" : "-";
Expand Down Expand Up @@ -1775,7 +1789,16 @@ private static Iec61850ControlCommandResult MapNativeControlResult(
FeedbackElapsedText = feedbackElapsed.HasValue ? $"{feedbackElapsed.Value.TotalMilliseconds:0.###} ms" : "-",
TotalElapsedText = totalElapsed.HasValue ? $"{totalElapsed.Value.TotalMilliseconds:0.###} ms" : $"{result.Elapsed.TotalMilliseconds:0.###} ms",
RequestHex = result.RequestHex,
ResponseHex = result.ResponseHex
ResponseHex = result.ResponseHex,
WireSteps = result.WireSteps.Select(step => new Iec61850ControlWireEvidence
{
Action = step.Action.ToString(),
Reference = step.Reference,
RequestAccepted = step.RequestAccepted,
RequestHex = step.RequestHex,
ResponseHex = step.ResponseHex,
Detail = step.Detail
}).ToArray()
};

private static string InferControlCdc(
Expand Down Expand Up @@ -1881,6 +1904,46 @@ private static Iec61850ControlCommandResult ControlFailure(
FeedbackValue = capabilities.CurrentValue
};

private static Iec61850ControlCommandResult ControlNotSentFailure(
Exception exception,
Iec61850ControlCapabilities capabilities,
string requestedValue)
=> new()
{
IsSuccess = false,
ServiceAccepted = false,
FeedbackConfirmed = false,
CompletionState = "NotSent",
Stage = "NOT SENT TO IED",
Message = $"Local IEC 61850 control preparation failed before any MMS control request was built or sent. {exception.GetType().Name}: {exception.Message}",
ControlModelText = capabilities.ControlModelText,
SequenceText = capabilities.SequenceText,
RequestedValue = requestedValue,
FeedbackValue = capabilities.CurrentValue
};

private static Iec61850ControlCommandResult ControlWireUnknownFailure(
Exception exception,
Iec61850ControlCapabilities capabilities,
string requestedValue,
string requestHex,
string responseHex)
=> new()
{
IsSuccess = false,
ServiceAccepted = false,
FeedbackConfirmed = false,
CompletionState = "WireStateUnknown",
Stage = "MMS control transport incomplete",
Message = $"An MMS control request was encoded and transport may have started, but the control sequence did not complete. {exception.GetType().Name}: {exception.Message}",
ControlModelText = capabilities.ControlModelText,
SequenceText = capabilities.SequenceText,
RequestedValue = requestedValue,
FeedbackValue = capabilities.CurrentValue,
RequestHex = requestHex ?? string.Empty,
ResponseHex = responseHex ?? string.Empty
};

public async ValueTask DisposeAsync()
{
await DisposeControlSessionsAsync().ConfigureAwait(false);
Expand Down
Loading
Loading