From df45ab79f7e2fd49c99da3eb787b1a3618d33c99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 13:53:07 +0700 Subject: [PATCH 01/17] chore(g1): stage deterministic ARSAS control integration --- scripts/apply-g1-control-app-integration.py | 121 ++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 scripts/apply-g1-control-app-integration.py diff --git a/scripts/apply-g1-control-app-integration.py b/scripts/apply-g1-control-app-integration.py new file mode 100644 index 000000000..c3f13fa46 --- /dev/null +++ b/scripts/apply-g1-control-app-integration.py @@ -0,0 +1,121 @@ +from pathlib import Path +import json + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: Path, old: str, new: str) -> None: + text = path.read_text(encoding="utf-8") + if old not in text: + raise SystemExit(f"expected block not found: {path}\n---\n{old[:500]}") + path.write_text(text.replace(old, new, 1), encoding="utf-8", newline="\n") + + +# Pin exact G1 engine candidate and preserve provenance history. +lock_path = ROOT / "engines/ARIEC61850.lock.json" +lock = json.loads(lock_path.read_text(encoding="utf-8")) +lock["commit"] = "e2c26fc4c081b785c2fe12005ada26ba9580bd61" +lock["sourcePullRequest"] = 90 +suffix = ( + ", and PR #90 fixes live MMS TypeSpecification size semantics for Smart Control by decoding signed primitive constraints " + "(-N = variable length with maximum N, +N = fixed N), so the IEC 61850 two-bit Check field is no longer misread as 254 bits; " + "the control builder keeps exact two-bit synchro/interlock semantics and fixed-width validation remains fail-closed." +) +if "PR #90" not in lock["purpose"]: + lock["purpose"] = lock["purpose"].rstrip(".") + suffix +lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8", newline="\n") + +native = ROOT / "Services/NativeIec61850Client.cs" +replace_once( + native, + ''' ArControl.Iec61850ControlActionResult action;\n try\n {\n action = await RunMmsOperationAsync(\n () => control.OperateAsync(nativeRequest, cancellationToken),\n cancellationToken).ConfigureAwait(false);\n }\n catch (OperationCanceledException)\n {\n throw;\n }\n catch (Exception ex)\n {\n return ControlFailure(\n "Control exception",\n $"{ex.GetType().Name}: {ex.Message}",\n capabilities,\n expectedValue);\n }\n''', + ''' ArControl.Iec61850ControlActionResult action;\n var wireRequestBeforeControl = _session.LastReadRequestHex;\n var wireResponseBeforeControl = _session.LastReadResponseHex;\n try\n {\n action = await RunMmsOperationAsync(\n () => control.OperateAsync(nativeRequest, cancellationToken),\n cancellationToken).ConfigureAwait(false);\n }\n catch (OperationCanceledException)\n {\n throw;\n }\n catch (Exception ex)\n {\n var requestChanged = !string.Equals(\n wireRequestBeforeControl,\n _session.LastReadRequestHex,\n StringComparison.Ordinal);\n var responseChanged = !string.Equals(\n wireResponseBeforeControl,\n _session.LastReadResponseHex,\n StringComparison.Ordinal);\n\n return requestChanged\n ? ControlWireUnknownFailure(\n ex,\n capabilities,\n expectedValue,\n _session.LastReadRequestHex,\n responseChanged ? _session.LastReadResponseHex : string.Empty)\n : ControlNotSentFailure(ex, capabilities, expectedValue);\n }\n''') + +replace_once( + native, + ''' private static Iec61850ControlCommandResult ControlFailure(\n string stage,\n string message,\n Iec61850ControlCapabilities capabilities,\n string requestedValue)\n => new()\n {\n IsSuccess = false,\n ServiceAccepted = false,\n FeedbackConfirmed = false,\n CompletionState = "Rejected",\n Stage = stage,\n Message = message,\n ControlModelText = capabilities.ControlModelText,\n SequenceText = capabilities.SequenceText,\n RequestedValue = requestedValue,\n FeedbackValue = capabilities.CurrentValue\n };\n''', + ''' private static Iec61850ControlCommandResult ControlFailure(\n string stage,\n string message,\n Iec61850ControlCapabilities capabilities,\n string requestedValue)\n => new()\n {\n IsSuccess = false,\n ServiceAccepted = false,\n FeedbackConfirmed = false,\n CompletionState = "Rejected",\n Stage = stage,\n Message = message,\n ControlModelText = capabilities.ControlModelText,\n SequenceText = capabilities.SequenceText,\n RequestedValue = requestedValue,\n FeedbackValue = capabilities.CurrentValue\n };\n\n private static Iec61850ControlCommandResult ControlNotSentFailure(\n Exception exception,\n Iec61850ControlCapabilities capabilities,\n string requestedValue)\n => new()\n {\n IsSuccess = false,\n ServiceAccepted = false,\n FeedbackConfirmed = false,\n CompletionState = "NotSent",\n Stage = "NOT SENT TO IED",\n Message = $"Local IEC 61850 control preparation failed before any MMS control request was built or sent. {exception.GetType().Name}: {exception.Message}",\n ControlModelText = capabilities.ControlModelText,\n SequenceText = capabilities.SequenceText,\n RequestedValue = requestedValue,\n FeedbackValue = capabilities.CurrentValue\n };\n\n private static Iec61850ControlCommandResult ControlWireUnknownFailure(\n Exception exception,\n Iec61850ControlCapabilities capabilities,\n string requestedValue,\n string requestHex,\n string responseHex)\n => new()\n {\n IsSuccess = false,\n ServiceAccepted = false,\n FeedbackConfirmed = false,\n CompletionState = "WireStateUnknown",\n Stage = "MMS control transport incomplete",\n Message = $"An MMS control request was encoded and transport may have started, but the control sequence did not complete. {exception.GetType().Name}: {exception.Message}",\n ControlModelText = capabilities.ControlModelText,\n SequenceText = capabilities.SequenceText,\n RequestedValue = requestedValue,\n FeedbackValue = capabilities.CurrentValue,\n RequestHex = requestHex ?? string.Empty,\n ResponseHex = responseHex ?? string.Empty\n };\n''') + +runtime = ROOT / "Services/Iec61850MonitorRuntime.cs" +replace_once( + runtime, + ''' var protocolEvidence = string.Join("; ", new[]\n {\n string.IsNullOrWhiteSpace(result.CompletionState) ? null : $"completion={result.CompletionState}",\n result.CommandTerminationReceived ? $"termination={(result.PositiveTermination ? "positive" : "negative")}" : null,\n string.IsNullOrWhiteSpace(result.ControlError) ? null : $"controlError={result.ControlError}",\n string.IsNullOrWhiteSpace(result.AddCause) ? null : $"addCause={result.AddCause}",\n result.ControlNumber == "-" ? null : $"ctlNum={result.ControlNumber}",\n result.ElapsedText == "-" ? null : $"control={result.ElapsedText}",\n result.FeedbackElapsedText == "-" ? null : $"feedback={result.FeedbackElapsedText}",\n result.TotalElapsedText == "-" ? null : $"engineTotal={result.TotalElapsedText}",\n $"clientTotal={clientStopwatch.Elapsed.TotalMilliseconds:0.###} ms"\n }.Where(text => !string.IsNullOrWhiteSpace(text)));\n\n Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,\n $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");\n return result;\n''', + ''' var wireState = result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase)\n ? "NOT SENT TO IED"\n : !string.IsNullOrWhiteSpace(result.ResponseHex)\n ? "MMS response received"\n : !string.IsNullOrWhiteSpace(result.RequestHex)\n ? "MMS request encoded / no response captured"\n : result.ServiceAccepted\n ? "MMS service accepted"\n : "no wire evidence returned";\n\n var protocolEvidence = string.Join("; ", new[]\n {\n string.IsNullOrWhiteSpace(result.CompletionState) ? null : $"completion={result.CompletionState}",\n $"wire={wireState}",\n result.CommandTerminationReceived ? $"termination={(result.PositiveTermination ? "positive" : "negative")}" : null,\n string.IsNullOrWhiteSpace(result.ControlError) ? null : $"controlError={result.ControlError}",\n string.IsNullOrWhiteSpace(result.AddCause) ? null : $"addCause={result.AddCause}",\n result.ControlNumber == "-" ? null : $"ctlNum={result.ControlNumber}",\n result.ElapsedText == "-" ? null : $"control={result.ElapsedText}",\n result.FeedbackElapsedText == "-" ? null : $"feedback={result.FeedbackElapsedText}",\n result.TotalElapsedText == "-" ? null : $"engineTotal={result.TotalElapsedText}",\n $"clientTotal={clientStopwatch.Elapsed.TotalMilliseconds:0.###} ms"\n }.Where(text => !string.IsNullOrWhiteSpace(text)));\n\n Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,\n $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");\n\n if (!string.IsNullOrWhiteSpace(result.RequestHex))\n Log("INFO", session.Device.Name,\n $"CONTROL_WIRE_REQUEST: {request.Signal.ObjectReference}; requestHEX={result.RequestHex}");\n if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n Log("INFO", session.Device.Name,\n $"CONTROL_WIRE_RESPONSE: {request.Signal.ObjectReference}; responseHEX={result.ResponseHex}");\n\n return result;\n''') + +window = ROOT / "ControlCommandWindow.xaml.cs" +replace_once( + window, + ''' private static string BuildCommandResultText(Iec61850ControlCommandResult result)\n {\n var details = new List { result.Message };\n if (result.CommandTerminationReceived)\n''', + ''' private static string BuildCommandResultText(Iec61850ControlCommandResult result)\n {\n var details = new List { result.Message };\n if (result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase))\n details.Add("No MMS control request was sent to the IED.");\n else if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n details.Add("MMS request/response wire evidence was captured.");\n else if (!string.IsNullOrWhiteSpace(result.RequestHex))\n details.Add("MMS request encoding was captured, but no MMS response was captured.");\n if (result.CommandTerminationReceived)\n''') + +# Focused source/provenance regression: G1 must not touch acquisition/reconnect semantics. +test = ROOT / "tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs" +test.write_text(r'''using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class G1ControlCorrectnessRegressionTests +{ + [Fact] + public void EngineLock_PinsExactG1ControlEngine() + { + var root = RepoRoot(); + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"))); + var json = doc.RootElement; + Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); + Assert.Equal("main", json.GetProperty("ref").GetString()); + Assert.Equal("e2c26fc4c081b785c2fe12005ada26ba9580bd61", json.GetProperty("commit").GetString()); + Assert.Equal(90, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Contains("signed primitive constraints", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LocalControlPreparationFailure_IsExplicitlyNotSent() + { + var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs")); + Assert.Contains("wireRequestBeforeControl", source, StringComparison.Ordinal); + Assert.Contains("ControlNotSentFailure", source, StringComparison.Ordinal); + Assert.Contains("CompletionState = \"NotSent\"", source, StringComparison.Ordinal); + Assert.Contains("Stage = \"NOT SENT TO IED\"", source, StringComparison.Ordinal); + Assert.Contains("before any MMS control request was built or sent", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ControlWireUnknownFailure", source, StringComparison.Ordinal); + } + + [Fact] + public void Runtime_EmitsExactWireEvidenceOnlyWhenReturnedByControlStack() + { + var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + Assert.Contains("CONTROL_WIRE_REQUEST:", source, StringComparison.Ordinal); + Assert.Contains("CONTROL_WIRE_RESPONSE:", source, StringComparison.Ordinal); + Assert.Contains("MMS request encoded / no response captured", source, StringComparison.Ordinal); + Assert.Contains("MMS response received", source, StringComparison.Ordinal); + Assert.DoesNotContain("MMS command submitted", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() + { + var engineLock = File.ReadAllText(Path.Combine(RepoRoot(), "engines", "ARIEC61850.lock.json")); + Assert.Contains("PR #89 quarantines automatic full dynamic DataSet activation", engineLock, StringComparison.OrdinalIgnoreCase); + + var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + Assert.Contains("SmartReconnectPolicy", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal); + } + + private static string RepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj"))) + return current.FullName; + current = current.Parent; + } + throw new DirectoryNotFoundException("ARSAS repository root not found."); + } +} +''', encoding="utf-8", newline="\n") + +print("G1 ARSAS control integration patch applied") From 27c577417c9cf84a50a7279c2d22679d496a9bd1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 13:53:24 +0700 Subject: [PATCH 02/17] chore(g1): add one-shot ARSAS control applicator --- .../apply-g1-control-app-integration.yml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/apply-g1-control-app-integration.yml diff --git a/.github/workflows/apply-g1-control-app-integration.yml b/.github/workflows/apply-g1-control-app-integration.yml new file mode 100644 index 000000000..67777c42b --- /dev/null +++ b/.github/workflows/apply-g1-control-app-integration.yml @@ -0,0 +1,41 @@ +name: Apply G1 control app integration + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - fix/p6-2-c-smart-reconnect + +permissions: + contents: write + +jobs: + apply: + if: github.head_ref == 'fix/g1-control-correctness' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Apply deterministic G1 app patch + run: python scripts/apply-g1-control-app-integration.py + - name: Remove one-shot staging files + run: | + rm scripts/apply-g1-control-app-integration.py + rm .github/workflows/apply-g1-control-app-integration.yml + - name: Commit materialized source + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + if git diff --cached --quiet; then + echo 'No changes to commit.' + exit 0 + fi + git commit -m 'fix(control): integrate signed type constraints and wire-stage evidence' + git push origin HEAD:${{ github.head_ref }} From fe3234e3946767684b4a54a8e57dd00215f02abf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:54:00 +0000 Subject: [PATCH 03/17] fix(control): integrate signed type constraints and wire-stage evidence --- .../apply-g1-control-app-integration.yml | 41 ------ ControlCommandWindow.xaml.cs | 6 + Services/Iec61850MonitorRuntime.cs | 19 +++ Services/NativeIec61850Client.cs | 64 ++++++++- engines/ARIEC61850.lock.json | 6 +- scripts/apply-g1-control-app-integration.py | 121 ------------------ .../G1ControlCorrectnessRegressionTests.cs | 66 ++++++++++ 7 files changed, 153 insertions(+), 170 deletions(-) delete mode 100644 .github/workflows/apply-g1-control-app-integration.yml delete mode 100644 scripts/apply-g1-control-app-integration.py create mode 100644 tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs diff --git a/.github/workflows/apply-g1-control-app-integration.yml b/.github/workflows/apply-g1-control-app-integration.yml deleted file mode 100644 index 67777c42b..000000000 --- a/.github/workflows/apply-g1-control-app-integration.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Apply G1 control app integration - -on: - pull_request: - types: [opened, synchronize, reopened] - branches: - - fix/p6-2-c-smart-reconnect - -permissions: - contents: write - -jobs: - apply: - if: github.head_ref == 'fix/g1-control-correctness' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - name: Apply deterministic G1 app patch - run: python scripts/apply-g1-control-app-integration.py - - name: Remove one-shot staging files - run: | - rm scripts/apply-g1-control-app-integration.py - rm .github/workflows/apply-g1-control-app-integration.yml - - name: Commit materialized source - shell: bash - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - if git diff --cached --quiet; then - echo 'No changes to commit.' - exit 0 - fi - git commit -m 'fix(control): integrate signed type constraints and wire-stage evidence' - git push origin HEAD:${{ github.head_ref }} diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 37adfc131..8e58a6ff0 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -273,6 +273,12 @@ private void PopulateValueOptions(string cdc, string currentValue) private static string BuildCommandResultText(Iec61850ControlCommandResult result) { var details = new List { 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.CommandTerminationReceived) details.Add(result.PositiveTermination ? "Positive CommandTermination received." : "Negative CommandTermination received."); if (!string.IsNullOrWhiteSpace(result.ControlError)) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 5c219d068..0c7e3787a 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -507,9 +507,20 @@ public async Task 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" + : !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}", @@ -522,6 +533,14 @@ public async Task 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}"); + + 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; } diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 49f1bf232..2f9b532ea 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1229,6 +1229,8 @@ private async Task ExecuteControlCoreAsync( }; ArControl.Iec61850ControlActionResult action; + var wireRequestBeforeControl = _session.LastReadRequestHex; + var wireResponseBeforeControl = _session.LastReadResponseHex; try { action = await RunMmsOperationAsync( @@ -1241,11 +1243,23 @@ private async Task 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) @@ -1881,6 +1895,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); diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index b563912a5..2c3efe30d 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "249fb130e0e18e7a98e07e8894f24610bdb5642e", - "sourcePullRequest": 89, - "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative." + "commit": "e2c26fc4c081b785c2fe12005ada26ba9580bd61", + "sourcePullRequest": 90, + "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative, and PR #90 fixes live MMS TypeSpecification size semantics for Smart Control by decoding signed primitive constraints (-N = variable length with maximum N, +N = fixed N), so the IEC 61850 two-bit Check field is no longer misread as 254 bits; the control builder keeps exact two-bit synchro/interlock semantics and fixed-width validation remains fail-closed." } diff --git a/scripts/apply-g1-control-app-integration.py b/scripts/apply-g1-control-app-integration.py deleted file mode 100644 index c3f13fa46..000000000 --- a/scripts/apply-g1-control-app-integration.py +++ /dev/null @@ -1,121 +0,0 @@ -from pathlib import Path -import json - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: Path, old: str, new: str) -> None: - text = path.read_text(encoding="utf-8") - if old not in text: - raise SystemExit(f"expected block not found: {path}\n---\n{old[:500]}") - path.write_text(text.replace(old, new, 1), encoding="utf-8", newline="\n") - - -# Pin exact G1 engine candidate and preserve provenance history. -lock_path = ROOT / "engines/ARIEC61850.lock.json" -lock = json.loads(lock_path.read_text(encoding="utf-8")) -lock["commit"] = "e2c26fc4c081b785c2fe12005ada26ba9580bd61" -lock["sourcePullRequest"] = 90 -suffix = ( - ", and PR #90 fixes live MMS TypeSpecification size semantics for Smart Control by decoding signed primitive constraints " - "(-N = variable length with maximum N, +N = fixed N), so the IEC 61850 two-bit Check field is no longer misread as 254 bits; " - "the control builder keeps exact two-bit synchro/interlock semantics and fixed-width validation remains fail-closed." -) -if "PR #90" not in lock["purpose"]: - lock["purpose"] = lock["purpose"].rstrip(".") + suffix -lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8", newline="\n") - -native = ROOT / "Services/NativeIec61850Client.cs" -replace_once( - native, - ''' ArControl.Iec61850ControlActionResult action;\n try\n {\n action = await RunMmsOperationAsync(\n () => control.OperateAsync(nativeRequest, cancellationToken),\n cancellationToken).ConfigureAwait(false);\n }\n catch (OperationCanceledException)\n {\n throw;\n }\n catch (Exception ex)\n {\n return ControlFailure(\n "Control exception",\n $"{ex.GetType().Name}: {ex.Message}",\n capabilities,\n expectedValue);\n }\n''', - ''' ArControl.Iec61850ControlActionResult action;\n var wireRequestBeforeControl = _session.LastReadRequestHex;\n var wireResponseBeforeControl = _session.LastReadResponseHex;\n try\n {\n action = await RunMmsOperationAsync(\n () => control.OperateAsync(nativeRequest, cancellationToken),\n cancellationToken).ConfigureAwait(false);\n }\n catch (OperationCanceledException)\n {\n throw;\n }\n catch (Exception ex)\n {\n var requestChanged = !string.Equals(\n wireRequestBeforeControl,\n _session.LastReadRequestHex,\n StringComparison.Ordinal);\n var responseChanged = !string.Equals(\n wireResponseBeforeControl,\n _session.LastReadResponseHex,\n StringComparison.Ordinal);\n\n return requestChanged\n ? ControlWireUnknownFailure(\n ex,\n capabilities,\n expectedValue,\n _session.LastReadRequestHex,\n responseChanged ? _session.LastReadResponseHex : string.Empty)\n : ControlNotSentFailure(ex, capabilities, expectedValue);\n }\n''') - -replace_once( - native, - ''' private static Iec61850ControlCommandResult ControlFailure(\n string stage,\n string message,\n Iec61850ControlCapabilities capabilities,\n string requestedValue)\n => new()\n {\n IsSuccess = false,\n ServiceAccepted = false,\n FeedbackConfirmed = false,\n CompletionState = "Rejected",\n Stage = stage,\n Message = message,\n ControlModelText = capabilities.ControlModelText,\n SequenceText = capabilities.SequenceText,\n RequestedValue = requestedValue,\n FeedbackValue = capabilities.CurrentValue\n };\n''', - ''' private static Iec61850ControlCommandResult ControlFailure(\n string stage,\n string message,\n Iec61850ControlCapabilities capabilities,\n string requestedValue)\n => new()\n {\n IsSuccess = false,\n ServiceAccepted = false,\n FeedbackConfirmed = false,\n CompletionState = "Rejected",\n Stage = stage,\n Message = message,\n ControlModelText = capabilities.ControlModelText,\n SequenceText = capabilities.SequenceText,\n RequestedValue = requestedValue,\n FeedbackValue = capabilities.CurrentValue\n };\n\n private static Iec61850ControlCommandResult ControlNotSentFailure(\n Exception exception,\n Iec61850ControlCapabilities capabilities,\n string requestedValue)\n => new()\n {\n IsSuccess = false,\n ServiceAccepted = false,\n FeedbackConfirmed = false,\n CompletionState = "NotSent",\n Stage = "NOT SENT TO IED",\n Message = $"Local IEC 61850 control preparation failed before any MMS control request was built or sent. {exception.GetType().Name}: {exception.Message}",\n ControlModelText = capabilities.ControlModelText,\n SequenceText = capabilities.SequenceText,\n RequestedValue = requestedValue,\n FeedbackValue = capabilities.CurrentValue\n };\n\n private static Iec61850ControlCommandResult ControlWireUnknownFailure(\n Exception exception,\n Iec61850ControlCapabilities capabilities,\n string requestedValue,\n string requestHex,\n string responseHex)\n => new()\n {\n IsSuccess = false,\n ServiceAccepted = false,\n FeedbackConfirmed = false,\n CompletionState = "WireStateUnknown",\n Stage = "MMS control transport incomplete",\n Message = $"An MMS control request was encoded and transport may have started, but the control sequence did not complete. {exception.GetType().Name}: {exception.Message}",\n ControlModelText = capabilities.ControlModelText,\n SequenceText = capabilities.SequenceText,\n RequestedValue = requestedValue,\n FeedbackValue = capabilities.CurrentValue,\n RequestHex = requestHex ?? string.Empty,\n ResponseHex = responseHex ?? string.Empty\n };\n''') - -runtime = ROOT / "Services/Iec61850MonitorRuntime.cs" -replace_once( - runtime, - ''' var protocolEvidence = string.Join("; ", new[]\n {\n string.IsNullOrWhiteSpace(result.CompletionState) ? null : $"completion={result.CompletionState}",\n result.CommandTerminationReceived ? $"termination={(result.PositiveTermination ? "positive" : "negative")}" : null,\n string.IsNullOrWhiteSpace(result.ControlError) ? null : $"controlError={result.ControlError}",\n string.IsNullOrWhiteSpace(result.AddCause) ? null : $"addCause={result.AddCause}",\n result.ControlNumber == "-" ? null : $"ctlNum={result.ControlNumber}",\n result.ElapsedText == "-" ? null : $"control={result.ElapsedText}",\n result.FeedbackElapsedText == "-" ? null : $"feedback={result.FeedbackElapsedText}",\n result.TotalElapsedText == "-" ? null : $"engineTotal={result.TotalElapsedText}",\n $"clientTotal={clientStopwatch.Elapsed.TotalMilliseconds:0.###} ms"\n }.Where(text => !string.IsNullOrWhiteSpace(text)));\n\n Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,\n $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");\n return result;\n''', - ''' var wireState = result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase)\n ? "NOT SENT TO IED"\n : !string.IsNullOrWhiteSpace(result.ResponseHex)\n ? "MMS response received"\n : !string.IsNullOrWhiteSpace(result.RequestHex)\n ? "MMS request encoded / no response captured"\n : result.ServiceAccepted\n ? "MMS service accepted"\n : "no wire evidence returned";\n\n var protocolEvidence = string.Join("; ", new[]\n {\n string.IsNullOrWhiteSpace(result.CompletionState) ? null : $"completion={result.CompletionState}",\n $"wire={wireState}",\n result.CommandTerminationReceived ? $"termination={(result.PositiveTermination ? "positive" : "negative")}" : null,\n string.IsNullOrWhiteSpace(result.ControlError) ? null : $"controlError={result.ControlError}",\n string.IsNullOrWhiteSpace(result.AddCause) ? null : $"addCause={result.AddCause}",\n result.ControlNumber == "-" ? null : $"ctlNum={result.ControlNumber}",\n result.ElapsedText == "-" ? null : $"control={result.ElapsedText}",\n result.FeedbackElapsedText == "-" ? null : $"feedback={result.FeedbackElapsedText}",\n result.TotalElapsedText == "-" ? null : $"engineTotal={result.TotalElapsedText}",\n $"clientTotal={clientStopwatch.Elapsed.TotalMilliseconds:0.###} ms"\n }.Where(text => !string.IsNullOrWhiteSpace(text)));\n\n Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,\n $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");\n\n if (!string.IsNullOrWhiteSpace(result.RequestHex))\n Log("INFO", session.Device.Name,\n $"CONTROL_WIRE_REQUEST: {request.Signal.ObjectReference}; requestHEX={result.RequestHex}");\n if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n Log("INFO", session.Device.Name,\n $"CONTROL_WIRE_RESPONSE: {request.Signal.ObjectReference}; responseHEX={result.ResponseHex}");\n\n return result;\n''') - -window = ROOT / "ControlCommandWindow.xaml.cs" -replace_once( - window, - ''' private static string BuildCommandResultText(Iec61850ControlCommandResult result)\n {\n var details = new List { result.Message };\n if (result.CommandTerminationReceived)\n''', - ''' private static string BuildCommandResultText(Iec61850ControlCommandResult result)\n {\n var details = new List { result.Message };\n if (result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase))\n details.Add("No MMS control request was sent to the IED.");\n else if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n details.Add("MMS request/response wire evidence was captured.");\n else if (!string.IsNullOrWhiteSpace(result.RequestHex))\n details.Add("MMS request encoding was captured, but no MMS response was captured.");\n if (result.CommandTerminationReceived)\n''') - -# Focused source/provenance regression: G1 must not touch acquisition/reconnect semantics. -test = ROOT / "tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs" -test.write_text(r'''using System.Text.Json; - -namespace ARSAS.Tests; - -public sealed class G1ControlCorrectnessRegressionTests -{ - [Fact] - public void EngineLock_PinsExactG1ControlEngine() - { - var root = RepoRoot(); - using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"))); - var json = doc.RootElement; - Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); - Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("e2c26fc4c081b785c2fe12005ada26ba9580bd61", json.GetProperty("commit").GetString()); - Assert.Equal(90, json.GetProperty("sourcePullRequest").GetInt32()); - Assert.Contains("signed primitive constraints", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void LocalControlPreparationFailure_IsExplicitlyNotSent() - { - var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs")); - Assert.Contains("wireRequestBeforeControl", source, StringComparison.Ordinal); - Assert.Contains("ControlNotSentFailure", source, StringComparison.Ordinal); - Assert.Contains("CompletionState = \"NotSent\"", source, StringComparison.Ordinal); - Assert.Contains("Stage = \"NOT SENT TO IED\"", source, StringComparison.Ordinal); - Assert.Contains("before any MMS control request was built or sent", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("ControlWireUnknownFailure", source, StringComparison.Ordinal); - } - - [Fact] - public void Runtime_EmitsExactWireEvidenceOnlyWhenReturnedByControlStack() - { - var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); - Assert.Contains("CONTROL_WIRE_REQUEST:", source, StringComparison.Ordinal); - Assert.Contains("CONTROL_WIRE_RESPONSE:", source, StringComparison.Ordinal); - Assert.Contains("MMS request encoded / no response captured", source, StringComparison.Ordinal); - Assert.Contains("MMS response received", source, StringComparison.Ordinal); - Assert.DoesNotContain("MMS command submitted", source, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() - { - var engineLock = File.ReadAllText(Path.Combine(RepoRoot(), "engines", "ARIEC61850.lock.json")); - Assert.Contains("PR #89 quarantines automatic full dynamic DataSet activation", engineLock, StringComparison.OrdinalIgnoreCase); - - var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); - Assert.Contains("SmartReconnectPolicy", runtime, StringComparison.Ordinal); - Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); - Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal); - } - - private static string RepoRoot() - { - var current = new DirectoryInfo(AppContext.BaseDirectory); - while (current != null) - { - if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj"))) - return current.FullName; - current = current.Parent; - } - throw new DirectoryNotFoundException("ARSAS repository root not found."); - } -} -''', encoding="utf-8", newline="\n") - -print("G1 ARSAS control integration patch applied") diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs new file mode 100644 index 000000000..2da327e4f --- /dev/null +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class G1ControlCorrectnessRegressionTests +{ + [Fact] + public void EngineLock_PinsExactG1ControlEngine() + { + var root = RepoRoot(); + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"))); + var json = doc.RootElement; + Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); + Assert.Equal("main", json.GetProperty("ref").GetString()); + Assert.Equal("e2c26fc4c081b785c2fe12005ada26ba9580bd61", json.GetProperty("commit").GetString()); + Assert.Equal(90, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Contains("signed primitive constraints", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LocalControlPreparationFailure_IsExplicitlyNotSent() + { + var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs")); + Assert.Contains("wireRequestBeforeControl", source, StringComparison.Ordinal); + Assert.Contains("ControlNotSentFailure", source, StringComparison.Ordinal); + Assert.Contains("CompletionState = \"NotSent\"", source, StringComparison.Ordinal); + Assert.Contains("Stage = \"NOT SENT TO IED\"", source, StringComparison.Ordinal); + Assert.Contains("before any MMS control request was built or sent", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ControlWireUnknownFailure", source, StringComparison.Ordinal); + } + + [Fact] + public void Runtime_EmitsExactWireEvidenceOnlyWhenReturnedByControlStack() + { + var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + Assert.Contains("CONTROL_WIRE_REQUEST:", source, StringComparison.Ordinal); + Assert.Contains("CONTROL_WIRE_RESPONSE:", source, StringComparison.Ordinal); + Assert.Contains("MMS request encoded / no response captured", source, StringComparison.Ordinal); + Assert.Contains("MMS response received", source, StringComparison.Ordinal); + Assert.DoesNotContain("MMS command submitted", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() + { + var engineLock = File.ReadAllText(Path.Combine(RepoRoot(), "engines", "ARIEC61850.lock.json")); + Assert.Contains("PR #89 quarantines automatic full dynamic DataSet activation", engineLock, StringComparison.OrdinalIgnoreCase); + + var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + Assert.Contains("SmartReconnectPolicy", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal); + } + + private static string RepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj"))) + return current.FullName; + current = current.Parent; + } + throw new DirectoryNotFoundException("ARSAS repository root not found."); + } +} From a71cf12d7d1c4ef7fad835761496c9ef258e1d6a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 13:56:23 +0700 Subject: [PATCH 04/17] test(g1): require truthful no-send control UI --- tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 2da327e4f..c269159f7 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -40,6 +40,15 @@ public void Runtime_EmitsExactWireEvidenceOnlyWhenReturnedByControlStack() Assert.DoesNotContain("MMS command submitted", source, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void CommandUi_DistinguishesNotSentFromWireEvidence() + { + var source = File.ReadAllText(Path.Combine(RepoRoot(), "ControlCommandWindow.xaml.cs")); + Assert.Contains("No MMS control request was sent to the IED", source, StringComparison.Ordinal); + Assert.Contains("MMS request/response wire evidence was captured", source, StringComparison.Ordinal); + Assert.Contains("MMS request encoding was captured, but no MMS response was captured", source, StringComparison.Ordinal); + } + [Fact] public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() { From 765552daa034f8b0b69b3c5fbd803bc0ce08cea2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:05:44 +0700 Subject: [PATCH 05/17] chore(g1): stage final ordered control wire integration --- scripts/apply-g1-ordered-wire-integration.py | 54 ++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 scripts/apply-g1-ordered-wire-integration.py diff --git a/scripts/apply-g1-ordered-wire-integration.py b/scripts/apply-g1-ordered-wire-integration.py new file mode 100644 index 000000000..380cd7aaa --- /dev/null +++ b/scripts/apply-g1-ordered-wire-integration.py @@ -0,0 +1,54 @@ +from pathlib import Path +import json + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: Path, old: str, new: str) -> None: + text = path.read_text(encoding="utf-8") + if old not in text: + raise SystemExit(f"expected block not found in {path}:\n{old[:700]}") + path.write_text(text.replace(old, new, 1), encoding="utf-8", newline="\n") + +# Final immutable G1 engine pin. +lock_path = ROOT / "engines/ARIEC61850.lock.json" +lock = json.loads(lock_path.read_text(encoding="utf-8")) +lock["commit"] = "438d14b0dd6dce1b86b9d1c63d6bddd13510b11a" +lock["sourcePullRequest"] = 90 +if "ordered SBO/SBOw" not in lock["purpose"]: + lock["purpose"] = lock["purpose"].rstrip(".") + ", and G1 final PR #90 preserves ordered SBO/SBOw-to-Operate wire evidence in the native control result so field acceptance can prove each control service independently before process-feedback confirmation." +lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8", newline="\n") + +models = ROOT / "Models/ControlModels.cs" +replace_once( + models, + """public sealed class Iec61850ControlCommandResult\n{\n""", + """public sealed class Iec61850ControlWireEvidence\n{\n public string Action { get; init; } = string.Empty;\n public string Reference { get; init; } = string.Empty;\n public bool RequestAccepted { get; init; }\n public string RequestHex { get; init; } = string.Empty;\n public string ResponseHex { get; init; } = string.Empty;\n public string Detail { get; init; } = string.Empty;\n}\n\npublic sealed class Iec61850ControlCommandResult\n{\n""") +replace_once( + models, + """ public string RequestHex { get; init; } = string.Empty;\n public string ResponseHex { get; init; } = string.Empty;\n}\n""", + """ public string RequestHex { get; init; } = string.Empty;\n public string ResponseHex { get; init; } = string.Empty;\n public IReadOnlyList WireSteps { get; init; } = Array.Empty();\n}\n""") + +native = ROOT / "Services/NativeIec61850Client.cs" +replace_once( + native, + """ TotalElapsedText = totalElapsed.HasValue ? $\"{totalElapsed.Value.TotalMilliseconds:0.###} ms\" : $\"{result.Elapsed.TotalMilliseconds:0.###} ms\",\n RequestHex = result.RequestHex,\n ResponseHex = result.ResponseHex\n };\n""", + """ TotalElapsedText = totalElapsed.HasValue ? $\"{totalElapsed.Value.TotalMilliseconds:0.###} ms\" : $\"{result.Elapsed.TotalMilliseconds:0.###} ms\",\n RequestHex = result.RequestHex,\n ResponseHex = result.ResponseHex,\n WireSteps = result.WireSteps.Select(step => new Iec61850ControlWireEvidence\n {\n Action = step.Action.ToString(),\n Reference = step.Reference,\n RequestAccepted = step.RequestAccepted,\n RequestHex = step.RequestHex,\n ResponseHex = step.ResponseHex,\n Detail = step.Detail\n }).ToArray()\n };\n""") + +runtime = ROOT / "Services/Iec61850MonitorRuntime.cs" +replace_once( + runtime, + """ var wireState = result.CompletionState.Equals(\"NotSent\", StringComparison.OrdinalIgnoreCase)\n ? \"NOT SENT TO IED\"\n : !string.IsNullOrWhiteSpace(result.ResponseHex)\n ? \"MMS response received\"\n : !string.IsNullOrWhiteSpace(result.RequestHex)\n ? \"MMS request encoded / no response captured\"\n : result.ServiceAccepted\n ? \"MMS service accepted\"\n : \"no wire evidence returned\";\n""", + """ var wireState = result.CompletionState.Equals(\"NotSent\", StringComparison.OrdinalIgnoreCase)\n ? \"NOT SENT TO IED\"\n : result.WireSteps.Count > 0 && result.WireSteps.All(step => !string.IsNullOrWhiteSpace(step.ResponseHex))\n ? $\"{result.WireSteps.Count} ordered MMS control response(s) captured\"\n : result.WireSteps.Count > 0\n ? $\"{result.WireSteps.Count} ordered MMS control step(s); incomplete response evidence\"\n : !string.IsNullOrWhiteSpace(result.ResponseHex)\n ? \"MMS response received\"\n : !string.IsNullOrWhiteSpace(result.RequestHex)\n ? \"MMS request encoded / no response captured\"\n : result.ServiceAccepted\n ? \"MMS service accepted\"\n : \"no wire evidence returned\";\n""") +replace_once( + runtime, + """ if (!string.IsNullOrWhiteSpace(result.RequestHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_REQUEST: {request.Signal.ObjectReference}; requestHEX={result.RequestHex}\");\n if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_RESPONSE: {request.Signal.ObjectReference}; responseHEX={result.ResponseHex}\");\n\n return result;\n""", + """ if (result.WireSteps.Count > 0)\n {\n for (var index = 0; index < result.WireSteps.Count; index++)\n {\n var step = result.WireSteps[index];\n Log(step.RequestAccepted ? \"INFO\" : \"WARN\", session.Device.Name,\n $\"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}\");\n if (!string.IsNullOrWhiteSpace(step.RequestHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_REQUEST: order={index + 1}; action={step.Action}; reference={step.Reference}; requestHEX={step.RequestHex}\");\n if (!string.IsNullOrWhiteSpace(step.ResponseHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_RESPONSE: order={index + 1}; action={step.Action}; reference={step.Reference}; responseHEX={step.ResponseHex}\");\n }\n }\n else\n {\n // Compatibility fallback for a local failure or older action result without\n // ordered service evidence. Never infer server acceptance from request HEX alone.\n if (!string.IsNullOrWhiteSpace(result.RequestHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_REQUEST: {request.Signal.ObjectReference}; requestHEX={result.RequestHex}\");\n if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_RESPONSE: {request.Signal.ObjectReference}; responseHEX={result.ResponseHex}\");\n }\n\n return result;\n""") + +window = ROOT / "ControlCommandWindow.xaml.cs" +replace_once( + window, + """ else if (!string.IsNullOrWhiteSpace(result.RequestHex))\n details.Add(\"MMS request encoding was captured, but no MMS response was captured.\");\n if (result.CommandTerminationReceived)\n""", + """ else if (!string.IsNullOrWhiteSpace(result.RequestHex))\n details.Add(\"MMS request encoding was captured, but no MMS response was captured.\");\n if (result.WireSteps.Count > 0)\n details.Add($\"Wire sequence: {string.Join(\" → \", result.WireSteps.Select(step => step.Action))}.\");\n if (result.CommandTerminationReceived)\n""") + +print("G1 final ordered wire integration applied") From 75991d40510b0b4ed7324144030b511cebd10dcd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:06:02 +0700 Subject: [PATCH 06/17] chore(g1): add one-shot ordered wire integration applicator --- .../apply-g1-ordered-wire-integration.yml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/apply-g1-ordered-wire-integration.yml diff --git a/.github/workflows/apply-g1-ordered-wire-integration.yml b/.github/workflows/apply-g1-ordered-wire-integration.yml new file mode 100644 index 000000000..cba2559db --- /dev/null +++ b/.github/workflows/apply-g1-ordered-wire-integration.yml @@ -0,0 +1,41 @@ +name: Apply G1 final ordered wire integration + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - main + +permissions: + contents: write + +jobs: + apply: + if: github.head_ref == 'fix/g1-control-correctness' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Apply final ordered wire integration + run: python scripts/apply-g1-ordered-wire-integration.py + - name: Remove one-shot staging files + run: | + rm scripts/apply-g1-ordered-wire-integration.py + rm .github/workflows/apply-g1-ordered-wire-integration.yml + - name: Commit materialized source + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + if git diff --cached --quiet; then + echo 'No changes to commit.' + exit 0 + fi + git commit -m 'feat(control): surface ordered SBO and Oper wire evidence' + git push origin HEAD:${{ github.head_ref }} From 62559149d6a168e2366bb30766c44ced821713db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:06:15 +0000 Subject: [PATCH 07/17] feat(control): surface ordered SBO and Oper wire evidence --- .../apply-g1-ordered-wire-integration.yml | 41 -------------- ControlCommandWindow.xaml.cs | 2 + Models/ControlModels.cs | 11 ++++ Services/Iec61850MonitorRuntime.cs | 50 ++++++++++++----- Services/NativeIec61850Client.cs | 11 +++- engines/ARIEC61850.lock.json | 4 +- scripts/apply-g1-ordered-wire-integration.py | 54 ------------------- 7 files changed, 62 insertions(+), 111 deletions(-) delete mode 100644 .github/workflows/apply-g1-ordered-wire-integration.yml delete mode 100644 scripts/apply-g1-ordered-wire-integration.py diff --git a/.github/workflows/apply-g1-ordered-wire-integration.yml b/.github/workflows/apply-g1-ordered-wire-integration.yml deleted file mode 100644 index cba2559db..000000000 --- a/.github/workflows/apply-g1-ordered-wire-integration.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Apply G1 final ordered wire integration - -on: - pull_request: - types: [opened, synchronize, reopened] - branches: - - main - -permissions: - contents: write - -jobs: - apply: - if: github.head_ref == 'fix/g1-control-correctness' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - name: Apply final ordered wire integration - run: python scripts/apply-g1-ordered-wire-integration.py - - name: Remove one-shot staging files - run: | - rm scripts/apply-g1-ordered-wire-integration.py - rm .github/workflows/apply-g1-ordered-wire-integration.yml - - name: Commit materialized source - shell: bash - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - if git diff --cached --quiet; then - echo 'No changes to commit.' - exit 0 - fi - git commit -m 'feat(control): surface ordered SBO and Oper wire evidence' - git push origin HEAD:${{ github.head_ref }} diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 8e58a6ff0..4185205f6 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -279,6 +279,8 @@ private static string BuildCommandResultText(Iec61850ControlCommandResult result 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)) diff --git a/Models/ControlModels.cs b/Models/ControlModels.cs index 2ac81f8ac..b7ddb3d7d 100644 --- a/Models/ControlModels.cs +++ b/Models/ControlModels.cs @@ -51,6 +51,16 @@ public sealed class Iec61850ControlCommandRequest 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; } @@ -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 WireSteps { get; init; } = Array.Empty(); } diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 0c7e3787a..345f4292f 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -509,13 +509,17 @@ public async Task ExecuteControlAsync( var wireState = result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase) ? "NOT SENT TO IED" - : !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"; + : 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[] { @@ -534,12 +538,32 @@ public async Task 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}"); - 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}"); + 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; } diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 2f9b532ea..239e38d82 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1789,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( diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 2c3efe30d..76b6f4d8b 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "e2c26fc4c081b785c2fe12005ada26ba9580bd61", + "commit": "438d14b0dd6dce1b86b9d1c63d6bddd13510b11a", "sourcePullRequest": 90, - "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative, and PR #90 fixes live MMS TypeSpecification size semantics for Smart Control by decoding signed primitive constraints (-N = variable length with maximum N, +N = fixed N), so the IEC 61850 two-bit Check field is no longer misread as 254 bits; the control builder keeps exact two-bit synchro/interlock semantics and fixed-width validation remains fail-closed." + "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative, and PR #90 fixes live MMS TypeSpecification size semantics for Smart Control by decoding signed primitive constraints (-N = variable length with maximum N, +N = fixed N), so the IEC 61850 two-bit Check field is no longer misread as 254 bits; the control builder keeps exact two-bit synchro/interlock semantics and fixed-width validation remains fail-closed, and G1 final PR #90 preserves ordered SBO/SBOw-to-Operate wire evidence in the native control result so field acceptance can prove each control service independently before process-feedback confirmation." } diff --git a/scripts/apply-g1-ordered-wire-integration.py b/scripts/apply-g1-ordered-wire-integration.py deleted file mode 100644 index 380cd7aaa..000000000 --- a/scripts/apply-g1-ordered-wire-integration.py +++ /dev/null @@ -1,54 +0,0 @@ -from pathlib import Path -import json - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: Path, old: str, new: str) -> None: - text = path.read_text(encoding="utf-8") - if old not in text: - raise SystemExit(f"expected block not found in {path}:\n{old[:700]}") - path.write_text(text.replace(old, new, 1), encoding="utf-8", newline="\n") - -# Final immutable G1 engine pin. -lock_path = ROOT / "engines/ARIEC61850.lock.json" -lock = json.loads(lock_path.read_text(encoding="utf-8")) -lock["commit"] = "438d14b0dd6dce1b86b9d1c63d6bddd13510b11a" -lock["sourcePullRequest"] = 90 -if "ordered SBO/SBOw" not in lock["purpose"]: - lock["purpose"] = lock["purpose"].rstrip(".") + ", and G1 final PR #90 preserves ordered SBO/SBOw-to-Operate wire evidence in the native control result so field acceptance can prove each control service independently before process-feedback confirmation." -lock_path.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8", newline="\n") - -models = ROOT / "Models/ControlModels.cs" -replace_once( - models, - """public sealed class Iec61850ControlCommandResult\n{\n""", - """public sealed class Iec61850ControlWireEvidence\n{\n public string Action { get; init; } = string.Empty;\n public string Reference { get; init; } = string.Empty;\n public bool RequestAccepted { get; init; }\n public string RequestHex { get; init; } = string.Empty;\n public string ResponseHex { get; init; } = string.Empty;\n public string Detail { get; init; } = string.Empty;\n}\n\npublic sealed class Iec61850ControlCommandResult\n{\n""") -replace_once( - models, - """ public string RequestHex { get; init; } = string.Empty;\n public string ResponseHex { get; init; } = string.Empty;\n}\n""", - """ public string RequestHex { get; init; } = string.Empty;\n public string ResponseHex { get; init; } = string.Empty;\n public IReadOnlyList WireSteps { get; init; } = Array.Empty();\n}\n""") - -native = ROOT / "Services/NativeIec61850Client.cs" -replace_once( - native, - """ TotalElapsedText = totalElapsed.HasValue ? $\"{totalElapsed.Value.TotalMilliseconds:0.###} ms\" : $\"{result.Elapsed.TotalMilliseconds:0.###} ms\",\n RequestHex = result.RequestHex,\n ResponseHex = result.ResponseHex\n };\n""", - """ TotalElapsedText = totalElapsed.HasValue ? $\"{totalElapsed.Value.TotalMilliseconds:0.###} ms\" : $\"{result.Elapsed.TotalMilliseconds:0.###} ms\",\n RequestHex = result.RequestHex,\n ResponseHex = result.ResponseHex,\n WireSteps = result.WireSteps.Select(step => new Iec61850ControlWireEvidence\n {\n Action = step.Action.ToString(),\n Reference = step.Reference,\n RequestAccepted = step.RequestAccepted,\n RequestHex = step.RequestHex,\n ResponseHex = step.ResponseHex,\n Detail = step.Detail\n }).ToArray()\n };\n""") - -runtime = ROOT / "Services/Iec61850MonitorRuntime.cs" -replace_once( - runtime, - """ var wireState = result.CompletionState.Equals(\"NotSent\", StringComparison.OrdinalIgnoreCase)\n ? \"NOT SENT TO IED\"\n : !string.IsNullOrWhiteSpace(result.ResponseHex)\n ? \"MMS response received\"\n : !string.IsNullOrWhiteSpace(result.RequestHex)\n ? \"MMS request encoded / no response captured\"\n : result.ServiceAccepted\n ? \"MMS service accepted\"\n : \"no wire evidence returned\";\n""", - """ var wireState = result.CompletionState.Equals(\"NotSent\", StringComparison.OrdinalIgnoreCase)\n ? \"NOT SENT TO IED\"\n : result.WireSteps.Count > 0 && result.WireSteps.All(step => !string.IsNullOrWhiteSpace(step.ResponseHex))\n ? $\"{result.WireSteps.Count} ordered MMS control response(s) captured\"\n : result.WireSteps.Count > 0\n ? $\"{result.WireSteps.Count} ordered MMS control step(s); incomplete response evidence\"\n : !string.IsNullOrWhiteSpace(result.ResponseHex)\n ? \"MMS response received\"\n : !string.IsNullOrWhiteSpace(result.RequestHex)\n ? \"MMS request encoded / no response captured\"\n : result.ServiceAccepted\n ? \"MMS service accepted\"\n : \"no wire evidence returned\";\n""") -replace_once( - runtime, - """ if (!string.IsNullOrWhiteSpace(result.RequestHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_REQUEST: {request.Signal.ObjectReference}; requestHEX={result.RequestHex}\");\n if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_RESPONSE: {request.Signal.ObjectReference}; responseHEX={result.ResponseHex}\");\n\n return result;\n""", - """ if (result.WireSteps.Count > 0)\n {\n for (var index = 0; index < result.WireSteps.Count; index++)\n {\n var step = result.WireSteps[index];\n Log(step.RequestAccepted ? \"INFO\" : \"WARN\", session.Device.Name,\n $\"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}\");\n if (!string.IsNullOrWhiteSpace(step.RequestHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_REQUEST: order={index + 1}; action={step.Action}; reference={step.Reference}; requestHEX={step.RequestHex}\");\n if (!string.IsNullOrWhiteSpace(step.ResponseHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_RESPONSE: order={index + 1}; action={step.Action}; reference={step.Reference}; responseHEX={step.ResponseHex}\");\n }\n }\n else\n {\n // Compatibility fallback for a local failure or older action result without\n // ordered service evidence. Never infer server acceptance from request HEX alone.\n if (!string.IsNullOrWhiteSpace(result.RequestHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_REQUEST: {request.Signal.ObjectReference}; requestHEX={result.RequestHex}\");\n if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n Log(\"INFO\", session.Device.Name,\n $\"CONTROL_WIRE_RESPONSE: {request.Signal.ObjectReference}; responseHEX={result.ResponseHex}\");\n }\n\n return result;\n""") - -window = ROOT / "ControlCommandWindow.xaml.cs" -replace_once( - window, - """ else if (!string.IsNullOrWhiteSpace(result.RequestHex))\n details.Add(\"MMS request encoding was captured, but no MMS response was captured.\");\n if (result.CommandTerminationReceived)\n""", - """ else if (!string.IsNullOrWhiteSpace(result.RequestHex))\n details.Add(\"MMS request encoding was captured, but no MMS response was captured.\");\n if (result.WireSteps.Count > 0)\n details.Add($\"Wire sequence: {string.Join(\" → \", result.WireSteps.Select(step => step.Action))}.\");\n if (result.CommandTerminationReceived)\n""") - -print("G1 final ordered wire integration applied") From c68df34b40483bade2611d1cdc5543d48e03e6d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:07:06 +0700 Subject: [PATCH 08/17] test(g1): lock final ordered control wire contract --- .../G1ControlCorrectnessRegressionTests.cs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index c269159f7..cfe97fd19 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,9 +12,10 @@ public void EngineLock_PinsExactG1ControlEngine() var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("e2c26fc4c081b785c2fe12005ada26ba9580bd61", json.GetProperty("commit").GetString()); + Assert.Equal("438d14b0dd6dce1b86b9d1c63d6bddd13510b11a", json.GetProperty("commit").GetString()); Assert.Equal(90, json.GetProperty("sourcePullRequest").GetInt32()); Assert.Contains("signed primitive constraints", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); } [Fact] @@ -30,23 +31,41 @@ public void LocalControlPreparationFailure_IsExplicitlyNotSent() } [Fact] - public void Runtime_EmitsExactWireEvidenceOnlyWhenReturnedByControlStack() + public void App_MapsOrderedNativeControlWireSteps() + { + var model = File.ReadAllText(Path.Combine(RepoRoot(), "Models", "ControlModels.cs")); + var client = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs")); + Assert.Contains("class Iec61850ControlWireEvidence", model, StringComparison.Ordinal); + Assert.Contains("IReadOnlyList WireSteps", model, StringComparison.Ordinal); + Assert.Contains("WireSteps = result.WireSteps.Select", client, StringComparison.Ordinal); + Assert.Contains("Action = step.Action.ToString()", client, StringComparison.Ordinal); + Assert.Contains("Reference = step.Reference", client, StringComparison.Ordinal); + } + + [Fact] + public void Runtime_EmitsOrderedWireStepsAndExactRequestResponseEvidence() { var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + Assert.Contains("CONTROL_WIRE_STEP:", source, StringComparison.Ordinal); + Assert.Contains("order={index + 1}", source, StringComparison.Ordinal); + Assert.Contains("action={step.Action}", source, StringComparison.Ordinal); + Assert.Contains("reference={step.Reference}", source, StringComparison.Ordinal); Assert.Contains("CONTROL_WIRE_REQUEST:", source, StringComparison.Ordinal); Assert.Contains("CONTROL_WIRE_RESPONSE:", source, StringComparison.Ordinal); - Assert.Contains("MMS request encoded / no response captured", source, StringComparison.Ordinal); - Assert.Contains("MMS response received", source, StringComparison.Ordinal); + Assert.Contains("ordered MMS control response(s) captured", source, StringComparison.Ordinal); + Assert.Contains("Never infer server acceptance from request HEX alone", source, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MMS command submitted", source, StringComparison.OrdinalIgnoreCase); } [Fact] - public void CommandUi_DistinguishesNotSentFromWireEvidence() + public void CommandUi_DistinguishesNotSentAndShowsWireSequence() { var source = File.ReadAllText(Path.Combine(RepoRoot(), "ControlCommandWindow.xaml.cs")); Assert.Contains("No MMS control request was sent to the IED", source, StringComparison.Ordinal); Assert.Contains("MMS request/response wire evidence was captured", source, StringComparison.Ordinal); Assert.Contains("MMS request encoding was captured, but no MMS response was captured", source, StringComparison.Ordinal); + Assert.Contains("Wire sequence:", source, StringComparison.Ordinal); + Assert.Contains("result.WireSteps.Select(step => step.Action)", source, StringComparison.Ordinal); } [Fact] From 0b497cf975510c645c1e750083e1e786ddfb6213 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:07:57 +0700 Subject: [PATCH 09/17] test(g1): keep P6.2-B provenance semantic across later engine pin --- ...idReportAssociationCapabilityP3RegressionTests.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs index 7590cd8ea..88ff45a7b 100644 --- a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs +++ b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace ARSAS.Tests; public sealed class HybridReportAssociationCapabilityP3RegressionTests @@ -18,12 +20,16 @@ public void HybridPlanning_UsesAssociationCapabilityForInitialPlanAndFreshRevali } [Fact] - public void EngineLock_PinsP62BStabilityEngineWhilePreservingP61AndP62EvidenceHistory() + public void EngineLock_PreservesP62BStabilityHistoryAcrossLaterReviewedEnginePins() { var source = Read("engines/ARIEC61850.lock.json"); + using var document = JsonDocument.Parse(source); + var root = document.RootElement; - Assert.Contains("249fb130e0e18e7a98e07e8894f24610bdb5642e", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 89", source, StringComparison.Ordinal); + Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); + Assert.Equal("main", root.GetProperty("ref").GetString()); + Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); Assert.Contains("PR #87", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("baseline-safe static precedence", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #88", source, StringComparison.OrdinalIgnoreCase); From 6c6bd9ceaa2c4758b2c4eafe8fa7e6200757d6b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:08:46 +0700 Subject: [PATCH 10/17] test(g1): preserve P6.2-B field policy across later engine pin --- tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs index 9039fcd6d..3910c301c 100644 --- a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs @@ -7,7 +7,7 @@ namespace ARSAS.Tests; public sealed class P62BFieldStabilityRegressionTests { [Fact] - public void EngineLock_PinsReviewedP62BEngine() + public void EngineLock_PreservesReviewedP62BPolicyAcrossLaterEnginePins() { var source = ReadRepoFile("engines/ARIEC61850.lock.json"); using var document = JsonDocument.Parse(source); @@ -15,9 +15,11 @@ public void EngineLock_PinsReviewedP62BEngine() Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); Assert.Equal("main", root.GetProperty("ref").GetString()); - Assert.Equal("249fb130e0e18e7a98e07e8894f24610bdb5642e", root.GetProperty("commit").GetString()); - Assert.Equal(89, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); + Assert.Contains("PR #89", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("quarantines automatic full dynamic DataSet activation", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("successful one-member NVL probation does not guarantee association survival", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("ambiguous structures remain raw", source, StringComparison.OrdinalIgnoreCase); } From d1a151d4dd5d0fd61a304cbd60fe80d5bcda30f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:09:18 +0700 Subject: [PATCH 11/17] test(g1): preserve offline report provenance across later engine pin --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index b8223a9a0..b9d397a60 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -69,14 +69,16 @@ public void SignalSelectionRecovery_RunsAgainAfterLegacyConstructorDisplayPrepar } [Fact] - public void EngineLock_PreservesReportProjectionHistoryAndPinsP62BStabilityEngine() + public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEnginePins() { var source = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); using var document = JsonDocument.Parse(source); var root = document.RootElement; - Assert.Equal("249fb130e0e18e7a98e07e8894f24610bdb5642e", root.GetProperty("commit").GetString()); - Assert.Equal(89, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); + Assert.Equal("main", root.GetProperty("ref").GetString()); + Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); From 2495d8981708e66286250e59ae519cbbb1637eba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:49:26 +0700 Subject: [PATCH 12/17] G1.1 apply field-derived control diagnostics and origin fix --- .github/workflows/g11-field-control-patch.yml | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/g11-field-control-patch.yml diff --git a/.github/workflows/g11-field-control-patch.yml b/.github/workflows/g11-field-control-patch.yml new file mode 100644 index 000000000..36190a8e2 --- /dev/null +++ b/.github/workflows/g11-field-control-patch.yml @@ -0,0 +1,122 @@ +name: G1.1 field control patch + +on: + push: + branches: + - fix/g1-control-correctness + +permissions: + contents: write + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/g1-control-correctness + fetch-depth: 0 + - name: Apply G1.1 field-derived patch + shell: python + run: | + from pathlib import Path + import json, re + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text(encoding='utf-8') + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected one occurrence, found {count}: {old[:100]!r}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + def replace_regex(path, pattern, replacement): + p = Path(path) + text = p.read_text(encoding='utf-8') + updated, count = re.subn(pattern, replacement, text, count=1, flags=re.S) + if count != 1: + raise SystemExit(f'{path}: regex replacement count={count}') + p.write_text(updated, encoding='utf-8') + + # Manual workstation commands are station-level operator controls, not maintenance-tool controls. + replace_once( + 'Models/ControlModels.cs', + 'public string OriginCategory { get; init; } = "Maintenance";', + 'public string OriginCategory { get; init; } = "StationControl";') + + replace_once( + 'ControlCommandWindow.xaml.cs', + 'OriginCategory = "Maintenance"', + 'OriginCategory = "StationControl"') + + replace_regex( + 'ControlCommandWindow.xaml.cs', + r' private static string BuildCommandResultText\(Iec61850ControlCommandResult result\)\n \{.*?\n \}\n\n private static bool TryExtractNumber', + ''' private static string BuildCommandResultText(Iec61850ControlCommandResult result)\n {\n var details = new List();\n var rejectedStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);\n if (rejectedStep != null)\n {\n var rejectedStage = rejectedStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) ? "SBOw" : rejectedStep.Action;\n details.Add($"IED REJECTED {rejectedStage}: {result.Message}");\n var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));\n if (rejectedStage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent)\n {\n details.Add("Operate was NOT sent because SBOw selection failed.");\n details.Add("CommandTermination is not expected because Operate never started.");\n }\n }\n else\n {\n details.Add(result.Message);\n }\n\n if (result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase))\n details.Add("No MMS control request was sent to the IED.");\n else if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n details.Add("MMS request/response wire evidence was captured.");\n else if (!string.IsNullOrWhiteSpace(result.RequestHex))\n details.Add("MMS request encoding was captured, but no MMS response was captured.");\n if (result.WireSteps.Count > 0)\n details.Add($"Wire sequence: {string.Join(" → ", result.WireSteps.Select(step => step.Action))}.");\n if (result.CommandTerminationReceived)\n details.Add(result.PositiveTermination ? "Positive CommandTermination received." : "Negative CommandTermination received.");\n if (!string.IsNullOrWhiteSpace(result.ControlError))\n details.Add($"ControlError: {result.ControlError}.");\n if (!string.IsNullOrWhiteSpace(result.AddCause))\n {\n details.Add($"AddCause: {result.AddCause}.");\n details.Add(ExplainAddCause(result.AddCause));\n }\n if (!string.IsNullOrWhiteSpace(result.LastApplErrorText))\n details.Add(result.LastApplErrorText);\n if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-")\n details.Add($"Control service: {result.ElapsedText}.");\n if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")\n details.Add($"Process feedback: {result.FeedbackElapsedText}.");\n if (!string.IsNullOrWhiteSpace(result.TotalElapsedText) && result.TotalElapsedText != "-")\n details.Add($"Total: {result.TotalElapsedText}.");\n return string.Join(" ", details.Where(text => !string.IsNullOrWhiteSpace(text)));\n }\n\n private static string ExplainAddCause(string addCause)\n => (addCause ?? string.Empty).Trim().ToLowerInvariant() switch\n {\n "blocked-by-interlocking" => "IED BLOCKED COMMAND BY INTERLOCKING.",\n "blocked-by-synchrocheck" => "IED BLOCKED COMMAND BY SYNCHROCHECK.",\n "blocked-by-mode" => "IED blocked the command because the active control mode does not permit it.",\n "blocked-by-process" => "IED blocked the command by process conditions.",\n "blocked-by-health" => "IED blocked the command because of device/process health conditions.",\n "no-access-authority" => "IED reports that this client/origin has no control access authority.",\n "not-supported" => "IED reports that the requested control condition/service is not supported.",\n _ => string.Empty\n };\n\n private static bool TryExtractNumber''') + + # Quick command path: remove the false implication that wire send already happened, + # use station-level origin, and surface the rejected service prominently. + replace_once( + 'MainWindow.xaml.cs', + '$"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."') + replace_once( + 'MainWindow.xaml.cs', + 'OriginCategory = "Maintenance"', + 'OriginCategory = "StationControl"') + replace_regex( + 'MainWindow.xaml.cs', + r' private static string BuildQuickControlResult\(Iec61850ControlCommandResult result\)\n \{.*?\n \}\n\n private async void ControlDetails_Click', + ''' private static string BuildQuickControlResult(Iec61850ControlCommandResult result)\n {\n var timing = new List();\n if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-")\n timing.Add($"control {result.ElapsedText}");\n if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")\n timing.Add($"feedback {result.FeedbackElapsedText}");\n var suffix = timing.Count == 0 ? string.Empty : $" • {string.Join(" • ", timing)}";\n\n if (!result.IsSuccess)\n {\n var rejectedStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);\n if (rejectedStep != null)\n {\n var stage = rejectedStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) ? "SBOw" : rejectedStep.Action;\n var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));\n var stopped = stage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent\n ? " • Operate NOT sent"\n : string.Empty;\n var cause = string.IsNullOrWhiteSpace(result.AddCause) ? string.Empty : $" • AddCause={result.AddCause}";\n return $"IED REJECTED {stage}: {result.Message}{cause}{stopped}{suffix}";\n }\n }\n\n return result.IsSuccess\n ? $"{result.Stage}: {result.FeedbackValue}{suffix}"\n : $"{result.Stage}: {result.Message}{suffix}";\n }\n\n private async void ControlDetails_Click''') + + # Invalid/unknown caller category must default to a station-level manual control origin. + replace_once( + 'Services/NativeIec61850Client.cs', + ': ArControl.Iec61850OriginCategory.Maintenance;', + ': ArControl.Iec61850OriginCategory.StationControl;') + + # Runtime diagnostic: expose effective origin and a high-level IED rejection line. + replace_once( + 'Services/Iec61850MonitorRuntime.cs', + '$"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}."', + '$"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}."') + + runtime_marker = ''' Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,\n $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");\n\n if (result.WireSteps.Count > 0)''' + runtime_replacement = ''' Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,\n $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");\n\n var rejectedWireStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);\n if (rejectedWireStep != null)\n {\n var rejectedStage = rejectedWireStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase)\n ? "SBOw"\n : rejectedWireStep.Action;\n var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));\n Log("ERROR", session.Device.Name,\n $"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}.");\n }\n\n if (result.WireSteps.Count > 0)''' + replace_once('Services/Iec61850MonitorRuntime.cs', runtime_marker, runtime_replacement) + + # Pin the exact G1.1 engine that names the field MMS DataAccessError. + lock_path = Path('engines/ARIEC61850.lock.json') + lock = json.loads(lock_path.read_text(encoding='utf-8')) + lock['commit'] = 'a18e550d07f7bbe4ff7753c180b02615075f6292' + lock['sourcePullRequest'] = 90 + if 'G1.1' not in lock['purpose']: + lock['purpose'] += ' G1.1 decodes MMS Write DataAccessError values explicitly, including physical SIPROTEC SBOw response code 3 as object-access-denied, while preserving unknown numeric vendor codes for diagnostics.' + lock_path.write_text(json.dumps(lock, indent=2) + '\n', encoding='utf-8') + + # Keep G1 regressions tied to field evidence and prevent silent origin/retry regressions. + test_path = Path('tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs') + test = test_path.read_text(encoding='utf-8') + test = test.replace('438d14b0dd6dce1b86b9d1c63d6bddd13510b11a', 'a18e550d07f7bbe4ff7753c180b02615075f6292') + test = test.replace( + ' Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase);', + ' Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase);\n Assert.Contains("object-access-denied", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase);') + anchor = ''' [Fact]\n public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy()''' + inserted = ''' [Fact]\n public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl()\n {\n var model = File.ReadAllText(Path.Combine(RepoRoot(), "Models", "ControlModels.cs"));\n var dialog = File.ReadAllText(Path.Combine(RepoRoot(), "ControlCommandWindow.xaml.cs"));\n var main = File.ReadAllText(Path.Combine(RepoRoot(), "MainWindow.xaml.cs"));\n var client = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs"));\n var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs"));\n\n Assert.Contains("OriginCategory { get; init; } = \\"StationControl\\"", model, StringComparison.Ordinal);\n Assert.Contains("OriginCategory = \\"StationControl\\"", dialog, StringComparison.Ordinal);\n Assert.Contains("OriginCategory = \\"StationControl\\"", main, StringComparison.Ordinal);\n Assert.Contains("Iec61850OriginCategory.StationControl", client, StringComparison.Ordinal);\n Assert.DoesNotContain("MMS command submitted:", main, StringComparison.Ordinal);\n Assert.Contains("wire send is not assumed until native evidence is returned", main, StringComparison.OrdinalIgnoreCase);\n Assert.Contains("IED REJECTED SBOw", dialog, StringComparison.Ordinal);\n Assert.Contains("Operate was NOT sent because SBOw selection failed", dialog, StringComparison.Ordinal);\n Assert.Contains("IED BLOCKED COMMAND BY INTERLOCKING", dialog, StringComparison.Ordinal);\n Assert.Contains("IED BLOCKED COMMAND BY SYNCHROCHECK", dialog, StringComparison.Ordinal);\n Assert.Contains("requested control condition/service is not supported", dialog, StringComparison.Ordinal);\n Assert.Contains("CONTROL_REJECTED_BY_IED:", runtime, StringComparison.Ordinal);\n Assert.Contains("OperateSent={operateSent}", runtime, StringComparison.Ordinal);\n Assert.Contains("origin={request.OriginCategory}/{request.Originator}", runtime, StringComparison.Ordinal);\n Assert.DoesNotContain("RetryControl", runtime, StringComparison.OrdinalIgnoreCase);\n }\n\n [Fact]\n public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy()''' + if anchor not in test: + raise SystemExit('G1 test insertion anchor missing') + test = test.replace(anchor, inserted, 1) + test_path.write_text(test, encoding='utf-8') + + # This workflow is an applicator only; remove it from the resulting source commit. + Path('.github/workflows/g11-field-control-patch.yml').unlink() + + - name: Commit G1.1 source + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "G1.1 surface IED control rejection and use station origin" + git push origin HEAD:fix/g1-control-correctness From 66cd852ca830d3c322ba57da3680a528940cf67e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:49:38 +0000 Subject: [PATCH 13/17] G1.1 surface IED control rejection and use station origin --- .github/workflows/g11-field-control-patch.yml | 122 ------------------ ControlCommandWindow.xaml.cs | 37 +++++- MainWindow.xaml.cs | 20 ++- Models/ControlModels.cs | 2 +- Services/Iec61850MonitorRuntime.cs | 13 +- Services/NativeIec61850Client.cs | 2 +- engines/ARIEC61850.lock.json | 4 +- .../G1ControlCorrectnessRegressionTests.cs | 29 ++++- 8 files changed, 97 insertions(+), 132 deletions(-) delete mode 100644 .github/workflows/g11-field-control-patch.yml diff --git a/.github/workflows/g11-field-control-patch.yml b/.github/workflows/g11-field-control-patch.yml deleted file mode 100644 index 36190a8e2..000000000 --- a/.github/workflows/g11-field-control-patch.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: G1.1 field control patch - -on: - push: - branches: - - fix/g1-control-correctness - -permissions: - contents: write - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/g1-control-correctness - fetch-depth: 0 - - name: Apply G1.1 field-derived patch - shell: python - run: | - from pathlib import Path - import json, re - - def replace_once(path, old, new): - p = Path(path) - text = p.read_text(encoding='utf-8') - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected one occurrence, found {count}: {old[:100]!r}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - def replace_regex(path, pattern, replacement): - p = Path(path) - text = p.read_text(encoding='utf-8') - updated, count = re.subn(pattern, replacement, text, count=1, flags=re.S) - if count != 1: - raise SystemExit(f'{path}: regex replacement count={count}') - p.write_text(updated, encoding='utf-8') - - # Manual workstation commands are station-level operator controls, not maintenance-tool controls. - replace_once( - 'Models/ControlModels.cs', - 'public string OriginCategory { get; init; } = "Maintenance";', - 'public string OriginCategory { get; init; } = "StationControl";') - - replace_once( - 'ControlCommandWindow.xaml.cs', - 'OriginCategory = "Maintenance"', - 'OriginCategory = "StationControl"') - - replace_regex( - 'ControlCommandWindow.xaml.cs', - r' private static string BuildCommandResultText\(Iec61850ControlCommandResult result\)\n \{.*?\n \}\n\n private static bool TryExtractNumber', - ''' private static string BuildCommandResultText(Iec61850ControlCommandResult result)\n {\n var details = new List();\n var rejectedStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);\n if (rejectedStep != null)\n {\n var rejectedStage = rejectedStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) ? "SBOw" : rejectedStep.Action;\n details.Add($"IED REJECTED {rejectedStage}: {result.Message}");\n var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));\n if (rejectedStage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent)\n {\n details.Add("Operate was NOT sent because SBOw selection failed.");\n details.Add("CommandTermination is not expected because Operate never started.");\n }\n }\n else\n {\n details.Add(result.Message);\n }\n\n if (result.CompletionState.Equals("NotSent", StringComparison.OrdinalIgnoreCase))\n details.Add("No MMS control request was sent to the IED.");\n else if (!string.IsNullOrWhiteSpace(result.ResponseHex))\n details.Add("MMS request/response wire evidence was captured.");\n else if (!string.IsNullOrWhiteSpace(result.RequestHex))\n details.Add("MMS request encoding was captured, but no MMS response was captured.");\n if (result.WireSteps.Count > 0)\n details.Add($"Wire sequence: {string.Join(" → ", result.WireSteps.Select(step => step.Action))}.");\n if (result.CommandTerminationReceived)\n details.Add(result.PositiveTermination ? "Positive CommandTermination received." : "Negative CommandTermination received.");\n if (!string.IsNullOrWhiteSpace(result.ControlError))\n details.Add($"ControlError: {result.ControlError}.");\n if (!string.IsNullOrWhiteSpace(result.AddCause))\n {\n details.Add($"AddCause: {result.AddCause}.");\n details.Add(ExplainAddCause(result.AddCause));\n }\n if (!string.IsNullOrWhiteSpace(result.LastApplErrorText))\n details.Add(result.LastApplErrorText);\n if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-")\n details.Add($"Control service: {result.ElapsedText}.");\n if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")\n details.Add($"Process feedback: {result.FeedbackElapsedText}.");\n if (!string.IsNullOrWhiteSpace(result.TotalElapsedText) && result.TotalElapsedText != "-")\n details.Add($"Total: {result.TotalElapsedText}.");\n return string.Join(" ", details.Where(text => !string.IsNullOrWhiteSpace(text)));\n }\n\n private static string ExplainAddCause(string addCause)\n => (addCause ?? string.Empty).Trim().ToLowerInvariant() switch\n {\n "blocked-by-interlocking" => "IED BLOCKED COMMAND BY INTERLOCKING.",\n "blocked-by-synchrocheck" => "IED BLOCKED COMMAND BY SYNCHROCHECK.",\n "blocked-by-mode" => "IED blocked the command because the active control mode does not permit it.",\n "blocked-by-process" => "IED blocked the command by process conditions.",\n "blocked-by-health" => "IED blocked the command because of device/process health conditions.",\n "no-access-authority" => "IED reports that this client/origin has no control access authority.",\n "not-supported" => "IED reports that the requested control condition/service is not supported.",\n _ => string.Empty\n };\n\n private static bool TryExtractNumber''') - - # Quick command path: remove the false implication that wire send already happened, - # use station-level origin, and surface the rejected service prominently. - replace_once( - 'MainWindow.xaml.cs', - '$"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."') - replace_once( - 'MainWindow.xaml.cs', - 'OriginCategory = "Maintenance"', - 'OriginCategory = "StationControl"') - replace_regex( - 'MainWindow.xaml.cs', - r' private static string BuildQuickControlResult\(Iec61850ControlCommandResult result\)\n \{.*?\n \}\n\n private async void ControlDetails_Click', - ''' private static string BuildQuickControlResult(Iec61850ControlCommandResult result)\n {\n var timing = new List();\n if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-")\n timing.Add($"control {result.ElapsedText}");\n if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-")\n timing.Add($"feedback {result.FeedbackElapsedText}");\n var suffix = timing.Count == 0 ? string.Empty : $" • {string.Join(" • ", timing)}";\n\n if (!result.IsSuccess)\n {\n var rejectedStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);\n if (rejectedStep != null)\n {\n var stage = rejectedStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase) ? "SBOw" : rejectedStep.Action;\n var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));\n var stopped = stage.Equals("SBOw", StringComparison.OrdinalIgnoreCase) && !operateSent\n ? " • Operate NOT sent"\n : string.Empty;\n var cause = string.IsNullOrWhiteSpace(result.AddCause) ? string.Empty : $" • AddCause={result.AddCause}";\n return $"IED REJECTED {stage}: {result.Message}{cause}{stopped}{suffix}";\n }\n }\n\n return result.IsSuccess\n ? $"{result.Stage}: {result.FeedbackValue}{suffix}"\n : $"{result.Stage}: {result.Message}{suffix}";\n }\n\n private async void ControlDetails_Click''') - - # Invalid/unknown caller category must default to a station-level manual control origin. - replace_once( - 'Services/NativeIec61850Client.cs', - ': ArControl.Iec61850OriginCategory.Maintenance;', - ': ArControl.Iec61850OriginCategory.StationControl;') - - # Runtime diagnostic: expose effective origin and a high-level IED rejection line. - replace_once( - 'Services/Iec61850MonitorRuntime.cs', - '$"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}."', - '$"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}."') - - runtime_marker = ''' Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,\n $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");\n\n if (result.WireSteps.Count > 0)''' - runtime_replacement = ''' Log(result.IsSuccess ? "INFO" : "ERROR", session.Device.Name,\n $"Control {result.Stage}: {request.Signal.ObjectReference}; sequence={result.SequenceText}; requested={result.RequestedValue}; feedback={result.FeedbackValue}; {protocolEvidence}; {result.Message}");\n\n var rejectedWireStep = result.WireSteps.FirstOrDefault(step => !step.RequestAccepted);\n if (rejectedWireStep != null)\n {\n var rejectedStage = rejectedWireStep.Action.Equals("SelectWithValue", StringComparison.OrdinalIgnoreCase)\n ? "SBOw"\n : rejectedWireStep.Action;\n var operateSent = result.WireSteps.Any(step => step.Action.Equals("Operate", StringComparison.OrdinalIgnoreCase));\n Log("ERROR", session.Device.Name,\n $"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}.");\n }\n\n if (result.WireSteps.Count > 0)''' - replace_once('Services/Iec61850MonitorRuntime.cs', runtime_marker, runtime_replacement) - - # Pin the exact G1.1 engine that names the field MMS DataAccessError. - lock_path = Path('engines/ARIEC61850.lock.json') - lock = json.loads(lock_path.read_text(encoding='utf-8')) - lock['commit'] = 'a18e550d07f7bbe4ff7753c180b02615075f6292' - lock['sourcePullRequest'] = 90 - if 'G1.1' not in lock['purpose']: - lock['purpose'] += ' G1.1 decodes MMS Write DataAccessError values explicitly, including physical SIPROTEC SBOw response code 3 as object-access-denied, while preserving unknown numeric vendor codes for diagnostics.' - lock_path.write_text(json.dumps(lock, indent=2) + '\n', encoding='utf-8') - - # Keep G1 regressions tied to field evidence and prevent silent origin/retry regressions. - test_path = Path('tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs') - test = test_path.read_text(encoding='utf-8') - test = test.replace('438d14b0dd6dce1b86b9d1c63d6bddd13510b11a', 'a18e550d07f7bbe4ff7753c180b02615075f6292') - test = test.replace( - ' Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase);', - ' Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase);\n Assert.Contains("object-access-denied", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase);') - anchor = ''' [Fact]\n public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy()''' - inserted = ''' [Fact]\n public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl()\n {\n var model = File.ReadAllText(Path.Combine(RepoRoot(), "Models", "ControlModels.cs"));\n var dialog = File.ReadAllText(Path.Combine(RepoRoot(), "ControlCommandWindow.xaml.cs"));\n var main = File.ReadAllText(Path.Combine(RepoRoot(), "MainWindow.xaml.cs"));\n var client = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs"));\n var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs"));\n\n Assert.Contains("OriginCategory { get; init; } = \\"StationControl\\"", model, StringComparison.Ordinal);\n Assert.Contains("OriginCategory = \\"StationControl\\"", dialog, StringComparison.Ordinal);\n Assert.Contains("OriginCategory = \\"StationControl\\"", main, StringComparison.Ordinal);\n Assert.Contains("Iec61850OriginCategory.StationControl", client, StringComparison.Ordinal);\n Assert.DoesNotContain("MMS command submitted:", main, StringComparison.Ordinal);\n Assert.Contains("wire send is not assumed until native evidence is returned", main, StringComparison.OrdinalIgnoreCase);\n Assert.Contains("IED REJECTED SBOw", dialog, StringComparison.Ordinal);\n Assert.Contains("Operate was NOT sent because SBOw selection failed", dialog, StringComparison.Ordinal);\n Assert.Contains("IED BLOCKED COMMAND BY INTERLOCKING", dialog, StringComparison.Ordinal);\n Assert.Contains("IED BLOCKED COMMAND BY SYNCHROCHECK", dialog, StringComparison.Ordinal);\n Assert.Contains("requested control condition/service is not supported", dialog, StringComparison.Ordinal);\n Assert.Contains("CONTROL_REJECTED_BY_IED:", runtime, StringComparison.Ordinal);\n Assert.Contains("OperateSent={operateSent}", runtime, StringComparison.Ordinal);\n Assert.Contains("origin={request.OriginCategory}/{request.Originator}", runtime, StringComparison.Ordinal);\n Assert.DoesNotContain("RetryControl", runtime, StringComparison.OrdinalIgnoreCase);\n }\n\n [Fact]\n public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy()''' - if anchor not in test: - raise SystemExit('G1 test insertion anchor missing') - test = test.replace(anchor, inserted, 1) - test_path.write_text(test, encoding='utf-8') - - # This workflow is an applicator only; remove it from the resulting source commit. - Path('.github/workflows/g11-field-control-patch.yml').unlink() - - - name: Commit G1.1 source - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "G1.1 surface IED control rejection and use station origin" - git push origin HEAD:fix/g1-control-correctness diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 4185205f6..23d9014ee 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -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); @@ -272,7 +272,24 @@ private void PopulateValueOptions(string cdc, string currentValue) private static string BuildCommandResultText(Iec61850ControlCommandResult result) { - var details = new List { result.Message }; + var details = new List(); + 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)) @@ -286,7 +303,10 @@ private static string BuildCommandResultText(Iec61850ControlCommandResult result 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 != "-") @@ -298,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; diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 80793e070..408cb0f29 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -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 @@ -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); @@ -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}"; diff --git a/Models/ControlModels.cs b/Models/ControlModels.cs index b7ddb3d7d..b0a20a9d7 100644 --- a/Models/ControlModels.cs +++ b/Models/ControlModels.cs @@ -46,7 +46,7 @@ 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; } diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 345f4292f..d0b3f39a4 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -486,7 +486,7 @@ public async Task 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 intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}."); var clientStopwatch = Stopwatch.StartNew(); Interlocked.Increment(ref session.ControlCommandActive); @@ -538,6 +538,17 @@ public async Task 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++) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 239e38d82..507f2d34e 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1719,7 +1719,7 @@ _ when cdc.Contains("INC", StringComparison.OrdinalIgnoreCase) || cdc.Contains(" private static ArControl.Iec61850OriginCategory ParseOriginCategory(string? text) => Enum.TryParse(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" : "-"; diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 76b6f4d8b..290c27523 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "438d14b0dd6dce1b86b9d1c63d6bddd13510b11a", + "commit": "a18e550d07f7bbe4ff7753c180b02615075f6292", "sourcePullRequest": 90, - "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative, and PR #90 fixes live MMS TypeSpecification size semantics for Smart Control by decoding signed primitive constraints (-N = variable length with maximum N, +N = fixed N), so the IEC 61850 two-bit Check field is no longer misread as 254 bits; the control builder keeps exact two-bit synchro/interlock semantics and fixed-width validation remains fail-closed, and G1 final PR #90 preserves ordered SBO/SBOw-to-Operate wire evidence in the native control result so field acceptance can prove each control service independently before process-feedback confirmation." + "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative, and PR #90 fixes live MMS TypeSpecification size semantics for Smart Control by decoding signed primitive constraints (-N = variable length with maximum N, +N = fixed N), so the IEC 61850 two-bit Check field is no longer misread as 254 bits; the control builder keeps exact two-bit synchro/interlock semantics and fixed-width validation remains fail-closed, and G1 final PR #90 preserves ordered SBO/SBOw-to-Operate wire evidence in the native control result so field acceptance can prove each control service independently before process-feedback confirmation. G1.1 decodes MMS Write DataAccessError values explicitly, including physical SIPROTEC SBOw response code 3 as object-access-denied, while preserving unknown numeric vendor codes for diagnostics." } diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index cfe97fd19..78bca7217 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,10 +12,11 @@ public void EngineLock_PinsExactG1ControlEngine() var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("438d14b0dd6dce1b86b9d1c63d6bddd13510b11a", json.GetProperty("commit").GetString()); + Assert.Equal("a18e550d07f7bbe4ff7753c180b02615075f6292", json.GetProperty("commit").GetString()); Assert.Equal(90, json.GetProperty("sourcePullRequest").GetInt32()); Assert.Contains("signed primitive constraints", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("object-access-denied", json.GetProperty("purpose").GetString(), StringComparison.OrdinalIgnoreCase); } [Fact] @@ -68,6 +69,32 @@ public void CommandUi_DistinguishesNotSentAndShowsWireSequence() Assert.Contains("result.WireSteps.Select(step => step.Action)", source, StringComparison.Ordinal); } + [Fact] + public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl() + { + var model = File.ReadAllText(Path.Combine(RepoRoot(), "Models", "ControlModels.cs")); + var dialog = File.ReadAllText(Path.Combine(RepoRoot(), "ControlCommandWindow.xaml.cs")); + var main = File.ReadAllText(Path.Combine(RepoRoot(), "MainWindow.xaml.cs")); + var client = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "NativeIec61850Client.cs")); + var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + + Assert.Contains("OriginCategory { get; init; } = \"StationControl\"", model, StringComparison.Ordinal); + Assert.Contains("OriginCategory = \"StationControl\"", dialog, StringComparison.Ordinal); + Assert.Contains("OriginCategory = \"StationControl\"", main, StringComparison.Ordinal); + Assert.Contains("Iec61850OriginCategory.StationControl", client, StringComparison.Ordinal); + Assert.DoesNotContain("MMS command submitted:", main, StringComparison.Ordinal); + Assert.Contains("wire send is not assumed until native evidence is returned", main, StringComparison.OrdinalIgnoreCase); + Assert.Contains("IED REJECTED SBOw", dialog, StringComparison.Ordinal); + Assert.Contains("Operate was NOT sent because SBOw selection failed", dialog, StringComparison.Ordinal); + Assert.Contains("IED BLOCKED COMMAND BY INTERLOCKING", dialog, StringComparison.Ordinal); + Assert.Contains("IED BLOCKED COMMAND BY SYNCHROCHECK", dialog, StringComparison.Ordinal); + Assert.Contains("requested control condition/service is not supported", dialog, StringComparison.Ordinal); + Assert.Contains("CONTROL_REJECTED_BY_IED:", runtime, StringComparison.Ordinal); + Assert.Contains("OperateSent={operateSent}", runtime, StringComparison.Ordinal); + Assert.Contains("origin={request.OriginCategory}/{request.Originator}", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("RetryControl", runtime, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() { From 1255e5d73521ba3c4bb3d49ba77a472894901d7b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:50:55 +0700 Subject: [PATCH 14/17] G1.1 remove ambiguous control accepted wording --- .github/workflows/g11-wording-patch.yml | 43 +++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/g11-wording-patch.yml diff --git a/.github/workflows/g11-wording-patch.yml b/.github/workflows/g11-wording-patch.yml new file mode 100644 index 000000000..a7083e329 --- /dev/null +++ b/.github/workflows/g11-wording-patch.yml @@ -0,0 +1,43 @@ +name: G1.1 control wording patch + +on: + push: + branches: [fix/g1-control-correctness] + +permissions: + contents: write + +jobs: + apply: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/g1-control-correctness + - shell: python + run: | + from pathlib import Path + p = Path('Services/Iec61850MonitorRuntime.cs') + text = p.read_text(encoding='utf-8') + old = '$"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}."' + new = '$"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."' + if text.count(old) != 1: + raise SystemExit(f'expected one runtime wording occurrence, found {text.count(old)}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + t = Path('tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs') + test = t.read_text(encoding='utf-8') + anchor = ' Assert.Contains("CONTROL_REJECTED_BY_IED:", runtime, StringComparison.Ordinal);\n' + addition = anchor + ' Assert.Contains("Control execution requested:", runtime, StringComparison.Ordinal);\n Assert.DoesNotContain("Control intent accepted:", runtime, StringComparison.Ordinal);\n' + if test.count(anchor) != 1: + raise SystemExit('test anchor missing') + t.write_text(test.replace(anchor, addition, 1), encoding='utf-8') + Path('.github/workflows/g11-wording-patch.yml').unlink() + - run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "G1.1 make IED acceptance wording evidence-based" + git push origin HEAD:fix/g1-control-correctness From 414b1cf4f74688574ca1b92531eb941ea0aa4305 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:51:05 +0000 Subject: [PATCH 15/17] G1.1 make IED acceptance wording evidence-based --- .github/workflows/g11-wording-patch.yml | 43 ------------------- Services/Iec61850MonitorRuntime.cs | 2 +- .../G1ControlCorrectnessRegressionTests.cs | 2 + 3 files changed, 3 insertions(+), 44 deletions(-) delete mode 100644 .github/workflows/g11-wording-patch.yml diff --git a/.github/workflows/g11-wording-patch.yml b/.github/workflows/g11-wording-patch.yml deleted file mode 100644 index a7083e329..000000000 --- a/.github/workflows/g11-wording-patch.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: G1.1 control wording patch - -on: - push: - branches: [fix/g1-control-correctness] - -permissions: - contents: write - -jobs: - apply: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/g1-control-correctness - - shell: python - run: | - from pathlib import Path - p = Path('Services/Iec61850MonitorRuntime.cs') - text = p.read_text(encoding='utf-8') - old = '$"Control intent accepted: {request.Signal.ObjectReference} value={request.ValueText}; test={request.TestMode}; interlock={request.InterlockCheck}; synchro={request.SynchroCheck}; origin={request.OriginCategory}/{request.Originator}."' - new = '$"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."' - if text.count(old) != 1: - raise SystemExit(f'expected one runtime wording occurrence, found {text.count(old)}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - t = Path('tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs') - test = t.read_text(encoding='utf-8') - anchor = ' Assert.Contains("CONTROL_REJECTED_BY_IED:", runtime, StringComparison.Ordinal);\n' - addition = anchor + ' Assert.Contains("Control execution requested:", runtime, StringComparison.Ordinal);\n Assert.DoesNotContain("Control intent accepted:", runtime, StringComparison.Ordinal);\n' - if test.count(anchor) != 1: - raise SystemExit('test anchor missing') - t.write_text(test.replace(anchor, addition, 1), encoding='utf-8') - Path('.github/workflows/g11-wording-patch.yml').unlink() - - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "G1.1 make IED acceptance wording evidence-based" - git push origin HEAD:fix/g1-control-correctness diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index d0b3f39a4..942d0b001 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -486,7 +486,7 @@ public async Task 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}; origin={request.OriginCategory}/{request.Originator}."); + $"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); diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 78bca7217..fb86eaab9 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -90,6 +90,8 @@ public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl() Assert.Contains("IED BLOCKED COMMAND BY SYNCHROCHECK", dialog, StringComparison.Ordinal); Assert.Contains("requested control condition/service is not supported", dialog, StringComparison.Ordinal); Assert.Contains("CONTROL_REJECTED_BY_IED:", runtime, StringComparison.Ordinal); + Assert.Contains("Control execution requested:", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("Control intent accepted:", runtime, StringComparison.Ordinal); Assert.Contains("OperateSent={operateSent}", runtime, StringComparison.Ordinal); Assert.Contains("origin={request.OriginCategory}/{request.Originator}", runtime, StringComparison.Ordinal); Assert.DoesNotContain("RetryControl", runtime, StringComparison.OrdinalIgnoreCase); From 502a1e82b11af1b483fff5d14f55fe9c06f915eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:52:02 +0700 Subject: [PATCH 16/17] G1.1 lock manual station-control origin --- tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index fb86eaab9..b4e7298e4 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -81,6 +81,8 @@ public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl() Assert.Contains("OriginCategory { get; init; } = \"StationControl\"", model, StringComparison.Ordinal); Assert.Contains("OriginCategory = \"StationControl\"", dialog, StringComparison.Ordinal); Assert.Contains("OriginCategory = \"StationControl\"", main, StringComparison.Ordinal); + Assert.DoesNotContain("OriginCategory = \"Maintenance\"", dialog, StringComparison.Ordinal); + Assert.DoesNotContain("OriginCategory = \"Maintenance\"", main, StringComparison.Ordinal); Assert.Contains("Iec61850OriginCategory.StationControl", client, StringComparison.Ordinal); Assert.DoesNotContain("MMS command submitted:", main, StringComparison.Ordinal); Assert.Contains("wire send is not assumed until native evidence is returned", main, StringComparison.OrdinalIgnoreCase); From 2c8de5b28667af623c8e5e0c3dfeeb7d723d7290 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 19 Aug 2026 14:54:29 +0700 Subject: [PATCH 17/17] G1.1 fix staged rejection regression assertion --- tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index b4e7298e4..06c8bb3cb 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -86,7 +86,9 @@ public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl() Assert.Contains("Iec61850OriginCategory.StationControl", client, StringComparison.Ordinal); Assert.DoesNotContain("MMS command submitted:", main, StringComparison.Ordinal); Assert.Contains("wire send is not assumed until native evidence is returned", main, StringComparison.OrdinalIgnoreCase); - Assert.Contains("IED REJECTED SBOw", dialog, StringComparison.Ordinal); + Assert.Contains("rejectedStep.Action.Equals(\"SelectWithValue\"", dialog, StringComparison.Ordinal); + Assert.Contains("? \"SBOw\" : rejectedStep.Action", dialog, StringComparison.Ordinal); + Assert.Contains("IED REJECTED {rejectedStage}", dialog, StringComparison.Ordinal); Assert.Contains("Operate was NOT sent because SBOw selection failed", dialog, StringComparison.Ordinal); Assert.Contains("IED BLOCKED COMMAND BY INTERLOCKING", dialog, StringComparison.Ordinal); Assert.Contains("IED BLOCKED COMMAND BY SYNCHROCHECK", dialog, StringComparison.Ordinal);