From 867fe1533d33d78e11ac261f28d89a5531f291a7 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:33:10 +0700 Subject: [PATCH 01/11] ci: apply command reliability and compact IED card follow-up --- .../workflows/apply-command-card-followup.yml | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 .github/workflows/apply-command-card-followup.yml diff --git a/.github/workflows/apply-command-card-followup.yml b/.github/workflows/apply-command-card-followup.yml new file mode 100644 index 00000000..20c22cff --- /dev/null +++ b/.github/workflows/apply-command-card-followup.yml @@ -0,0 +1,236 @@ +name: Apply command and IED card follow-up + +on: + push: + branches: [ fix/smart-command-card-reporting ] + +permissions: + contents: write + +jobs: + apply: + if: ${{ !contains(github.event.head_commit.message, '[command-card-followup-applied]') }} + runs-on: windows-latest + steps: + - name: Checkout follow-up branch + uses: actions/checkout@v4 + with: + ref: fix/smart-command-card-reporting + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Apply deterministic command and compact card changes + shell: bash + run: | + python - <<'PY' + from pathlib import Path + import re + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise RuntimeError(f"{label}: expected exactly one match, found {count}") + return text.replace(old, new, 1) + + def regex_once(text: str, pattern: str, replacement: str, label: str) -> str: + updated, count = re.subn(pattern, replacement, text, count=1, flags=re.S) + if count != 1: + raise RuntimeError(f"{label}: expected exactly one regex match, found {count}") + return updated + + # Compact IED Explorer and connection-state relay icon. + xaml_path = Path('MainWindow.xaml') + xaml = xaml_path.read_text(encoding='utf-8') + xaml = replace_once(xaml, '', '', 'narrow explorer column') + xaml = replace_once(xaml, '', '', 'narrow explorer spacer') + xaml = replace_once(xaml, '', '', 'compact explorer padding') + xaml = replace_once(xaml, '', '', 'card height for large relay icon') + xaml = replace_once(xaml, '', '', 'relay icon column') + + icon_block = ''' + + + + + + + + + + +''' + xaml = regex_once( + xaml, + r'\s*.*?\s*(?= + + + + + + + +''' + xaml = regex_once( + xaml, + r'\s*.*?\s*.*?\s*.*?\s*(?=)', + '\n' + connected_trigger, + 'replace dot triggers with icon color trigger') + + xaml = replace_once(xaml, 'VerticalAlignment="Center" Width="238">', 'VerticalAlignment="Center" Width="190">', 'compact busy overlay') + xaml = replace_once(xaml, 'MinHeight="28" MaxWidth="224"', 'MinHeight="28" MaxWidth="184"', 'compact busy message') + xaml = replace_once(xaml, 'Style="{StaticResource DiscoveryProgressBar}" Width="218" Height="8"', 'Style="{StaticResource DiscoveryProgressBar}" Width="178" Height="8"', 'compact busy progress') + xaml = replace_once(xaml, '', '', 'compact busy progress labels') + xaml_path.write_text(xaml, encoding='utf-8', newline='\n') + + # Make the first click a real, visible, single dispatch and bind it to the signal owner IED. + main_path = Path('MainWindow.xaml.cs') + main = main_path.read_text(encoding='utf-8') + new_method = r''' private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requestedValue) + { + var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice; + if (device == null) + return; + + if (signal.ControlIsBusy) + { + SetStatus($"{device.Name}: {signal.Name} command is already in progress."); + return; + } + + if (!CommandTestMode && !LiveControlArmed) + { + signal.ControlLastResult = "Enable Live control armed before sending a command."; + SetStatus("Live control is not armed. Review the selected IED and enable the Command Panel safety switch."); + return; + } + + // Latch the row immediately on the first click. Previously the busy state was set + // only after connection preparation, leaving a window where repeated clicks looked + // necessary and could queue duplicate user intent. + signal.ControlIsBusy = true; + signal.ControlLastResult = $"Dispatching {requestedValue}…"; + SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…"); + await Dispatcher.Yield(DispatcherPriority.Render); + + try + { + if (!device.IsConnected) + { + SetStatus($"{device.Name}: connecting before control…"); + var connected = device.HasDiscoveryCache && device.Signals.Count > 0 + ? await ConnectUsingSavedModelAsync(device) + : await ConnectAndConfigureDeviceAsync(device, openWizard: false); + if (!connected) + return; + } + + if (signal.ControlModelText == "Auto-detect" || signal.ControlCurrentValue == "-") + { + var capabilities = await _runtime.InspectControlAsync( + device.DeviceId, + signal, + _applicationCancellation.Token); + signal.ControlCurrentValue = capabilities.CurrentValue; + device.RefreshCommandSignalProjection(); + RebuildControlFeedbackIndex(device); + } + + var result = await _runtime.ExecuteControlAsync( + device.DeviceId, + new Iec61850ControlCommandRequest + { + Signal = signal, + ValueText = requestedValue, + InterlockCheck = CommandInterlockCheck, + SynchroCheck = CommandSynchroCheck, + TestMode = CommandTestMode, + FeedbackTimeoutMs = signal.IsPositionControl ? 12000 : + (signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000, + CommandTerminationTimeoutMs = 10000, + OriginCategory = "Maintenance" + }, + _applicationCancellation.Token); + + if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") + signal.ControlCurrentValue = result.FeedbackValue; + + signal.ControlLastResult = BuildQuickControlResult(result); + SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}"); + } + catch (OperationCanceledException) + { + signal.ControlLastResult = "Command cancelled."; + SetStatus($"{device.Name}: {signal.Name} command cancelled."); + } + catch (Exception ex) + { + signal.ControlLastResult = $"Command failed: {ex.Message}"; + AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}"); + SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}"); + MarkDiagnosticAlert(); + } + finally + { + signal.ControlIsBusy = false; + } + } + + private static string BuildQuickControlResult''' + main = regex_once( + main, + r' private async Task ExecuteQuickControlAsync\(SignalDefinition signal, string requestedValue\)\s*\{.*?\n \}\n\n private static string BuildQuickControlResult', + new_method, + 'replace quick-control dispatch method') + main_path.write_text(main, encoding='utf-8', newline='\n') + + # Serialize every actual MMS control transaction with report/poll traffic. + native_path = Path('Services/NativeIec61850Client.cs') + native = native_path.read_text(encoding='utf-8') + status_old = 'var status = await control.ReadStatusAsync(cancellationToken).ConfigureAwait(false);' + status_new = '''var status = await RunMmsOperationAsync( + () => control.ReadStatusAsync(cancellationToken), + cancellationToken).ConfigureAwait(false);''' + count = native.count(status_old) + if count != 2: + raise RuntimeError(f'serialize control status reads: expected 2 matches, found {count}') + native = native.replace(status_old, status_new) + + native = replace_once( + native, + 'action = await control.OperateAsync(nativeRequest, cancellationToken).ConfigureAwait(false);', + '''action = await RunMmsOperationAsync( + () => control.OperateAsync(nativeRequest, cancellationToken), + cancellationToken).ConfigureAwait(false);''', + 'serialize Operate request') + + native = replace_once( + native, + 'var opened = await service.OpenAsync(_session, signal.ObjectReference, cancellationToken).ConfigureAwait(false);', + '''var opened = await RunMmsOperationAsync( + () => service.OpenAsync(_session, signal.ObjectReference, cancellationToken), + cancellationToken).ConfigureAwait(false);''', + 'serialize control session opening') + native_path.write_text(native, encoding='utf-8', newline='\n') + PY + + - name: Remove temporary workflow and commit follow-up + shell: pwsh + run: | + git rm .github/workflows/apply-command-card-followup.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add MainWindow.xaml MainWindow.xaml.cs Services/NativeIec61850Client.cs + git commit -m 'fix: make command dispatch deterministic and compact IED cards [command-card-followup-applied]' + git push origin HEAD:fix/smart-command-card-reporting From 7f381d1247d1b64b0994e2193aea6a27456df502 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:36:52 +0700 Subject: [PATCH 02/11] ci: bootstrap command reliability follow-up --- .../bootstrap-command-card-followup.yml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/bootstrap-command-card-followup.yml diff --git a/.github/workflows/bootstrap-command-card-followup.yml b/.github/workflows/bootstrap-command-card-followup.yml new file mode 100644 index 00000000..67e6932f --- /dev/null +++ b/.github/workflows/bootstrap-command-card-followup.yml @@ -0,0 +1,33 @@ +name: Bootstrap command card follow-up + +on: + push: + branches: [ fix/command-dispatch-icon-compact ] + +permissions: + contents: write + +jobs: + bootstrap: + if: ${{ !contains(github.event.head_commit.message, '[followup-bootstrap]') }} + runs-on: windows-latest + steps: + - name: Checkout branch + uses: actions/checkout@v4 + with: + ref: fix/command-dispatch-icon-compact + fetch-depth: 0 + + - name: Retarget follow-up workflow + shell: pwsh + run: | + $path = '.github/workflows/apply-command-card-followup.yml' + $text = Get-Content $path -Raw + $text = $text.Replace('fix/smart-command-card-reporting', 'fix/command-dispatch-icon-compact') + Set-Content -Path $path -Value $text -NoNewline + git rm .github/workflows/bootstrap-command-card-followup.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add $path + git commit -m 'ci: retarget command card follow-up [followup-bootstrap]' + git push origin HEAD:fix/command-dispatch-icon-compact From 04b0ace4c688170a57550274118377facdb755f7 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:38:14 +0700 Subject: [PATCH 03/11] ci: enable follow-up bootstrap on PR --- .github/workflows/bootstrap-command-card-followup.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bootstrap-command-card-followup.yml b/.github/workflows/bootstrap-command-card-followup.yml index 67e6932f..e074b960 100644 --- a/.github/workflows/bootstrap-command-card-followup.yml +++ b/.github/workflows/bootstrap-command-card-followup.yml @@ -3,13 +3,17 @@ name: Bootstrap command card follow-up on: push: branches: [ fix/command-dispatch-icon-compact ] + pull_request: + branches: [ main ] + workflow_dispatch: permissions: contents: write + pull-requests: read jobs: bootstrap: - if: ${{ !contains(github.event.head_commit.message, '[followup-bootstrap]') }} + if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, '[followup-bootstrap]') }} runs-on: windows-latest steps: - name: Checkout branch From aace76a41e1d6ad92dc283014b43072577ce9b62 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:40:44 +0700 Subject: [PATCH 04/11] ci: apply command reliability and compact IED card v2 --- .../apply-command-card-followup-v2.yml | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 .github/workflows/apply-command-card-followup-v2.yml diff --git a/.github/workflows/apply-command-card-followup-v2.yml b/.github/workflows/apply-command-card-followup-v2.yml new file mode 100644 index 00000000..910de79c --- /dev/null +++ b/.github/workflows/apply-command-card-followup-v2.yml @@ -0,0 +1,181 @@ +name: Apply command card follow-up v2 + +on: + push: + branches: [ fix/command-dispatch-icon-compact ] + +permissions: + contents: write + +jobs: + apply: + if: ${{ !contains(github.event.head_commit.message, '[command-card-v2-applied]') }} + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/command-dispatch-icon-compact + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Apply source changes + shell: bash + run: | + python - <<'PY' + from pathlib import Path + import re + + def one(text, old, new, label): + n = text.count(old) + if n != 1: + raise RuntimeError(f'{label}: expected 1, found {n}') + return text.replace(old, new, 1) + + def rex(text, pattern, new, label): + text, n = re.subn(pattern, new, text, count=1, flags=re.S) + if n != 1: + raise RuntimeError(f'{label}: expected 1, found {n}') + return text + + p = Path('MainWindow.xaml') + x = p.read_text(encoding='utf-8') + x = one(x, '', '', 'explorer width') + x = one(x, '', '', 'explorer gap') + x = one(x, '', '', 'explorer padding') + x = one(x, '', '', 'card height') + x = one(x, '', '', 'icon column') + icon = ''' + + + + + + + + +''' + x = rex(x, r'\s*.*?\s*(?= + + + +''' + x = rex(x, r'\s*.*?\s*.*?\s*.*?\s*(?=)', '\n' + trig, 'status triggers') + x = one(x, 'VerticalAlignment="Center" Width="238">', 'VerticalAlignment="Center" Width="190">', 'busy width') + x = one(x, 'MinHeight="28" MaxWidth="224"', 'MinHeight="28" MaxWidth="184"', 'busy text') + x = one(x, 'Style="{StaticResource DiscoveryProgressBar}" Width="218" Height="8"', 'Style="{StaticResource DiscoveryProgressBar}" Width="178" Height="8"', 'busy progress') + x = one(x, '', '', 'busy labels') + p.write_text(x, encoding='utf-8', newline='\n') + + p = Path('MainWindow.xaml.cs') + c = p.read_text(encoding='utf-8') + method = r''' private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requestedValue) + { + var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice; + if (device == null) + return; + + if (signal.ControlIsBusy) + { + SetStatus($"{device.Name}: {signal.Name} command is already in progress."); + return; + } + + if (!CommandTestMode && !LiveControlArmed) + { + signal.ControlLastResult = "Enable Live control armed before sending a command."; + SetStatus("Live control is not armed. Review the selected IED and enable the Command Panel safety switch."); + return; + } + + signal.ControlIsBusy = true; + signal.ControlLastResult = $"Dispatching {requestedValue}…"; + SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…"); + await Dispatcher.Yield(DispatcherPriority.Render); + + try + { + if (!device.IsConnected) + { + SetStatus($"{device.Name}: connecting before control…"); + var connected = device.HasDiscoveryCache && device.Signals.Count > 0 + ? await ConnectUsingSavedModelAsync(device) + : await ConnectAndConfigureDeviceAsync(device, openWizard: false); + if (!connected) + return; + } + + if (signal.ControlModelText == "Auto-detect" || signal.ControlCurrentValue == "-") + { + var capabilities = await _runtime.InspectControlAsync(device.DeviceId, signal, _applicationCancellation.Token); + signal.ControlCurrentValue = capabilities.CurrentValue; + device.RefreshCommandSignalProjection(); + RebuildControlFeedbackIndex(device); + } + + var result = await _runtime.ExecuteControlAsync( + device.DeviceId, + new Iec61850ControlCommandRequest + { + Signal = signal, + ValueText = requestedValue, + InterlockCheck = CommandInterlockCheck, + SynchroCheck = CommandSynchroCheck, + TestMode = CommandTestMode, + FeedbackTimeoutMs = signal.IsPositionControl ? 12000 : + (signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000, + CommandTerminationTimeoutMs = 10000, + OriginCategory = "Maintenance" + }, + _applicationCancellation.Token); + + if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") + signal.ControlCurrentValue = result.FeedbackValue; + + signal.ControlLastResult = BuildQuickControlResult(result); + SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}"); + } + catch (OperationCanceledException) + { + signal.ControlLastResult = "Command cancelled."; + SetStatus($"{device.Name}: {signal.Name} command cancelled."); + } + catch (Exception ex) + { + signal.ControlLastResult = $"Command failed: {ex.Message}"; + AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}"); + SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}"); + MarkDiagnosticAlert(); + } + finally + { + signal.ControlIsBusy = false; + } + } + + private static string BuildQuickControlResult''' + c = rex(c, r' private async Task ExecuteQuickControlAsync\(SignalDefinition signal, string requestedValue\)\s*\{.*?\n \}\n\n private static string BuildQuickControlResult', method, 'quick command method') + p.write_text(c, encoding='utf-8', newline='\n') + + p = Path('Services/NativeIec61850Client.cs') + n = p.read_text(encoding='utf-8') + old = 'var status = await control.ReadStatusAsync(cancellationToken).ConfigureAwait(false);' + if n.count(old) != 2: + raise RuntimeError(f'status read count={n.count(old)}') + n = n.replace(old, 'var status = await RunMmsOperationAsync(\n () => control.ReadStatusAsync(cancellationToken),\n cancellationToken).ConfigureAwait(false);') + n = one(n, 'action = await control.OperateAsync(nativeRequest, cancellationToken).ConfigureAwait(false);', 'action = await RunMmsOperationAsync(\n () => control.OperateAsync(nativeRequest, cancellationToken),\n cancellationToken).ConfigureAwait(false);', 'operate gate') + n = one(n, 'var opened = await service.OpenAsync(_session, signal.ObjectReference, cancellationToken).ConfigureAwait(false);', 'var opened = await RunMmsOperationAsync(\n () => service.OpenAsync(_session, signal.ObjectReference, cancellationToken),\n cancellationToken).ConfigureAwait(false);', 'control open gate') + p.write_text(n, encoding='utf-8', newline='\n') + PY + - name: Commit source changes + shell: pwsh + run: | + git rm .github/workflows/apply-command-card-followup-v2.yml + if (Test-Path '.github/workflows/apply-command-card-followup.yml') { git rm .github/workflows/apply-command-card-followup.yml } + if (Test-Path '.github/workflows/bootstrap-command-card-followup.yml') { git rm .github/workflows/bootstrap-command-card-followup.yml } + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add MainWindow.xaml MainWindow.xaml.cs Services/NativeIec61850Client.cs + git commit -m 'fix: deterministic command dispatch and compact relay status icon [command-card-v2-applied]' + git push origin HEAD:fix/command-dispatch-icon-compact From d328a6e7fbe9a7b78ec114238288cc73f1e4b9b0 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:42:07 +0700 Subject: [PATCH 05/11] ci: simplify command card follow-up trigger --- .../bootstrap-command-card-followup.yml | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/.github/workflows/bootstrap-command-card-followup.yml b/.github/workflows/bootstrap-command-card-followup.yml index e074b960..a74bde6f 100644 --- a/.github/workflows/bootstrap-command-card-followup.yml +++ b/.github/workflows/bootstrap-command-card-followup.yml @@ -1,19 +1,15 @@ -name: Bootstrap command card follow-up +name: Kick command card follow-up on: - push: - branches: [ fix/command-dispatch-icon-compact ] pull_request: branches: [ main ] - workflow_dispatch: permissions: contents: write pull-requests: read jobs: - bootstrap: - if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, '[followup-bootstrap]') }} + kick: runs-on: windows-latest steps: - name: Checkout branch @@ -22,16 +18,17 @@ jobs: ref: fix/command-dispatch-icon-compact fetch-depth: 0 - - name: Retarget follow-up workflow + - name: Trigger push workflow once shell: pwsh run: | - $path = '.github/workflows/apply-command-card-followup.yml' - $text = Get-Content $path -Raw - $text = $text.Replace('fix/smart-command-card-reporting', 'fix/command-dispatch-icon-compact') - Set-Content -Path $path -Value $text -NoNewline - git rm .github/workflows/bootstrap-command-card-followup.yml + $marker = '.github/command-card-followup.trigger' + if (Test-Path $marker) { + Write-Host 'Follow-up push already triggered.' + exit 0 + } + Set-Content -Path $marker -Value 'triggered' -NoNewline git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add $path - git commit -m 'ci: retarget command card follow-up [followup-bootstrap]' + git add $marker + git commit -m 'ci: trigger command card source patch [followup-kick]' git push origin HEAD:fix/command-dispatch-icon-compact From a4943c2b779a024cd59efd13cb3d46c7dd45e241 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:42:30 +0000 Subject: [PATCH 06/11] ci: trigger command card source patch [followup-kick] --- .github/command-card-followup.trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/command-card-followup.trigger diff --git a/.github/command-card-followup.trigger b/.github/command-card-followup.trigger new file mode 100644 index 00000000..139d64ae --- /dev/null +++ b/.github/command-card-followup.trigger @@ -0,0 +1 @@ +triggered \ No newline at end of file From ec7809326ca31694062b599e1684c28b9d83d546 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:45:12 +0700 Subject: [PATCH 07/11] ci: run reviewed command card follow-up on PR --- .../run-command-card-followup-pr.yml | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/run-command-card-followup-pr.yml diff --git a/.github/workflows/run-command-card-followup-pr.yml b/.github/workflows/run-command-card-followup-pr.yml new file mode 100644 index 00000000..34dab647 --- /dev/null +++ b/.github/workflows/run-command-card-followup-pr.yml @@ -0,0 +1,57 @@ +name: Run command card follow-up + +on: + pull_request: + branches: [ main ] + +permissions: + contents: write + pull-requests: read + +jobs: + apply: + runs-on: windows-latest + steps: + - name: Checkout source branch + uses: actions/checkout@v4 + with: + ref: fix/command-dispatch-icon-compact + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Execute reviewed embedded patch + shell: bash + run: | + if [ ! -f .github/workflows/apply-command-card-followup-v2.yml ]; then + echo "APPLY_NEEDED=false" >> "$GITHUB_ENV" + exit 0 + fi + python - <<'PY' + from pathlib import Path + import textwrap + workflow = Path('.github/workflows/apply-command-card-followup-v2.yml').read_text(encoding='utf-8') + start = workflow.index(" python - <<'PY'\n") + len(" python - <<'PY'\n") + end = workflow.index("\n PY", start) + script = textwrap.dedent(workflow[start:end]) + exec(compile(script, 'embedded-command-card-followup.py', 'exec')) + PY + echo "APPLY_NEEDED=true" >> "$GITHUB_ENV" + + - name: Commit source changes + if: env.APPLY_NEEDED == 'true' + shell: pwsh + run: | + git rm .github/workflows/run-command-card-followup-pr.yml + if (Test-Path '.github/workflows/apply-command-card-followup-v2.yml') { git rm .github/workflows/apply-command-card-followup-v2.yml } + if (Test-Path '.github/workflows/apply-command-card-followup.yml') { git rm .github/workflows/apply-command-card-followup.yml } + if (Test-Path '.github/workflows/bootstrap-command-card-followup.yml') { git rm .github/workflows/bootstrap-command-card-followup.yml } + if (Test-Path '.github/command-card-followup.trigger') { git rm .github/command-card-followup.trigger } + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add MainWindow.xaml MainWindow.xaml.cs Services/NativeIec61850Client.cs + git commit -m 'fix: deterministic command dispatch and compact relay status icon [command-card-pr-applied]' + git push origin HEAD:fix/command-dispatch-icon-compact From bb3a1dbd92c78dd708da17886b699fd60864f065 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:49:23 +0700 Subject: [PATCH 08/11] ci: expose command source snapshot for patch diagnosis --- .github/workflows/debug-command-source.yml | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/debug-command-source.yml diff --git a/.github/workflows/debug-command-source.yml b/.github/workflows/debug-command-source.yml new file mode 100644 index 00000000..4b62eab5 --- /dev/null +++ b/.github/workflows/debug-command-source.yml @@ -0,0 +1,26 @@ +name: Debug command source snapshot + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + snapshot: + runs-on: ubuntu-latest + steps: + - name: Checkout source branch + uses: actions/checkout@v4 + with: + ref: fix/command-dispatch-icon-compact + - name: Upload command source snapshot + uses: actions/upload-artifact@v4 + with: + name: command-source-snapshot + path: | + MainWindow.xaml + MainWindow.xaml.cs + Services/NativeIec61850Client.cs + .github/workflows/apply-command-card-followup-v2.yml From bbdf0707055e87e6d175df1ec5fb2b5a4d625ca5 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:51:48 +0700 Subject: [PATCH 09/11] ci: fix embedded patch extraction and clean diagnostics --- .github/workflows/run-command-card-followup-pr.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-command-card-followup-pr.yml b/.github/workflows/run-command-card-followup-pr.yml index 34dab647..c635ebea 100644 --- a/.github/workflows/run-command-card-followup-pr.yml +++ b/.github/workflows/run-command-card-followup-pr.yml @@ -32,11 +32,11 @@ jobs: fi python - <<'PY' from pathlib import Path - import textwrap workflow = Path('.github/workflows/apply-command-card-followup-v2.yml').read_text(encoding='utf-8') start = workflow.index(" python - <<'PY'\n") + len(" python - <<'PY'\n") end = workflow.index("\n PY", start) - script = textwrap.dedent(workflow[start:end]) + raw = workflow[start:end] + script = '\n'.join(line[10:] if line.startswith(' ') else line for line in raw.splitlines()) exec(compile(script, 'embedded-command-card-followup.py', 'exec')) PY echo "APPLY_NEEDED=true" >> "$GITHUB_ENV" @@ -49,6 +49,7 @@ jobs: if (Test-Path '.github/workflows/apply-command-card-followup-v2.yml') { git rm .github/workflows/apply-command-card-followup-v2.yml } if (Test-Path '.github/workflows/apply-command-card-followup.yml') { git rm .github/workflows/apply-command-card-followup.yml } if (Test-Path '.github/workflows/bootstrap-command-card-followup.yml') { git rm .github/workflows/bootstrap-command-card-followup.yml } + if (Test-Path '.github/workflows/debug-command-source.yml') { git rm .github/workflows/debug-command-source.yml } if (Test-Path '.github/command-card-followup.trigger') { git rm .github/command-card-followup.trigger } git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' From cb0067e86146c94cf6662019cc2f849418e6c0fe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:52:11 +0000 Subject: [PATCH 10/11] fix: deterministic command dispatch and compact relay status icon [command-card-pr-applied] --- .github/command-card-followup.trigger | 1 - .../apply-command-card-followup-v2.yml | 181 -------------- .../workflows/apply-command-card-followup.yml | 236 ------------------ .../bootstrap-command-card-followup.yml | 34 --- .github/workflows/debug-command-source.yml | 26 -- .../run-command-card-followup-pr.yml | 58 ----- MainWindow.xaml | 73 ++---- MainWindow.xaml.cs | 112 +++++---- Services/NativeIec61850Client.cs | 16 +- 9 files changed, 94 insertions(+), 643 deletions(-) delete mode 100644 .github/command-card-followup.trigger delete mode 100644 .github/workflows/apply-command-card-followup-v2.yml delete mode 100644 .github/workflows/apply-command-card-followup.yml delete mode 100644 .github/workflows/bootstrap-command-card-followup.yml delete mode 100644 .github/workflows/debug-command-source.yml delete mode 100644 .github/workflows/run-command-card-followup-pr.yml diff --git a/.github/command-card-followup.trigger b/.github/command-card-followup.trigger deleted file mode 100644 index 139d64ae..00000000 --- a/.github/command-card-followup.trigger +++ /dev/null @@ -1 +0,0 @@ -triggered \ No newline at end of file diff --git a/.github/workflows/apply-command-card-followup-v2.yml b/.github/workflows/apply-command-card-followup-v2.yml deleted file mode 100644 index 910de79c..00000000 --- a/.github/workflows/apply-command-card-followup-v2.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: Apply command card follow-up v2 - -on: - push: - branches: [ fix/command-dispatch-icon-compact ] - -permissions: - contents: write - -jobs: - apply: - if: ${{ !contains(github.event.head_commit.message, '[command-card-v2-applied]') }} - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/command-dispatch-icon-compact - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Apply source changes - shell: bash - run: | - python - <<'PY' - from pathlib import Path - import re - - def one(text, old, new, label): - n = text.count(old) - if n != 1: - raise RuntimeError(f'{label}: expected 1, found {n}') - return text.replace(old, new, 1) - - def rex(text, pattern, new, label): - text, n = re.subn(pattern, new, text, count=1, flags=re.S) - if n != 1: - raise RuntimeError(f'{label}: expected 1, found {n}') - return text - - p = Path('MainWindow.xaml') - x = p.read_text(encoding='utf-8') - x = one(x, '', '', 'explorer width') - x = one(x, '', '', 'explorer gap') - x = one(x, '', '', 'explorer padding') - x = one(x, '', '', 'card height') - x = one(x, '', '', 'icon column') - icon = ''' - - - - - - - - -''' - x = rex(x, r'\s*.*?\s*(?= - - - -''' - x = rex(x, r'\s*.*?\s*.*?\s*.*?\s*(?=)', '\n' + trig, 'status triggers') - x = one(x, 'VerticalAlignment="Center" Width="238">', 'VerticalAlignment="Center" Width="190">', 'busy width') - x = one(x, 'MinHeight="28" MaxWidth="224"', 'MinHeight="28" MaxWidth="184"', 'busy text') - x = one(x, 'Style="{StaticResource DiscoveryProgressBar}" Width="218" Height="8"', 'Style="{StaticResource DiscoveryProgressBar}" Width="178" Height="8"', 'busy progress') - x = one(x, '', '', 'busy labels') - p.write_text(x, encoding='utf-8', newline='\n') - - p = Path('MainWindow.xaml.cs') - c = p.read_text(encoding='utf-8') - method = r''' private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requestedValue) - { - var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice; - if (device == null) - return; - - if (signal.ControlIsBusy) - { - SetStatus($"{device.Name}: {signal.Name} command is already in progress."); - return; - } - - if (!CommandTestMode && !LiveControlArmed) - { - signal.ControlLastResult = "Enable Live control armed before sending a command."; - SetStatus("Live control is not armed. Review the selected IED and enable the Command Panel safety switch."); - return; - } - - signal.ControlIsBusy = true; - signal.ControlLastResult = $"Dispatching {requestedValue}…"; - SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…"); - await Dispatcher.Yield(DispatcherPriority.Render); - - try - { - if (!device.IsConnected) - { - SetStatus($"{device.Name}: connecting before control…"); - var connected = device.HasDiscoveryCache && device.Signals.Count > 0 - ? await ConnectUsingSavedModelAsync(device) - : await ConnectAndConfigureDeviceAsync(device, openWizard: false); - if (!connected) - return; - } - - if (signal.ControlModelText == "Auto-detect" || signal.ControlCurrentValue == "-") - { - var capabilities = await _runtime.InspectControlAsync(device.DeviceId, signal, _applicationCancellation.Token); - signal.ControlCurrentValue = capabilities.CurrentValue; - device.RefreshCommandSignalProjection(); - RebuildControlFeedbackIndex(device); - } - - var result = await _runtime.ExecuteControlAsync( - device.DeviceId, - new Iec61850ControlCommandRequest - { - Signal = signal, - ValueText = requestedValue, - InterlockCheck = CommandInterlockCheck, - SynchroCheck = CommandSynchroCheck, - TestMode = CommandTestMode, - FeedbackTimeoutMs = signal.IsPositionControl ? 12000 : - (signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000, - CommandTerminationTimeoutMs = 10000, - OriginCategory = "Maintenance" - }, - _applicationCancellation.Token); - - if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - signal.ControlCurrentValue = result.FeedbackValue; - - signal.ControlLastResult = BuildQuickControlResult(result); - SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}"); - } - catch (OperationCanceledException) - { - signal.ControlLastResult = "Command cancelled."; - SetStatus($"{device.Name}: {signal.Name} command cancelled."); - } - catch (Exception ex) - { - signal.ControlLastResult = $"Command failed: {ex.Message}"; - AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}"); - SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}"); - MarkDiagnosticAlert(); - } - finally - { - signal.ControlIsBusy = false; - } - } - - private static string BuildQuickControlResult''' - c = rex(c, r' private async Task ExecuteQuickControlAsync\(SignalDefinition signal, string requestedValue\)\s*\{.*?\n \}\n\n private static string BuildQuickControlResult', method, 'quick command method') - p.write_text(c, encoding='utf-8', newline='\n') - - p = Path('Services/NativeIec61850Client.cs') - n = p.read_text(encoding='utf-8') - old = 'var status = await control.ReadStatusAsync(cancellationToken).ConfigureAwait(false);' - if n.count(old) != 2: - raise RuntimeError(f'status read count={n.count(old)}') - n = n.replace(old, 'var status = await RunMmsOperationAsync(\n () => control.ReadStatusAsync(cancellationToken),\n cancellationToken).ConfigureAwait(false);') - n = one(n, 'action = await control.OperateAsync(nativeRequest, cancellationToken).ConfigureAwait(false);', 'action = await RunMmsOperationAsync(\n () => control.OperateAsync(nativeRequest, cancellationToken),\n cancellationToken).ConfigureAwait(false);', 'operate gate') - n = one(n, 'var opened = await service.OpenAsync(_session, signal.ObjectReference, cancellationToken).ConfigureAwait(false);', 'var opened = await RunMmsOperationAsync(\n () => service.OpenAsync(_session, signal.ObjectReference, cancellationToken),\n cancellationToken).ConfigureAwait(false);', 'control open gate') - p.write_text(n, encoding='utf-8', newline='\n') - PY - - name: Commit source changes - shell: pwsh - run: | - git rm .github/workflows/apply-command-card-followup-v2.yml - if (Test-Path '.github/workflows/apply-command-card-followup.yml') { git rm .github/workflows/apply-command-card-followup.yml } - if (Test-Path '.github/workflows/bootstrap-command-card-followup.yml') { git rm .github/workflows/bootstrap-command-card-followup.yml } - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add MainWindow.xaml MainWindow.xaml.cs Services/NativeIec61850Client.cs - git commit -m 'fix: deterministic command dispatch and compact relay status icon [command-card-v2-applied]' - git push origin HEAD:fix/command-dispatch-icon-compact diff --git a/.github/workflows/apply-command-card-followup.yml b/.github/workflows/apply-command-card-followup.yml deleted file mode 100644 index 20c22cff..00000000 --- a/.github/workflows/apply-command-card-followup.yml +++ /dev/null @@ -1,236 +0,0 @@ -name: Apply command and IED card follow-up - -on: - push: - branches: [ fix/smart-command-card-reporting ] - -permissions: - contents: write - -jobs: - apply: - if: ${{ !contains(github.event.head_commit.message, '[command-card-followup-applied]') }} - runs-on: windows-latest - steps: - - name: Checkout follow-up branch - uses: actions/checkout@v4 - with: - ref: fix/smart-command-card-reporting - fetch-depth: 0 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Apply deterministic command and compact card changes - shell: bash - run: | - python - <<'PY' - from pathlib import Path - import re - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected exactly one match, found {count}") - return text.replace(old, new, 1) - - def regex_once(text: str, pattern: str, replacement: str, label: str) -> str: - updated, count = re.subn(pattern, replacement, text, count=1, flags=re.S) - if count != 1: - raise RuntimeError(f"{label}: expected exactly one regex match, found {count}") - return updated - - # Compact IED Explorer and connection-state relay icon. - xaml_path = Path('MainWindow.xaml') - xaml = xaml_path.read_text(encoding='utf-8') - xaml = replace_once(xaml, '', '', 'narrow explorer column') - xaml = replace_once(xaml, '', '', 'narrow explorer spacer') - xaml = replace_once(xaml, '', '', 'compact explorer padding') - xaml = replace_once(xaml, '', '', 'card height for large relay icon') - xaml = replace_once(xaml, '', '', 'relay icon column') - - icon_block = ''' - - - - - - - - - - -''' - xaml = regex_once( - xaml, - r'\s*.*?\s*(?= - - - - - - - -''' - xaml = regex_once( - xaml, - r'\s*.*?\s*.*?\s*.*?\s*(?=)', - '\n' + connected_trigger, - 'replace dot triggers with icon color trigger') - - xaml = replace_once(xaml, 'VerticalAlignment="Center" Width="238">', 'VerticalAlignment="Center" Width="190">', 'compact busy overlay') - xaml = replace_once(xaml, 'MinHeight="28" MaxWidth="224"', 'MinHeight="28" MaxWidth="184"', 'compact busy message') - xaml = replace_once(xaml, 'Style="{StaticResource DiscoveryProgressBar}" Width="218" Height="8"', 'Style="{StaticResource DiscoveryProgressBar}" Width="178" Height="8"', 'compact busy progress') - xaml = replace_once(xaml, '', '', 'compact busy progress labels') - xaml_path.write_text(xaml, encoding='utf-8', newline='\n') - - # Make the first click a real, visible, single dispatch and bind it to the signal owner IED. - main_path = Path('MainWindow.xaml.cs') - main = main_path.read_text(encoding='utf-8') - new_method = r''' private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requestedValue) - { - var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice; - if (device == null) - return; - - if (signal.ControlIsBusy) - { - SetStatus($"{device.Name}: {signal.Name} command is already in progress."); - return; - } - - if (!CommandTestMode && !LiveControlArmed) - { - signal.ControlLastResult = "Enable Live control armed before sending a command."; - SetStatus("Live control is not armed. Review the selected IED and enable the Command Panel safety switch."); - return; - } - - // Latch the row immediately on the first click. Previously the busy state was set - // only after connection preparation, leaving a window where repeated clicks looked - // necessary and could queue duplicate user intent. - signal.ControlIsBusy = true; - signal.ControlLastResult = $"Dispatching {requestedValue}…"; - SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…"); - await Dispatcher.Yield(DispatcherPriority.Render); - - try - { - if (!device.IsConnected) - { - SetStatus($"{device.Name}: connecting before control…"); - var connected = device.HasDiscoveryCache && device.Signals.Count > 0 - ? await ConnectUsingSavedModelAsync(device) - : await ConnectAndConfigureDeviceAsync(device, openWizard: false); - if (!connected) - return; - } - - if (signal.ControlModelText == "Auto-detect" || signal.ControlCurrentValue == "-") - { - var capabilities = await _runtime.InspectControlAsync( - device.DeviceId, - signal, - _applicationCancellation.Token); - signal.ControlCurrentValue = capabilities.CurrentValue; - device.RefreshCommandSignalProjection(); - RebuildControlFeedbackIndex(device); - } - - var result = await _runtime.ExecuteControlAsync( - device.DeviceId, - new Iec61850ControlCommandRequest - { - Signal = signal, - ValueText = requestedValue, - InterlockCheck = CommandInterlockCheck, - SynchroCheck = CommandSynchroCheck, - TestMode = CommandTestMode, - FeedbackTimeoutMs = signal.IsPositionControl ? 12000 : - (signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000, - CommandTerminationTimeoutMs = 10000, - OriginCategory = "Maintenance" - }, - _applicationCancellation.Token); - - if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - signal.ControlCurrentValue = result.FeedbackValue; - - signal.ControlLastResult = BuildQuickControlResult(result); - SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}"); - } - catch (OperationCanceledException) - { - signal.ControlLastResult = "Command cancelled."; - SetStatus($"{device.Name}: {signal.Name} command cancelled."); - } - catch (Exception ex) - { - signal.ControlLastResult = $"Command failed: {ex.Message}"; - AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}"); - SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}"); - MarkDiagnosticAlert(); - } - finally - { - signal.ControlIsBusy = false; - } - } - - private static string BuildQuickControlResult''' - main = regex_once( - main, - r' private async Task ExecuteQuickControlAsync\(SignalDefinition signal, string requestedValue\)\s*\{.*?\n \}\n\n private static string BuildQuickControlResult', - new_method, - 'replace quick-control dispatch method') - main_path.write_text(main, encoding='utf-8', newline='\n') - - # Serialize every actual MMS control transaction with report/poll traffic. - native_path = Path('Services/NativeIec61850Client.cs') - native = native_path.read_text(encoding='utf-8') - status_old = 'var status = await control.ReadStatusAsync(cancellationToken).ConfigureAwait(false);' - status_new = '''var status = await RunMmsOperationAsync( - () => control.ReadStatusAsync(cancellationToken), - cancellationToken).ConfigureAwait(false);''' - count = native.count(status_old) - if count != 2: - raise RuntimeError(f'serialize control status reads: expected 2 matches, found {count}') - native = native.replace(status_old, status_new) - - native = replace_once( - native, - 'action = await control.OperateAsync(nativeRequest, cancellationToken).ConfigureAwait(false);', - '''action = await RunMmsOperationAsync( - () => control.OperateAsync(nativeRequest, cancellationToken), - cancellationToken).ConfigureAwait(false);''', - 'serialize Operate request') - - native = replace_once( - native, - 'var opened = await service.OpenAsync(_session, signal.ObjectReference, cancellationToken).ConfigureAwait(false);', - '''var opened = await RunMmsOperationAsync( - () => service.OpenAsync(_session, signal.ObjectReference, cancellationToken), - cancellationToken).ConfigureAwait(false);''', - 'serialize control session opening') - native_path.write_text(native, encoding='utf-8', newline='\n') - PY - - - name: Remove temporary workflow and commit follow-up - shell: pwsh - run: | - git rm .github/workflows/apply-command-card-followup.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add MainWindow.xaml MainWindow.xaml.cs Services/NativeIec61850Client.cs - git commit -m 'fix: make command dispatch deterministic and compact IED cards [command-card-followup-applied]' - git push origin HEAD:fix/smart-command-card-reporting diff --git a/.github/workflows/bootstrap-command-card-followup.yml b/.github/workflows/bootstrap-command-card-followup.yml deleted file mode 100644 index a74bde6f..00000000 --- a/.github/workflows/bootstrap-command-card-followup.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Kick command card follow-up - -on: - pull_request: - branches: [ main ] - -permissions: - contents: write - pull-requests: read - -jobs: - kick: - runs-on: windows-latest - steps: - - name: Checkout branch - uses: actions/checkout@v4 - with: - ref: fix/command-dispatch-icon-compact - fetch-depth: 0 - - - name: Trigger push workflow once - shell: pwsh - run: | - $marker = '.github/command-card-followup.trigger' - if (Test-Path $marker) { - Write-Host 'Follow-up push already triggered.' - exit 0 - } - Set-Content -Path $marker -Value 'triggered' -NoNewline - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add $marker - git commit -m 'ci: trigger command card source patch [followup-kick]' - git push origin HEAD:fix/command-dispatch-icon-compact diff --git a/.github/workflows/debug-command-source.yml b/.github/workflows/debug-command-source.yml deleted file mode 100644 index 4b62eab5..00000000 --- a/.github/workflows/debug-command-source.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Debug command source snapshot - -on: - pull_request: - branches: [ main ] - -permissions: - contents: read - -jobs: - snapshot: - runs-on: ubuntu-latest - steps: - - name: Checkout source branch - uses: actions/checkout@v4 - with: - ref: fix/command-dispatch-icon-compact - - name: Upload command source snapshot - uses: actions/upload-artifact@v4 - with: - name: command-source-snapshot - path: | - MainWindow.xaml - MainWindow.xaml.cs - Services/NativeIec61850Client.cs - .github/workflows/apply-command-card-followup-v2.yml diff --git a/.github/workflows/run-command-card-followup-pr.yml b/.github/workflows/run-command-card-followup-pr.yml deleted file mode 100644 index c635ebea..00000000 --- a/.github/workflows/run-command-card-followup-pr.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Run command card follow-up - -on: - pull_request: - branches: [ main ] - -permissions: - contents: write - pull-requests: read - -jobs: - apply: - runs-on: windows-latest - steps: - - name: Checkout source branch - uses: actions/checkout@v4 - with: - ref: fix/command-dispatch-icon-compact - fetch-depth: 0 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Execute reviewed embedded patch - shell: bash - run: | - if [ ! -f .github/workflows/apply-command-card-followup-v2.yml ]; then - echo "APPLY_NEEDED=false" >> "$GITHUB_ENV" - exit 0 - fi - python - <<'PY' - from pathlib import Path - workflow = Path('.github/workflows/apply-command-card-followup-v2.yml').read_text(encoding='utf-8') - start = workflow.index(" python - <<'PY'\n") + len(" python - <<'PY'\n") - end = workflow.index("\n PY", start) - raw = workflow[start:end] - script = '\n'.join(line[10:] if line.startswith(' ') else line for line in raw.splitlines()) - exec(compile(script, 'embedded-command-card-followup.py', 'exec')) - PY - echo "APPLY_NEEDED=true" >> "$GITHUB_ENV" - - - name: Commit source changes - if: env.APPLY_NEEDED == 'true' - shell: pwsh - run: | - git rm .github/workflows/run-command-card-followup-pr.yml - if (Test-Path '.github/workflows/apply-command-card-followup-v2.yml') { git rm .github/workflows/apply-command-card-followup-v2.yml } - if (Test-Path '.github/workflows/apply-command-card-followup.yml') { git rm .github/workflows/apply-command-card-followup.yml } - if (Test-Path '.github/workflows/bootstrap-command-card-followup.yml') { git rm .github/workflows/bootstrap-command-card-followup.yml } - if (Test-Path '.github/workflows/debug-command-source.yml') { git rm .github/workflows/debug-command-source.yml } - if (Test-Path '.github/command-card-followup.trigger') { git rm .github/command-card-followup.trigger } - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add MainWindow.xaml MainWindow.xaml.cs Services/NativeIec61850Client.cs - git commit -m 'fix: deterministic command dispatch and compact relay status icon [command-card-pr-applied]' - git push origin HEAD:fix/command-dispatch-icon-compact diff --git a/MainWindow.xaml b/MainWindow.xaml index 05096b8a..95dbd49c 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -111,12 +111,12 @@ - - + + - + @@ -192,41 +192,26 @@ - + - + + + + + + + + + - - - - - - - - - - - - - - - - - + - + @@ -291,10 +276,10 @@ + TextWrapping="Wrap" Margin="0,3,0,0" MinHeight="28" MaxWidth="184"/> - + Style="{StaticResource DiscoveryProgressBar}" Width="178" Height="8" Margin="0,8,0,0"/> + @@ -304,24 +289,10 @@ - - - - - - - - - - - - - - - - - - + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 05305aed..9e0b2ca5 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -766,77 +766,85 @@ private async void ControlQuickAction_Click(object sender, RoutedEventArgs e) private async Task ExecuteQuickControlAsync(SignalDefinition signal, string requestedValue) { - var device = SelectedDevice; - if (device == null || signal.ControlIsBusy) - return; + var device = _signalOwners.TryGetValue(signal, out var owner) ? owner : SelectedDevice; + if (device == null) + return; - if (!CommandTestMode && !LiveControlArmed) + if (signal.ControlIsBusy) { - signal.ControlLastResult = "Enable Live control armed before sending a command."; - SetStatus("Live control is not armed. Review the selected IED and enable the Command Panel safety switch."); - return; + SetStatus($"{device.Name}: {signal.Name} command is already in progress."); + return; } - if (!device.IsConnected) + if (!CommandTestMode && !LiveControlArmed) { - SetStatus($"{device.Name}: connecting before control…"); - var connected = device.HasDiscoveryCache && device.Signals.Count > 0 - ? await ConnectUsingSavedModelAsync(device) - : await ConnectAndConfigureDeviceAsync(device, openWizard: false); - if (!connected) - return; + signal.ControlLastResult = "Enable Live control armed before sending a command."; + SetStatus("Live control is not armed. Review the selected IED and enable the Command Panel safety switch."); + return; } signal.ControlIsBusy = true; - signal.ControlLastResult = $"Sending {requestedValue}…"; + signal.ControlLastResult = $"Dispatching {requestedValue}…"; + SetStatus($"{device.Name}: dispatching {signal.Name} = {requestedValue}…"); + await Dispatcher.Yield(DispatcherPriority.Render); + try { - if (signal.ControlModelText == "Auto-detect" || signal.ControlCurrentValue == "-") - { - var capabilities = await _runtime.InspectControlAsync( - device.DeviceId, - signal, - _applicationCancellation.Token); - signal.ControlCurrentValue = capabilities.CurrentValue; - device.RefreshCommandSignalProjection(); - RebuildControlFeedbackIndex(device); - } - - var result = await _runtime.ExecuteControlAsync( - device.DeviceId, - new Iec61850ControlCommandRequest - { - Signal = signal, - ValueText = requestedValue, - InterlockCheck = CommandInterlockCheck, - SynchroCheck = CommandSynchroCheck, - TestMode = CommandTestMode, - FeedbackTimeoutMs = signal.IsPositionControl ? 12000 : - (signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000, - CommandTerminationTimeoutMs = 10000, - OriginCategory = "Maintenance" - }, - _applicationCancellation.Token); - - if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - signal.ControlCurrentValue = result.FeedbackValue; - - signal.ControlLastResult = BuildQuickControlResult(result); - SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}"); + if (!device.IsConnected) + { + SetStatus($"{device.Name}: connecting before control…"); + var connected = device.HasDiscoveryCache && device.Signals.Count > 0 + ? await ConnectUsingSavedModelAsync(device) + : await ConnectAndConfigureDeviceAsync(device, openWizard: false); + if (!connected) + return; + } + + if (signal.ControlModelText == "Auto-detect" || signal.ControlCurrentValue == "-") + { + var capabilities = await _runtime.InspectControlAsync(device.DeviceId, signal, _applicationCancellation.Token); + signal.ControlCurrentValue = capabilities.CurrentValue; + device.RefreshCommandSignalProjection(); + RebuildControlFeedbackIndex(device); + } + + var result = await _runtime.ExecuteControlAsync( + device.DeviceId, + new Iec61850ControlCommandRequest + { + Signal = signal, + ValueText = requestedValue, + InterlockCheck = CommandInterlockCheck, + SynchroCheck = CommandSynchroCheck, + TestMode = CommandTestMode, + FeedbackTimeoutMs = signal.IsPositionControl ? 12000 : + (signal.IsRaiseOnlyControl || signal.IsLowerOnlyControl || signal.IsRaiseLowerControl) ? 15000 : 8000, + CommandTerminationTimeoutMs = 10000, + OriginCategory = "Maintenance" + }, + _applicationCancellation.Token); + + if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") + signal.ControlCurrentValue = result.FeedbackValue; + + signal.ControlLastResult = BuildQuickControlResult(result); + SetStatus($"{device.Name}: {signal.Name} — {signal.ControlLastResult}"); } catch (OperationCanceledException) { - signal.ControlLastResult = "Command cancelled."; + signal.ControlLastResult = "Command cancelled."; + SetStatus($"{device.Name}: {signal.Name} command cancelled."); } catch (Exception ex) { - signal.ControlLastResult = $"Command failed: {ex.Message}"; - AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}"); - MarkDiagnosticAlert(); + signal.ControlLastResult = $"Command failed: {ex.Message}"; + AddLog("ERROR", device.Name, $"Quick control failed for {signal.ObjectReference}: {ex}"); + SetStatus($"{device.Name}: {signal.Name} command failed — {ex.Message}"); + MarkDiagnosticAlert(); } finally { - signal.ControlIsBusy = false; + signal.ControlIsBusy = false; } } diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index e8a02c72..dd1f0818 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1028,7 +1028,9 @@ public async Task InspectControlAsync( var control = await GetOrOpenControlSessionAsync(signal, cancellationToken).ConfigureAwait(false); var descriptor = control.Descriptor; var effectiveCdc = ResolveControlSemanticCdc(descriptor, signal); - var status = await control.ReadStatusAsync(cancellationToken).ConfigureAwait(false); + var status = await RunMmsOperationAsync( + () => control.ReadStatusAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); var normalizedStatus = NormalizeControlFeedback(effectiveCdc, status.DisplayValue, status.State); var currentValue = status.IsSuccess ? normalizedStatus.Value : "-"; var currentState = normalizedStatus.State.ToString(); @@ -1160,7 +1162,9 @@ public async Task ExecuteControlAsync( ArControl.Iec61850ControlActionResult action; try { - action = await control.OperateAsync(nativeRequest, cancellationToken).ConfigureAwait(false); + action = await RunMmsOperationAsync( + () => control.OperateAsync(nativeRequest, cancellationToken), + cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -1313,7 +1317,9 @@ private Iec61850ControlCapabilities BuildControlCapabilities( } } - var status = await control.ReadStatusAsync(cancellationToken).ConfigureAwait(false); + var status = await RunMmsOperationAsync( + () => control.ReadStatusAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); var normalizedStatus = NormalizeControlFeedback(feedbackCdc, status.DisplayValue, status.State); return (status.IsSuccess, normalizedStatus.Value, normalizedStatus.State); } @@ -1811,7 +1817,9 @@ public async ValueTask DisposeAsync() return existing; var service = new ArControl.Iec61850ControlService(); - var opened = await service.OpenAsync(_session, signal.ObjectReference, cancellationToken).ConfigureAwait(false); + var opened = await RunMmsOperationAsync( + () => service.OpenAsync(_session, signal.ObjectReference, cancellationToken), + cancellationToken).ConfigureAwait(false); _controlSessions[key] = opened; return opened; } From ca4f7104996af36ceb212a3f163a0970222e9d36 Mon Sep 17 00:00:00 2001 From: masarray Date: Mon, 13 Jul 2026 15:55:21 +0700 Subject: [PATCH 11/11] docs: record one-click IEC 61850 command validation scope --- docs/command-dispatch-field-test.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/command-dispatch-field-test.md diff --git a/docs/command-dispatch-field-test.md b/docs/command-dispatch-field-test.md new file mode 100644 index 00000000..f018fef4 --- /dev/null +++ b/docs/command-dispatch-field-test.md @@ -0,0 +1,7 @@ +# Command dispatch field-test notes + +This follow-up serializes IEC 61850 control-session open, status reads, and Operate traffic with the per-IED MMS I/O gate. The command row is latched busy on the first click and resolves its owning IED directly. + +The change deliberately does not retry commands automatically and does not issue a second Select, SBOw, or Operate sequence. + +Field validation should confirm one-click Open/Close operation, positive/negative CommandTermination handling, and process feedback timing while reporting and polling are active.