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 @@
-
-
-
-
+
+
-
-
-
-
-
+
+
+
+
@@ -101,21 +101,21 @@
-
-
+
+
-
+
-
+
-
+
@@ -137,7 +137,7 @@