diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index 988483ff3..9c6b3883d 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -7,12 +7,18 @@ on: - "ComtradeWorkspaceWindow*.cs" - "ComtradeWorkspaceWindow.xaml" - "Controls/Comtrade*.cs" - - "Services/ArdIrec*.cs" + - "ArIED61850Tester.csproj" + - "Services/ArdIrecNativeBridge.cs" + - "Services/ArdIrecLocusNativeSession.cs" + - "Services/ArdIrecEmbeddedBridgeBootstrap.cs" + - "Services/ArdIrecViewerLauncher.cs" - "Services/Comtrade*.cs" - - "tests/ARSAS.Tests/ArdIrec*Tests.cs" - - "tests/ARSAS.Tests/Comtrade*Tests.cs" - - "scripts/stage-ardirec-viewer.ps1" - - "engines/ARIEC61850.lock.json" + - "Properties/AssemblyInfo.Tests.cs" + - "tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs" + - "tests/ARSAS.Tests/ArdIrecLocusNativeSessionIntegrationTests.cs" + - "tests/ARSAS.Tests/Comtrade*.cs" + - "scripts/build-ardirec-bridge.ps1" + - "scripts/publish-windows-portable.ps1" - "engines/ARDIREC.lock.json" - "docs/COMTRADE_VIEWER_INTEGRATION.md" - ".github/workflows/comtrade-viewer-integration.yml" @@ -23,9 +29,9 @@ permissions: jobs: windows-viewer-integration: - name: Qualify native ARSAS COMTRADE workstation + name: Build pinned ArdIrec native bridge and enforce in-process routing runs-on: windows-latest - timeout-minutes: 35 + timeout-minutes: 25 steps: - name: Checkout ARSAS @@ -33,41 +39,39 @@ jobs: with: path: ARSAS - - name: Resolve immutable engine locks + - name: Resolve immutable field-tested ArdIrec bridge lock shell: powershell run: | - $iecLock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json - if ($iecLock.repository -notmatch '^[^/]+/[^/]+$' -or - $iecLock.commit -notmatch '^[0-9a-f]{40}$') { - throw "ARIEC61850 lock metadata is invalid." - } + $lockPath = ".\ARSAS\engines\ARDIREC.lock.json" + $lock = Get-Content $lockPath -Raw | ConvertFrom-Json - $lock = Get-Content ".\ARSAS\engines\ARDIREC.lock.json" -Raw | ConvertFrom-Json if ($lock.schema -ne 3 -or $lock.repository -notmatch '^[^/]+/[^/]+$' -or - $lock.commit -notmatch '^[0-9a-f]{40}$' -or - $lock.bridge.abi -ne 1 -or - $lock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll' -or - $lock.bridge.mode -ne 'native-only') { - throw "ArdIrec P1D.5 bridge-only lock metadata is invalid." + [string]::IsNullOrWhiteSpace([string]$lock.ref) -or + $lock.ref -notmatch '^[A-Za-z0-9._/-]+$') { + throw "ArdIrec repository/ref lock is invalid." + } + if ($lock.commit -notmatch '^[0-9a-f]{40}$') { + throw "ArdIrec commit lock is invalid." + } + if ($lock.bridge.abi -ne 1 -or + $lock.bridge.mode -ne 'native-only' -or + $lock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { + throw "ArdIrec native bridge contract is invalid." + } + + $required = @('cursor_measurement','channel_semantics','value_representation','status_state','digital_edge_snap','phasor','harmonics','distance_locus') + $declared = @($lock.bridge.requiredCapabilities) + foreach ($capability in $required) { + if ($declared -notcontains $capability) { + throw "ArdIrec lock is missing required field-tested capability '$capability'." + } } - "ARIEC61850_REPOSITORY=$($iecLock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($iecLock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_REF=$($lock.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Checkout immutable ARIEC61850 revision - shell: powershell - run: | - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 - git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT - git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT - $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() - if ($actual -ne $env:ARIEC61850_COMMIT) { - throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." - } - - name: Checkout immutable ArdIrec revision shell: powershell run: | @@ -79,62 +83,64 @@ jobs: throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT, got $actual." } - - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Build and test ARSAS - shell: powershell - run: | - dotnet restore .\ARSAS\ArIED61850Tester.sln - if ($LASTEXITCODE -ne 0) { throw "Solution restore failed." } - dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore - if ($LASTEXITCODE -ne 0) { throw "Solution build failed." } - dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore - if ($LASTEXITCODE -ne 0) { throw "Application regression tests failed." } - - - name: Build test and stage native ArdIrec bridge + - name: Build and test pinned native bridge shell: powershell run: | - $publish = Join-Path $env:RUNNER_TEMP "arsas-comtrade-publish" - if (Test-Path $publish) { Remove-Item $publish -Recurse -Force } - New-Item -ItemType Directory -Path $publish -Force | Out-Null - .\ARSAS\scripts\stage-ardirec-viewer.ps1 ` + $stage = Join-Path $env:RUNNER_TEMP "arsas-native-comtrade" + .\ARSAS\scripts\build-ardirec-bridge.ps1 ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` - -PublishedDirectory $publish ` - -BuildDirectory "$env:RUNNER_TEMP\ardirec-bridge-build" - "COMTRADE_PUBLISH=$publish" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + -OutputDirectory $stage ` + -BuildDirectory "$env:RUNNER_TEMP\ardirec-native-build" - - name: Verify bridge-only deployment contract - shell: powershell - run: | - $bridge = Join-Path $env:COMTRADE_PUBLISH "Tools\ArdIrec\ardirec_bridge.dll" - if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not staged: $bridge" } - foreach ($relative in @( - "Tools\ArdIrec\ardirec.exe", - "Tools\ArdIrec\Qt6Core.dll", - "Tools\ArdIrec\Qt6Gui.dll", - "Tools\ArdIrec\Qt6Qml.dll", - "Tools\ArdIrec\Qt6Quick.dll", - "Tools\ArdIrec\platforms\qwindows.dll" - )) { - $path = Join-Path $env:COMTRADE_PUBLISH $relative - if (Test-Path $path) { throw "Removed Qt/desktop fallback returned to package: $path" } + $bridge = Join-Path $stage "ardirec_bridge.dll" + if (-not (Test-Path $bridge -PathType Leaf)) { + throw "Pinned ArdIrec native bridge was not staged: $bridge" } - "ARSAS_ARDIREC_BRIDGE_PATH=$bridge" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "NATIVE_BRIDGE_PATH=$bridge" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Exercise managed bridge and distance locus against ArdIrec fixtures + - name: Enforce field-tested native-only ARSAS COMTRADE routing shell: powershell run: | - $basic = Join-Path $env:RUNNER_TEMP "COMTRADE üñîçødé 日本 with spaces" - New-Item -ItemType Directory -Path $basic -Force | Out-Null - Copy-Item ".\ArdIrec\tests\data\minimal_1999.cfg" (Join-Path $basic "minimal_1999.cfg") -Force - Copy-Item ".\ArdIrec\tests\data\minimal_1999.dat" (Join-Path $basic "minimal_1999.dat") -Force - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = Join-Path $basic "minimal_1999.cfg" - $env:ARSAS_NATIVE_LOCUS_TEST_CFG = Join-Path $env:GITHUB_WORKSPACE "ArdIrec\tests\data\distance_p1.cfg" - - dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj ` - -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" - if ($LASTEXITCODE -ne 0) { throw "Managed ArdIrec bridge/locus integration test failed." } + $openPath = ".\ARSAS\FaultRecordWindow.ComtradeOpen.cs" + $launcherPath = ".\ARSAS\Services\ArdIrecViewerLauncher.cs" + $bridgePath = ".\ARSAS\Services\ArdIrecNativeBridge.cs" + $bootstrapPath = ".\ARSAS\Services\ArdIrecEmbeddedBridgeBootstrap.cs" + $projectPath = ".\ARSAS\ArIED61850Tester.csproj" + $workspacePath = ".\ARSAS\ComtradeWorkspaceWindow.xaml" + $locusViewPath = ".\ARSAS\Controls\ComtradeLocusView.cs" + + $open = Get-Content $openPath -Raw + $launcher = Get-Content $launcherPath -Raw + $bridge = Get-Content $bridgePath -Raw + $bootstrap = Get-Content $bootstrapPath -Raw + $project = Get-Content $projectPath -Raw + $workspace = Get-Content $workspacePath -Raw + $locusView = Get-Content $locusViewPath -Raw + + if ($open -notmatch 'ArdIrecNativeBridge\.TryOpen' -or + $open -notmatch 'ComtradeWorkspaceWindow' -or + $open -match 'Process\.Start|TryLaunch\(') { + throw "Fault-record Open must route only to the in-process native COMTRADE workspace." + } + + if ($launcher -match 'Process\.Start|ProcessStartInfo|ardirec\.exe|ARSAS_ARDIREC_PATH|ARDIREC_VIEWER_PATH|TryLaunch\(') { + throw "External ArdIrec process-launch compatibility code is not allowed." + } + + if ($bridge -notmatch 'ardirec_bridge\.dll' -or + $bridge -notmatch 'ExpectedAbiVersion = 1' -or + $bridge -notmatch 'Harmonic' -or + $bridge -notmatch 'Distance' -or + $bootstrap -notmatch 'ArIED61850Tester\.Native\.ardirec_bridge\.dll' -or + $project -notmatch 'ArIED61850Tester\.Native\.ardirec_bridge\.dll' -or + $project -notmatch 'Tools\\ArdIrec\\ardirec_bridge\.dll') { + throw "Field-tested native ArdIrec analysis/packaging contract is incomplete." + } + + if ($workspace -notmatch 'ComtradePhasorView' -or + $workspace -notmatch 'ComtradeHarmonicsWorkstationView' -or + $locusView -notmatch 'class\s+ComtradeLocusView') { + throw "Production COMTRADE Phasor, Harmonics or Locus analysis surface is missing." + } + + Write-Host "COMTRADE Open uses the field-tested in-process workstation with Phasor, Harmonics and Locus; no Qt/external ArdIrec process launch remains." diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index beea51f1c..3a2e1c271 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -91,17 +91,30 @@ jobs: } $ardirecLock = Get-Content ".\ArIED61850Tester\engines\ARDIREC.lock.json" -Raw | ConvertFrom-Json - if ($ardirecLock.schema -ne 2 -or + if ($ardirecLock.schema -ne 3 -or $ardirecLock.repository -notmatch '^[^/]+/[^/]+$' -or - $ardirecLock.ref -ne 'main' -or + [string]::IsNullOrWhiteSpace([string]$ardirecLock.ref) -or $ardirecLock.commit -notmatch '^[0-9a-f]{40}$' -or $ardirecLock.bridge.abi -ne 1 -or - $ardirecLock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll' -or - $ardirecLock.qt.version -ne '6.8.3' -or - $ardirecLock.qt.arch -ne 'win64_msvc2022_64' -or - $ardirecLock.runtime.relativeExecutable -ne 'Tools/ArdIrec/ardirec.exe' -or - $ardirecLock.runtime.launchArgument -ne '--arsas-open') { - throw "ArdIrec P1 lock metadata is invalid." + $ardirecLock.bridge.mode -ne 'native-only' -or + $ardirecLock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { + throw "ArdIrec field-tested native bridge lock metadata is invalid." + } + + $requiredCapabilities = @( + 'cursor_measurement', + 'channel_semantics', + 'value_representation', + 'status_state', + 'digital_edge_snap', + 'phasor', + 'harmonics', + 'distance_locus' + ) + $lockedCapabilities = @($ardirecLock.bridge.requiredCapabilities) + $missingCapabilities = @($requiredCapabilities | Where-Object { $lockedCapabilities -notcontains $_ }) + if ($missingCapabilities.Count -gt 0) { + throw "ArdIrec release bridge is missing required analysis capabilities: $($missingCapabilities -join ', ')." } "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -126,7 +139,7 @@ jobs: throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." } - - name: Checkout immutable ArdIrec P1 engine revision + - name: Checkout immutable ArdIrec native engine revision shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec @@ -147,13 +160,6 @@ jobs: with: python-version: "3.12" - - name: Install Qt 6.8.3 for COMTRADE fallback - uses: jurplel/install-qt-action@v4 - with: - version: '6.8.3' - arch: 'win64_msvc2022_64' - cache: true - - name: Verify source and licensing boundaries shell: powershell run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 @@ -181,7 +187,7 @@ jobs: path: ArIED61850Tester/TestResults/*.trx if-no-files-found: error - - name: Publish installer source folder + - name: Publish installer source folder with pinned native bridge shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -190,33 +196,29 @@ jobs: -SingleFile $false ` -SelfContained $true ` -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` - -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" - - name: Stage pinned ArdIrec P1 native bridge and Qt fallback - shell: powershell - run: | - .\ArIED61850Tester\scripts\stage-ardirec-viewer.ps1 ` - -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` - -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" ` - -BuildDirectory "$env:RUNNER_TEMP\ardirec-release-build" - - - name: Exercise managed P1 bridge before release packaging + - name: Exercise managed native bridge before release packaging shell: powershell run: | $publish = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" $bridge = Join-Path $publish "Tools\ArdIrec\ardirec_bridge.dll" $fixture = "$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\fixtures\comtrade\p1-release-smoke.cfg" - if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not staged: $bridge" } - if (-not (Test-Path $fixture -PathType Leaf)) { throw "ARSAS P1 release fixture was not found: $fixture" } + $locusFixture = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" + if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not published: $bridge" } + if (-not (Test-Path $fixture -PathType Leaf)) { throw "ARSAS native release fixture was not found: $fixture" } + if (-not (Test-Path $locusFixture -PathType Leaf)) { throw "Pinned ArdIrec locus fixture was not found: $locusFixture" } $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridge $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $fixture + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $locusFixture dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests" - if ($LASTEXITCODE -ne 0) { throw "Release managed ArdIrec P1 bridge integration test failed." } + --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { throw "Release managed ArdIrec bridge/locus integration test failed." } - - name: Publish real portable single EXE + - name: Publish real portable single EXE with embedded native bridge shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -225,7 +227,8 @@ jobs: -SingleFile $true ` -SelfContained $true ` -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` - -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" - name: Smoke-test real portable single EXE shell: powershell @@ -283,18 +286,26 @@ jobs: "AR.Iec61850.Transports.Npcap.dll", "SharpPcap.dll", "PacketDotNet.dll", - "Tools\ArdIrec\ardirec_bridge.dll", + "Tools\ArdIrec\ardirec_bridge.dll" + ) + foreach ($file in $requiredInstalledFiles) { + $path = Join-Path $installRoot $file + if (-not (Test-Path $path -PathType Leaf)) { + throw "Installed package is incomplete: $path" + } + } + + foreach ($forbidden in @( "Tools\ArdIrec\ardirec.exe", "Tools\ArdIrec\Qt6Core.dll", "Tools\ArdIrec\Qt6Gui.dll", "Tools\ArdIrec\Qt6Qml.dll", "Tools\ArdIrec\Qt6Quick.dll", "Tools\ArdIrec\platforms\qwindows.dll" - ) - foreach ($file in $requiredInstalledFiles) { - $path = Join-Path $installRoot $file - if (-not (Test-Path $path -PathType Leaf)) { - throw "Installed package is incomplete: $path" + )) { + $path = Join-Path $installRoot $forbidden + if (Test-Path $path) { + throw "External ArdIrec/Qt runtime must not be packaged: $path" } } @@ -385,7 +396,7 @@ jobs: $provenance.comtradeViewerCommit -ne $env:ARDIREC_COMMIT -or $provenance.comtradeBridgeAbi -ne 1 -or $provenance.comtradeBridgeRelativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { - throw "Release provenance does not match the tested app, IEC engine and COMTRADE P1 bridge revisions." + throw "Release provenance does not match the tested app, IEC engine and native COMTRADE bridge revisions." } - name: Attest installer artifact digest @@ -483,7 +494,6 @@ jobs: commit = $env:ARDIREC_COMMIT nativeBridgeAbi = [int]$env:ARDIREC_BRIDGE_ABI nativeBridgeRelativeLibrary = $env:ARDIREC_BRIDGE_RELATIVE_LIBRARY - relativeExecutable = "Tools/ArdIrec/ardirec.exe" } installer = [ordered]@{ name = "ARSAS-Windows-x64-Setup.exe" diff --git a/.release/windows.json b/.release/windows.json index 0a1048aa2..72b79c437 100644 --- a/.release/windows.json +++ b/.release/windows.json @@ -1,7 +1,7 @@ { - "version": "1.6.35", + "version": "1.6.36", "channel": "stable", "platform": "windows-x64", "primaryAsset": "ARSAS-Windows-x64-Setup.exe", - "publicationRequest": 23 + "publicationRequest": 24 } diff --git a/App.xaml.cs b/App.xaml.cs index 7a0d16197..51a039ebb 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -6,6 +6,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Threading; +using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -22,6 +23,20 @@ protected override void OnStartup(StartupEventArgs e) WindowsApplicationIdentity.Apply(); base.OnStartup(e); + if (SclSafeTrialCommand.IsRequested(e.Args)) + { + var trial = SclSafeTrialRunner.RunAsync(e.Args, CancellationToken.None) + .GetAwaiter() + .GetResult(); + MessageBox.Show( + $"{trial.Message}\n\nEvidence: {trial.EvidencePath}", + trial.IsSuccess ? "SCL Safe Trial — PASS" : "SCL Safe Trial — NOT PROVEN", + MessageBoxButton.OK, + trial.IsSuccess ? MessageBoxImage.Information : MessageBoxImage.Warning); + Shutdown(trial.ExitCode); + return; + } + // P2 installs one calm industrial visual system before StartupUri materializes. // Existing XAML keeps its semantic resource keys while the overlay replaces // glare-heavy white/blue surfaces with Blue Steel + Light Greige equivalents. @@ -64,6 +79,12 @@ protected override void OnActivated(EventArgs e) // tiny P2 adapter when windows activate so newly opened FAT workspaces also // inherit the selected industrial theme without touching engine workflows. P2BlueSteelGreigeUx.ApplyToOpenWindows(this); + + // Keep onboarding task-first while preserving the existing protocol handlers: + // Add IED exposes SCL/CID/ICD or IP discovery, and bulk connect appears only + // when multiple loaded IEDs make the action useful. The behavior is dispatcher-safe. + if (Current?.MainWindow is MainWindow mainWindow) + SmartIedOnboardingBehavior.Install(mainWindow); } private void InstallP2BlueSteelGreigeTheme() diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index 2daa0b18f..627b0d5a6 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -15,9 +15,9 @@ ARSAS ARSAS - IEC 61850 Engineering Workstation Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation. - 1.6.35 - 1.6.35.0 - 1.6.35.0 + 1.6.36 + 1.6.36.0 + 1.6.36.0 https://github.com/masarray/arsas https://github.com/masarray/arsas git @@ -32,6 +32,8 @@ $(MSBuildProjectDirectory)\scripts\validate-ariec61850-lock.ps1 powershell pwsh + $(ARSAS_ARDIREC_BRIDGE_PATH) + false @@ -58,6 +60,19 @@ + + + + + + @@ -78,6 +93,12 @@ Text="ARIEC61850 integration lock was not found at '$(ArIec61850LockPath)'." /> + + + + diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 23d9014ee..d9769afa5 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -187,10 +187,12 @@ private async void SendCommand_Click(object sender, RoutedEventArgs e) }, _cancellation.Token); - CommandStage = result.Stage; + CommandStage = result.IsSuccess && !TestMode ? "Command accepted" : result.Stage; CommandStatus = BuildCommandResultText(result); - if (!string.IsNullOrWhiteSpace(result.FeedbackValue) && result.FeedbackValue != "-") - CurrentValue = result.FeedbackValue; + if (result.IsSuccess && !TestMode) + { + CommandStatus += " Command accepted by the IEC 61850 control service. Waiting for independent IED process feedback; monitored stVal is not changed from the command path."; + } SetResultTone(result.IsSuccess ? "Success" : "Error"); } catch (OperationCanceledException) @@ -312,7 +314,7 @@ private static string BuildCommandResultText(Iec61850ControlCommandResult result if (!string.IsNullOrWhiteSpace(result.ElapsedText) && result.ElapsedText != "-") details.Add($"Control service: {result.ElapsedText}."); if (!string.IsNullOrWhiteSpace(result.FeedbackElapsedText) && result.FeedbackElapsedText != "-") - details.Add($"Process feedback: {result.FeedbackElapsedText}."); + details.Add($"Control-side feedback verification: {result.FeedbackElapsedText}. This does not overwrite monitored stVal."); if (!string.IsNullOrWhiteSpace(result.TotalElapsedText) && result.TotalElapsedText != "-") details.Add($"Total: {result.TotalElapsedText}."); return string.Join(" ", details.Where(text => !string.IsNullOrWhiteSpace(text))); diff --git a/Controls/ComtradeHarmonicsWorkstationView.cs b/Controls/ComtradeHarmonicsWorkstationView.cs index 17fe7bfa4..73580609a 100644 --- a/Controls/ComtradeHarmonicsWorkstationView.cs +++ b/Controls/ComtradeHarmonicsWorkstationView.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Globalization; using System.Windows; using System.Windows.Input; @@ -65,7 +66,10 @@ public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement private static readonly Pen FooterDividerPen = FreezePen(Color.FromRgb(233, 237, 243), 1); private static readonly string[] OrderLabels = CreateOrderLabels(); + private const double PresentationTimeConstantMs = 92.0; private IReadOnlyList _spectra = Array.Empty(); + private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); + private long _lastPresentationTimestamp; private PreparedSpectrumRow[] _preparedRows = Array.Empty(); private int _maximumDisplayedOrder = -1; private string _title = "Harmonics"; @@ -105,7 +109,14 @@ internal void ShowSpectra( { _title = title ?? string.Empty; _subtitle = subtitle ?? string.Empty; - _spectra = spectra ?? Array.Empty(); + var targetSpectra = spectra ?? Array.Empty(); + var now = Stopwatch.GetTimestamp(); + var elapsedMilliseconds = _lastPresentationTimestamp == 0 + ? double.PositiveInfinity + : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds; + _lastPresentationTimestamp = now; + _smoothedSpectra = SmoothSpectra(_smoothedSpectra, targetSpectra, elapsedMilliseconds); + _spectra = _smoothedSpectra; _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra); _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); _preparedRows = PrepareRows(_spectra, _maximumDisplayedOrder); @@ -118,6 +129,8 @@ internal void ShowMessage(string title, string message) _title = title ?? string.Empty; _subtitle = message ?? string.Empty; _spectra = Array.Empty(); + _smoothedSpectra = Array.Empty(); + _lastPresentationTimestamp = 0; _preparedRows = Array.Empty(); _maximumDisplayedOrder = -1; _rowTargets.Clear(); @@ -296,6 +309,79 @@ private void DrawFooter(DrawingContext dc, Rect bounds, double dpi) FooterRateBrush, new Point(bounds.Right - 16, y), dpi); } + private static ComtradeHarmonicOverviewSpectrum[] SmoothSpectra( + IReadOnlyList previous, + IReadOnlyList target, + double elapsedMilliseconds) + { + if (target.Count == 0) + return Array.Empty(); + + var topologyMatches = previous.Count == target.Count && previous.Count > 0; + if (topologyMatches) + { + for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++) + { + var before = previous[spectrumIndex]; + var next = target[spectrumIndex]; + if (!string.Equals(before.SignalName, next.SignalName, StringComparison.Ordinal) || + !string.Equals(before.Units, next.Units, StringComparison.Ordinal) || + before.Bins.Count != next.Bins.Count) + { + topologyMatches = false; + break; + } + for (var binIndex = 0; binIndex < next.Bins.Count; binIndex++) + { + if (before.Bins[binIndex].Order != next.Bins[binIndex].Order) + { + topologyMatches = false; + break; + } + } + if (!topologyMatches) break; + } + } + + var result = new ComtradeHarmonicOverviewSpectrum[target.Count]; + for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++) + { + var next = target[spectrumIndex]; + if (!topologyMatches) + { + result[spectrumIndex] = next with { Bins = next.Bins.ToArray() }; + continue; // First sample/channel-set change snaps; never invent a ramp from zero. + } + + var before = previous[spectrumIndex]; + var bins = new ComtradeHarmonicDisplayBin[next.Bins.Count]; + for (var binIndex = 0; binIndex < bins.Length; binIndex++) + { + var previousBin = before.Bins[binIndex]; + var targetBin = next.Bins[binIndex]; + bins[binIndex] = new ComtradeHarmonicDisplayBin( + targetBin.Order, + PresentationEasingMath.Smooth(previousBin.MagnitudeRms, targetBin.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.Smooth(previousBin.PercentOfFundamental, targetBin.PercentOfFundamental, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.SmoothAngleDegrees(previousBin.AngleDegrees, targetBin.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs)); + } + + result[spectrumIndex] = new ComtradeHarmonicOverviewSpectrum( + next.SignalName, + next.Units, + PresentationEasingMath.Smooth(before.DcComponent, next.DcComponent, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.Smooth(before.FundamentalRms, next.FundamentalRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.Smooth(before.ThdPercent, next.ThdPercent, elapsedMilliseconds, PresentationTimeConstantMs), + next.DominantOrder, + PresentationEasingMath.Smooth(before.DominantRms, next.DominantRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.Smooth(before.DominantPercent, next.DominantPercent, elapsedMilliseconds, PresentationTimeConstantMs), + next.EstimatedSampleRateHz, + next.MaximumResolvableOrder, + bins); + } + return result; + } + private static PreparedSpectrumRow[] PrepareRows( IReadOnlyList spectra, int maximumOrder) diff --git a/Controls/ComtradePhasorView.cs b/Controls/ComtradePhasorView.cs index 369d105dd..9564c3081 100644 --- a/Controls/ComtradePhasorView.cs +++ b/Controls/ComtradePhasorView.cs @@ -1,6 +1,8 @@ +using System.Diagnostics; using System.Globalization; using System.Windows; using System.Windows.Media; +using ArIED61850Tester.Services; namespace ArIED61850Tester.Controls; @@ -34,9 +36,13 @@ public sealed class ComtradePhasorView : FrameworkElement private PreparedPhasorPanel _voltagePanel = PreparedPhasorPanel.Empty; private PreparedPhasorPanel _currentPanel = PreparedPhasorPanel.Empty; + private const double PresentationTimeConstantMs = 78.0; private string _headerLabel = "Fundamental phasors at C1"; private string _referenceDetail = "Select a valid analysis reference"; private string _message = string.Empty; + private ComtradePhasorVector[] _smoothedVoltageVectors = Array.Empty(); + private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty(); + private long _lastPresentationTimestamp; internal void ShowPhasors( string referenceLabel, @@ -47,8 +53,17 @@ internal void ShowPhasors( var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; _headerLabel = $"Fundamental phasors at {resolvedReference}"; _referenceDetail = referenceDetail ?? string.Empty; - _voltagePanel = PreparePanel(voltageVectors); - _currentPanel = PreparePanel(currentVectors); + + var now = Stopwatch.GetTimestamp(); + var elapsedMilliseconds = _lastPresentationTimestamp == 0 + ? double.PositiveInfinity + : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds; + _lastPresentationTimestamp = now; + + _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, voltageVectors, elapsedMilliseconds); + _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, currentVectors, elapsedMilliseconds); + _voltagePanel = PreparePanel(_smoothedVoltageVectors); + _currentPanel = PreparePanel(_smoothedCurrentVectors); _message = string.Empty; InvalidateVisual(); } @@ -60,6 +75,9 @@ internal void ShowMessage(string title, string message) _referenceDetail = message ?? string.Empty; _voltagePanel = PreparedPhasorPanel.Empty; _currentPanel = PreparedPhasorPanel.Empty; + _smoothedVoltageVectors = Array.Empty(); + _smoothedCurrentVectors = Array.Empty(); + _lastPresentationTimestamp = 0; _message = message ?? string.Empty; InvalidateVisual(); } @@ -111,6 +129,51 @@ protected override void OnRender(DrawingContext dc) } } + private static ComtradePhasorVector[] SmoothVectors( + IReadOnlyList previous, + IReadOnlyList? target, + double elapsedMilliseconds) + { + if (target is null || target.Count == 0) + return Array.Empty(); + + var topologyMatches = previous.Count == target.Count && previous.Count > 0; + if (topologyMatches) + { + for (var index = 0; index < target.Count; index++) + { + if (!string.Equals(previous[index].Label, target[index].Label, StringComparison.Ordinal) || + !string.Equals(previous[index].Phase, target[index].Phase, StringComparison.Ordinal) || + !string.Equals(previous[index].Units, target[index].Units, StringComparison.Ordinal)) + { + topologyMatches = false; + break; + } + } + } + + var output = new ComtradePhasorVector[target.Count]; + if (!topologyMatches) + { + for (var index = 0; index < target.Count; index++) + output[index] = target[index]; + return output; // First sample/topology change snaps: no artificial ramp from zero. + } + + for (var index = 0; index < target.Count; index++) + { + var before = previous[index]; + var next = target[index]; + output[index] = new ComtradePhasorVector( + next.Label, + next.Phase, + next.Units, + PresentationEasingMath.Smooth(before.MagnitudeRms, next.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs), + PresentationEasingMath.SmoothAngleDegrees(before.AngleDegrees, next.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs)); + } + return output; + } + private static PreparedPhasorPanel PreparePanel(IReadOnlyList? source) { if (source is null || source.Count == 0) diff --git a/Directory.Build.props b/Directory.Build.props index 9bc80221d..4270638ce 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 1.6.35 - 1.6.35.0 - 1.6.35.0 - 1.6.35 + 1.6.36 + 1.6.36.0 + 1.6.36.0 + 1.6.36 diff --git a/FaultRecordWindow.ComtradeOpen.cs b/FaultRecordWindow.ComtradeOpen.cs index 207edf735..420d653a8 100644 --- a/FaultRecordWindow.ComtradeOpen.cs +++ b/FaultRecordWindow.ComtradeOpen.cs @@ -36,7 +36,7 @@ private void EnsureComtradeOpenColumn() buttonFactory.SetValue(Control.BackgroundProperty, new SolidColorBrush(Color.FromRgb(244, 248, 255))); buttonFactory.SetValue(Control.BorderBrushProperty, new SolidColorBrush(Color.FromRgb(166, 190, 221))); buttonFactory.SetValue(Control.BorderThicknessProperty, new Thickness(1)); - buttonFactory.SetValue(FrameworkElement.ToolTipProperty, "Open in ARSAS COMTRADE Workspace"); + buttonFactory.SetValue(FrameworkElement.ToolTipProperty, "Open in COMTRADE Viewer"); buttonFactory.SetValue(ToolTipService.InitialShowDelayProperty, 650); buttonFactory.SetValue(FrameworkElement.CursorProperty, Cursors.Hand); buttonFactory.SetBinding( @@ -48,6 +48,11 @@ private void EnsureComtradeOpenColumn() }); buttonFactory.AddHandler(Button.ClickEvent, new RoutedEventHandler(OpenComtrade_Click)); + var cellTemplate = new DataTemplate + { + VisualTree = buttonFactory + }; + FaultRecordsGrid.Columns.Add(new DataGridTemplateColumn { Header = new TextBlock @@ -62,7 +67,7 @@ private void EnsureComtradeOpenColumn() IsReadOnly = true, CanUserSort = false, CanUserResize = false, - CellTemplate = new DataTemplate { VisualTree = buttonFactory } + CellTemplate = cellTemplate }); _comtradeOpenColumnInstalled = true; @@ -74,6 +79,7 @@ private async void OpenComtrade_Click(object sender, RoutedEventArgs e) return; e.Handled = true; + if (row.LocalState != FaultRecordLocalState.Downloaded) { ShowToast("Download the complete COMTRADE record before opening it.", ToastKind.Warning); @@ -97,57 +103,58 @@ private async void OpenComtrade_Click(object sender, RoutedEventArgs e) return; } - StatusText = $"Opening {Path.GetFileName(cfgPath)} in the ARSAS COMTRADE workspace…"; - ShowToast("Loading COMTRADE record…", ToastKind.Information); + StatusText = $"Opening {Path.GetFileName(cfgPath)} in the native COMTRADE workspace…"; + ShowToast("Loading COMTRADE record with native ArdIrec core…", ToastKind.Information); - // Native record decode can be substantial. Keep it off WPF's dispatcher so large field - // records never freeze the fault-record browser while the workspace is being created. + // ArdIrec's reference DatReader eagerly decodes the record while opening. Keep that work + // off WPF's dispatcher thread so large field records do not freeze the Fault Records UI. var nativeOpen = await Task.Run(() => { var opened = ArdIrecNativeBridge.TryOpen(cfgPath, out var record, out var error); return (Opened: opened, Record: record, Error: error); }).ConfigureAwait(true); - if (!nativeOpen.Opened || nativeOpen.Record is null) - { - var message = string.IsNullOrWhiteSpace(nativeOpen.Error) - ? "The ARSAS COMTRADE analysis engine could not open this record." - : nativeOpen.Error; - StatusText = $"COMTRADE workspace could not open {Path.GetFileName(cfgPath)}."; - ShowToast("COMTRADE workspace could not be started.", ToastKind.Error); - MessageBox.Show( - this, - message, - "COMTRADE Workspace", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - - try + if (nativeOpen.Opened && nativeOpen.Record is not null) { - var workspace = new ComtradeWorkspaceWindow(nativeOpen.Record) + try { - Owner = this - }; - workspace.Show(); - StatusText = $"Opened {Path.GetFileName(cfgPath)} in the ARSAS COMTRADE workspace."; - ShowToast("COMTRADE record opened.", ToastKind.Success); - } - catch - { - nativeOpen.Record.Dispose(); - throw; + var workspace = new ComtradeWorkspaceWindow(nativeOpen.Record) + { + Owner = this + }; + workspace.Show(); + StatusText = $"Opened {Path.GetFileName(cfgPath)} in the ARSAS native COMTRADE workspace."; + ShowToast("COMTRADE record opened natively.", ToastKind.Success); + return; + } + catch + { + nativeOpen.Record.Dispose(); + throw; + } } + + var nativeError = string.IsNullOrWhiteSpace(nativeOpen.Error) + ? "The ArdIrec native COMTRADE bridge could not open this record." + : nativeOpen.Error; + + StatusText = $"Native COMTRADE workspace could not open {Path.GetFileName(cfgPath)}: {nativeError}"; + ShowToast("Native COMTRADE workspace unavailable.", ToastKind.Error); + MessageBox.Show( + this, + nativeError, + "Native COMTRADE workspace", + MessageBoxButton.OK, + MessageBoxImage.Warning); } - catch (Exception ex) when (ex is InvalidOperationException or SEHException or BadImageFormatException) + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or SEHException or BadImageFormatException) { - StatusText = $"COMTRADE startup failed for {row.RecordName}: {ex.Message}"; - ShowToast("COMTRADE workspace startup failed.", ToastKind.Error); + StatusText = $"Native COMTRADE startup failed for {row.RecordName}: {ex.Message}"; + ShowToast("Native COMTRADE workspace startup failed.", ToastKind.Error); MessageBox.Show( this, ex.Message, - "COMTRADE Workspace startup failed", + "Native COMTRADE workspace startup failed", MessageBoxButton.OK, MessageBoxImage.Error); } diff --git a/IoListTestingWindow.EmbeddedEngineeringHost.cs b/IoListTestingWindow.EmbeddedEngineeringHost.cs new file mode 100644 index 000000000..38a163cfb --- /dev/null +++ b/IoListTestingWindow.EmbeddedEngineeringHost.cs @@ -0,0 +1,288 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Hosts the proven IoListTestingWindow production workspace inside MainWindow's FAT tab. +/// The production Window remains loaded-but-hidden so its existing session controller, +/// auto-capture lifecycle, persistence and report-preview code stay the single FAT authority. +/// Only its central workspace + FAT status footer are re-parented into Engineering. +/// +public partial class IoListTestingWindow +{ + private bool _engineeringEmbeddedMountQueued; + private bool _engineeringEmbeddedMounted; + private FrameworkElement? _engineeringEmbeddedSurface; + + [ModuleInitializer] + internal static void RegisterEmbeddedEngineeringFatHost() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(EmbeddedEngineeringFatHost_Loaded), + handledEventsToo: true); + } + + internal void PrepareForEmbeddedEngineeringHost() + { + // WPF refuses Window.Show() when ShowActivated=false while WindowState=Maximized. + // The production FAT XAML historically starts maximized, therefore normalize the + // invisible donor BEFORE Show(). Its exact center is re-parented into MainWindow on + // Loaded; the donor itself never needs a maximized native HWND. + WindowState = WindowState.Normal; + ShowActivated = false; + ShowInTaskbar = false; + Opacity = 0d; + WindowStartupLocation = WindowStartupLocation.Manual; + Left = -32000d; + Top = -32000d; + } + + private static void EmbeddedEngineeringFatHost_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || + !ReferenceEquals(e.OriginalSource, window) || + window._engineeringEmbeddedMounted || + window._engineeringEmbeddedMountQueued || + window.Owner is not MainWindow owner || + !owner.ProductionFatTabReady) + { + return; + } + + window._engineeringEmbeddedMountQueued = true; + + // MainWindow's legacy launcher still calls Show() on this Window. Make that bootstrap + // surface invisible immediately; the actual production visual is moved into Engineering + // on the next Loaded-priority dispatcher turn, before ContextIdle command-panel work. + window.PrepareForEmbeddedEngineeringHost(); + + window.Dispatcher.BeginInvoke( + DispatcherPriority.Loaded, + new Action(() => window.TryMountIntoEngineering(owner))); + } + + private void TryMountIntoEngineering(MainWindow owner) + { + _engineeringEmbeddedMountQueued = false; + if (_engineeringEmbeddedMounted || !IsLoaded || !ReferenceEquals(Owner, owner) || !owner.ProductionFatTabReady) + return; + + try + { + EnsureProductionFatPresentationForEmbeddedHost(); + DisableLegacyEmbeddedCommandPanel(); + var surface = DetachProductionFatCentralWorkspace() + ?? throw new InvalidOperationException("Production FAT center could not be detached from its donor window."); + + _engineeringEmbeddedSurface = surface; + _engineeringEmbeddedMounted = owner.MountProductionFatWorkspace(this, surface); + if (!_engineeringEmbeddedMounted) + throw new InvalidOperationException("Engineering FAT host rejected the production workspace surface."); + + // The central FAT view now belongs to MainWindow. Keep this Window loaded and + // hidden because existing controller/session/event code is intentionally reused. + Hide(); + } + catch (Exception ex) + { + // Never leave an invisible off-screen donor as a silent blank FAT failure. + // Restore the historical standalone presentation so the operator has a usable + // production FAT surface and a visible diagnostic if embedding itself fails. + WindowState = WindowState.Maximized; + Opacity = 1d; + ShowInTaskbar = true; + ShowActivated = true; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + Left = double.NaN; + Top = double.NaN; + MessageBox.Show( + owner, + $"ARSAS could not embed the production FAT workspace. The standalone FAT window will remain available.\n\n{ex.Message}", + "FAT workspace host", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + private void EnsureProductionFatPresentationForEmbeddedHost() + { + // P1 normally installs this during Loaded. Calling it explicitly is safe/idempotent + // and guarantees the first embedded frame is the same V2 FAT grid as the old Window. + InstallFatV2WorkspaceUx(); + + // PrintPreview historically installs from OnContentRendered. The bootstrap Window is + // intentionally transparent, so install the exact same production preview explicitly + // before the central workspace is detached. This preserves full-center mode switching. + if (!_printPreviewInstalled) + { + InstallPerIedPrintPreview(); + PropertyChanged += PrintPreviewWindow_PropertyChanged; + Session.PropertyChanged += PrintPreviewSession_PropertyChanged; + Closed += PrintPreviewWindow_Closed; + _printPreviewInstalled = true; + } + + // The embedded center already declares WorkspacePreviewToggle in XAML. Make that + // visible button the production toggle authority instead of the hidden Window header. + if (WorkspacePreviewToggle != null) + _printPreviewToggle = WorkspacePreviewToggle; + } + + private void DisableLegacyEmbeddedCommandPanel() + { + // Engineering already owns one shared Command Dock. Prevent the old FAT window's + // duplicate command panel from being created/refreshed while embedded. A non-null + // sentinel makes the queued legacy installer return immediately; null row/summary + // references make its queued refresh a no-op. No command runtime semantics change. + DetachFatCommandDevice(); + if (_fatCommandPanelShell?.Parent is Grid existingHost) + { + var row = Grid.GetRow(_fatCommandPanelShell); + existingHost.Children.Remove(_fatCommandPanelShell); + if (row >= 0 && row < existingHost.RowDefinitions.Count) + existingHost.RowDefinitions[row].Height = new GridLength(0); + if (row - 1 >= 0 && row - 1 < existingHost.RowDefinitions.Count) + existingHost.RowDefinitions[row - 1].Height = new GridLength(0); + } + + _fatCommandPanelShell ??= new Border { Visibility = Visibility.Collapsed }; + _fatCommandRows = null; + _fatCommandSummary = null; + _fatCommandEmptyState = null; + } + + private FrameworkElement? DetachProductionFatCentralWorkspace() + { + if (Content is not Grid root) + return null; + + var middle = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 2); + var workspaceBorder = middle?.Children + .OfType() + .FirstOrDefault(child => Grid.GetColumn(child) == 2); + if (middle == null || workspaceBorder == null) + return null; + + var footer = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 4); + + middle.Children.Remove(workspaceBorder); + if (footer != null) + root.Children.Remove(footer); + + var host = new Grid + { + DataContext = this, + Margin = new Thickness(0) + }; + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + if (footer != null) + { + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + host.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + } + + workspaceBorder.Margin = new Thickness(0); + Grid.SetRow(workspaceBorder, 0); + Grid.SetColumn(workspaceBorder, 0); + host.Children.Add(workspaceBorder); + + if (footer != null) + { + footer.Margin = new Thickness(0); + Grid.SetRow(footer, 2); + Grid.SetColumn(footer, 0); + host.Children.Add(footer); + } + + return host; + } + + internal void SelectEngineeringDeviceForEmbeddedFat(Iec61850MonitorDevice? device) + { + if (!_engineeringEmbeddedMounted) + return; + + // M3 viewed-device contract: the persistent Engineering IED Explorer owns + // what the FAT grid displays. SelectedIed/Session.SelectContext changes only that + // projection; an active capture remains latched inside its per-IED controller. + // Clearing the Explorer selection or selecting an IED outside this FAT projection + // must clear the FAT view instead of silently leaving the previous IED on screen. + if (device == null) + { + if (CanSelectIed) + SelectedIed = null; + return; + } + + // Resolve by strongest Engineering identity first. Do not use one OR predicate: + // a weak fallback on an earlier project row must never beat an exact live DeviceId. + var match = Project.Ieds.FirstOrDefault(_ => false); + if (!string.IsNullOrWhiteSpace(device.DeviceId)) + { + match = Project.Ieds.FirstOrDefault(ied => + !string.IsNullOrWhiteSpace(ied.LiveDeviceId) && + ied.LiveDeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)); + } + + if (match == null && !string.IsNullOrWhiteSpace(device.SclIedName)) + { + match = Project.Ieds.FirstOrDefault(ied => + ied.IedName.Equals(device.SclIedName, StringComparison.OrdinalIgnoreCase)); + } + + if (match == null && !string.IsNullOrWhiteSpace(device.Name)) + { + match = Project.Ieds.FirstOrDefault(ied => + ied.IedName.Equals(device.Name, StringComparison.OrdinalIgnoreCase)); + } + + if (match == null && !string.IsNullOrWhiteSpace(device.IpAddress)) + { + match = Project.Ieds.FirstOrDefault(ied => + !string.IsNullOrWhiteSpace(ied.IpAddress) && + ied.IpAddress.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)); + } + + if (ReferenceEquals(SelectedIed, match) || !CanSelectIed) + return; + + SelectedIed = match; + } + + internal void NotifyEmbeddedHostActivated() + { + if (!_engineeringEmbeddedMounted) + return; + + RefreshFatV2WorkspaceUx(refreshRows: true); + if (_printPreviewActive) + RefreshPrintPreview(); + } + + private void EmbeddedEngineeringFatHost_Closed(object? sender, EventArgs e) + { + if (Owner is MainWindow owner) + owner.UnmountProductionFatWorkspace(this); + Closed -= EmbeddedEngineeringFatHost_Closed; + _engineeringEmbeddedMounted = false; + _engineeringEmbeddedSurface = null; + } + + // Field initializer cannot attach an instance event. Hook cleanup once the embedded + // surface has actually been mounted; this helper is called from the production owner. + internal void RegisterEmbeddedHostCloseCleanup() + { + Closed -= EmbeddedEngineeringFatHost_Closed; + Closed += EmbeddedEngineeringFatHost_Closed; + } +} diff --git a/IoListTestingWindow.ReleaseNavigationGuard.cs b/IoListTestingWindow.ReleaseNavigationGuard.cs new file mode 100644 index 000000000..bb7e0f31a --- /dev/null +++ b/IoListTestingWindow.ReleaseNavigationGuard.cs @@ -0,0 +1,56 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Release guard for the obsolete compatibility navigation control. Native FAT now lives in +/// the Engineering workstation itself, so the old "Engineering" return button has no valid +/// user-facing purpose and can re-enter the compatibility mount/unmount path unexpectedly. +/// Keep the legacy host code available for explicit compatibility work, but remove its risky +/// navigation button from the shipped UI. +/// +public partial class IoListTestingWindow +{ + private bool _releaseObsoleteEngineeringButtonHidden; + + [ModuleInitializer] + internal static void RegisterIoListFatReleaseNavigationGuard() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(IoListFatReleaseNavigationGuard_Loaded), + handledEventsToo: true); + } + + private static void IoListFatReleaseNavigationGuard_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._releaseObsoleteEngineeringButtonHidden) + return; + + window._releaseObsoleteEngineeringButtonHidden = true; + window.Dispatcher.BeginInvoke( + new Action(() => HideObsoleteEngineeringNavigation(window)), + DispatcherPriority.Loaded); + } + + private static void HideObsoleteEngineeringNavigation(DependencyObject root) + { + if (root is Button button && + string.Equals(button.Content?.ToString()?.Trim(), "Engineering", StringComparison.OrdinalIgnoreCase)) + { + button.IsEnabled = false; + button.Focusable = false; + button.Visibility = Visibility.Collapsed; + return; + } + + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < childCount; index++) + HideObsoleteEngineeringNavigation(VisualTreeHelper.GetChild(root, index)); + } +} diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml index 48b1a7bb5..1b0803998 100644 --- a/IoListTestingWindow.xaml +++ b/IoListTestingWindow.xaml @@ -7,7 +7,7 @@ Height="900" Width="1500" MinHeight="760" MinWidth="1180" WindowStartupLocation="CenterOwner" WindowState="Maximized" Background="{StaticResource AppBackgroundGradient}" - FontFamily="Aptos, Segoe UI Variable Text, Segoe UI, Calibri" + FontFamily="Inter, Segoe UI Variable Text, Segoe UI, Calibri" Icon="Assets/app-icon.ico" Closing="Window_Closing" ContentRendered="IoListTestingWindow_ContentRendered"> @@ -17,11 +17,11 @@ - + - - + + - + @@ -76,24 +76,24 @@ - - - - + + -